feat(backend): provider-neutral EmailService on Mailchimp Transactional (Mandrill), replaces Resend

- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
  Notification resilience policy; provider confined to one transport method and the
  Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-22 23:26:32 +02:00
parent e857e99436
commit 8675e7ee06
17 changed files with 630 additions and 286 deletions
+24 -1
View File
@@ -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
+2
View File
@@ -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
@@ -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;
/// <summary>
/// Coverage for the provider-neutral <see cref="EmailService"/> (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.
/// <list type="bullet">
/// <item>Neither send method ever throws — not on a missing key, missing recipient,
/// HTTP 500, a rejected recipient, nor a malformed body.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>Tracking is off on the wire (trust-page claim: provider receives only the
/// recipient address and tenant domain).</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
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<string, string?>
{
["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<EmailService> logger) Build(
string apiKey,
Dictionary<string, string?>? extra = null,
System.Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
{
var settings = new Dictionary<string, string?> { ["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<EmailService>();
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<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
protected override async Task<HttpResponseMessage> 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<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
/// <summary>
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. 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 <see cref="ThrowOnSend"/>
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// to simulate downstream failures and prove the handler's fail-soft contract.
/// </summary>
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
@@ -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());
@@ -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;
/// <summary>
/// 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:
/// <list type="bullet">
/// <item><see cref="ResendEmailService.SendAsync"/> 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.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class ResendEmailServiceTests
{
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
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<ResendEmailService>();
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<ResendEmailService>();
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<ResendEmailService> logger)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["RESEND_API_KEY"] = apiKey,
})
.Build();
return new ResendEmailService(config, logger);
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -21,7 +21,7 @@ public class TenantApprovalServiceTests
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<ResendEmailService> _resendMock;
private readonly Mock<IEmailService> _emailMock;
private readonly TenantApprovalService _sut;
public TenantApprovalServiceTests()
@@ -37,9 +37,10 @@ public class TenantApprovalServiceTests
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_resendMock = new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance);
_emailMock = new Mock<IEmailService>();
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
@@ -47,7 +48,7 @@ public class TenantApprovalServiceTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_resendMock.Object);
_emailMock.Object);
}
[Fact]
@@ -94,7 +95,7 @@ public class TenantApprovalServiceTests
Assert.False(sent);
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()), 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<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>()), Times.Once);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
@@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance).Object)
Mock.Of<IEmailService>())
{ CallBase = false };
var opsRepo = new Mock<IOpsEventRepository>();
@@ -56,7 +56,6 @@
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
<ItemGroup>
@@ -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<PreviewWhitelistFunction> logger,
PreviewWhitelistService previewWhitelistService,
TenantApprovalService tenantApprovalService,
TenantConfigurationService tenantConfigurationService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantApprovalService = tenantApprovalService;
_tenantConfigurationService = tenantConfigurationService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -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.
@@ -437,8 +437,13 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddSingleton<ResendEmailService>();
builder.Services.AddSingleton<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<ResendEmailService>());
// 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<EmailService>()
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddTransient<IEmailService>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddTransient<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddSingleton<GlobalNotificationService>();
builder.Services.AddSingleton<TenantNotificationService>();
@@ -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;
/// <summary>
/// Sends the product's transactional emails (tenant-activation welcome, post-offboarding
/// farewell). Best-effort: failures are logged as warnings and never propagated.
/// <para>
/// The class and its callers are provider-neutral. Everything provider-specific is confined
/// to the <c>Email:*</c> configuration section and <see cref="SendViaMandrillAsync"/>, so a
/// future provider swap touches the transport method and the app settings only.
/// Current provider: Mailchimp Transactional (Mandrill) — <c>POST {Email:Endpoint}</c> 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).
/// </para>
/// <para>
/// Configuration (App Settings use <c>Email__Key</c>):
/// <c>Email:ApiKey</c> (required; empty ⇒ every send is a logged no-op),
/// <c>Email:Endpoint</c> (default <see cref="DefaultEndpoint"/>),
/// <c>Email:FromAddress</c> (default <see cref="DefaultFromAddress"/>),
/// <c>Email:FromName</c> (default <see cref="DefaultFromName"/>).
/// </para>
/// </summary>
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<EmailService> _logger;
private readonly string _apiKey;
private readonly string _endpoint;
private readonly string _fromAddress;
private readonly string _fromName;
public EmailService(
HttpClient http,
IConfiguration configuration,
ILogger<EmailService> 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);
}
/// <inheritdoc />
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);
}
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <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>
/// count as success, <c>rejected</c>/<c>invalid</c> as failure (with the provider's
/// <c>reject_reason</c> in the warning). Never throws.
/// </summary>
private async Task<bool> 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<MandrillSendResult[]>(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<MandrillRecipient>();
[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<string>();
}
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; }
}
}
@@ -3,7 +3,7 @@ using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Email templates for Resend notifications.
/// Templates for the product's transactional emails (sent via <see cref="EmailService"/>).
/// Keep all email content here for easy maintenance.
/// </summary>
public static class EmailTemplates
@@ -0,0 +1,19 @@
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Provider-neutral transactional email contract for the tenant-activation welcome mail.
/// <para>
/// 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
/// <see cref="IOffboardFarewellEmailSender"/> contract; both are served by <see cref="EmailService"/>.
/// </para>
/// </summary>
public interface IEmailService
{
/// <summary>
/// Sends the tenant-activation welcome email. No-op when the provider is not configured
/// or the recipient is empty.
/// </summary>
Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
}
@@ -1,118 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Resend;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Sends transactional emails via Resend.com for tenant-activation notifications.
/// Best-effort: failures are logged as warnings and never propagated.
/// Temporary — remove after GA.
/// </summary>
public class ResendEmailService : IOffboardFarewellEmailSender
{
private readonly string _apiKey;
private readonly ILogger<ResendEmailService> _logger;
public ResendEmailService(
IConfiguration configuration,
ILogger<ResendEmailService> logger)
{
_logger = logger;
_apiKey = configuration["RESEND_API_KEY"] ?? string.Empty;
}
/// <summary>
/// Sends the tenant-activation welcome email.
/// No-op if the API key or recipient email is not configured.
/// </summary>
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 <noreply@autopilotmonitor.com>",
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);
}
}
/// <summary>
/// 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).
/// </summary>
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 <noreply@autopilotmonitor.com>",
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);
}
}
}
@@ -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<TenantApprovalService> logger,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService,
TenantAdminsService tenantAdminsService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
_tenantAdminsService = tenantAdminsService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -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),
@@ -140,9 +140,10 @@ export default function PrivacyPage() {
<h2 className="text-lg font-semibold text-gray-900 mt-6">External Services</h2>
<p className="text-gray-700">
<strong>Microsoft Azure is the only place your data is stored.</strong> Resend delivers the onboarding
approval email when a tenant is activated — it receives an administrator&apos;s email address and the tenant
domain, never enrollment telemetry. Vulnerability reference data is read inbound from NVD, the CISA KEV
<strong>Microsoft Azure is the only place your data is stored.</strong> Mailchimp Transactional (Mandrill)
delivers the welcome email when a tenant is activated and the farewell email after offboarding — it receives
an administrator&apos;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.
</p>
<p className="text-gray-700">