E-mail delivery: the provider status word and message id travel into the test result and the EmailDelivery event

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 17:46:54 +02:00
parent 309e15bd33
commit 0ce59b700c
3 changed files with 44 additions and 26 deletions
@@ -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());
@@ -25,6 +25,16 @@ namespace AutopilotMonitor.Functions.Services;
/// <c>Email:FromName</c> (default <see cref="DefaultFromName"/>).
/// </para>
/// </summary>
/// <summary>
/// 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.
/// </summary>
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
/// (<see cref="Notifications.EmailNotificationService"/>) on the same provider path as the
/// transactional mails. Returns true when the provider accepted it. Never throws.
/// </summary>
public virtual async Task<bool> SendMessageAsync(string toEmail, string subject, string html, string tag, CancellationToken ct = default)
public virtual async Task<EmailSendOutcome> 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 <c>messages/send</c> request and
/// interprets the per-recipient result array: <c>sent</c>/<c>queued</c>/<c>scheduled</c>
/// count as success, <c>rejected</c>/<c>invalid</c> as failure (with the provider's
/// <c>reject_reason</c> in the warning). Never throws.
/// <c>reject_reason</c> 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.
/// </summary>
private async Task<bool> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct)
private async Task<EmailSendOutcome> 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<MandrillSendResult[]>(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;
}
}
@@ -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<EmailSendOutcome>();
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