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