From 075ca0fb1e42b534d1e87e37a7419843d2847a87 Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Tue, 14 Jul 2026 14:53:45 +0200 Subject: [PATCH] add lettermint dispatcher --- .gitignore | 1 + mixtape/Mails/Dispatchers/IMailDispatcher.cs | 5 ++ .../Lettermint/LettermintDispatcher.cs | 67 +++++++++++++++++-- .../Lettermint/LettermintOptions.cs | 2 + .../Lettermint/LettermintResponse.cs | 47 +++++++++++++ mixtape/Mails/MailSuppressionReason.cs | 29 ++++++++ mixtape/Utils/Safenames.cs | 67 +++++++++++++------ 7 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 mixtape/Mails/MailSuppressionReason.cs diff --git a/.gitignore b/.gitignore index 058edd64..35efec95 100644 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,7 @@ paket-files/ # JetBrains Rider .idea/ *.sln.iml +.junie # CodeRush .cr/ diff --git a/mixtape/Mails/Dispatchers/IMailDispatcher.cs b/mixtape/Mails/Dispatchers/IMailDispatcher.cs index 0c1aba59..4af9eb2b 100644 --- a/mixtape/Mails/Dispatchers/IMailDispatcher.cs +++ b/mixtape/Mails/Dispatchers/IMailDispatcher.cs @@ -11,4 +11,9 @@ public interface IMailDispatcher : IDisposable /// Whether a certain sender signature is supported by this dispatcher /// Task IsSenderSupported(string email, CancellationToken token = default) => Task.FromResult(true); + + /// + /// Check whether a certain email address is suppressed + /// + Task GetSuppressionReason(string email, CancellationToken token = default) => Task.FromResult(MailSuppressionReason.None); } \ No newline at end of file diff --git a/mixtape/Mails/Dispatchers/Lettermint/LettermintDispatcher.cs b/mixtape/Mails/Dispatchers/Lettermint/LettermintDispatcher.cs index a69e8798..1d0d342e 100644 --- a/mixtape/Mails/Dispatchers/Lettermint/LettermintDispatcher.cs +++ b/mixtape/Mails/Dispatchers/Lettermint/LettermintDispatcher.cs @@ -1,8 +1,12 @@ -using System.IO; +using System.Collections.Specialized; +using System.IO; using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Net.Mail; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,8 +16,6 @@ namespace Mixtape.Mails.Dispatchers.Lettermint; public class LettermintDispatcher : IMailDispatcher { - protected Queue Queue { get; } = new(); - protected MailOptions Options { get; set; } protected IWebHostEnvironment Env { get; set; } @@ -31,9 +33,10 @@ public class LettermintDispatcher : IMailDispatcher Env = env; Http = http; Http.DefaultRequestHeaders.Add("x-lettermint-token", Options.Lettermint.Token); - JsonSerializerOptions = new JsonSerializerOptions + JsonSerializerOptions = new() { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new JsonStringEnumConverter() } }; Logger = logger; @@ -47,6 +50,58 @@ public class LettermintDispatcher : IMailDispatcher return Task.FromResult(true); } + + /// + public async Task GetSuppressionReason(string email, CancellationToken token = default) + { + if (Options.Lettermint.TeamToken.IsNullOrEmpty()) + { + Logger.LogWarning("Lettermint team token is not set. Suppression status cannot be checked."); + return MailSuppressionReason.None; + } + + // build url + NameValueCollection query = new() + { + { "filter[value]", email }, + { "page[size]", "1" } + }; + string uri = Options.Lettermint.ApiUrl + $"/v1/suppressions?" + query; + + try + { + using HttpRequestMessage request = new(HttpMethod.Get, uri); + request.Headers.Authorization = new("Bearer", Options.Lettermint.TeamToken); + + using HttpResponseMessage responseMessage = await Http.SendAsync(request, token); + LettermintResponse.ListSuppressions response = await responseMessage.Content.ReadFromJsonAsync(JsonSerializerOptions, token); + + if (!responseMessage.IsSuccessStatusCode) + { + throw new($"Could not get suppression status via Lettermint API. Status code: {responseMessage.StatusCode} Message: {response.ErrorMessage}"); + } + + LettermintResponse.ListSuppressionsItem suppression = response.Suppressions.FirstOrDefault(); + + // the suppression has to be an email address + // and match the query email because the API endpoint does partial matching for email address, + // which can lead to false-positives + if (suppression is not { Type: LettermintResponse.SuppressionType.Email } || !suppression.Value.Equals(email, StringComparison.OrdinalIgnoreCase)) + { + return MailSuppressionReason.None; + } + + Logger.LogDebug("Suppression status {status} for email {email} retrieved from Lettermint API", suppression.Reason, email); + return suppression.Reason; + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not send message via Lettermint API"); + } + + return MailSuppressionReason.None; + } + /// public async Task Send(Mail message, CancellationToken token = default) @@ -75,7 +130,7 @@ public class LettermintDispatcher : IMailDispatcher Subject = message.Subject, // tag/group - Tag = message.Tag, + Tag = Safenames.Tag(message.Tag), // metadata Metadata = message.Metadata, diff --git a/mixtape/Mails/Dispatchers/Lettermint/LettermintOptions.cs b/mixtape/Mails/Dispatchers/Lettermint/LettermintOptions.cs index 6231ba3a..d24af0f6 100644 --- a/mixtape/Mails/Dispatchers/Lettermint/LettermintOptions.cs +++ b/mixtape/Mails/Dispatchers/Lettermint/LettermintOptions.cs @@ -5,6 +5,8 @@ public class LettermintOptions public string ApiUrl { get; set; } = "https://api.lettermint.co"; public string Token { get; set; } + + public string TeamToken { get; set; } public string Route { get; set; } = "outgoing"; } \ No newline at end of file diff --git a/mixtape/Mails/Dispatchers/Lettermint/LettermintResponse.cs b/mixtape/Mails/Dispatchers/Lettermint/LettermintResponse.cs index 2bbd5eeb..c5c35be0 100644 --- a/mixtape/Mails/Dispatchers/Lettermint/LettermintResponse.cs +++ b/mixtape/Mails/Dispatchers/Lettermint/LettermintResponse.cs @@ -13,4 +13,51 @@ public class LettermintResponse [JsonPropertyName("message")] public string ErrorMessage { get; set; } } + + public class ListSuppressions + { + [JsonPropertyName("data")] + public ListSuppressionsItem[] Suppressions { get; set; } = []; + + [JsonPropertyName("message")] + public string ErrorMessage { get; set; } + } + + public class ListSuppressionsItem + { + public string Id { get; set; } + + public SuppressionType Type { get; set; } + + public string Value { get; set; } + + public MailSuppressionReason Reason { get; set; } = MailSuppressionReason.None; + + public SuppressionScope Scope { get; set; } + + public string ProjectId { get; set; } + + public string RouteId { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedDate { get; set; } + + [JsonPropertyName("updated_at")] + public DateTimeOffset UpdatedDate { get; set; } + } + + public enum SuppressionType + { + Email, + Domain, + Extension + } + + public enum SuppressionScope + { + Global, + Team, + Project, + Route + } } \ No newline at end of file diff --git a/mixtape/Mails/MailSuppressionReason.cs b/mixtape/Mails/MailSuppressionReason.cs new file mode 100644 index 00000000..1a682383 --- /dev/null +++ b/mixtape/Mails/MailSuppressionReason.cs @@ -0,0 +1,29 @@ +namespace Mixtape.Mails; + +public enum MailSuppressionReason +{ + /// + /// Not suppressed + /// + None = 0, + /// + /// Suppressed after permanent delivery failure + /// + HardBounce = 1, + /// + /// Suppressed after a recipient reports spam + /// + SpamComplaint = 2, + /// + /// Manually suppressed via dashboard or API + /// + Manual = 3, + /// + /// Hosted unsubscribe and unsubscribe workflows + /// + Unsubscribe = 4, + /// + /// Unknown reason + /// + Other = 9 +} diff --git a/mixtape/Utils/Safenames.cs b/mixtape/Utils/Safenames.cs index 597ea889..b17d5a70 100644 --- a/mixtape/Utils/Safenames.cs +++ b/mixtape/Utils/Safenames.cs @@ -3,19 +3,21 @@ using System.Text; namespace Mixtape.Utils; -public class Safenames +public static class Safenames { public enum Scope { Url, - File + File, + Tag } - - const char HYPHEN = '-'; - - const char DOT = '.'; - - static char[] TICKS = ['`', '\'', '´']; + + const char Underscore = '_'; + const char Hyphen = '-'; + const char Dot = '.'; + const char Plus = '+'; + const char Ampersand = '&'; + static readonly char[] Ticks = ['`', '\'', '´']; /// @@ -43,6 +45,15 @@ public class Safenames { return Generate(value?.ToString(), Scope.Url); } + + + /// + /// Converts an untrusted to a safe tag ([a-z][0-9][-][_]) + /// + public static string Tag(string value) + { + return Generate(value, Scope.Tag); + } /// @@ -55,13 +66,13 @@ public class Safenames return string.Empty; } - char previous = default; + char previous = '\0'; StringBuilder output = new(); - for (int i = 0; i < value.Length; i++) + foreach (char t in value) { // get character in lower case - char character = char.ToLower(value[i]); + char character = char.ToLower(t); char target; // do not handle surrogates @@ -69,8 +80,9 @@ public class Safenames { continue; } + // do not handle ticks - else if (TICKS.Contains(character)) + if (Ticks.Contains(character)) { continue; } @@ -90,27 +102,44 @@ public class Safenames { target = character; } - // - sign for + and & - else if (scope == Scope.File && character == DOT) + // + sign for + and & + else if (scope is not Scope.Tag && character is Plus or Ampersand) { - target = DOT; + target = Plus; + } + else if (scope == Scope.File && character == Dot) + { + target = Dot; + } + else if (scope is Scope.Tag && character == Underscore) + { + target = Underscore; } // add hyphen for all other characters else { - target = HYPHEN; + target = Hyphen; } // add default characters - if (target != HYPHEN) + if (target != Hyphen && target != Plus) { output.Append(target); } // add hyphen if it isn't first and previous char is not + or - - else if (target == HYPHEN && previous != default(char) && previous != HYPHEN) + else if (target == Hyphen && previous != 0 && previous != Plus && previous != Hyphen) { output.Append(target); } + // add plus. do remove hyphen it is the previous character + else if (target == Plus) + { + if (previous == Hyphen) + { + output.Remove(output.Length - 1, 1); + } + output.Append(target); + } if (output.Length > 0) { @@ -125,7 +154,7 @@ public class Safenames if (output.Length == 0) { - output.Append(HYPHEN); + output.Append(Hyphen); } return output.ToString();