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
.idea/
*.sln.iml
.junie
# CodeRush
.cr/
@@ -11,4 +11,9 @@ public interface IMailDispatcher : IDisposable
/// Whether a certain sender signature is supported by this dispatcher
/// </summary>
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.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<Mail> 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;
@@ -48,6 +51,58 @@ public class LettermintDispatcher : IMailDispatcher
}
/// <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 />
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,
@@ -6,5 +6,7 @@ public class LettermintOptions
public string Token { get; set; }
public string TeamToken { get; set; }
public string Route { get; set; } = "outgoing";
}
@@ -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
}
}
+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
}
+47 -18
View File
@@ -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 = ['`', '\'', '´'];
/// <summary>
@@ -45,6 +47,15 @@ public class Safenames
}
/// <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;
}
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();