add scaleway email dispatcher; allow multiple dispatchers with priority handling

This commit is contained in:
2026-04-28 15:23:22 +02:00
parent e3eeaf3056
commit 332bf021d4
13 changed files with 538 additions and 212 deletions
+5 -2
View File
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration;
using Finch.Mails.Scaleway;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Finch.Mails;
@@ -7,9 +8,11 @@ internal class FinchMailModule : FinchModule
{
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<ScalewayDispatcher>().RemoveAllLoggers();
services.AddScoped<IMailProvider, MailProvider>();
//services.AddScoped<IMailDispatcher, LoggerMailDispatcher>();
services.AddScoped<IMailDispatcher, LoggerMailDispatcher>();
services.AddScoped<IMailDispatcher, PostmarkDispatcher>();
services.AddScoped<IMailDispatcher, ScalewayDispatcher>();
services.AddOptions<MailOptions>().Bind(configuration.GetSection("Finch:Mails")).Configure(opts =>
{
+10 -5
View File
@@ -3,17 +3,22 @@
public interface IMailDispatcher : IDisposable
{
/// <summary>
/// Adds a new mail message to the outgoing queue
/// If multiple dispatchers are available, the dispatcher with the highest priority will be used
/// </summary>
void Enqueue(Mail message);
int Priority { get; }
/// <summary>
/// Sends all mails which have been added to the queue previously
/// Whether this dispatcher is properly configured and can send mails
/// </summary>
Task Send(CancellationToken token = default);
bool CanSend();
/// <summary>
/// Sends a mail message
/// </summary>
Task Send(Mail message, CancellationToken token = default);
/// <summary>
/// Whether a certain sender signature is supported by this dispatcher
/// </summary>
Task<bool> IsSenderSupported(string email) => Task.FromResult(true);
Task<bool> IsSenderSupported(string email, CancellationToken token = default) => Task.FromResult(true);
}
+7 -23
View File
@@ -7,35 +7,19 @@ namespace Finch.Mails;
/// and therefore not using the SMTP channel.
/// Implementing real mail sending is up to the consuming application.
/// </summary>
public class LoggerMailDispatcher : IMailDispatcher
public class LoggerMailDispatcher(ILogger<LoggerMailDispatcher> logger) : IMailDispatcher
{
protected Queue<Mail> Queue { get; } = new Queue<Mail>();
/// <inheritdoc />
public int Priority { get; } = -10;
protected ILogger<LoggerMailDispatcher> Logger { get; set; }
public LoggerMailDispatcher(ILogger<LoggerMailDispatcher> logger)
{
Logger = logger;
}
/// <inheritdoc />
public bool CanSend() => true;
/// <inheritdoc />
public void Enqueue(Mail message)
public Task Send(Mail message, CancellationToken token = default)
{
Queue.Enqueue(message);
}
/// <inheritdoc />
public Task Send(CancellationToken token = default)
{
while (Queue.Count > 0)
{
Mail message = Queue.Dequeue();
Logger.LogInformation("Mail to {to}. Subject: {subject}", message.To[0].Address, message.Subject);
}
logger.LogInformation("Mail to {to}. Subject: {subject}", message.To[0].Address, message.Subject);
return Task.CompletedTask;
}
+14
View File
@@ -0,0 +1,14 @@
using Microsoft.Extensions.Options;
namespace Finch.Mails;
public class MailDispatcherResolver(IEnumerable<IMailDispatcher> dispatchers, IOptionsMonitor<MailOptions> options)
{
/// <inheritdoc />
public IMailDispatcher Resolve()
{
return dispatchers
.OrderByDescending(x => x.Priority)
.FirstOrDefault(dispatcher => dispatcher.CanSend());
}
}
+3 -1
View File
@@ -26,7 +26,9 @@ public class MailOptions
public string SenderName { get; set; }
public PostmarkOptions Postmark { get; set; } = new();
public PostmarkOptions Postmark { get; set; }
public Scaleway.ScalewayOptions Scaleway { get; set; }
public Func<Mail, string> BuildViewPath { get; set; }
}
+17 -19
View File
@@ -6,35 +6,25 @@ using Microsoft.Extensions.Options;
namespace Finch.Mails;
public class MailProvider : IMailProvider
public class MailProvider(IFinchContext finch, IOptionsMonitor<MailOptions> mailOptions, ILogger<IMailProvider> logger, IEnumerable<IMailDispatcher> dispatchers, IRazorRenderer renderer) : IMailProvider
{
protected ILogger<IMailProvider> Logger { get; set; }
protected ILogger<IMailProvider> Logger { get; set; } = logger;
protected IFinchContext Finch { get; set; }
protected IFinchContext Finch { get; set; } = finch;
protected IMailDispatcher MailSender { get; set; }
protected IEnumerable<IMailDispatcher> Dispatchers { get; set; } = dispatchers;
protected IRazorRenderer Renderer { get; set; }
protected IRazorRenderer Renderer { get; set; } = renderer;
protected MailOptions Options { get; set; }
protected MailOptions Options { get; set; } = mailOptions.CurrentValue;
private readonly Encoding _encoding = Encoding.UTF8;
public MailProvider(IFinchContext finch, IOptionsMonitor<MailOptions> mailOptions, ILogger<IMailProvider> logger, IMailDispatcher mailSender, IRazorRenderer renderer)
{
Finch = finch;
Logger = logger;
MailSender = mailSender;
Renderer = renderer;
Options = mailOptions.CurrentValue;
}
/// <inheritdoc />
public virtual async Task Send(Mail message, CancellationToken token = default)
{
await Send(message, MailSender, token);
await Send(message, GetDispatcher(), token);
}
@@ -49,8 +39,7 @@ public class MailProvider : IMailProvider
try
{
await Prepare(message);
dispatcher.Enqueue(message);
await dispatcher.Send(token);
await dispatcher.Send(message, token);
Logger.LogInformation("Dispatched email to {recipient} (cc: {cc}, bcc: {bcc})", message.To, message.CC, message.Bcc);
}
@@ -99,6 +88,15 @@ public class MailProvider : IMailProvider
message.IsRendered = true;
return message.Body;
}
/// <inheritdoc />
protected IMailDispatcher GetDispatcher()
{
return Dispatchers
.OrderByDescending(x => x.Priority)
.FirstOrDefault(dispatcher => dispatcher.CanSend());
}
}
+152
View File
@@ -0,0 +1,152 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using PostmarkDotNet;
using PostmarkDotNet.Model;
namespace Finch.Mails;
public class PostmarkDispatcher : IMailDispatcher
{
/// <inheritdoc />
public int Priority { get; } = 3;
protected PostmarkClient Postmark { get; set; }
protected PostmarkAdminClient PostmarkAdmin { get; set; }
protected MailOptions Options { get; set; }
protected IWebHostEnvironment Env { get; set; }
public PostmarkDispatcher(IOptionsMonitor<MailOptions> monitor, IWebHostEnvironment env)
{
Options = monitor.CurrentValue;
PostmarkOptions opts = Options.Postmark ?? new();
Postmark = new(opts.ServerToken);
PostmarkAdmin = new(opts.AccountToken);
Env = env;
monitor.OnChange(_opts =>
{
Options = _opts;
Postmark = new(_opts.Postmark?.ServerToken);
});
}
/// <inheritdoc />
public bool CanSend()
{
return Options.Postmark != null && !Options.Postmark.ServerToken.IsNullOrEmpty();
}
/// <inheritdoc />
public async Task<bool> IsSenderSupported(string email)
{
if (email.IsNullOrWhiteSpace() || Options.Postmark.AccountToken.IsNullOrEmpty())
{
return true;
}
try
{
email = email.FullTrim();
PostmarkSenderSignatureList signatures = await PostmarkAdmin.GetSenderSignaturesAsync();
return signatures.SenderSignatures.Any(x => x.Confirmed && x.EmailAddress.Equals(email, StringComparison.InvariantCultureIgnoreCase));
}
catch
{
return true;
}
}
/// <inheritdoc />
public async Task Send(Mail message, CancellationToken token = default)
{
PostmarkMessage data = new()
{
// to addresses
To = message.To.ToString(),
Cc = message.CC.ToString(),
Bcc = message.Bcc.ToString(),
// from address
From = message.From.ToString(),
ReplyTo = message.ReplyToList.ToString(),
// subject
Subject = message.Subject,
// tracking
TrackLinks = LinkTrackingOptions.None,
TrackOpens = false,
// configuration
MessageStream = Options.Postmark.MessageStream,
//Tag = message.Template?.Key,
Metadata = message.Metadata
};
// set attachments
foreach (System.Net.Mail.Attachment attachment in message.Attachments)
{
data.AddAttachment(attachment.ContentStream, attachment.Name, attachment.ContentType.MediaType, attachment.ContentId);
}
// set body
if (!message.IsBodyHtml)
{
data.TextBody = message.Body;
}
else
{
data.HtmlBody = message.Body;
}
// overwrite for debug mode
if (Options.Debug || (Env != null && !Env.IsProduction()))
{
data.From = "noreply@post.swcs.pro";
string[] allowedTlds = ["swcs.pro", "alias.swcs.pro"];
string tld = data.To.TrimEnd('>').Split('@').LastOrDefault();
data.Cc = null; // "cee-maildev@gmx.at,anaheimcore@gmail.com,ceemaildev@yahoo.com";
data.Bcc = null;
if (!allowedTlds.Contains(tld, StringComparer.InvariantCultureIgnoreCase))
{
data.Subject = $"{data.Subject} (test; für {data.To})";
data.To = "maildev@alias.swcs.pro";
}
else
{
data.Subject = $"{data.Subject} (test)";
}
}
// finally sends the message
PostmarkResponse response = await Postmark.SendMessageAsync(data);
if (response.ErrorCode > 0)
{
throw new PostmarkSendException($"Could not send message via Postmark API. Code: {response.ErrorCode}, Message: {response.Message}");
}
}
/// <inheritdoc />
public void Dispose() { }
}
public class PostmarkSendException : Exception
{
public PostmarkSendException() : base() { }
public PostmarkSendException(string message) : base(message) { }
}
-162
View File
@@ -1,162 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using PostmarkDotNet;
using PostmarkDotNet.Model;
namespace Finch.Mails;
public class PostmarkDispatcher : IMailDispatcher
{
protected Queue<Mail> Queue { get; } = new();
protected PostmarkClient Postmark { get; set; }
protected PostmarkAdminClient PostmarkAdmin { get; set; }
protected MailOptions Options { get; set; }
protected IWebHostEnvironment Env { get; set; }
public PostmarkDispatcher(IOptionsMonitor<MailOptions> monitor, IWebHostEnvironment env)
{
Options = monitor.CurrentValue;
Postmark = new(Options.Postmark.ServerToken);
PostmarkAdmin = new(Options.Postmark.AccountToken);
Env = env;
monitor.OnChange(opts =>
{
Options = opts;
Postmark = new(Options.Postmark.ServerToken);
});
}
/// <inheritdoc />
public async Task<bool> IsSenderSupported(string email)
{
if (email.IsNullOrWhiteSpace() || Options.Postmark.AccountToken.IsNullOrEmpty())
{
return true;
}
try
{
email = email.FullTrim();
PostmarkSenderSignatureList signatures = await PostmarkAdmin.GetSenderSignaturesAsync();
return signatures.SenderSignatures.Any(x => x.Confirmed && x.EmailAddress.Equals(email, StringComparison.InvariantCultureIgnoreCase));
}
catch
{
return true;
}
}
/// <inheritdoc />
public void Enqueue(Mail message)
{
Queue.Enqueue(message);
}
/// <inheritdoc />
public async Task Send(CancellationToken token = default)
{
HashSet<PostmarkMessage> messages = [];
while (Queue.Count > 0)
{
Mail message = Queue.Dequeue();
PostmarkMessage data = new()
{
// to addresses
To = message.To.ToString(),
Cc = message.CC.ToString(),
Bcc = message.Bcc.ToString(),
// from address
From = message.From.ToString(),
ReplyTo = message.ReplyToList.ToString(),
// subject
Subject = message.Subject,
// tracking
TrackLinks = LinkTrackingOptions.None,
TrackOpens = false,
// configuration
MessageStream = Options.Postmark.MessageStream,
//Tag = message.Template?.Key,
Metadata = message.Metadata
};
// set attachments
foreach (System.Net.Mail.Attachment attachment in message.Attachments)
{
data.AddAttachment(attachment.ContentStream, attachment.Name, attachment.ContentType.MediaType, attachment.ContentId);
}
// set body
if (!message.IsBodyHtml)
{
data.TextBody = message.Body;
}
else
{
data.HtmlBody = message.Body;
}
// overwrite for debug mode
if (Options.Debug || (Env != null && !Env.IsProduction()))
{
data.From = "noreply@post.swcs.pro";
string[] allowedTlds = ["swcs.pro", "alias.swcs.pro"];
string tld = data.To.TrimEnd('>').Split('@').LastOrDefault();
data.Cc = null; // "cee-maildev@gmx.at,anaheimcore@gmail.com,ceemaildev@yahoo.com";
data.Bcc = null;
if (!allowedTlds.Contains(tld, StringComparer.InvariantCultureIgnoreCase))
{
data.Subject = $"{data.Subject} (test; für {data.To})";
data.To = "maildev@alias.swcs.pro";
}
else
{
data.Subject = $"{data.Subject} (test)";
}
}
messages.Add(data);
}
// finally sends the message
IEnumerable<PostmarkResponse> responses = await Postmark.SendMessagesAsync(messages);
foreach (PostmarkResponse response in responses)
{
if (response.ErrorCode > 0)
{
throw new PostmarkSendException($"Could not send message via Postmark API. Code: {response.ErrorCode}, Message: {response.Message}");
}
}
}
/// <inheritdoc />
public void Dispose() { }
}
public class PostmarkSendException : Exception
{
public PostmarkSendException() : base() { }
public PostmarkSendException(string message) : base(message) { }
}
+209
View File
@@ -0,0 +1,209 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Mail;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Attachment = System.Net.Mail.Attachment;
namespace Finch.Mails.Scaleway;
public class ScalewayDispatcher : IMailDispatcher
{
/// <inheritdoc />
public int Priority { get; } = 3;
protected Queue<Mail> Queue { get; } = new();
protected MailOptions Options { get; set; }
protected IWebHostEnvironment Env { get; set; }
protected HttpClient Http { get; }
protected JsonSerializerOptions JsonSerializerOptions { get; }
protected ILogger<ScalewayDispatcher> Logger { get; }
public ScalewayDispatcher(IOptionsMonitor<MailOptions> monitor, IWebHostEnvironment env, HttpClient http, ILogger<ScalewayDispatcher> logger)
{
Options = monitor.CurrentValue;
Env = env;
Http = http;
Http.DefaultRequestHeaders.Add("X-Auth-Token", Options.Scaleway.SecretKey);
JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
Logger = logger;
monitor.OnChange(opts => Options = opts);
}
/// <inheritdoc />
public bool CanSend()
{
return Options.Scaleway != null && !Options.Scaleway.ProjectId.IsNullOrEmpty() && !Options.Scaleway.SecretKey.IsNullOrEmpty();
}
/// <inheritdoc />
public async Task<bool> IsSenderSupported(string email, CancellationToken token = default)
{
if (email.IsNullOrWhiteSpace())
{
return true;
}
try
{
string domain = email.FullTrim().Split('@').LastOrDefault();
string uri = Options.Scaleway.ApiUrl + $"/transactional-email/v1alpha1/regions/{Options.Scaleway.Region}/domains?project_id={Options.Scaleway.ProjectId}&status=checked";
ScalewayResponse.ListDomains response = await Http.GetFromJsonAsync<ScalewayResponse.ListDomains>(uri, JsonSerializerOptions, token);
return response.Domains.Any(x => x.Name.Equals(domain, StringComparison.InvariantCultureIgnoreCase));
}
catch
{
return true;
}
}
/// <inheritdoc />
public async Task Send(Mail message, CancellationToken token = default)
{
string uri = Options.Scaleway.ApiUrl + $"/transactional-email/v1alpha1/regions/{Options.Scaleway.Region}/emails";
ScalewayRequest.SendEmail data = new()
{
// to addresses
To = Convert(message.To),
Cc = Convert(message.CC),
Bcc = Convert(message.Bcc),
// from address
From = new ScalewayRequest.EmailAddress()
{
Email = message.From!.Address,
Name = message.From.DisplayName
},
// subject
Subject = message.Subject
};
// add reply-to header
if (message.ReplyToList.Any())
{
data.AdditionalHeaders.Add(new()
{
Key = "Reply-To",
Value = message.ReplyToList.ToString()
});
}
// add additional headers
if (message.Metadata.Any())
{
foreach (KeyValuePair<string, string> item in message.Metadata)
{
data.AdditionalHeaders.Add(new()
{
Key = item.Key,
Value = item.Value
});
}
}
// set attachments
foreach (Attachment attachment in message.Attachments)
{
data.Attachments.Add(Convert(attachment));
}
// set body
if (!message.IsBodyHtml)
{
data.Text = message.Body;
}
else
{
data.Html = message.Body;
}
// overwrite for debug mode
if (Options.Debug || (Env != null && !Env.IsProduction()))
{
data.From = new ScalewayRequest.EmailAddress()
{
Email = "noreply@post.swcs.pro"
};
data.Cc = null; // "cee-maildev@gmx.at,anaheimcore@gmail.com,ceemaildev@yahoo.com";
data.Bcc = null;
data.To = [new ScalewayRequest.EmailAddress()
{
Email = "maildev@alias.swcs.pro"
}];
data.Subject = $"{data.Subject} (test)";
}
try
{
using HttpResponseMessage responseMessage = await Http.PostAsJsonAsync(uri, data, JsonSerializerOptions, token);
ScalewayResponse.Email response = await responseMessage.Content.ReadFromJsonAsync<ScalewayResponse.Email>(JsonSerializerOptions, token);
Logger.LogDebug("Email {id} sent via Scaleway API with status {status}", response.Id, response.Status);
}
catch (Exception ex)
{
Logger.LogError(ex, "Could not send message via Scaleway API");
}
}
/// <inheritdoc />
public void Dispose() { }
/// <summary>
/// Convert a collection of addresses to a scaleway email addresses
/// </summary>
protected List<ScalewayRequest.EmailAddress> Convert(MailAddressCollection addresses)
{
return addresses
.Select(address => new ScalewayRequest.EmailAddress()
{
Email = address.Address,
Name = address.DisplayName
})
.ToList();
}
/// <summary>
/// Convert an attachment to a scaleway email attachment
/// </summary>
protected ScalewayRequest.EmailAttachment Convert(Attachment attachment)
{
byte[] buffer = new byte[8067];
using MemoryStream memoryStream = new();
int count;
while ((count = attachment.ContentStream.Read(buffer, 0, buffer.Length)) > 0)
{
memoryStream.Write(buffer, 0, count);
}
string base64String = System.Convert.ToBase64String(memoryStream.ToArray());
return new()
{
Name = attachment.Name,
Type = attachment.ContentType.MediaType,
Content = base64String
};
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Finch.Mails.Scaleway;
public class ScalewayOptions
{
public string ApiUrl { get; set; } = "https://api.scaleway.com";
public string ProjectId { get; set; }
public string SecretKey { get; set; }
public string Region { get; set; } = "fr-par";
}
+64
View File
@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace Finch.Mails.Scaleway;
public class ScalewayRequest
{
public class SendEmail
{
public EmailAddress From { get; set; }
public List<EmailAddress> To { get; set; } = [];
public List<EmailAddress> Cc { get; set; } = [];
public List<EmailAddress> Bcc { get; set; } = [];
public string Subject { get; set; }
public string Text { get; set; }
public string Html { get; set; }
[JsonPropertyName("project_id")]
public string ProjectId { get; set; }
public List<EmailAttachment> Attachments { get; set; } = [];
[JsonPropertyName("additional_headers")]
public List<EmailHeader> AdditionalHeaders { get; set; } = [];
}
public class EmailAddress
{
public string Email { get; set; }
public string Name { get; set; }
}
public class EmailAttachment
{
/// <summary>
/// Filename of the attachment.
/// </summary>
public string Name { get; set; }
/// <summary>
/// MIME type of the attachment.
/// </summary>
public string Type { get; set; }
/// <summary>
/// Content of the attachment encoded in base64.
/// </summary>
public string Content { get; set; }
}
public class EmailHeader
{
public string Key { get; set; }
public string Value { get; set; }
}
}
+45
View File
@@ -0,0 +1,45 @@
namespace Finch.Mails.Scaleway;
public class ScalewayResponse
{
public class ListDomains
{
public int TotalCount { get; set; }
public Domain[] Domains { get; set; } = [];
}
public class Domain
{
public string Name { get; set; }
}
public class Email
{
public string Id { get; set; }
public string MessageId { get; set; }
public string ProjectId { get; set; }
public string MailFrom { get; set; }
public string RcptTo { get; set; }
public string MailRcpt { get; set; }
public string RcptType { get; set; }
public string Subject { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string Status { get; set; }
public string StatusDetails { get; set; }
public int TryCount { get; set; }
}
}