add lettermint dispatcher
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
@@ -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 />
|
/// <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,
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ public class LettermintOptions
|
|||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
///
|
///
|
||||||
/// </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();
|
||||||
|
|||||||
Reference in New Issue
Block a user