diff --git a/Build.ps1 b/Build.ps1 index 5f4a1e6..8bf0f0d 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -10,7 +10,7 @@ if(Test-Path .\artifacts) { } & dotnet restore --no-cache -if($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" } +if($LASTEXITCODE -ne 0) { exit 1 } $branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$env:APPVEYOR_REPO_BRANCH -ne $NULL]; $revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL]; @@ -30,7 +30,7 @@ foreach ($src in ls src/*) { & dotnet publish -c Release -o ./obj/publish & dotnet pack -c Release -o ..\..\artifacts --no-build } - if($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" } + if($LASTEXITCODE -ne 0) { exit 1 } Pop-Location } @@ -41,9 +41,9 @@ foreach ($test in ls test/*.Tests) { echo "build: Testing project in $test" & dotnet test -c Release - if($LASTEXITCODE -ne 0) { throw "Build failed with exit code $LASTEXITCODE" } + if($LASTEXITCODE -ne 0) { exit 3 } Pop-Location } -Pop-Location +Pop-Location \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index 137223f..98c8907 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ version: '{build}' skip_tags: true -image: Visual Studio 2022 +image: Visual Studio 2019 install: build_script: - pwsh: ./Build.ps1 diff --git a/global.json b/bak/global.json similarity index 100% rename from global.json rename to bak/global.json diff --git a/src/Seq.App.EmailPlus/DeliveryType.cs b/src/Seq.App.EmailPlus/DeliveryType.cs new file mode 100644 index 0000000..ae985a5 --- /dev/null +++ b/src/Seq.App.EmailPlus/DeliveryType.cs @@ -0,0 +1,12 @@ +namespace Seq.App.EmailPlus +{ + public enum DeliveryType + { + MailHost, + MailFallback, + Dns, + DnsFallback, + HostDnsFallback, + None = -1 + } +} diff --git a/src/Seq.App.EmailPlus/DirectMailGateway.cs b/src/Seq.App.EmailPlus/DirectMailGateway.cs index 17a2c7f..2a629de 100644 --- a/src/Seq.App.EmailPlus/DirectMailGateway.cs +++ b/src/Seq.App.EmailPlus/DirectMailGateway.cs @@ -1,23 +1,203 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using DnsClient; +using DnsClient.Protocol; using MailKit.Net.Smtp; +using MailKit.Security; using MimeKit; namespace Seq.App.EmailPlus { class DirectMailGateway : IMailGateway { - public async Task SendAsync(SmtpOptions options, MimeMessage message) + static readonly SmtpClient Client = new SmtpClient(); + static readonly LookupClient DnsClient = new LookupClient(); + + public async Task SendAsync(SmtpOptions options, MimeMessage message) + { + if (message == null) throw new ArgumentNullException(nameof(message)); + var mailResult = new MailResult(); + var type = DeliveryType.MailHost; + var errors = new List(); + foreach (var server in options.Host) + { + mailResult = await TryDeliver(server, options, message, type); + if (!mailResult.Success) + { + errors.Add(mailResult.LastError); + type = DeliveryType.MailFallback; + } + else + { + break; + } + } + + mailResult.Errors = errors; + return mailResult; + } + + public async Task SendDnsAsync(DeliveryType deliveryType, SmtpOptions options, + MimeMessage message) { + var dnsResult = new DnsMailResult(); + var resultList = new List(); + var lastServer = string.Empty; + var errors = new List(); if (message == null) throw new ArgumentNullException(nameof(message)); + var type = deliveryType; + + try + { + var domains = GetDomains(message).ToList(); + var successCount = 0; + + foreach (var domain in domains) + { + type = deliveryType; + lastServer = domain; + var mx = await DnsClient.QueryAsync(domain, QueryType.MX); + var mxServers = + (from mxServer in mx.Answers + where !string.IsNullOrEmpty(((MxRecord)mxServer).Exchange) + select ((MxRecord)mxServer).Exchange).Select(dummy => (string)dummy).ToList(); + var mailResult = new MailResult(); + foreach (var server in mxServers) + { + lastServer = server; + mailResult = await TryDeliver(server, options, message, type); + var lastError = dnsResult.LastError; + errors.AddRange(mailResult.Errors); + + dnsResult = new DnsMailResult + { + LastServer = server, + LastError = mailResult.LastError ?? lastError, + Type = type, + }; + + resultList.Add(mailResult); + + if (mailResult.Success) + break; + type = DeliveryType.DnsFallback; + } + + if (mailResult.Success) + { + successCount++; + continue; + } + + if (dnsResult.LastError != null) continue; + dnsResult.LastError = mxServers.Count == 0 + ? new Exception("DNS delivery failed - no MX records detected for " + domain) + : new Exception("DNS delivery failed - no error detected"); + dnsResult.Success = false; + dnsResult.Errors.Add(dnsResult.LastError); + } + + if (!domains.Any()) + { + dnsResult.Success = false; + dnsResult.LastError = + new Exception("DNS delivery failed - no domains parsed from recipient addresses"); + } + + if (successCount < domains.Count) + { + if (successCount == 0) + { + dnsResult.Success = false; + dnsResult.LastError = + new Exception("DNS delivery failure - no domains could be successfully delivered."); + } + else + { + dnsResult.Success = true; // A qualified success ... + dnsResult.LastError = + new Exception( + $"DNS delivery partial failure - {domains.Count - successCount} of {successCount} domains could not be delivered."); + } + + dnsResult.Errors.Add(dnsResult.LastError); + } + else + { + dnsResult.Success = true; + } + } + catch (Exception ex) + { + dnsResult = new DnsMailResult + { + Type = type, + LastServer = lastServer, + Success = false, + LastError = ex + }; + } + + dnsResult.Errors = errors; + dnsResult.Results = resultList; + return dnsResult; + } + + + public static IEnumerable GetDomains(MimeMessage message) + { + var domains = new List(); + foreach (var to in message.To) + { + var toDomain = to.ToString().Split('@')[1]; + if (string.IsNullOrEmpty(toDomain)) continue; + if (!domains.Any(domain => domain.Equals(toDomain, StringComparison.OrdinalIgnoreCase))) + domains.Add(toDomain); + } + + foreach (var cc in message.Cc) + { + var ccDomain = cc.ToString().Split('@')[1]; + if (string.IsNullOrEmpty(ccDomain)) continue; + if (!domains.Any(domain => domain.Equals(ccDomain, StringComparison.OrdinalIgnoreCase))) + domains.Add(ccDomain); + } + + + foreach (var bcc in message.Bcc) + { + var bccDomain = bcc.ToString().Split('@')[1]; + if (string.IsNullOrEmpty(bccDomain)) continue; + if (!domains.Any(domain => domain.Equals(bccDomain, StringComparison.OrdinalIgnoreCase))) + domains.Add(bccDomain); + } + + return domains; + } + + private static async Task TryDeliver(string server, SmtpOptions options, MimeMessage message, + DeliveryType deliveryType) + { + if (string.IsNullOrEmpty(server)) + return new MailResult {Success = false, LastServer = server, Type = deliveryType}; + try + { + await Client.ConnectAsync(server, options.Port, (SecureSocketOptions)options.SocketOptions); + if (options.RequiresAuthentication) + await Client.AuthenticateAsync(options.Username, options.Password); + + + await Client.SendAsync(message); + await Client.DisconnectAsync(true); - var client = new SmtpClient(); - - await client.ConnectAsync(options.Host, options.Port, options.SocketOptions); - if (options.RequiresAuthentication) - await client.AuthenticateAsync(options.Username, options.Password); - await client.SendAsync(message); - await client.DisconnectAsync(true); + return new MailResult {Success = true, LastServer = server, Type = deliveryType}; + } + catch (Exception ex) + { + return new MailResult {Success = false, LastError = ex, LastServer = server, Type = deliveryType}; + } } } } \ No newline at end of file diff --git a/src/Seq.App.EmailPlus/DnsMailResult.cs b/src/Seq.App.EmailPlus/DnsMailResult.cs new file mode 100644 index 0000000..3fc296d --- /dev/null +++ b/src/Seq.App.EmailPlus/DnsMailResult.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace Seq.App.EmailPlus +{ + public class DnsMailResult + { + public bool Success { get; set; } + public DeliveryType Type { get; set; } + public string LastServer { get; set; } + public Exception LastError { get; set; } + public List Errors { get; set; } = new List(); + public List Results { get; set; } = new List(); + } +} diff --git a/src/Seq.App.EmailPlus/EmailApp.cs b/src/Seq.App.EmailPlus/EmailApp.cs index a1982c2..53875e4 100644 --- a/src/Seq.App.EmailPlus/EmailApp.cs +++ b/src/Seq.App.EmailPlus/EmailApp.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using HandlebarsDotNet; -using MailKit.Security; using MimeKit; using Seq.Apps; using Seq.Apps.LogEvents; @@ -16,13 +15,13 @@ namespace Seq.App.EmailPlus using Template = HandlebarsTemplate; [SeqApp("HTML Email", - Description = "Uses Handlebars templates to format events and notifications into HTML email.")] + Description = "Uses Handlebars templates to format events and notifications into HTML email, with optional alternate plain text body.")] public class EmailApp : SeqApp, ISubscribeToAsync { readonly IMailGateway _mailGateway; readonly IClock _clock; readonly Dictionary _suppressions = new Dictionary(); - Template _bodyTemplate, _subjectTemplate, _toAddressesTemplate; + Template _bodyTemplate, _plainTextTemplate, _subjectTemplate, _toAddressesTemplate, _replyToAddressesTemplate, _ccAddressesTemplate, _bccAddressesTemplate; SmtpOptions _options; const string DefaultSubjectTemplate = @"[{{$Level}}] {{{$Message}}} (via Seq)"; @@ -53,30 +52,63 @@ public EmailApp() [SeqAppSetting( DisplayName = "To address", - HelpText = "The account to which the email is being sent. Multiple addresses are separated by a comma. Handlebars syntax is supported.")] + HelpText = + "The account to which the email is being sent. Multiple addresses are separated by a comma. Handlebars syntax is supported.")] public string To { get; set; } + [SeqAppSetting( + DisplayName = "ReplyTo address", + HelpText = "Optional account to which replies will be sent. Multiple addresses are separated by a comma. Handlebars syntax is supported.", + IsOptional = true)] + public string ReplyTo { get; set; } + + [SeqAppSetting( + DisplayName = "CC address", + HelpText = "Optional account to which emails should be sent as CC. Multiple addresses are separated by a comma. Handlebars syntax is supported.", + IsOptional = true)] + public string Cc { get; set; } + + [SeqAppSetting( + DisplayName = "BCC address", + HelpText = "Optional account to which the email is being sent as BCC. Multiple addresses are separated by a comma. Handlebars syntax is supported.", + IsOptional = true)] + public string Bcc { get; set; } + [SeqAppSetting( IsOptional = true, DisplayName = "Subject template", - HelpText = "The subject of the email, using Handlebars syntax. If blank, a default subject will be generated.")] + HelpText = + "The subject of the email, using Handlebars syntax. If blank, a default subject will be generated.")] public string SubjectTemplate { get; set; } [SeqAppSetting( - HelpText = "The name of the SMTP server machine.")] + DisplayName = "SMTP Mail Host(s)", + HelpText = + "The name of the SMTP server machine. Optionally specify fallback hosts as comma-delimited string. If not specified, Deliver Using DNS should be enabled.", + IsOptional = true)] public new string Host { get; set; } + [SeqAppSetting( + DisplayName = "Deliver using DNS", + HelpText = + "Deliver directly using DNS. If SMTP Mail Host(s) is configured, this will be used as a fallback delivery mechanism. If not enabled, SMTP Mail Host(s) should be configured.")] + public bool? DeliverUsingDns { get; set; } + [SeqAppSetting( IsOptional = true, - HelpText = "The port on the SMTP server machine to send mail to. Leave this blank to use the standard port (25).")] + HelpText = + "The port on the SMTP server machine to send mail to. Leave this blank to use the standard port (25).")] public int? Port { get; set; } + //EnableSSL has been retired but is preserved for migration purposes + public bool? EnableSsl { get; set; } + [SeqAppSetting( IsOptional = true, DisplayName = "Require TLS", HelpText = "Check this box to require that the server supports SSL/TLS for sending messages. If the port used is 465, " + "implicit SSL will be enabled; otherwise, the STARTTLS extension will be used.")] - public bool? EnableSsl { get; set; } + public TlsOptions? EnableTls { get; set; } [SeqAppSetting( IsOptional = true, @@ -87,6 +119,31 @@ public EmailApp() "properties (https://github.com/datalust/seq-app-htmlemail/blob/main/src/Seq.App.EmailPlus/Resources/DefaultBodyTemplate.html).")] public string BodyTemplate { get; set; } + [SeqAppSetting( + IsOptional = true, + InputType = SettingInputType.LongText, + DisplayName = "Plain text template", + HelpText = "Optional plain text template to use when generating the email body, using Handlebars.NET syntax. Leave this blank if disable.")] + public string PlainTextTemplate { get; set; } + + [SeqAppSetting( + IsOptional = true, + DisplayName = "Email Priority Property", + HelpText = "Event Property that can be used to map email priority; properties can be mapped to email priority using the Email Priority or Property Mapping field.")] + public string PriorityProperty { get; set; } + + [SeqAppSetting( + IsOptional = true, + DisplayName = "Email Priority or Property Mapping", + HelpText = "The Priority of the email - High, Normal, Low - Default Normal, or 'Email Priority Property' mapping using Property=Mapping format, eg. Highest=High,Error=Normal,Low=Low.")] + public string Priority { get; set; } + + [SeqAppSetting( + IsOptional = true, + DisplayName = "Default Priority", + HelpText = "If using Email Priority mapping - Default for events not matching the mapping - High, Normal, or Low. Defaults to Normal.")] + public string DefaultPriority { get; set; } + [SeqAppSetting( DisplayName = "Suppression time (minutes)", IsOptional = true, @@ -106,23 +163,39 @@ public EmailApp() protected override void OnAttached() { + if (string.IsNullOrEmpty(Host) && (DeliverUsingDns == null || !(bool) DeliverUsingDns)) + throw new Exception("There are no delivery methods selected - you must specify at least one SMTP Mail Host, or enable Deliver Using DNS"); + var port = Port ?? DefaultPort; _options = _options = new SmtpOptions( Host, + DeliverUsingDns != null && (bool)DeliverUsingDns, port, - EnableSsl ?? false - ? RequireSslForPort(port) - : SecureSocketOptions.StartTlsWhenAvailable, + Priority, + DefaultPriority, + SmtpOptions.GetSocketOptions(port, EnableSsl, EnableTls), Username, - Password); + Password);; _subjectTemplate = Handlebars.Compile(string.IsNullOrEmpty(SubjectTemplate) ? DefaultSubjectTemplate : SubjectTemplate); + _bodyTemplate = Handlebars.Compile(string.IsNullOrEmpty(BodyTemplate) ? Resources.DefaultBodyTemplate : BodyTemplate); + + _plainTextTemplate = Handlebars.Compile(string.IsNullOrEmpty(PlainTextTemplate) + ? Resources.DefaultBodyTemplate + : PlainTextTemplate); + _toAddressesTemplate = string.IsNullOrEmpty(To) ? (_, __) => To : Handlebars.Compile(To); + + _replyToAddressesTemplate = string.IsNullOrEmpty(ReplyTo) ? (_, __) => ReplyTo : Handlebars.Compile(ReplyTo); + + _ccAddressesTemplate = string.IsNullOrEmpty(Cc) ? (_, __) => Cc : Handlebars.Compile(Cc); + + _bccAddressesTemplate = string.IsNullOrEmpty(Bcc) ? (_, __) => Bcc : Handlebars.Compile(Bcc); } public async Task OnAsync(Event evt) @@ -130,27 +203,145 @@ public async Task OnAsync(Event evt) if (ShouldSuppress(evt)) return; var to = FormatTemplate(_toAddressesTemplate, evt, base.Host) - .Split(new[]{','}, StringSplitOptions.RemoveEmptyEntries); + .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); - if (to.Length == 0) + if (to.Count == 0) { - Log.Warning("Email 'to' address template did not evaluate to one or more recipient addresses"); + Log.ForContext("To", _toAddressesTemplate).Error("Email 'to' address template did not evaluate to one or more recipient addresses - email cannot be sent!"); return; } + var replyTo = string.IsNullOrEmpty(ReplyTo) ? new List() : FormatTemplate(_replyToAddressesTemplate, evt, base.Host) + .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); + + var cc = string.IsNullOrEmpty(Cc) ? new List() : FormatTemplate(_ccAddressesTemplate, evt, base.Host) + .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); + + var bcc = string.IsNullOrEmpty(Bcc) ? new List() : FormatTemplate(_bccAddressesTemplate, evt, base.Host) + .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); + + var body = FormatTemplate(_bodyTemplate, evt, base.Host); + var textBody = FormatTemplate(_plainTextTemplate, evt, base.Host); var subject = FormatTemplate(_subjectTemplate, evt, base.Host).Trim().Replace("\r", "") .Replace("\n", ""); if (subject.Length > MaxSubjectLength) subject = subject.Substring(0, MaxSubjectLength); - await _mailGateway.SendAsync( - _options, - new MimeMessage( - new[] {MailboxAddress.Parse(From)}, - to.Select(MailboxAddress.Parse), - subject, - new BodyBuilder {HtmlBody = body}.ToMessageBody())); + var replyToList = replyTo.Select(MailboxAddress.Parse).ToList(); + var toList = to.Select(MailboxAddress.Parse).ToList(); + var ccList = cc.Select(MailboxAddress.Parse).ToList(); + var bccList = bcc.Select(MailboxAddress.Parse).ToList(); + var sent = false; + var logged = false; + var type = DeliveryType.None; + var message = new MimeMessage( + new List {InternetAddress.Parse(From)}, + toList, subject, + new BodyBuilder + {HtmlBody = body, TextBody = textBody == body ? string.Empty : textBody}.ToMessageBody()); + + if (replyToList.Any()) + message.ReplyTo.AddRange(replyToList); + + if (ccList.Any()) + message.Cc.AddRange(ccList); + + if (bccList.Any()) + message.Bcc.AddRange(bccList); + + var priority = EmailPriority.Normal; + switch (_options.Priority) + { + case EmailPriority.UseMapping: + if (!string.IsNullOrEmpty(PriorityProperty) && _options.PriorityMapping.Count > 0 && + TryGetPropertyValueCI(evt.Data.Properties, PriorityProperty, out var priorityProperty) && + priorityProperty is string priorityValue && + _options.PriorityMapping.TryGetValue(priorityValue, out var matchedPriority)) + priority = matchedPriority; + else + priority = _options.DefaultPriority; + break; + case EmailPriority.Low: + case EmailPriority.Normal: + case EmailPriority.High: + priority = _options.Priority; + break; + default: + priority = EmailPriority.Normal; + break; + } + + message.Priority = (MessagePriority) priority; + var errors = new List(); + var lastServer = string.Empty; + if (_options.Host != null && _options.Host.Any()) + { + type = DeliveryType.MailHost; + var result = await _mailGateway.SendAsync(_options, message); + errors = result.Errors; + sent = result.Success; + lastServer = result.LastServer; + + if (!result.Success) + { + Log.ForContext("From", From).ForContext("To", to) + .ForContext("ReplyTo", replyTo).ForContext("CC", cc).ForContext("BCC", bcc) + .ForContext("Priority", priority).ForContext("Subject", subject) + .ForContext("Success", sent).ForContext("Body", body) + .ForContext(nameof(result.LastServer), result.LastServer) + .ForContext(nameof(result.Type), result.Type).ForContext(nameof(result.Errors), result.Errors) + .Error(result.LastError, + "Error sending mail: {Message}, From: {From}, To: {To}, Subject: {Subject}", + result.LastError?.Message, From, to, subject); + logged = true; + } + } + + if (!sent && _options.DnsDelivery) + { + type = type == DeliveryType.None ? DeliveryType.Dns : DeliveryType.HostDnsFallback; + var result = await _mailGateway.SendDnsAsync(type, _options, message); + errors = result.Errors; + sent = result.Success; + lastServer = result.LastServer; + type = result.Type; + + if (!result.Success) + { + Log.ForContext("From", From).ForContext("To", to) + .ForContext("ReplyTo", replyTo).ForContext("CC", cc).ForContext("BCC", bcc) + .ForContext("Priority", priority).ForContext("Subject", subject) + .ForContext("Success", sent).ForContext("Body", body) + .ForContext(nameof(result.Results), result.Results, true).ForContext("Errors", errors) + .ForContext(nameof(result.Type), result.Type).ForContext(nameof(result.LastServer), result.LastServer) + .Error(result.LastError, + "Error sending mail via DNS: {Message}, From: {From}, To: {To}, Subject: {Subject}", + result.LastError?.Message, From, to, subject); + logged = true; + } + } + + if (sent) + { + Log.ForContext("From", From).ForContext("To", to) + .ForContext("ReplyTo", replyTo).ForContext("CC", cc).ForContext("BCC", bcc) + .ForContext("Priority", priority).ForContext("Subject", subject) + .ForContext("Success", true).ForContext("Body", body).ForContext("Errors", errors) + .ForContext("Type", type).ForContext("LastServer", lastServer) + .Information("Mail Sent, From: {From}, To: {To}, Subject: {Subject}", From, to, subject); + } + else if (!logged) + Log.ForContext("From", From).ForContext("To", to) + .ForContext("ReplyTo", replyTo).ForContext("CC", cc).ForContext("BCC", bcc) + .ForContext("Priority", priority).ForContext("Subject", subject) + .ForContext("Success", true).ForContext("Body", body).ForContext("Errors", errors) + .ForContext("Type", type).ForContext("LastServer", lastServer) + .Error("Unhandled mail error, From: {From}, To: {To}, Subject: {Subject}", From, to, subject); } bool ShouldSuppress(Event evt) @@ -181,31 +372,30 @@ bool ShouldSuppress(Event evt) // Suppressed return true; } - - internal static SecureSocketOptions RequireSslForPort(int port) - { - return (port == DefaultSslPort ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls); - } internal static string FormatTemplate(Template template, Event evt, Host host) { - var properties = (IDictionary) ToDynamic(evt.Data.Properties ?? new Dictionary()); + var properties = + (IDictionary) ToDynamic(evt.Data.Properties ?? new Dictionary()); - var payload = (IDictionary) ToDynamic(new Dictionary + var payload = (IDictionary) ToDynamic(new Dictionary { - { "$Id", evt.Id }, - { "$UtcTimestamp", evt.TimestampUtc }, - { "$LocalTimestamp", evt.Data.LocalTimestamp }, - { "$Level", evt.Data.Level }, - { "$MessageTemplate", evt.Data.MessageTemplate }, - { "$Message", evt.Data.RenderedMessage }, - { "$Exception", evt.Data.Exception }, - { "$Properties", properties }, - { "$EventType", "$" + evt.EventType.ToString("X8") }, - { "$Instance", host.InstanceName }, - { "$ServerUri", host.BaseUri }, + {"$Id", evt.Id}, + {"$UtcTimestamp", evt.TimestampUtc}, + {"$LocalTimestamp", evt.Data.LocalTimestamp}, + {"$Level", evt.Data.Level}, + {"$MessageTemplate", evt.Data.MessageTemplate}, + {"$Message", evt.Data.RenderedMessage}, + {"$Exception", evt.Data.Exception}, + {"$Properties", properties}, + {"$EventType", "$" + evt.EventType.ToString("X8")}, + {"$Instance", host.InstanceName}, + {"$ServerUri", host.BaseUri}, // Note, this will only be valid when events are streamed directly to the app, and not when the app is sending an alert notification. - { "$EventUri", string.Concat(host.BaseUri, "#/events?filter=@Id%20%3D%20'", evt.Id, "'&show=expanded") } + { + "$EventUri", + string.Concat(host.BaseUri, "#/events?filter=@Id%20%3D%20'", evt.Id, "'&show=expanded") + } }); foreach (var property in properties) @@ -234,5 +424,24 @@ static object ToDynamic(object o) return o; } + + static bool TryGetPropertyValueCI(IReadOnlyDictionary properties, string propertyName, + out object propertyValue) + { + var pair = properties.FirstOrDefault(p => p.Key.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); + if (pair.Key == null) + { + propertyValue = null; + return false; + } + + propertyValue = pair.Value; + return true; + } + + public SmtpOptions GetOptions() + { + return _options; + } } } diff --git a/src/Seq.App.EmailPlus/EmailPriority.cs b/src/Seq.App.EmailPlus/EmailPriority.cs new file mode 100644 index 0000000..f0f44ad --- /dev/null +++ b/src/Seq.App.EmailPlus/EmailPriority.cs @@ -0,0 +1,12 @@ +using MimeKit; + +namespace Seq.App.EmailPlus +{ + public enum EmailPriority + { + Low = MessagePriority.NonUrgent, + Normal = MessagePriority.Normal, + High = MessagePriority.Urgent, + UseMapping = -1, + } +} \ No newline at end of file diff --git a/src/Seq.App.EmailPlus/HandlebarsHelpers.cs b/src/Seq.App.EmailPlus/HandlebarsHelpers.cs index fae0363..8a8cb3d 100644 --- a/src/Seq.App.EmailPlus/HandlebarsHelpers.cs +++ b/src/Seq.App.EmailPlus/HandlebarsHelpers.cs @@ -2,7 +2,6 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; -using System.IO; using System.Linq; namespace Seq.App.EmailPlus diff --git a/src/Seq.App.EmailPlus/IMailGateway.cs b/src/Seq.App.EmailPlus/IMailGateway.cs index ed599a2..6baa09a 100644 --- a/src/Seq.App.EmailPlus/IMailGateway.cs +++ b/src/Seq.App.EmailPlus/IMailGateway.cs @@ -5,6 +5,7 @@ namespace Seq.App.EmailPlus { interface IMailGateway { - Task SendAsync(SmtpOptions options, MimeMessage message); + Task SendAsync(SmtpOptions options, MimeMessage message); + Task SendDnsAsync(DeliveryType deliveryType, SmtpOptions options, MimeMessage message); } } \ No newline at end of file diff --git a/src/Seq.App.EmailPlus/MailResult.cs b/src/Seq.App.EmailPlus/MailResult.cs index 6aa6fa1..0bbf3af 100644 --- a/src/Seq.App.EmailPlus/MailResult.cs +++ b/src/Seq.App.EmailPlus/MailResult.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; namespace Seq.App.EmailPlus { public class MailResult { - public bool Success; - public Exception Errors; + public bool Success { get; set; } + public DeliveryType Type { get; set; } + public string LastServer { get; set; } + public Exception LastError { get; set; } + public List Errors { get; set; } = new List(); } } \ No newline at end of file diff --git a/src/Seq.App.EmailPlus/Seq.App.EmailPlus.csproj b/src/Seq.App.EmailPlus/Seq.App.EmailPlus.csproj index 5082600..99f6a41 100644 --- a/src/Seq.App.EmailPlus/Seq.App.EmailPlus.csproj +++ b/src/Seq.App.EmailPlus/Seq.App.EmailPlus.csproj @@ -14,20 +14,24 @@ True LICENSE + Seq.App.EmailPlus + - - - + + + + - + + diff --git a/src/Seq.App.EmailPlus/SmtpOptions.cs b/src/Seq.App.EmailPlus/SmtpOptions.cs index ec2d0c5..ec5c7ae 100644 --- a/src/Seq.App.EmailPlus/SmtpOptions.cs +++ b/src/Seq.App.EmailPlus/SmtpOptions.cs @@ -1,25 +1,88 @@ using System; +using System.Collections.Generic; +using System.Linq; using MailKit.Security; namespace Seq.App.EmailPlus { - class SmtpOptions + public class SmtpOptions { - public string Host { get; } - public int Port { get; } - public string Username { get; } - public string Password { get; } - public SecureSocketOptions SocketOptions { get; } - + public List Host { get; set; } + public bool DnsDelivery { get; set; } + public int Port { get; set; } + public string Username { get; set; } + public string Password { get; set; } public bool RequiresAuthentication => !string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password); + public EmailPriority Priority { get; set; } + public Dictionary PriorityMapping { get; set; } + public EmailPriority DefaultPriority { get; set; } + public TlsOptions SocketOptions { get; set; } - public SmtpOptions(string host, int port, SecureSocketOptions socketOptions, string username = null, string password = null) + public SmtpOptions(string host, bool dnsDelivery, int port, string priority, string defaultPriority, TlsOptions socketOptions, string username = null, string password = null) { - Host = host ?? throw new ArgumentNullException(nameof(host)); + Host = GetServerList(host).ToList(); + DnsDelivery = dnsDelivery; + Priority = ParsePriority(priority, out var priorityMapping); + PriorityMapping = priorityMapping; + DefaultPriority = ParsePriority(defaultPriority); Port = port; Username = username; Password = password; SocketOptions = socketOptions; } + + static IEnumerable GetServerList(string hostName) + { + if (!string.IsNullOrEmpty(hostName)) + return hostName.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList(); + return new List(); + } + + public static TlsOptions GetSocketOptions(int port, bool? enableSsl, TlsOptions? enableTls) + { + if (enableSsl == null && enableTls == null) return TlsOptions.Auto; + + switch (enableTls) + { + case null when (bool)enableSsl && port == 465: //Implicit TLS + case TlsOptions.None when port == 465: + case TlsOptions.Auto when port == 465: + case TlsOptions.StartTlsWhenAvailable when port == 465: + return TlsOptions.SslOnConnect; + case null when (bool)enableSsl: + return TlsOptions.StartTls; //Explicit TLS + case null: + return TlsOptions.Auto; + default: + return (TlsOptions)enableTls; + } + } + + static EmailPriority ParsePriority(string priority, out Dictionary priorityList) + { + priorityList = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(priority)) return EmailPriority.Normal; + if (!priority.Contains('=')) + return Enum.TryParse(priority, out EmailPriority priorityValue) ? priorityValue : EmailPriority.Normal; + foreach (var kv in priority.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()).ToList().Select(p => p.Split(new[] {'='}, StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()) + .ToArray())) + { + if (kv.Length != 2 || !Enum.TryParse(kv[1], true, out EmailPriority value) || + value == EmailPriority.UseMapping) return EmailPriority.Normal; + + priorityList.Add(kv[0], value); + } + + return priorityList.Count > 0 ? EmailPriority.UseMapping : EmailPriority.Normal; + + } + + static EmailPriority ParsePriority(string priority) + { + if (string.IsNullOrEmpty(priority)) return EmailPriority.Normal; + return Enum.TryParse(priority, out EmailPriority priorityValue) ? priorityValue : EmailPriority.Normal; + } } } diff --git a/src/Seq.App.EmailPlus/TlsOptions.cs b/src/Seq.App.EmailPlus/TlsOptions.cs new file mode 100644 index 0000000..dc865e9 --- /dev/null +++ b/src/Seq.App.EmailPlus/TlsOptions.cs @@ -0,0 +1,29 @@ +using MailKit.Security; + +namespace Seq.App.EmailPlus +{ + public enum TlsOptions + { + /// + /// None + /// + None = SecureSocketOptions.None, + /// + /// Auto + /// + Auto = SecureSocketOptions.Auto, + /// + /// Implicit TLS + /// + SslOnConnect = SecureSocketOptions.SslOnConnect, + /// + /// Explicit TLS + /// + StartTls = SecureSocketOptions.StartTls, + /// + /// Optional TLS + /// + StartTlsWhenAvailable = SecureSocketOptions.StartTlsWhenAvailable + + } +} diff --git a/test/Seq.App.EmailPlus.Tests/EmailAppTests.cs b/test/Seq.App.EmailPlus.Tests/EmailAppTests.cs index 6513ae9..5894f4c 100644 --- a/test/Seq.App.EmailPlus.Tests/EmailAppTests.cs +++ b/test/Seq.App.EmailPlus.Tests/EmailAppTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using MailKit.Security; +using MimeKit; using Seq.App.EmailPlus.Tests.Support; using Seq.Apps; using Seq.Apps.LogEvents; @@ -131,6 +133,57 @@ public async Task ToAddressesAreTemplated() Assert.Equal("test@example.com", to.ToString()); } + [Fact] + public async Task OptionalAddressesAreTemplated() + { + var mail = new CollectingMailGateway(); + var app = new EmailApp(mail, new SystemClock()) + { + From = "from@example.com", + ReplyTo = "{{Name}}@example.com", + To = "{{Name}}@example.com", + Cc = "{{Name}}@example.com", + Bcc = "{{Name}}@example.com", + Host = "example.com" + }; + + app.Attach(new TestAppHost()); + + var data = Some.LogEvent(includedProperties: new Dictionary { { "Name", "test" } }); + await app.OnAsync(data); + + var sent = Assert.Single(mail.Sent); + Assert.Equal("test@example.com", sent.Message.ReplyTo.ToString()); + Assert.Equal("test@example.com", sent.Message.Cc.ToString()); + Assert.Equal("test@example.com", sent.Message.Bcc.ToString()); + } + + [Fact] + public void FallbackHostsCalculated() + { + var mail = new CollectingMailGateway(); + var reactor = new EmailApp(mail, new SystemClock()) + { + From = "from@example.com", + To = "{{Name}}@example.com", + Host = "example.com,example2.com" + }; + + reactor.Attach(new TestAppHost()); + Assert.True(reactor.GetOptions().Host.Count() == 2); + } + + [Fact] + public void ParseDomainTest() + { + var mail = new DirectMailGateway(); + var domains = DirectMailGateway.GetDomains(new MimeMessage( + new List {InternetAddress.Parse("test@example.com")}, + new List {InternetAddress.Parse("test2@example.com"), InternetAddress.Parse("test3@example.com"), InternetAddress.Parse("test@example2.com")}, "Test", + (new BodyBuilder {HtmlBody = "test"}).ToMessageBody())); + Assert.True(domains.Count() == 2); + } + [Fact] public async Task EventsAreSuppressedWithinWindow() { @@ -203,12 +256,19 @@ public async Task ToAddressesCanBeCommaSeparated() } [Theory] - [InlineData(25, SecureSocketOptions.StartTls)] - [InlineData(587, SecureSocketOptions.StartTls)] - [InlineData(465, SecureSocketOptions.SslOnConnect)] - public void CorrectSecureSocketOptionsAreChosenForPort(int port, SecureSocketOptions expected) + [InlineData(25, null, null, TlsOptions.Auto)] + [InlineData(25, true, null, TlsOptions.StartTls)] + [InlineData(25, false, TlsOptions.None, TlsOptions.None)] + [InlineData(25, false, TlsOptions.StartTlsWhenAvailable, TlsOptions.StartTlsWhenAvailable)] + [InlineData(587, true, TlsOptions.StartTls, TlsOptions.StartTls)] + [InlineData(587, false, TlsOptions.None, TlsOptions.None)] + [InlineData(587, false, TlsOptions.StartTlsWhenAvailable, TlsOptions.StartTlsWhenAvailable)] + [InlineData(465, true, TlsOptions.None, TlsOptions.SslOnConnect)] + [InlineData(465, false, TlsOptions.Auto, TlsOptions.SslOnConnect)] + [InlineData(465, false, TlsOptions.SslOnConnect, TlsOptions.SslOnConnect)] + public void CorrectSecureSocketOptionsAreChosenForPort(int port, bool? enableSsl, TlsOptions? enableTls, TlsOptions expected) { - Assert.Equal(expected, EmailApp.RequireSslForPort(port)); + Assert.Equal(expected, SmtpOptions.GetSocketOptions(port, enableSsl, enableTls)); } } } diff --git a/test/Seq.App.EmailPlus.Tests/Seq.App.EmailPlus.Tests.csproj b/test/Seq.App.EmailPlus.Tests/Seq.App.EmailPlus.Tests.csproj index e277edf..43525b5 100644 --- a/test/Seq.App.EmailPlus.Tests/Seq.App.EmailPlus.Tests.csproj +++ b/test/Seq.App.EmailPlus.Tests/Seq.App.EmailPlus.Tests.csproj @@ -1,14 +1,14 @@  - net6.0 + netstandard2.0 false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Seq.App.EmailPlus.Tests/Support/CollectingMailGateway.cs b/test/Seq.App.EmailPlus.Tests/Support/CollectingMailGateway.cs index d5e2ac7..088e021 100644 --- a/test/Seq.App.EmailPlus.Tests/Support/CollectingMailGateway.cs +++ b/test/Seq.App.EmailPlus.Tests/Support/CollectingMailGateway.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Threading.Tasks; -using MailKit.Net.Smtp; using MimeKit; namespace Seq.App.EmailPlus.Tests.Support @@ -9,10 +8,18 @@ class CollectingMailGateway : IMailGateway { public List Sent { get; } = new List(); - public Task SendAsync(SmtpOptions options, MimeMessage message) + public async Task SendAsync(SmtpOptions options, MimeMessage message) { - Sent.Add(new SentMessage(message)); - return Task.CompletedTask; + await Task.Run(() => Sent.Add(new SentMessage(message))); + + return new MailResult {Success = true}; + } + + public async Task SendDnsAsync(DeliveryType deliveryType, SmtpOptions options, MimeMessage message) + { + await Task.Run(() => Sent.Add(new SentMessage(message))); + + return new DnsMailResult {Success = true}; } } } diff --git a/test/Seq.App.EmailPlus.Tests/Support/SentMessage.cs b/test/Seq.App.EmailPlus.Tests/Support/SentMessage.cs index ebd7712..4e1a6ee 100644 --- a/test/Seq.App.EmailPlus.Tests/Support/SentMessage.cs +++ b/test/Seq.App.EmailPlus.Tests/Support/SentMessage.cs @@ -1,6 +1,4 @@ - -using MailKit.Net.Smtp; -using MimeKit; +using MimeKit; namespace Seq.App.EmailPlus.Tests.Support {