mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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 <kid=abc>", html); // section text escaped
|
||||
Assert.DoesNotContain("<kid=abc>", html);
|
||||
Assert.Contains("href=\"https://portal.example.invalid/admin/settings/alerts\"", html);
|
||||
Assert.Contains("<script>", 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);
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
+29
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user