From 0ce59b700c4eed844f9558876e37d55c4e80a2cb Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 17:46:54 +0200 Subject: [PATCH] E-mail delivery: the provider status word and message id travel into the test result and the EmailDelivery event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test mail that Mandrill accepted did not arrive; the backend had logged nothing beyond "accepted". SendViaMandrillAsync now returns the status (sent/queued/rejected) and the message id, the channel test reports them ("Provider status: queued, id ..."), and the EmailDelivery custom event carries them as status and providerIds — enough to look a mail up in the provider's activity log. Co-Authored-By: Claude Fable 5.1 --- .../EmailChannelProviderTests.cs | 3 +- .../Services/EmailService.cs | 41 ++++++++++++------- .../Notifications/EmailNotificationService.cs | 26 +++++++----- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs index dfe7d925..7dc55600 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs @@ -182,7 +182,7 @@ public class EmailChannelProviderTests var result = await service.SendAlertWithResultAsync("ops@example.invalid; second@example.invalid", Alert()); Assert.True(result.Success); - Assert.Equal("Sent to 2 recipient(s).", result.Message); + Assert.Equal("Sent to 2 recipient(s). Provider status: sent, id abc,abc.", result.Message); Assert.Equal(2, handler.Bodies.Count); Assert.Contains("\"to\":[{\"email\":\"ops@example.invalid\"", handler.Bodies[0]); Assert.Contains("\"subject\":\"[Autopilot Monitor] Error: Ops Alert: Platform/PushDeliveryFailed\"", handler.Bodies[0]); @@ -198,6 +198,7 @@ public class EmailChannelProviderTests var rejected = await service.SendAlertWithResultAsync("ops@example.invalid", Alert()); Assert.False(rejected.Success); Assert.Contains("accepted 0 of 1", rejected.Message); + Assert.Contains("Provider status: rejected", rejected.Message); var (unconfigured, _) = Sender(new StubHandler(), apiKey: ""); var skipped = await unconfigured.SendAlertWithResultAsync("ops@example.invalid", Alert()); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs index 27202802..e2527065 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs @@ -25,6 +25,16 @@ namespace AutopilotMonitor.Functions.Services; /// Email:FromName (default ). /// /// +/// +/// What the provider answered for one message: accepted (sent/queued/scheduled) or not, its +/// status word, its message id (to look the mail up in the provider's activity log) and the +/// rejection reason when it refused. +/// +public sealed record EmailSendOutcome(bool Accepted, string? Status, string? ProviderId, string? RejectReason) +{ + public static readonly EmailSendOutcome NotSent = new(false, null, null, null); +} + public class EmailService : IEmailService, IOffboardFarewellEmailSender { public const string ApiKeyConfigKey = "Email:ApiKey"; @@ -75,12 +85,12 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender return false; } - var sent = await SendViaMandrillAsync( + var sent = (await SendViaMandrillAsync( toEmail, EmailTemplates.PreviewApprovedSubject, await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct), tag: "welcome", - ct); + ct)).Accepted; if (sent) { @@ -119,12 +129,12 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender return false; } - var sent = await SendViaMandrillAsync( + var sent = (await SendViaMandrillAsync( toEmail, EmailTemplates.OffboardingFarewellSubject, await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct), tag: "offboarding-farewell", - ct); + ct)).Accepted; if (sent) { @@ -149,7 +159,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender ? await _templates.GetHtmlAsync(kind, domainName, ct) : EmailTemplateService.Render(draftHtml, domainName); - var sent = await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct); + var sent = (await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct)).Accepted; if (sent) _logger.LogInformation("{Kind} test email sent to {ToEmail} (draft={IsDraft})", kind, toEmail, draftHtml is not null); return sent; @@ -163,15 +173,15 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender /// () on the same provider path as the /// transactional mails. Returns true when the provider accepted it. Never throws. /// - public virtual async Task SendMessageAsync(string toEmail, string subject, string html, string tag, CancellationToken ct = default) + public virtual async Task SendMessageAsync(string toEmail, string subject, string html, string tag, CancellationToken ct = default) { if (!IsConfigured) { _logger.LogWarning("{ConfigKey} not configured — skipping {Tag} mail", ApiKeyConfigKey, tag); - return false; + return EmailSendOutcome.NotSent; } if (string.IsNullOrWhiteSpace(toEmail)) - return false; + return EmailSendOutcome.NotSent; return await SendViaMandrillAsync(toEmail, subject, html, tag, ct); } @@ -179,9 +189,10 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender /// The only provider-specific code path. Posts a Mandrill messages/send request and /// interprets the per-recipient result array: sent/queued/scheduled /// count as success, rejected/invalid as failure (with the provider's - /// reject_reason in the warning). Never throws. + /// reject_reason in the warning). Never throws; the outcome carries the provider's status + /// word and message id so a mail that never arrived can be looked up in its activity log. /// - private async Task SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct) + private async Task SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct) { try { @@ -210,7 +221,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender _logger.LogWarning( "Email provider returned {StatusCode} for {Tag} mail to {ToEmail}: {Body}", (int)response.StatusCode, tag, toEmail, Truncate(body)); - return false; + return EmailSendOutcome.NotSent; } var results = JsonSerializer.Deserialize(body, JsonOptions); @@ -220,7 +231,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender _logger.LogWarning( "Email provider returned an empty result for {Tag} mail to {ToEmail}: {Body}", tag, toEmail, Truncate(body)); - return false; + return EmailSendOutcome.NotSent; } if (!IsAcceptedStatus(result.Status)) @@ -228,15 +239,15 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender _logger.LogWarning( "Email provider did not accept {Tag} mail to {ToEmail}: status={Status} reason={Reason}", tag, toEmail, result.Status, result.RejectReason ?? "n/a"); - return false; + return new EmailSendOutcome(false, result.Status, result.Id, result.RejectReason); } - return true; + return new EmailSendOutcome(true, result.Status, result.Id, null); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to send {Tag} mail to {ToEmail}", tag, toEmail); - return false; + return EmailSendOutcome.NotSent; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs index fc506dcb..56357747 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/EmailNotificationService.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.ApplicationInsights; using Microsoft.Extensions.Logging; @@ -73,28 +74,31 @@ public class EmailNotificationService : IEmailChannelSender var subject = EmailAlertRenderer.Subject(alert); var html = EmailAlertRenderer.Html(alert); - var accepted = 0; + var outcomes = new List(); foreach (var address in addresses.Take(MaxRecipients)) - { - if (await _email.SendMessageAsync(address, subject, html, Tag).ConfigureAwait(false)) - accepted++; - } + outcomes.Add(await _email.SendMessageAsync(address, subject, html, Tag).ConfigureAwait(false)); - var attempted = Math.Min(addresses.Count, MaxRecipients); - Track(accepted == attempted ? "delivered" : accepted == 0 ? "failed" : "partial", attempted, accepted); + var attempted = outcomes.Count; + var accepted = outcomes.Count(o => o.Accepted); + // The provider's status word and message ids: "queued" (deferred by the provider) is not + // "sent", and the id is what its activity log is searched by when a mail never arrives. + var statuses = string.Join(",", outcomes.Where(o => o.Status != null).Select(o => o.Status!).Distinct()); + var ids = string.Join(",", outcomes.Where(o => o.ProviderId != null).Select(o => o.ProviderId!)); + Track(accepted == attempted ? "delivered" : accepted == 0 ? "failed" : "partial", attempted, accepted, statuses, ids); if (accepted < attempted) _logger.LogWarning("E-mail alert {EventType}: provider accepted {Accepted} of {Attempted} recipient(s)", alert.EventType, accepted, attempted); + var detail = statuses.Length > 0 ? $" Provider status: {statuses}{(ids.Length > 0 ? $", id {ids}" : string.Empty)}." : string.Empty; return new WebhookTestResult { Success = accepted > 0, - Message = accepted == attempted + Message = (accepted == attempted ? $"Sent to {accepted} recipient(s)." - : $"The e-mail provider accepted {accepted} of {attempted} recipient(s) — see the backend log for the rejection.", + : $"The e-mail provider accepted {accepted} of {attempted} recipient(s) — see the backend log for the rejection.") + detail, }; } - private void Track(string outcome, int attempted, int accepted) + private void Track(string outcome, int attempted, int accepted, string statuses = "", string ids = "") { try { @@ -103,6 +107,8 @@ public class EmailNotificationService : IEmailChannelSender ["outcome"] = outcome, ["attempted"] = attempted.ToString(), ["accepted"] = accepted.ToString(), + ["status"] = statuses, + ["providerIds"] = ids, }); } catch