add lettermint dispatcher

This commit is contained in:
2026-07-14 14:53:45 +02:00
parent 77337cb476
commit 075ca0fb1e
7 changed files with 193 additions and 25 deletions
+1
View File
@@ -253,6 +253,7 @@ paket-files/
# JetBrains Rider # JetBrains Rider
.idea/ .idea/
*.sln.iml *.sln.iml
.junie
# CodeRush # CodeRush
.cr/ .cr/
@@ -11,4 +11,9 @@ public interface IMailDispatcher : IDisposable
/// Whether a certain sender signature is supported by this dispatcher /// Whether a certain sender signature is supported by this dispatcher
/// </summary> /// </summary>
Task<bool> IsSenderSupported(string email, CancellationToken token = default) => Task.FromResult(true); Task<bool> IsSenderSupported(string email, CancellationToken token = default) => Task.FromResult(true);
/// <summary>
/// Check whether a certain email address is suppressed
/// </summary>
Task<MailSuppressionReason> GetSuppressionReason(string email, CancellationToken token = default) => Task.FromResult(MailSuppressionReason.None);
} }
@@ -1,8 +1,12 @@
using System.IO; using System.Collections.Specialized;
using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Net.Mail; using System.Net.Mail;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,8 +16,6 @@ namespace Mixtape.Mails.Dispatchers.Lettermint;
public class LettermintDispatcher : IMailDispatcher public class LettermintDispatcher : IMailDispatcher
{ {
protected Queue<Mail> Queue { get; } = new();
protected MailOptions Options { get; set; } protected MailOptions Options { get; set; }
protected IWebHostEnvironment Env { get; set; } protected IWebHostEnvironment Env { get; set; }
@@ -31,9 +33,10 @@ public class LettermintDispatcher : IMailDispatcher
Env = env; Env = env;
Http = http; Http = http;
Http.DefaultRequestHeaders.Add("x-lettermint-token", Options.Lettermint.Token); 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; Logger = logger;
@@ -47,6 +50,58 @@ public class LettermintDispatcher : IMailDispatcher
return Task.FromResult(true); return Task.FromResult(true);
} }
/// <inheritdoc />
public async Task<MailSuppressionReason> 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<LettermintResponse.ListSuppressions>(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;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task Send(Mail message, CancellationToken token = default) public async Task Send(Mail message, CancellationToken token = default)
@@ -75,7 +130,7 @@ public class LettermintDispatcher : IMailDispatcher
Subject = message.Subject, Subject = message.Subject,
// tag/group // tag/group
Tag = message.Tag, Tag = Safenames.Tag(message.Tag),
// metadata // metadata
Metadata = message.Metadata, Metadata = message.Metadata,
@@ -5,6 +5,8 @@ public class LettermintOptions
public string ApiUrl { get; set; } = "https://api.lettermint.co"; public string ApiUrl { get; set; } = "https://api.lettermint.co";
public string Token { get; set; } public string Token { get; set; }
public string TeamToken { get; set; }
public string Route { get; set; } = "outgoing"; public string Route { get; set; } = "outgoing";
} }
@@ -13,4 +13,51 @@ public class LettermintResponse
[JsonPropertyName("message")] [JsonPropertyName("message")]
public string ErrorMessage { get; set; } 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
}
} }
+29
View File
@@ -0,0 +1,29 @@
namespace Mixtape.Mails;
public enum MailSuppressionReason
{
/// <summary>
/// Not suppressed
/// </summary>
None = 0,
/// <summary>
/// Suppressed after permanent delivery failure
/// </summary>
HardBounce = 1,
/// <summary>
/// Suppressed after a recipient reports spam
/// </summary>
SpamComplaint = 2,
/// <summary>
/// Manually suppressed via dashboard or API
/// </summary>
Manual = 3,
/// <summary>
/// Hosted unsubscribe and unsubscribe workflows
/// </summary>
Unsubscribe = 4,
/// <summary>
/// Unknown reason
/// </summary>
Other = 9
}
+48 -19
View File
@@ -3,19 +3,21 @@ using System.Text;
namespace Mixtape.Utils; namespace Mixtape.Utils;
public class Safenames public static class Safenames
{ {
public enum Scope public enum Scope
{ {
Url, Url,
File File,
Tag
} }
const char HYPHEN = '-'; const char Underscore = '_';
const char Hyphen = '-';
const char DOT = '.'; const char Dot = '.';
const char Plus = '+';
static char[] TICKS = ['`', '\'', '´']; const char Ampersand = '&';
static readonly char[] Ticks = ['`', '\'', '´'];
/// <summary> /// <summary>
@@ -43,6 +45,15 @@ public class Safenames
{ {
return Generate(value?.ToString(), Scope.Url); return Generate(value?.ToString(), Scope.Url);
} }
/// <summary>
/// Converts an untrusted to a safe tag ([a-z][0-9][-][_])
/// </summary>
public static string Tag(string value)
{
return Generate(value, Scope.Tag);
}
/// <summary> /// <summary>
@@ -55,13 +66,13 @@ public class Safenames
return string.Empty; return string.Empty;
} }
char previous = default; char previous = '\0';
StringBuilder output = new(); StringBuilder output = new();
for (int i = 0; i < value.Length; i++) foreach (char t in value)
{ {
// get character in lower case // get character in lower case
char character = char.ToLower(value[i]); char character = char.ToLower(t);
char target; char target;
// do not handle surrogates // do not handle surrogates
@@ -69,8 +80,9 @@ public class Safenames
{ {
continue; continue;
} }
// do not handle ticks // do not handle ticks
else if (TICKS.Contains(character)) if (Ticks.Contains(character))
{ {
continue; continue;
} }
@@ -90,27 +102,44 @@ public class Safenames
{ {
target = character; target = character;
} }
// - sign for + and & // + sign for + and &
else if (scope == Scope.File && character == DOT) 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 // add hyphen for all other characters
else else
{ {
target = HYPHEN; target = Hyphen;
} }
// add default characters // add default characters
if (target != HYPHEN) if (target != Hyphen && target != Plus)
{ {
output.Append(target); output.Append(target);
} }
// add hyphen if it isn't first and previous char is not + or - // 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); 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) if (output.Length > 0)
{ {
@@ -125,7 +154,7 @@ public class Safenames
if (output.Length == 0) if (output.Length == 0)
{ {
output.Append(HYPHEN); output.Append(Hyphen);
} }
return output.ToString(); return output.ToString();