mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user