From 42b57328088e24ab119dd876a1f246e1e6821b28 Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Thu, 5 Mar 2026 15:06:28 +0100 Subject: [PATCH] add postmark as default mail dispatcher --- zero/Mails/Mail.cs | 25 +++++ zero/Mails/MailOptions.cs | 13 ++- zero/Mails/PostmarkDispatcher.cs | 162 +++++++++++++++++++++++++++++++ zero/Mails/PostmarkOptions.cs | 10 ++ zero/Mails/ZeroMailModule.cs | 3 +- zero/zero.csproj | 1 + 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 zero/Mails/PostmarkDispatcher.cs create mode 100644 zero/Mails/PostmarkOptions.cs diff --git a/zero/Mails/Mail.cs b/zero/Mails/Mail.cs index 08515087..6a8f69ff 100644 --- a/zero/Mails/Mail.cs +++ b/zero/Mails/Mail.cs @@ -1,4 +1,5 @@ using System.Net.Mail; +using System.Text; namespace zero.Mails; @@ -26,6 +27,30 @@ public class Mail : MailMessage public MailPlaceholders Placeholders { get; set; } = new(); public MailMetadata Metadata { get; set; } = new(); + + /// + /// Set To and From addresses based on values in MailOptions + /// + public Mail For(MailOptions options) + { + To.Add(new MailAddress(options.To, options.ToName)); + From = new MailAddress(options.From, options.FromName); + return this; + } + + /// + /// Sets the body as plain text + /// + public string PlainText + { + get => Body; + set + { + Body = value; + IsBodyHtml = false; + BodyEncoding = Encoding.UTF8; + } + } } public class MailMetadata : Dictionary diff --git a/zero/Mails/MailOptions.cs b/zero/Mails/MailOptions.cs index fc54f253..6b1dea44 100644 --- a/zero/Mails/MailOptions.cs +++ b/zero/Mails/MailOptions.cs @@ -10,12 +10,23 @@ public class MailOptions //public string Password { get; set; } - //public string To { get; set; } + public string From { get; set; } + + public string FromName { get; set; } + + public string To { get; set; } + + public string ToName { get; set; } + + public string ReplyTo { get; set; } + + public bool Debug { get; set; } = true; public string SenderEmail { get; set; } public string SenderName { get; set; } + public PostmarkOptions Postmark { get; set; } = new(); public Func BuildViewPath { get; set; } } \ No newline at end of file diff --git a/zero/Mails/PostmarkDispatcher.cs b/zero/Mails/PostmarkDispatcher.cs new file mode 100644 index 00000000..3b8c6e82 --- /dev/null +++ b/zero/Mails/PostmarkDispatcher.cs @@ -0,0 +1,162 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using PostmarkDotNet; +using PostmarkDotNet.Model; + +namespace zero.Mails; + +public class PostmarkDispatcher : IMailDispatcher +{ + protected Queue Queue { get; private set; } = 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.IsDevelopment())) + { + 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/zero/Mails/PostmarkOptions.cs b/zero/Mails/PostmarkOptions.cs new file mode 100644 index 00000000..3b6e6f1d --- /dev/null +++ b/zero/Mails/PostmarkOptions.cs @@ -0,0 +1,10 @@ +namespace zero.Mails; + +public class PostmarkOptions +{ + public string ServerToken { get; set; } + + public string AccountToken { get; set; } + + public string MessageStream { get; set; } = "outbound"; +} \ No newline at end of file diff --git a/zero/Mails/ZeroMailModule.cs b/zero/Mails/ZeroMailModule.cs index af6a7ccd..fe5e4140 100644 --- a/zero/Mails/ZeroMailModule.cs +++ b/zero/Mails/ZeroMailModule.cs @@ -8,7 +8,8 @@ internal class ZeroMailModule : ZeroModule public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); - services.AddScoped(); + //services.AddScoped(); + services.AddScoped(); services.AddOptions().Bind(configuration.GetSection("Zero:Mails")).Configure(opts => { diff --git a/zero/zero.csproj b/zero/zero.csproj index f5253c99..b6d14167 100644 --- a/zero/zero.csproj +++ b/zero/zero.csproj @@ -16,6 +16,7 @@ +