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..b0a0ace 100644 --- a/src/Seq.App.EmailPlus/DirectMailGateway.cs +++ b/src/Seq.App.EmailPlus/DirectMailGateway.cs @@ -1,5 +1,9 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using DnsClient; +using DnsClient.Protocol; using MailKit.Net.Smtp; using MimeKit; @@ -7,17 +11,192 @@ 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, 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 03d0569..21079ae 100644 --- a/src/Seq.App.EmailPlus/EmailApp.cs +++ b/src/Seq.App.EmailPlus/EmailApp.cs @@ -4,10 +4,11 @@ using System.Linq; using System.Threading.Tasks; using HandlebarsDotNet; -using MailKit.Security; using MimeKit; using Seq.Apps; using Seq.Apps.LogEvents; +using System.Timers; +using Timer = System.Timers.Timer; // ReSharper disable UnusedAutoPropertyAccessor.Global, MemberCanBePrivate.Global @@ -16,19 +17,36 @@ 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(); - readonly Lazy