diff --git a/Finch/Mails/FinchMailModule.cs b/Finch/Mails/FinchMailModule.cs index 00b660c4..b83ced93 100644 --- a/Finch/Mails/FinchMailModule.cs +++ b/Finch/Mails/FinchMailModule.cs @@ -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().RemoveAllLoggers(); services.AddScoped(); - //services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("Finch:Mails")).Configure(opts => { diff --git a/Finch/Mails/IMailDispatcher.cs b/Finch/Mails/IMailDispatcher.cs index 98a888b1..6dd5e4b5 100644 --- a/Finch/Mails/IMailDispatcher.cs +++ b/Finch/Mails/IMailDispatcher.cs @@ -3,17 +3,22 @@ public interface IMailDispatcher : IDisposable { /// - /// Adds a new mail message to the outgoing queue + /// If multiple dispatchers are available, the dispatcher with the highest priority will be used /// - void Enqueue(Mail message); + int Priority { get; } /// - /// Sends all mails which have been added to the queue previously + /// Whether this dispatcher is properly configured and can send mails /// - Task Send(CancellationToken token = default); + bool CanSend(); + + /// + /// Sends a mail message + /// + Task Send(Mail message, CancellationToken token = default); /// /// Whether a certain sender signature is supported by this dispatcher /// - Task IsSenderSupported(string email) => Task.FromResult(true); + Task IsSenderSupported(string email, CancellationToken token = default) => Task.FromResult(true); } \ No newline at end of file diff --git a/Finch/Mails/LoggerMailDispatcher.cs b/Finch/Mails/LoggerMailDispatcher.cs index ab1ea024..53239fe4 100644 --- a/Finch/Mails/LoggerMailDispatcher.cs +++ b/Finch/Mails/LoggerMailDispatcher.cs @@ -7,35 +7,19 @@ namespace Finch.Mails; /// and therefore not using the SMTP channel. /// Implementing real mail sending is up to the consuming application. /// -public class LoggerMailDispatcher : IMailDispatcher +public class LoggerMailDispatcher(ILogger logger) : IMailDispatcher { - protected Queue Queue { get; } = new Queue(); + /// + public int Priority { get; } = -10; - protected ILogger Logger { get; set; } - - - public LoggerMailDispatcher(ILogger logger) - { - Logger = logger; - } + /// + public bool CanSend() => true; /// - public void Enqueue(Mail message) + public Task Send(Mail message, CancellationToken token = default) { - Queue.Enqueue(message); - } - - - /// - 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; } diff --git a/Finch/Mails/MailDispatcherResolver.cs b/Finch/Mails/MailDispatcherResolver.cs new file mode 100644 index 00000000..aa62b3bd --- /dev/null +++ b/Finch/Mails/MailDispatcherResolver.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Options; + +namespace Finch.Mails; + +public class MailDispatcherResolver(IEnumerable dispatchers, IOptionsMonitor options) +{ + /// + public IMailDispatcher Resolve() + { + return dispatchers + .OrderByDescending(x => x.Priority) + .FirstOrDefault(dispatcher => dispatcher.CanSend()); + } +} \ No newline at end of file diff --git a/Finch/Mails/MailOptions.cs b/Finch/Mails/MailOptions.cs index bd7d7984..468c4841 100644 --- a/Finch/Mails/MailOptions.cs +++ b/Finch/Mails/MailOptions.cs @@ -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 BuildViewPath { get; set; } } \ No newline at end of file diff --git a/Finch/Mails/MailProvider.cs b/Finch/Mails/MailProvider.cs index aed01a25..01e75e05 100644 --- a/Finch/Mails/MailProvider.cs +++ b/Finch/Mails/MailProvider.cs @@ -6,35 +6,25 @@ using Microsoft.Extensions.Options; namespace Finch.Mails; -public class MailProvider : IMailProvider +public class MailProvider(IFinchContext finch, IOptionsMonitor mailOptions, ILogger logger, IEnumerable dispatchers, IRazorRenderer renderer) : IMailProvider { - protected ILogger Logger { get; set; } + protected ILogger Logger { get; set; } = logger; - protected IFinchContext Finch { get; set; } + protected IFinchContext Finch { get; set; } = finch; - protected IMailDispatcher MailSender { get; set; } + protected IEnumerable 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, ILogger logger, IMailDispatcher mailSender, IRazorRenderer renderer) - { - Finch = finch; - Logger = logger; - MailSender = mailSender; - Renderer = renderer; - Options = mailOptions.CurrentValue; - } - - /// 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; } + + + /// + protected IMailDispatcher GetDispatcher() + { + return Dispatchers + .OrderByDescending(x => x.Priority) + .FirstOrDefault(dispatcher => dispatcher.CanSend()); + } } diff --git a/Finch/Mails/Postmark/PostmarkDispatcher.cs b/Finch/Mails/Postmark/PostmarkDispatcher.cs new file mode 100644 index 00000000..e30d2ff6 --- /dev/null +++ b/Finch/Mails/Postmark/PostmarkDispatcher.cs @@ -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 +{ + /// + 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 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); + }); + } + + + /// + public bool CanSend() + { + return Options.Postmark != null && !Options.Postmark.ServerToken.IsNullOrEmpty(); + } + + + /// + public async Task 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; + } + } + + + /// + 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}"); + } + } + + + /// + public void Dispose() { } +} + + +public class PostmarkSendException : Exception +{ + public PostmarkSendException() : base() { } + + public PostmarkSendException(string message) : base(message) { } +} diff --git a/Finch/Mails/PostmarkOptions.cs b/Finch/Mails/Postmark/PostmarkOptions.cs similarity index 100% rename from Finch/Mails/PostmarkOptions.cs rename to Finch/Mails/Postmark/PostmarkOptions.cs diff --git a/Finch/Mails/PostmarkDispatcher.cs b/Finch/Mails/PostmarkDispatcher.cs deleted file mode 100644 index 2ed70470..00000000 --- a/Finch/Mails/PostmarkDispatcher.cs +++ /dev/null @@ -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 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 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); - }); - } - - - /// - public async Task 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; - } - } - - - /// - public void Enqueue(Mail message) - { - Queue.Enqueue(message); - } - - - /// - public async Task Send(CancellationToken token = default) - { - HashSet 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 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}"); - } - } - } - - - /// - public void Dispose() { } -} - - -public class PostmarkSendException : Exception -{ - public PostmarkSendException() : base() { } - - public PostmarkSendException(string message) : base(message) { } -} diff --git a/Finch/Mails/Scaleway/ScalewayDispatcher.cs b/Finch/Mails/Scaleway/ScalewayDispatcher.cs new file mode 100644 index 00000000..5d206695 --- /dev/null +++ b/Finch/Mails/Scaleway/ScalewayDispatcher.cs @@ -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 +{ + /// + public int Priority { get; } = 3; + + protected Queue Queue { get; } = new(); + + protected MailOptions Options { get; set; } + + protected IWebHostEnvironment Env { get; set; } + + protected HttpClient Http { get; } + + protected JsonSerializerOptions JsonSerializerOptions { get; } + + protected ILogger Logger { get; } + + + public ScalewayDispatcher(IOptionsMonitor monitor, IWebHostEnvironment env, HttpClient http, ILogger 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); + } + + + /// + public bool CanSend() + { + return Options.Scaleway != null && !Options.Scaleway.ProjectId.IsNullOrEmpty() && !Options.Scaleway.SecretKey.IsNullOrEmpty(); + } + + + /// + public async Task 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(uri, JsonSerializerOptions, token); + return response.Domains.Any(x => x.Name.Equals(domain, StringComparison.InvariantCultureIgnoreCase)); + } + catch + { + return true; + } + } + + + /// + 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 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(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"); + } + } + + + /// + public void Dispose() { } + + + /// + /// Convert a collection of addresses to a scaleway email addresses + /// + protected List Convert(MailAddressCollection addresses) + { + return addresses + .Select(address => new ScalewayRequest.EmailAddress() + { + Email = address.Address, + Name = address.DisplayName + }) + .ToList(); + } + + + /// + /// Convert an attachment to a scaleway email attachment + /// + 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 + }; + } +} diff --git a/Finch/Mails/Scaleway/ScalewayOptions.cs b/Finch/Mails/Scaleway/ScalewayOptions.cs new file mode 100644 index 00000000..3c435fde --- /dev/null +++ b/Finch/Mails/Scaleway/ScalewayOptions.cs @@ -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"; +} \ No newline at end of file diff --git a/Finch/Mails/Scaleway/ScalewayRequest.cs b/Finch/Mails/Scaleway/ScalewayRequest.cs new file mode 100644 index 00000000..f7d3d90e --- /dev/null +++ b/Finch/Mails/Scaleway/ScalewayRequest.cs @@ -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 To { get; set; } = []; + + public List Cc { get; set; } = []; + + public List 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 Attachments { get; set; } = []; + + [JsonPropertyName("additional_headers")] + public List AdditionalHeaders { get; set; } = []; + } + + + public class EmailAddress + { + public string Email { get; set; } + + public string Name { get; set; } + } + + public class EmailAttachment + { + /// + /// Filename of the attachment. + /// + public string Name { get; set; } + + /// + /// MIME type of the attachment. + /// + public string Type { get; set; } + + /// + /// Content of the attachment encoded in base64. + /// + public string Content { get; set; } + } + + public class EmailHeader + { + public string Key { get; set; } + + public string Value { get; set; } + } +} \ No newline at end of file diff --git a/Finch/Mails/Scaleway/ScalewayResponse.cs b/Finch/Mails/Scaleway/ScalewayResponse.cs new file mode 100644 index 00000000..ad7d00f3 --- /dev/null +++ b/Finch/Mails/Scaleway/ScalewayResponse.cs @@ -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; } + } +} \ No newline at end of file