diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs
new file mode 100644
index 00000000..dfe7d925
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs
@@ -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;
+
+///
+/// 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.
+///
+public class EmailChannelProviderTests
+{
+ private const int Email = (int)WebhookProviderType.Email;
+ private static readonly NotificationScope Platform = NotificationScope.Platform;
+
+ private static Mock WebhookMock()
+ => new(new HttpClient(), NullLogger.Instance) { CallBase = false };
+
+ private static Mock TelegramMock()
+ => new(new HttpClient(), Mock.Of(), NullLogger.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 " } },
+ 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();
+ var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of(), 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(), It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()), Times.Never);
+ telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result()
+ {
+ var email = new Mock();
+ email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny()))
+ .ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." });
+ var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of(), email.Object);
+
+ await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
+ email.Verify(e => e.SendOpsAlertAsync(It.IsAny(), It.IsAny()), 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());
+
+ 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 ")]
+ [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\":\"