E-mail as a notification-channel provider, Global-Admin only (D-332)

WebhookProviderType.Email = 60 joins Telegram and Push behind the Global-Admin gate: it sends
under the platform's sender identity and provider key (the welcome/farewell path,
noreply@autopilotmonitor.com). The channel's Url names one to five plain recipient addresses
(ValidateEmailDestination, no SSRF URL check). EmailNotificationService renders the alert
(EmailAlertRenderer: severity-prefixed subject, escaped HTML with summary, facts, sections,
links, and the structured payload as preformatted text when the rule opted in) and sends one
message per recipient through EmailService.SendMessageAsync; outcomes land in the EmailDelivery
custom event and the log, never in an ops event (a failing e-mail channel must not feed itself).
The dispatcher takes the transport through a chained fourth-parameter constructor so the
existing mocks keep working. ChannelEditor offers the provider to Global Admins with a
"Recipients" field. Tests: routing, validation, gate, renderer, send against a provider stub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 17:20:36 +02:00
parent 36aafb655f
commit 28da10e9fb
13 changed files with 600 additions and 12 deletions
@@ -0,0 +1,248 @@
using System.Net;
using System.Net.Http;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
/// send against a stubbed provider endpoint.
/// </summary>
public class EmailChannelProviderTests
{
private const int Email = (int)WebhookProviderType.Email;
private static readonly NotificationScope Platform = NotificationScope.Platform;
private static Mock<WebhookNotificationService> WebhookMock()
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
private static Mock<TelegramNotificationService> TelegramMock()
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
private static NotificationAlert Alert() => new()
{
EventType = "PushDeliveryFailed",
Title = "Ops Alert: Platform/PushDeliveryFailed",
Summary = "A push service refused the platform key",
Severity = NotificationSeverity.Error,
Facts = { new() { Name = "Category", Value = "Platform" }, new() { Name = "Tenant", Value = "11111111-1111-1111-1111-111111111111" } },
Sections = { new() { Title = "What happened", Text = "status 401 <kid=abc>" } },
Actions = { new() { Type = "openUrl", Title = "Open alerts", Url = "https://portal.example.invalid/admin/settings/alerts" } },
};
// ── Dispatch routing ──────────────────────────────────────────────────
[Fact]
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var email = new Mock<IEmailChannelSender>();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
email.Verify(e => e.SendOpsAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result()
{
var email = new Mock<IEmailChannelSender>();
email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." });
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
email.Verify(e => e.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
Assert.True(result.Success);
Assert.Equal("Sent to 1 recipient(s).", result.Message);
}
[Fact]
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
{
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
Assert.False(result.Success);
Assert.Contains("not registered", result.Message);
}
// ── Validation and gate ───────────────────────────────────────────────
[Theory]
[InlineData("ops@example.invalid")]
[InlineData("ops@example.invalid; second@example.invalid")]
[InlineData("ops@example.invalid, second@example.invalid ,third@sub.example.invalid")]
[InlineData(" first.last+tag@example.invalid ")]
public void ValidateEmailDestination_accepts_one_to_five_plain_addresses(string recipients)
=> Assert.Null(TenantConfigValidation.ValidateEmailDestination(recipients));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(";,")]
[InlineData("not-an-address")]
[InlineData("Ops Team <ops@example.invalid>")]
[InlineData("ops@localhost")]
[InlineData("https://hooks.example.invalid/x")]
[InlineData("a@x.invalid;b@x.invalid;c@x.invalid;d@x.invalid;e@x.invalid;f@x.invalid")]
public void ValidateEmailDestination_rejects_blank_malformed_named_hostless_and_too_many(string? recipients)
=> Assert.NotNull(TenantConfigValidation.ValidateEmailDestination(recipients));
[Fact]
public void ValidateNotificationChannels_uses_the_recipient_rule_not_the_ssrf_url_gate()
{
var ok = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(ok));
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
}
[Fact]
public void Email_provider_is_global_admin_only()
{
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
}
// ── Rendering ─────────────────────────────────────────────────────────
[Fact]
public void Renderer_builds_a_prefixed_subject_and_escaped_html_with_facts_sections_links_and_payload()
{
var alert = Alert();
alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"<script>\"}";
Assert.Equal("[Autopilot Monitor] Error: Ops Alert: Platform/PushDeliveryFailed", EmailAlertRenderer.Subject(alert));
var html = EmailAlertRenderer.Html(alert);
Assert.Contains("Ops Alert: Platform/PushDeliveryFailed", html);
Assert.Contains("A push service refused the platform key", html);
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html);
Assert.Contains("status 401 &lt;kid=abc&gt;", html); // section text escaped
Assert.DoesNotContain("<kid=abc>", html);
Assert.Contains("href=\"https://portal.example.invalid/admin/settings/alerts\"", html);
Assert.Contains("&lt;script&gt;", html); // payload escaped
Assert.DoesNotContain("<script>", html);
Assert.Contains("PushDeliveryFailed</p>", html); // event type in the footer
}
[Fact]
public void Renderer_drops_non_http_links_caps_the_payload_and_survives_an_empty_alert()
{
var alert = new NotificationAlert { Title = "", Summary = "", DataJson = new string('x', EmailAlertRenderer.MaxPayloadChars + 100) };
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "bad", Url = "javascript:alert(1)" });
Assert.Equal("[Autopilot Monitor] Notification", EmailAlertRenderer.Subject(alert));
var html = EmailAlertRenderer.Html(alert);
Assert.DoesNotContain("javascript:", html);
Assert.Contains("(truncated)", html);
Assert.True(html.Length < EmailAlertRenderer.MaxPayloadChars + 3000);
}
// ── Sending through the provider path ─────────────────────────────────
[Fact]
public async Task Sender_posts_one_message_per_recipient_and_reports_what_the_provider_accepted()
{
var handler = new StubHandler();
var (service, _) = Sender(handler, apiKey: "key");
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(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]);
Assert.Contains("\"tags\":[\"alert\"]", handler.Bodies[0]);
Assert.Contains("\"track_opens\":false", handler.Bodies[0]);
}
[Fact]
public async Task Sender_reports_a_rejection_and_an_unconfigured_provider_without_throwing()
{
var handler = new StubHandler { Responder = _ => StubHandler.Json(HttpStatusCode.OK, "[{\"email\":\"ops@example.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\"}]") };
var (service, _) = Sender(handler, apiKey: "key");
var rejected = await service.SendAlertWithResultAsync("ops@example.invalid", Alert());
Assert.False(rejected.Success);
Assert.Contains("accepted 0 of 1", rejected.Message);
var (unconfigured, _) = Sender(new StubHandler(), apiKey: "");
var skipped = await unconfigured.SendAlertWithResultAsync("ops@example.invalid", Alert());
Assert.False(skipped.Success);
Assert.Contains("not configured", skipped.Message);
await unconfigured.SendOpsAlertAsync("ops@example.invalid", Alert()); // never throws
}
[Fact]
public void ParseRecipients_trims_dedupes_and_keeps_order()
{
var list = EmailNotificationService.ParseRecipients(" a@x.invalid ; B@x.invalid, a@x.invalid ,, c@x.invalid ");
Assert.Equal(new[] { "a@x.invalid", "B@x.invalid", "c@x.invalid" }, list);
Assert.Empty(EmailNotificationService.ParseRecipients(null));
}
private static (EmailNotificationService Service, EmailService Email) Sender(StubHandler handler, string apiKey)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
[EmailService.ApiKeyConfigKey] = apiKey,
["Email:Endpoint"] = "https://mail.example.invalid/send",
}).Build();
var templates = new EmailTemplateService(
Mock.Of<IConfigRepository>(),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
NullLogger<EmailTemplateService>.Instance);
var email = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, NullLogger<EmailService>.Instance);
return (new EmailNotificationService(email, NullLogger<EmailNotificationService>.Instance), email);
}
private sealed class StubHandler : HttpMessageHandler
{
public List<string> Bodies { get; } = new();
public Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
public static HttpResponseMessage Json(HttpStatusCode status, string body)
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Bodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken));
return Responder(request);
}
}
}
@@ -363,6 +363,8 @@ namespace AutopilotMonitor.Functions.Helpers
? ValidateTelegramChatId(channel.Url)
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push
? ValidatePushDestination(channel.Url)
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Email
? ValidateEmailDestination(channel.Url)
: SsrfGuard.ValidateWebhookUrlFormat(channel.Url);
if (destinationError != null)
return $"channel \"{label}\": {destinationError}";
@@ -425,12 +427,37 @@ namespace AutopilotMonitor.Functions.Helpers
internal static string? ValidatePushDestination(string? url)
=> string.IsNullOrWhiteSpace(url) ? null : "a Push channel has no destination — leave the URL empty.";
public const int MaxEmailRecipients = 5;
/// <summary>
/// An e-mail channel's destination: one to five plain addresses (no display names), separated
/// by ';' or ','. Returns an error message, or null when valid. Not a URL, so the SSRF gate does
/// not apply — the platform's provider sends, the caller only names recipients.
/// </summary>
internal static string? ValidateEmailDestination(string? recipients)
{
var addresses = Services.Notifications.EmailNotificationService.ParseRecipients(recipients);
if (addresses.Count == 0)
return "an e-mail channel needs at least one recipient address.";
if (addresses.Count > MaxEmailRecipients)
return $"an e-mail channel may name at most {MaxEmailRecipients} recipients.";
foreach (var address in addresses)
{
if (address.Length > 254
|| !System.Net.Mail.MailAddress.TryCreate(address, out var parsed)
|| !string.Equals(parsed.Address, address, StringComparison.Ordinal)
|| !parsed.Host.Contains('.'))
return $"\"{address}\" is not a valid e-mail address.";
}
return null;
}
/// <summary>
/// Global-Admin gate for the providers that send through PLATFORM-owned infrastructure
/// (<see cref="Shared.Models.Notifications.NotificationChannel.IsGlobalAdminOnlyProvider"/>):
/// Telegram through the platform bot (the token lives in PreviewConfig, not in tenant
/// config), Push through the platform's push sender and VAPID key, GA-only until the
/// customer release. A tenant admin must not be able to create or retarget one — hiding
/// config), Push through the platform's push sender and VAPID key, E-mail through the
/// platform's sender identity and provider key — GA-only until the customer release. A tenant admin must not be able to create or retarget one — hiding
/// the provider in the UI is not a control.
/// <para>
/// Compares against the stored config so a non-GA caller can still save unrelated changes
@@ -463,7 +490,12 @@ namespace AutopilotMonitor.Functions.Helpers
|| !string.Equals(stored.Url ?? string.Empty, channel.Url ?? string.Empty, StringComparison.Ordinal)
|| stored.Enabled != channel.Enabled)
{
var provider = channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push ? "Push" : "Telegram";
var provider = channel.ProviderType switch
{
(int)Shared.Models.Notifications.WebhookProviderType.Push => "Push",
(int)Shared.Models.Notifications.WebhookProviderType.Email => "E-mail",
_ => "Telegram",
};
return $"{provider} channels can only be configured by a Global Administrator.";
}
}
@@ -478,6 +478,11 @@ builder.Services.AddHttpClient<TelegramNotificationService>()
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
// vs. the platform Telegram bot). Transient: both transports are typed HttpClients.
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.NotificationChannelDispatcher>();
// E-mail as a channel provider (GA-gated): renders the alert and sends through EmailService's
// provider path. Transient like the dispatcher; EmailService is a typed HttpClient.
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.EmailNotificationService>();
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.IEmailChannelSender>(
sp => sp.GetRequiredService<AutopilotMonitor.Functions.Services.Notifications.EmailNotificationService>());
// What's new → channel digest (hourly timer). The feed client is a typed HttpClient reading the
// portal's static whats-new.json; the service is transient because the dispatcher it wraps is.
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.WhatsNew.IWhatsNewFeedClient,
@@ -155,6 +155,26 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
return sent;
}
/// <summary>True when an API key is configured; otherwise every send is a logged no-op.</summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(_apiKey);
/// <summary>
/// A rendered message to one recipient — the notification-channel provider
/// (<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)
{
if (!IsConfigured)
{
_logger.LogWarning("{ConfigKey} not configured — skipping {Tag} mail", ApiKeyConfigKey, tag);
return false;
}
if (string.IsNullOrWhiteSpace(toEmail))
return false;
return await SendViaMandrillAsync(toEmail, subject, html, tag, ct);
}
/// <summary>
/// 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>
@@ -0,0 +1,104 @@
using System.Net;
using System.Text;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Services.Notifications;
/// <summary>
/// Renders a <see cref="NotificationAlert"/> as an e-mail: subject and a self-contained HTML body
/// (inline styles, no images, no scripts) that reads well in every mail client and in its
/// auto-generated plain-text form. Every value from the alert is HTML-escaped; the structured
/// payload (<see cref="NotificationAlert.DataJson"/>, present only when the rule opted in) is
/// shown as preformatted text, capped so a large details object cannot bloat the mail.
/// </summary>
public static class EmailAlertRenderer
{
public const int MaxPayloadChars = 8 * 1024;
public const string SubjectPrefix = "[Autopilot Monitor]";
public static string Subject(NotificationAlert alert)
{
var severity = alert.Severity switch
{
NotificationSeverity.Error => "Error: ",
NotificationSeverity.Warning => "Warning: ",
_ => string.Empty,
};
var title = OneLine(alert.Title, 160);
return $"{SubjectPrefix} {severity}{(title.Length > 0 ? title : "Notification")}";
}
public static string Html(NotificationAlert alert)
{
var accent = alert.Severity switch
{
NotificationSeverity.Error => "#dc2626",
NotificationSeverity.Warning => "#f59e0b",
NotificationSeverity.Success => "#16a34a",
_ => "#0284c7",
};
var sb = new StringBuilder(2048);
sb.Append("<!DOCTYPE html><html><body style=\"margin:0;padding:24px;background:#f9fafb;font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111827;\">");
sb.Append("<div style=\"max-width:640px;margin:0 auto;background:#ffffff;border-radius:8px;border-left:4px solid ").Append(accent).Append(";padding:20px 24px;\">");
sb.Append("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">").Append(E(alert.Title)).Append("</h1>");
if (!string.IsNullOrWhiteSpace(alert.Summary))
sb.Append("<p style=\"margin:0 0 16px 0;font-size:14px;color:#374151;\">").Append(E(alert.Summary)).Append("</p>");
var facts = alert.Facts.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)).ToList();
if (facts.Count > 0)
{
sb.Append("<table style=\"border-collapse:collapse;font-size:14px;margin:0 0 16px 0;\">");
foreach (var fact in facts)
{
sb.Append("<tr><td style=\"padding:3px 16px 3px 0;color:#6b7280;vertical-align:top;white-space:nowrap;\">").Append(E(fact.Name))
.Append("</td><td style=\"padding:3px 0;\">").Append(E(fact.Value)).Append("</td></tr>");
}
sb.Append("</table>");
}
foreach (var section in alert.Sections.Where(s => s != null && !string.IsNullOrWhiteSpace(s.Text)))
{
if (!string.IsNullOrWhiteSpace(section.Title))
sb.Append("<h2 style=\"font-size:14px;margin:16px 0 4px 0;\">").Append(E(section.Title)).Append("</h2>");
sb.Append("<p style=\"margin:0 0 12px 0;font-size:14px;white-space:pre-wrap;\">").Append(E(section.Text)).Append("</p>");
}
var links = alert.Actions.Where(a => a != null && IsHttpUrl(a.Url)).ToList();
if (links.Count > 0)
{
sb.Append("<p style=\"margin:16px 0 0 0;font-size:14px;\">");
foreach (var action in links)
{
sb.Append("<a href=\"").Append(E(action.Url)).Append("\" style=\"color:#0369a1;margin-right:16px;\">")
.Append(E(string.IsNullOrWhiteSpace(action.Title) ? action.Url : action.Title)).Append("</a>");
}
sb.Append("</p>");
}
if (!string.IsNullOrWhiteSpace(alert.DataJson))
{
var payload = alert.DataJson.Length > MaxPayloadChars ? alert.DataJson[..MaxPayloadChars] + "\n… (truncated)" : alert.DataJson;
sb.Append("<h2 style=\"font-size:14px;margin:16px 0 4px 0;\">Details</h2>");
sb.Append("<pre style=\"margin:0;padding:12px;background:#f3f4f6;border-radius:6px;font-size:12px;overflow:auto;white-space:pre-wrap;\">").Append(E(payload)).Append("</pre>");
}
sb.Append("<p style=\"margin:20px 0 0 0;font-size:12px;color:#9ca3af;\">Sent by Autopilot Monitor");
if (!string.IsNullOrWhiteSpace(alert.EventType))
sb.Append(" · ").Append(E(alert.EventType));
sb.Append("</p></div></body></html>");
return sb.ToString();
}
private static string E(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
private static string OneLine(string? value, int max)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var flat = string.Join(" ", value.Split(new[] { '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries)).Trim();
return flat.Length > max ? flat[..max] + "…" : flat;
}
private static bool IsHttpUrl(string? url)
=> Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
}
@@ -0,0 +1,113 @@
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Notifications;
/// <summary>
/// E-mail as a notification-channel provider (plan email-notification-channel): renders the alert
/// with <see cref="EmailAlertRenderer"/> and sends one message per recipient through
/// <see cref="EmailService.SendMessageAsync"/>, the same provider path as the welcome and farewell
/// mails. Outcomes land in the <c>EmailDelivery</c> custom event and the log — never in an ops
/// event, because an ops event could be bound to this very channel.
/// </summary>
public class EmailNotificationService : IEmailChannelSender
{
public const string DeliveryEventName = "EmailDelivery";
public const int MaxRecipients = 5;
public const string Tag = "alert";
private readonly EmailService _email;
private readonly ILogger<EmailNotificationService> _logger;
private readonly TelemetryClient? _telemetry;
public EmailNotificationService(EmailService email, ILogger<EmailNotificationService> logger, TelemetryClient? telemetry = null)
{
_email = email;
_logger = logger;
_telemetry = telemetry;
}
/// <summary>
/// The recipients a channel's destination names: trimmed, de-duplicated (case-insensitive),
/// in order, blanks dropped. Validation of the addresses themselves happens when the channel
/// is saved (<c>TenantConfigValidation.ValidateEmailDestination</c>).
/// </summary>
public static IReadOnlyList<string> ParseRecipients(string? destination)
{
if (string.IsNullOrWhiteSpace(destination))
return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
foreach (var part in destination.Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var address = part.Trim();
if (address.Length > 0 && seen.Add(address))
result.Add(address);
}
return result;
}
public async Task SendOpsAlertAsync(string recipients, NotificationAlert alert)
{
try
{
await SendAlertWithResultAsync(recipients, alert).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "E-mail alert send failed for {EventType}", alert?.EventType);
}
}
public async Task<WebhookTestResult> SendAlertWithResultAsync(string recipients, NotificationAlert alert)
{
var addresses = ParseRecipients(recipients);
if (addresses.Count == 0)
return new WebhookTestResult { Success = false, Message = "This e-mail channel has no recipient." };
if (!_email.IsConfigured)
{
Track("not_configured", addresses.Count, 0);
return new WebhookTestResult { Success = false, Message = $"The e-mail provider is not configured ({EmailService.ApiKeyConfigKey})." };
}
var subject = EmailAlertRenderer.Subject(alert);
var html = EmailAlertRenderer.Html(alert);
var accepted = 0;
foreach (var address in addresses.Take(MaxRecipients))
{
if (await _email.SendMessageAsync(address, subject, html, Tag).ConfigureAwait(false))
accepted++;
}
var attempted = Math.Min(addresses.Count, MaxRecipients);
Track(accepted == attempted ? "delivered" : accepted == 0 ? "failed" : "partial", attempted, accepted);
if (accepted < attempted)
_logger.LogWarning("E-mail alert {EventType}: provider accepted {Accepted} of {Attempted} recipient(s)", alert.EventType, accepted, attempted);
return new WebhookTestResult
{
Success = accepted > 0,
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.",
};
}
private void Track(string outcome, int attempted, int accepted)
{
try
{
_telemetry?.TrackEvent(DeliveryEventName, new Dictionary<string, string>
{
["outcome"] = outcome,
["attempted"] = attempted.ToString(),
["accepted"] = accepted.ToString(),
});
}
catch
{
// Telemetry never fails a send.
}
}
}
@@ -0,0 +1,18 @@
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Services.Notifications;
/// <summary>
/// The dispatcher's e-mail transport: a channel of <see cref="WebhookProviderType.Email"/> carries
/// its recipients in <see cref="NotificationChannel.Url"/> (one to five addresses, ';' or ','
/// separated) and sends through the platform's own sender identity — which is why the provider is
/// Global-Admin-only. Never throws into the dispatcher.
/// </summary>
public interface IEmailChannelSender
{
/// <summary>Fire-and-forget semantics for the dispatcher: logs, never throws.</summary>
Task SendOpsAlertAsync(string recipients, NotificationAlert alert);
/// <summary>The "send test" path: reports what the provider accepted.</summary>
Task<WebhookTestResult> SendAlertWithResultAsync(string recipients, NotificationAlert alert);
}
@@ -10,7 +10,9 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// (enrollment, SLA, analyze rules, ops alerts) goes through here, so a new provider is added
/// in exactly one place.
/// <para>
/// Three transports: <see cref="WebhookProviderType.Telegram"/> goes to
/// Four transports: <see cref="WebhookProviderType.Email"/> goes to the
/// <see cref="IEmailChannelSender"/> (the platform's own sender, recipients in the channel's
/// Url); <see cref="WebhookProviderType.Telegram"/> goes to
/// <see cref="TelegramNotificationService"/> (plain text through the platform bot, the channel's
/// Url field carrying the chat ID); <see cref="WebhookProviderType.Push"/> goes to the
/// <see cref="IPushChannelSender"/> (encrypted Web Push to the paired devices of the
@@ -25,6 +27,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
private readonly WebhookNotificationService _webhook;
private readonly TelegramNotificationService _telegram;
private readonly IPushChannelSender _push;
private readonly IEmailChannelSender? _email;
public NotificationChannelDispatcher(
WebhookNotificationService webhook,
@@ -36,6 +39,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
_push = push;
}
/// <summary>With the e-mail transport (DI picks this one); the shorter overload stays for the existing mocks.</summary>
public NotificationChannelDispatcher(
WebhookNotificationService webhook,
TelegramNotificationService telegram,
IPushChannelSender push,
IEmailChannelSender email)
: this(webhook, telegram, push)
{
_email = email;
}
/// <summary>
/// Sends a notification to every channel in <paramref name="channels"/> (callers pre-filter
/// by <see cref="NotificationChannel.Enabled"/> and the relevant NotifyOn* toggle, or by
@@ -57,6 +71,13 @@ namespace AutopilotMonitor.Functions.Services.Notifications
continue;
}
if (channel.ProviderType == (int)WebhookProviderType.Email)
{
if (_email != null)
await _email.SendOpsAlertAsync(channel.Url!, alert);
continue;
}
if (channel.ProviderType == (int)WebhookProviderType.Telegram)
{
await _telegram.SendOpsAlertAsync(channel.Url!, alert);
@@ -85,6 +106,13 @@ namespace AutopilotMonitor.Functions.Services.Notifications
if (channel.ProviderType == (int)WebhookProviderType.Push)
return await _push.SendWithResultAsync(scope, alert);
if (channel.ProviderType == (int)WebhookProviderType.Email)
{
return _email == null
? new WebhookTestResult { Success = false, Message = "The e-mail transport is not registered." }
: await _email.SendAlertWithResultAsync(channel.Url!, alert);
}
if (channel.ProviderType == (int)WebhookProviderType.Telegram)
return await _telegram.SendAlertWithResultAsync(channel.Url!, alert);
@@ -71,12 +71,14 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// <summary>
/// Providers a tenant admin may not create or retarget: they send through PLATFORM-owned
/// infrastructure (the Telegram bot; the push sender and its VAPID key). Enforced server-side
/// in TenantConfigValidation, mirrored by the <c>gaOnly</c> flag in the web ChannelEditor.
/// infrastructure (the Telegram bot; the push sender and its VAPID key; the platform's e-mail
/// sender). Enforced server-side in TenantConfigValidation, mirrored by the <c>gaOnly</c> flag
/// in the web ChannelEditor.
/// </summary>
public static bool IsGlobalAdminOnlyProvider(int providerType)
=> providerType == (int)WebhookProviderType.Telegram
|| providerType == (int)WebhookProviderType.Push;
|| providerType == (int)WebhookProviderType.Push
|| providerType == (int)WebhookProviderType.Email;
/// <summary>
/// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }), applied
@@ -51,5 +51,14 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// reserved for this provider even if it were ever retired.
/// </summary>
Push = 50,
/// <summary>
/// E-mail through the platform's own sender (the same provider path as the welcome and
/// farewell mails, <c>noreply@autopilotmonitor.com</c>). Not a webhook: <c>Url</c> carries
/// the recipients — one to five addresses, ';' or ',' separated — and the message is rendered
/// by the backend. Global-Admin only (same gate as Telegram and Push, enforced in
/// TenantConfigValidation) because it sends under the platform's identity and API key.
/// </summary>
Email = 60,
}
}
@@ -13,6 +13,7 @@ import { PUSH_PROVIDER } from "@/lib/pushPortal";
const GENERIC_PROVIDER = 20;
const TELEGRAM_PROVIDER = 40;
const EMAIL_PROVIDER = 60;
/** Default destination sentence of a Push channel (tenant scope); the ops card passes its own. */
export const PUSH_DESTINATION_HINT_TENANT =
@@ -38,6 +39,9 @@ export const PROVIDERS: {
// recipients are the scope's paired devices, so the URL field gives way to one sentence.
// GA-only like Telegram until the customer release (K4: one gate until then).
{ value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "", gaOnly: true },
// E-mail through the platform's own sender (noreply@autopilotmonitor.com) and provider key —
// GA-only like Telegram and Push; the destination is one to five recipient addresses.
{ value: EMAIL_PROVIDER, label: "E-mail (from noreply@autopilotmonitor.com)", placeholder: "ops@example.com; second@example.com", gaOnly: true },
];
const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [
@@ -165,7 +169,7 @@ export function ChannelEditor({
onTest: () => void;
testing: boolean;
testResult: { success: boolean; message: string } | null;
/** Global Admin: may pick the platform-owned providers (Telegram bot, Web Push). */
/** Global Admin: may pick the platform-owned providers (Telegram bot, Web Push, e-mail). */
showTelegramProvider?: boolean;
/** Tenant channels subscribe to event kinds here; ops channels are targeted by rules instead. */
showEventToggles?: boolean;
@@ -175,6 +179,7 @@ export function ChannelEditor({
const placeholder = PROVIDERS.find((p) => p.value === channel.providerType)?.placeholder;
const isTelegram = channel.providerType === TELEGRAM_PROVIDER;
const isPush = channel.providerType === PUSH_PROVIDER;
const isEmail = channel.providerType === EMAIL_PROVIDER;
// A Push channel has no destination, so it is configured by being enabled alone.
const isActive = channel.enabled && (isPush || (channel.url ?? "").length > 0);
// Keep the channel's own provider listed even when gated, so a GA-created Telegram channel
@@ -261,7 +266,7 @@ export function ChannelEditor({
{!isPush && (
<div>
<label className="block">
<span className="text-gray-700 font-medium text-sm">{isTelegram ? "Chat ID" : "Webhook URL"}</span>
<span className="text-gray-700 font-medium text-sm">{isTelegram ? "Chat ID" : isEmail ? "Recipients" : "Webhook URL"}</span>
<p className="text-xs text-gray-500 mb-1">
{channel.providerType === 2
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
@@ -273,11 +278,13 @@ export function ChannelEditor({
? "Create a webhook in your Discord channel (Channel settings → Integrations → Webhooks) and paste the URL here."
: isTelegram
? "Telegram chat or channel ID (negative number for groups), or an @username. Messages are delivered by the platform bot — add it to the chat first."
: isEmail
? "One to five addresses, separated by semicolons. Each alert is sent as one e-mail per recipient from noreply@autopilotmonitor.com."
: ""}
</p>
<div className="flex items-center gap-2">
<input
type={isTelegram ? "text" : "url"}
type={isTelegram || isEmail ? "text" : "url"}
value={channel.url ?? ""}
onChange={(e) => onChange({ ...channel, url: e.target.value })}
placeholder={placeholder}
@@ -478,7 +478,8 @@ export const SHARED_MANIFEST = {
"GenericJson": 20,
"Discord": 30,
"Telegram": 40,
"Push": 50
"Push": 50,
"Email": 60
},
"annotationLanes": [
"operator",
@@ -474,7 +474,8 @@
"GenericJson": 20,
"Discord": 30,
"Telegram": 40,
"Push": 50
"Push": 50,
"Email": 60
},
"annotationLanes": [
"operator",