diff --git a/docs/backend/tenant-activation-welcome-mail.md b/docs/backend/tenant-activation-welcome-mail.md index b27c110d..ca5d9436 100644 --- a/docs/backend/tenant-activation-welcome-mail.md +++ b/docs/backend/tenant-activation-welcome-mail.md @@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other. resource: backend tags: [activation, auto-approve, welcome-email, preview-whitelist, race] -timestamp: 2026-08-02 +timestamp: 2026-08-22 --- # Schema @@ -58,6 +58,27 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI always sends (explicit intent) and consumes the marker best-effort so the automatic paths never duplicate afterwards. +## Mail transport + +Both transactional mails (welcome, post-offboarding farewell) go through the +provider-neutral `EmailService` (`IEmailService` for the welcome path, +`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation +detail confined to one private transport method plus the `Email:*` configuration +section — a provider swap must not rename classes, DI registrations or callers. + +* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}` + (`messages/send`), API key in the JSON body, typed `HttpClient` with the shared + `ResiliencePolicies.Notification` retry policy (15 s timeout). +* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every + send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress` + (default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`). +* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success; + `rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning. + Never throws — both callers rely on that fail-soft contract. +* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider + receives only the recipient address and the tenant domain, which is exactly the claim on + the privacy page and the trust data-flows page. Changing tracking = changing those pages. + # Examples * Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the @@ -73,3 +94,5 @@ paths never duplicate afterwards. * `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend * `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent) * `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins +* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off) +* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins diff --git a/docs/log.md b/docs/log.md index 4518a4fb..ff905f7c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-08-22 +* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it. + * **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace. ## 2026-08-21 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs new file mode 100644 index 00000000..5afa8f4b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs @@ -0,0 +1,311 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Coverage for the provider-neutral (welcome + farewell mails). +/// The provider HTTP call is driven through a stub handler, so the Mandrill wire format, +/// the result-array interpretation and the fail-soft contract are pinned without network. +/// +/// Neither send method ever throws — not on a missing key, missing recipient, +/// HTTP 500, a rejected recipient, nor a malformed body. +/// Gate ordering: missing-key short-circuits before missing-recipient. +/// Tracking is off on the wire (trust-page claim: provider receives only the +/// recipient address and tenant domain). +/// The farewell template is final: no [DRAFT]/placeholder residue, domain is +/// interpolated, empty domain falls back to "your organization". +/// +/// +public sealed class EmailServiceTests +{ + private const string TenantId = "88888888-8888-8888-8888-888888888888"; + + // ----- configuration gates ----- + + [Fact] + public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine() + { + var (sut, handler, logger) = Build(apiKey: ""); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None); + + Assert.Equal(0, handler.CallCount); + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured")); + } + + [Fact] + public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine() + { + var (sut, handler, logger) = Build(apiKey: "any-non-empty-key"); + + await sut.SendAsync("", "contoso.invalid", TenantId); + + Assert.Equal(0, handler.CallCount); + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured")); + } + + [Fact] + public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck() + { + // Pins the gate ordering: missing-key > missing-email. Both are empty here; only + // the key line must show up. + var (sut, _, logger) = Build(apiKey: ""); + + await sut.SendAsync("", "contoso.invalid", TenantId); + + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured")); + Assert.DoesNotContain(logger.Entries, + e => e.Message.Contains("No notification email captured")); + } + + [Fact] + public async Task SendPreviewApprovedEmailAsync_EmptyApiKey_NoOps() + { + var (sut, handler, logger) = Build(apiKey: ""); + + await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid"); + + Assert.Equal(0, handler.CallCount); + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured")); + } + + // ----- wire format ----- + + [Fact] + public async Task SendPreviewApprovedEmailAsync_PostsMandrillPayload_WithTrackingOff() + { + var (sut, handler, logger) = Build(apiKey: "md-test-key"); + + await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid"); + + Assert.Equal(1, handler.CallCount); + Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); + Assert.Equal(EmailService.DefaultEndpoint, handler.LastRequest.RequestUri!.ToString()); + + using var doc = JsonDocument.Parse(handler.LastBody!); + var root = doc.RootElement; + Assert.Equal("md-test-key", root.GetProperty("key").GetString()); + + var message = root.GetProperty("message"); + Assert.Equal(EmailService.DefaultFromAddress, message.GetProperty("from_email").GetString()); + Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString()); + Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString()); + Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString()); + Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString()); + Assert.Contains("contoso.invalid", message.GetProperty("html").GetString()); + Assert.True(message.GetProperty("auto_text").GetBoolean()); + Assert.False(message.GetProperty("track_opens").GetBoolean()); + Assert.False(message.GetProperty("track_clicks").GetBoolean()); + Assert.Equal("welcome", message.GetProperty("tags")[0].GetString()); + + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Information && e.Message.Contains("Preview approval email sent")); + } + + [Fact] + public async Task SendAsync_UsesFarewellTemplate_AndFarewellTag() + { + var (sut, handler, logger) = Build(apiKey: "md-test-key"); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId); + + using var doc = JsonDocument.Parse(handler.LastBody!); + var message = doc.RootElement.GetProperty("message"); + Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString()); + Assert.Equal("offboarding-farewell", message.GetProperty("tags")[0].GetString()); + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Information && e.Message.Contains("Offboard farewell email sent")); + } + + [Fact] + public async Task ConfiguredEndpointAndSender_OverrideDefaults() + { + var (sut, handler, _) = Build(apiKey: "md-test-key", extra: new Dictionary + { + ["Email:Endpoint"] = "https://mail.example.invalid/send", + ["Email:FromAddress"] = "hello@example.invalid", + ["Email:FromName"] = "Example Sender", + }); + + await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid"); + + Assert.Equal("https://mail.example.invalid/send", handler.LastRequest!.RequestUri!.ToString()); + using var doc = JsonDocument.Parse(handler.LastBody!); + var message = doc.RootElement.GetProperty("message"); + Assert.Equal("hello@example.invalid", message.GetProperty("from_email").GetString()); + Assert.Equal("Example Sender", message.GetProperty("from_name").GetString()); + } + + // ----- provider responses / fail-soft ----- + + [Fact] + public async Task ProviderHttpError_DoesNotThrow_AndLogsWarning_WithoutSuccessLine() + { + var (sut, _, logger) = Build(apiKey: "md-test-key", + responder: _ => Json(HttpStatusCode.InternalServerError, + "{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}")); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId); + + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key")); + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information); + } + + [Fact] + public async Task ProviderRejectsRecipient_LogsWarningWithReason_WithoutSuccessLine() + { + var (sut, _, logger) = Build(apiKey: "md-test-key", + responder: _ => Json(HttpStatusCode.OK, + "[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]")); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId); + + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce")); + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information); + } + + [Theory] + [InlineData("sent")] + [InlineData("queued")] + [InlineData("scheduled")] + public async Task ProviderAcceptedStatuses_CountAsSuccess(string status) + { + var (sut, _, logger) = Build(apiKey: "md-test-key", + responder: _ => Json(HttpStatusCode.OK, + $"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]")); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId); + + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information); + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning); + } + + [Fact] + public async Task MalformedProviderBody_DoesNotThrow_AndLogsWarning() + { + var (sut, _, logger) = Build(apiKey: "md-test-key", + responder: _ => Json(HttpStatusCode.OK, "not json")); + + await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId); + + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning); + Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information); + } + + [Fact] + public async Task TransportException_DoesNotThrow_AndLogsWarning() + { + var (sut, _, logger) = Build(apiKey: "md-test-key", + responder: _ => throw new HttpRequestException("simulated provider outage")); + + await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid"); + + Assert.Contains(logger.Entries, + e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to send welcome mail")); + } + + // ----- templates ----- + + [Fact] + public void FarewellTemplate_IsFinal_NoDraftResidue() + { + Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject); + + var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"); + Assert.DoesNotContain("DRAFT", html); + Assert.DoesNotContain("TEMPLATE NOT FINALISED", html); + Assert.DoesNotContain("TODO", html); + } + + [Fact] + public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels() + { + var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"); + + Assert.Contains("contoso.invalid", html); + // The mail is sent from a noreply address after portal access is gone, so the + // feedback pointers must be external channels the recipient can actually reach. + Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html); + Assert.Contains("linkedin.com", html); + } + + [Fact] + public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel() + { + var html = EmailTemplates.GetOffboardingFarewellHtml(""); + Assert.Contains("your organization", html); + } + + // ----- harness ----- + + private static HttpResponseMessage Json(HttpStatusCode status, string body) + => new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + private static (EmailService sut, StubHandler handler, CapturingLogger logger) Build( + string apiKey, + Dictionary? extra = null, + System.Func? responder = null) + { + var settings = new Dictionary { ["Email:ApiKey"] = apiKey }; + if (extra is not null) + foreach (var kv in extra) settings[kv.Key] = kv.Value; + + var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var handler = new StubHandler(); + if (responder is not null) handler.Responder = responder; + var logger = new CapturingLogger(); + var sut = new EmailService(new HttpClient(handler, disposeHandler: false), config, logger); + return (sut, handler, logger); + } + + private sealed class StubHandler : HttpMessageHandler + { + public int CallCount { get; private set; } + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastBody { get; private set; } + public System.Func Responder { get; set; } = _ => + Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]"); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + LastRequest = request; + LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return Responder(request); + } + } + + private sealed class CapturingLogger : ILogger + { + public readonly List<(LogLevel Level, string Message)> Entries = new(); + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log( + LogLevel logLevel, EventId eventId, TState state, System.Exception? exception, + System.Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardFarewellEmailSender.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardFarewellEmailSender.cs index 431fe9a0..d9fb3534 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardFarewellEmailSender.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardFarewellEmailSender.cs @@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding; /// /// Test double for . Records every invocation so /// the handler-side tests can assert "did the farewell send fire after History → Completed?" -/// without booting the real Resend client. Default = silent noop; set +/// without booting the real mail provider client. Default = silent noop; set /// to simulate downstream failures and prove the handler's fail-soft contract. /// internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs index 2a7886f0..6c7f4697 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -221,7 +221,7 @@ public class TenantOffboardingHandlerOrderingTests public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailNull() { // Tenants that never set a preview-notification email get null capture; the handler - // skip-gates the send so no Resend call is even attempted. + // skip-gates the send so no provider call is even attempted. var harness = Harness.New(); Assert.Null(harness.Repo.History[HistoryRowKey].NotificationEmail); @@ -235,7 +235,7 @@ public class TenantOffboardingHandlerOrderingTests public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailWhitespace() { // Defensive: legacy History rows may have a non-null empty/whitespace value. Treat - // these as "no email captured" so we never call Resend with a useless string. + // these as "no email captured" so we never call the mail provider with a useless string. var harness = Harness.New(); harness.Repo.History[HistoryRowKey].NotificationEmail = " "; @@ -248,11 +248,11 @@ public class TenantOffboardingHandlerOrderingTests [Fact] public async Task PostDrain_FarewellEmail_SenderThrows_HandlerSurvives_HistoryStillCompleted() { - // The send is best-effort. A Resend outage / template bug / serializer crash must + // The send is best-effort. A mail-provider outage / template bug / serializer crash must // NOT propagate to the queue worker — that would re-poison a Completed offboard. var harness = Harness.New(); harness.Repo.History[HistoryRowKey].NotificationEmail = "ops@contoso.invalid"; - harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated Resend outage"); + harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated mail-provider outage"); await harness.Sut.HandleAsync(harness.Envelope()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ResendEmailServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ResendEmailServiceTests.cs deleted file mode 100644 index fa651277..00000000 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ResendEmailServiceTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Functions.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace AutopilotMonitor.Functions.Tests; - -/// -/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP -/// call is not exercised here — every test drives one of the no-op gates. Pins: -/// -/// never throws — not when the API key -/// is empty and not when the recipient is missing. The handler's fail-soft -/// try/catch is a belt; this is the suspenders. -/// Gate ordering: missing-key short-circuits before missing-recipient. -/// The farewell template is final: no [DRAFT]/placeholder residue, domain is -/// interpolated, empty domain falls back to "your organization". -/// -/// -public sealed class ResendEmailServiceTests -{ - [Fact] - public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine() - { - var logger = new CapturingLogger(); - var sut = Build(apiKey: "", logger); - - await sut.SendAsync( - toEmail: "ops@contoso.invalid", - domainName: "contoso.invalid", - tenantId: "88888888-8888-8888-8888-888888888888", - ct: CancellationToken.None); - - Assert.Contains(logger.Entries, - e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured")); - } - - [Fact] - public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine() - { - var logger = new CapturingLogger(); - var sut = Build(apiKey: "any-non-empty-key", logger); - - await sut.SendAsync( - toEmail: "", - domainName: "contoso.invalid", - tenantId: "88888888-8888-8888-8888-888888888888"); - - Assert.Contains(logger.Entries, - e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured")); - } - - [Fact] - public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck() - { - // Pins the gate ordering: missing-key > missing-email. Both are empty here; only - // the key line must show up. - var logger = new CapturingLogger(); - var sut = Build(apiKey: "", logger); - - await sut.SendAsync( - toEmail: "", - domainName: "contoso.invalid", - tenantId: "88888888-8888-8888-8888-888888888888"); - - Assert.Contains(logger.Entries, - e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured")); - Assert.DoesNotContain(logger.Entries, - e => e.Message.Contains("No notification email captured")); - } - - [Fact] - public void FarewellTemplate_IsFinal_NoDraftResidue() - { - Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject); - - var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"); - Assert.DoesNotContain("DRAFT", html); - Assert.DoesNotContain("TEMPLATE NOT FINALISED", html); - Assert.DoesNotContain("TODO", html); - } - - [Fact] - public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels() - { - var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"); - - Assert.Contains("contoso.invalid", html); - // The mail is sent from a noreply address after portal access is gone, so the - // feedback pointers must be external channels the recipient can actually reach. - Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html); - Assert.Contains("linkedin.com", html); - } - - [Fact] - public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel() - { - var html = EmailTemplates.GetOffboardingFarewellHtml(""); - Assert.Contains("your organization", html); - } - - private static ResendEmailService Build(string apiKey, ILogger logger) - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["RESEND_API_KEY"] = apiKey, - }) - .Build(); - return new ResendEmailService(config, logger); - } - - private sealed class CapturingLogger : ILogger - { - public readonly List<(LogLevel Level, string Message)> Entries = new(); - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - public bool IsEnabled(LogLevel logLevel) => true; - public void Log( - LogLevel logLevel, EventId eventId, TState state, System.Exception? exception, - System.Func formatter) - { - Entries.Add((logLevel, formatter(state, exception))); - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - public void Dispose() { } - } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs index 2ea1f126..a03759ec 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs @@ -21,7 +21,7 @@ public class TenantApprovalServiceTests private readonly Mock _previewMock; private readonly Mock _tenantConfigMock; - private readonly Mock _resendMock; + private readonly Mock _emailMock; private readonly TenantApprovalService _sut; public TenantApprovalServiceTests() @@ -37,9 +37,10 @@ public class TenantApprovalServiceTests configRepo, cache, NullLogger.Instance, _tenantConfigMock.Object) { CallBase = false }; - _resendMock = new Mock( - Mock.Of(), - NullLogger.Instance); + _emailMock = new Mock(); + _emailMock.Setup(x => x.SendPreviewApprovedEmailAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); _sut = new TenantApprovalService( NullLogger.Instance, @@ -47,7 +48,7 @@ public class TenantApprovalServiceTests _tenantConfigMock.Object, new Mock( Mock.Of(), cache, NullLogger.Instance).Object, - _resendMock.Object); + _emailMock.Object); } [Fact] @@ -94,7 +95,7 @@ public class TenantApprovalServiceTests Assert.False(sent); _previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny()), Times.Never); - _resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + _emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -109,7 +110,7 @@ public class TenantApprovalServiceTests var sent = await _sut.TrySendWelcomeEmailAsync(TenantId); Assert.True(sent); - _resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once); + _emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny()), Times.Once); } [Fact] @@ -121,7 +122,7 @@ public class TenantApprovalServiceTests var sent = await _sut.TrySendWelcomeEmailAsync(TenantId); Assert.False(sent); - _resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + _emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -134,7 +135,7 @@ public class TenantApprovalServiceTests var sent = await _sut.TrySendWelcomeEmailAsync(TenantId); Assert.False(sent); - _resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + _emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -151,6 +152,6 @@ public class TenantApprovalServiceTests var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy); Assert.True(newlyApproved); - _resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny()), Times.Once); + _emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs index 20e11b09..3d102d4c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantAutoApproveHandlerTests.cs @@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests _tenantConfigMock.Object, new Mock( Mock.Of(), cache, NullLogger.Instance).Object, - new Mock( - Mock.Of(), - NullLogger.Instance).Object) + Mock.Of()) { CallBase = false }; var opsRepo = new Mock(); diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index d42d34af..06d1ed65 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -56,7 +56,6 @@ - diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs index 9324a4ec..9ced0a87 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs @@ -19,20 +19,20 @@ public class PreviewWhitelistFunction private readonly PreviewWhitelistService _previewWhitelistService; private readonly TenantApprovalService _tenantApprovalService; private readonly TenantConfigurationService _tenantConfigurationService; - private readonly ResendEmailService _resendEmailService; + private readonly IEmailService _emailService; public PreviewWhitelistFunction( ILogger logger, PreviewWhitelistService previewWhitelistService, TenantApprovalService tenantApprovalService, TenantConfigurationService tenantConfigurationService, - ResendEmailService resendEmailService) + IEmailService emailService) { _logger = logger; _previewWhitelistService = previewWhitelistService; _tenantApprovalService = tenantApprovalService; _tenantConfigurationService = tenantConfigurationService; - _resendEmailService = resendEmailService; + _emailService = emailService; } /// @@ -221,7 +221,7 @@ public class PreviewWhitelistFunction } var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId); - await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName); + await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName); // Explicit GA send always sends; consume the once-only marker (best-effort) so // the automatic paths won't produce a duplicate afterwards. diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 669a7d86..d598e8df 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -437,8 +437,13 @@ builder.Services.AddHttpClient sp.GetRequiredService().Notification); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); +// Transactional email (welcome / farewell). Typed client like the other outbound notifiers; +// the provider is an implementation detail of EmailService + the Email:* settings. +builder.Services.AddHttpClient() + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15)) + .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs new file mode 100644 index 00000000..c6925b7f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs @@ -0,0 +1,237 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Sends the product's transactional emails (tenant-activation welcome, post-offboarding +/// farewell). Best-effort: failures are logged as warnings and never propagated. +/// +/// The class and its callers are provider-neutral. Everything provider-specific is confined +/// to the Email:* configuration section and , so a +/// future provider swap touches the transport method and the app settings only. +/// Current provider: Mailchimp Transactional (Mandrill) — POST {Email:Endpoint} with the +/// API key in the JSON body. Open/click tracking is explicitly disabled so the provider +/// receives nothing beyond the recipient address and the tenant domain (trust-page claim). +/// +/// +/// Configuration (App Settings use Email__Key): +/// Email:ApiKey (required; empty ⇒ every send is a logged no-op), +/// Email:Endpoint (default ), +/// Email:FromAddress (default ), +/// Email:FromName (default ). +/// +/// +public class EmailService : IEmailService, IOffboardFarewellEmailSender +{ + public const string ApiKeyConfigKey = "Email:ApiKey"; + public const string DefaultEndpoint = "https://mandrillapp.com/api/1.0/messages/send"; + public const string DefaultFromAddress = "noreply@autopilotmonitor.com"; + public const string DefaultFromName = "Autopilot Monitor"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly string _apiKey; + private readonly string _endpoint; + private readonly string _fromAddress; + private readonly string _fromName; + + public EmailService( + HttpClient http, + IConfiguration configuration, + ILogger logger) + { + _http = http; + _logger = logger; + _apiKey = configuration[ApiKeyConfigKey] ?? string.Empty; + _endpoint = FirstNonBlank(configuration["Email:Endpoint"], DefaultEndpoint); + _fromAddress = FirstNonBlank(configuration["Email:FromAddress"], DefaultFromAddress); + _fromName = FirstNonBlank(configuration["Email:FromName"], DefaultFromName); + } + + /// + public async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_apiKey)) + { + _logger.LogDebug("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey); + return; + } + + if (string.IsNullOrWhiteSpace(toEmail)) + { + _logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName); + return; + } + + var sent = await SendViaMandrillAsync( + toEmail, + EmailTemplates.PreviewApprovedSubject, + EmailTemplates.GetPreviewApprovedHtml(domainName), + tag: "welcome", + ct); + + if (sent) + { + _logger.LogInformation( + "Preview approval email sent to {ToEmail} for domain {Domain}", + toEmail, domainName); + } + } + + /// + /// Sends the post-offboarding "sorry to see you go" farewell email. + /// No-op if the API key or recipient email is not configured. Best-effort: failures + /// are logged as warnings and never propagated (the offboarding correctness contract + /// does not depend on email delivery). + /// + public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_apiKey)) + { + _logger.LogDebug( + "{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}", + ApiKeyConfigKey, tenantId); + return; + } + + if (string.IsNullOrWhiteSpace(toEmail)) + { + _logger.LogDebug( + "No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})", + tenantId, domainName); + return; + } + + var sent = await SendViaMandrillAsync( + toEmail, + EmailTemplates.OffboardingFarewellSubject, + EmailTemplates.GetOffboardingFarewellHtml(domainName), + tag: "offboarding-farewell", + ct); + + if (sent) + { + _logger.LogInformation( + "Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})", + toEmail, tenantId, domainName); + } + } + + /// + /// The only provider-specific code path. Posts a Mandrill messages/send request and + /// interprets the per-recipient result array: sent/queued/scheduled + /// count as success, rejected/invalid as failure (with the provider's + /// reject_reason in the warning). Never throws. + /// + private async Task SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct) + { + try + { + var request = new MandrillSendRequest + { + Key = _apiKey, + Message = new MandrillMessage + { + FromEmail = _fromAddress, + FromName = _fromName, + To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } }, + Subject = subject, + Html = html, + AutoText = true, + TrackOpens = false, + TrackClicks = false, + Tags = new[] { tag }, + }, + }; + + using var response = await _http.PostAsJsonAsync(_endpoint, request, JsonOptions, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Email provider returned {StatusCode} for {Tag} mail to {ToEmail}: {Body}", + (int)response.StatusCode, tag, toEmail, Truncate(body)); + return false; + } + + var results = JsonSerializer.Deserialize(body, JsonOptions); + var result = results?.FirstOrDefault(); + if (result is null) + { + _logger.LogWarning( + "Email provider returned an empty result for {Tag} mail to {ToEmail}: {Body}", + tag, toEmail, Truncate(body)); + return false; + } + + if (!IsAcceptedStatus(result.Status)) + { + _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 true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send {Tag} mail to {ToEmail}", tag, toEmail); + return false; + } + } + + private static bool IsAcceptedStatus(string? status) + => status is "sent" or "queued" or "scheduled"; + + private static string FirstNonBlank(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + private static string Truncate(string value) + => value.Length <= 500 ? value : value[..500] + "…"; + + // --- Mandrill wire format (https://mailchimp.com/developer/transactional/api/messages/send-new-message/) --- + + private sealed class MandrillSendRequest + { + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("message")] public MandrillMessage Message { get; set; } = new(); + } + + private sealed class MandrillMessage + { + [JsonPropertyName("from_email")] public string FromEmail { get; set; } = string.Empty; + [JsonPropertyName("from_name")] public string FromName { get; set; } = string.Empty; + [JsonPropertyName("to")] public MandrillRecipient[] To { get; set; } = Array.Empty(); + [JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty; + [JsonPropertyName("html")] public string Html { get; set; } = string.Empty; + [JsonPropertyName("auto_text")] public bool AutoText { get; set; } + [JsonPropertyName("track_opens")] public bool TrackOpens { get; set; } + [JsonPropertyName("track_clicks")] public bool TrackClicks { get; set; } + [JsonPropertyName("tags")] public string[] Tags { get; set; } = Array.Empty(); + } + + private sealed class MandrillRecipient + { + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; + [JsonPropertyName("type")] public string Type { get; set; } = "to"; + } + + private sealed class MandrillSendResult + { + [JsonPropertyName("email")] public string? Email { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + [JsonPropertyName("reject_reason")] public string? RejectReason { get; set; } + [JsonPropertyName("_id")] public string? Id { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EmailTemplates.cs b/src/Backend/AutopilotMonitor.Functions/Services/EmailTemplates.cs index 25bcdc2c..ee30c809 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EmailTemplates.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EmailTemplates.cs @@ -3,7 +3,7 @@ using AutopilotMonitor.Shared; namespace AutopilotMonitor.Functions.Services; /// -/// Email templates for Resend notifications. +/// Templates for the product's transactional emails (sent via ). /// Keep all email content here for easy maintenance. /// public static class EmailTemplates diff --git a/src/Backend/AutopilotMonitor.Functions/Services/IEmailService.cs b/src/Backend/AutopilotMonitor.Functions/Services/IEmailService.cs new file mode 100644 index 00000000..e1832903 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/IEmailService.cs @@ -0,0 +1,19 @@ +namespace AutopilotMonitor.Functions.Services; + +/// +/// Provider-neutral transactional email contract for the tenant-activation welcome mail. +/// +/// Always fail-soft — implementations must NOT throw; activation correctness never depends +/// on email delivery (the Global Admin "Send welcome email" button is the manual fallback). +/// The farewell mail after offboarding lives on the sibling +/// contract; both are served by . +/// +/// +public interface IEmailService +{ + /// + /// Sends the tenant-activation welcome email. No-op when the provider is not configured + /// or the recipient is empty. + /// + Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ResendEmailService.cs b/src/Backend/AutopilotMonitor.Functions/Services/ResendEmailService.cs deleted file mode 100644 index a4cf3f4f..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Services/ResendEmailService.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Resend; - -namespace AutopilotMonitor.Functions.Services; - -/// -/// Sends transactional emails via Resend.com for tenant-activation notifications. -/// Best-effort: failures are logged as warnings and never propagated. -/// Temporary — remove after GA. -/// -public class ResendEmailService : IOffboardFarewellEmailSender -{ - private readonly string _apiKey; - private readonly ILogger _logger; - - public ResendEmailService( - IConfiguration configuration, - ILogger logger) - { - _logger = logger; - _apiKey = configuration["RESEND_API_KEY"] ?? string.Empty; - } - - /// - /// Sends the tenant-activation welcome email. - /// No-op if the API key or recipient email is not configured. - /// - public virtual async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName) - { - if (string.IsNullOrWhiteSpace(_apiKey)) - { - _logger.LogDebug("RESEND_API_KEY not configured — skipping preview approval email"); - return; - } - - if (string.IsNullOrWhiteSpace(toEmail)) - { - _logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName); - return; - } - - try - { - var resend = ResendClient.Create(_apiKey); - - var message = new EmailMessage - { - From = "Autopilot Monitor ", - To = toEmail, - Subject = EmailTemplates.PreviewApprovedSubject, - HtmlBody = EmailTemplates.GetPreviewApprovedHtml(domainName) - }; - - await resend.EmailSendAsync(message); - - _logger.LogInformation( - "Preview approval email sent to {ToEmail} for domain {Domain}", - toEmail, domainName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to send preview approval email to {ToEmail} for domain {Domain}", - toEmail, domainName); - } - } - - /// - /// Sends the post-offboarding "sorry to see you go" farewell email. - /// No-op if the API key or recipient email is not configured. Best-effort: failures - /// are logged as warnings and never propagated (the offboarding correctness contract - /// does not depend on email delivery). - /// - public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(_apiKey)) - { - _logger.LogDebug( - "RESEND_API_KEY not configured — skipping offboard farewell email for tenant {TenantId}", - tenantId); - return; - } - - if (string.IsNullOrWhiteSpace(toEmail)) - { - _logger.LogDebug( - "No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})", - tenantId, domainName); - return; - } - - try - { - var resend = ResendClient.Create(_apiKey); - - var message = new EmailMessage - { - From = "Autopilot Monitor ", - To = toEmail, - Subject = EmailTemplates.OffboardingFarewellSubject, - HtmlBody = EmailTemplates.GetOffboardingFarewellHtml(domainName) - }; - - await resend.EmailSendAsync(message, ct); - - _logger.LogInformation( - "Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})", - toEmail, tenantId, domainName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to send offboard farewell email to {ToEmail} for tenant {TenantId} ({Domain})", - toEmail, tenantId, domainName); - } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs index b1533492..08bef836 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs @@ -16,20 +16,20 @@ public class TenantApprovalService private readonly PreviewWhitelistService _previewWhitelistService; private readonly TenantConfigurationService _tenantConfigurationService; private readonly TenantAdminsService _tenantAdminsService; - private readonly ResendEmailService _resendEmailService; + private readonly IEmailService _emailService; public TenantApprovalService( ILogger logger, PreviewWhitelistService previewWhitelistService, TenantConfigurationService tenantConfigurationService, TenantAdminsService tenantAdminsService, - ResendEmailService resendEmailService) + IEmailService emailService) { _logger = logger; _previewWhitelistService = previewWhitelistService; _tenantConfigurationService = tenantConfigurationService; _tenantAdminsService = tenantAdminsService; - _resendEmailService = resendEmailService; + _emailService = emailService; } /// @@ -140,7 +140,7 @@ public class TenantApprovalService } var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId); - _ = _resendEmailService.SendPreviewApprovedEmailAsync( + _ = _emailService.SendPreviewApprovedEmailAsync( notificationEmail, tenantConfig.DomainName) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget welcome email failed for tenant {TenantId}", tenantId), diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index a7c3ef51..052bfd76 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -140,9 +140,10 @@ export default function PrivacyPage() {

External Services

- Microsoft Azure is the only place your data is stored. Resend delivers the onboarding - approval email when a tenant is activated — it receives an administrator's email address and the tenant - domain, never enrollment telemetry. Vulnerability reference data is read inbound from NVD, the CISA KEV + Microsoft Azure is the only place your data is stored. Mailchimp Transactional (Mandrill) + delivers the welcome email when a tenant is activated and the farewell email after offboarding — it receives + an administrator's email address and the tenant domain, never enrollment telemetry; open and click + tracking is disabled. Vulnerability reference data is read inbound from NVD, the CISA KEV catalog, and MSRC; nothing about your environment is sent to them.