Skip to content

ADR-0012: Transactional email via Amazon SES

Status

Accepted

Date

2026-09-16

Context

Two features need to send outbound email and nothing was wired.

  1. Auth magic links. Firebase Auth's built-in mailer sends the sign-in link from a shared default sender (noreply@cs-lewis-fueled.firebaseapp.com) with no SPF/DKIM/DMARC tied to us and a reputation shared across every Firebase project. Gmail files the mail as spam and disables link tapping inside spam, which is the CSL-155 root cause (not a Flutter bug). See ADR-0003.
  2. DMCC renewal reminders (CSL-383) must reach users in a durable medium; email qualifies, an in-app toast does not.

The infrastructure already runs on AWS (987292390939, us-east-2) with CDK (ADR-0002).

Decision Drivers

  • Must: sign-in mail lands in the inbox with aligned SPF/DKIM/DMARC; a backend send path for DMCC; own the sending domain and its reputation.
  • Should: minimise static secrets; fit the existing AWS + CDK stack; per-environment and opt-in.
  • Nice-to-have: cheap at MVP; bounce and complaint monitoring.

Considered Options

Option A: Amazon SES on a dedicated subdomain

  • Pros: same account and region as the rest of the infra; cheapest; gives the backend a role-based send path (SES v2 over the ECS task role, no static creds) and the SMTP path Firebase needs; full control of DKIM/DMARC on our own subdomain.
  • Cons: sandbox exit is a manual AWS case; DNS records land in a client-controlled Cloudflare zone; a warmed sending reputation is slow to build and hard to move later.

Option B: Firebase "customize domain" only

  • Pros: least setup; no new vendor.
  • Cons: still rides Firebase's mailer and shared IP pool; gives the backend no send path for DMCC; less control of reputation.

Option C: Postmark / Resend / SendGrid

  • Pros: strong deliverability out of the box; simple APIs.
  • Cons: an extra vendor and bill; no edge over SES when we already run on AWS; another credential to hold.

Decision

We will use Amazon SES as the shared transactional sender, on the dedicated subdomain mail.cslewis.com (never the client-owned marketing apex), with full SPF, DKIM, and DMARC.

Two send paths, two auth models:

  • Firebase magic links use SMTP with one static credential. Firebase runs off AWS and cannot assume an IAM role, so it needs a static SES SMTP credential from a dedicated IAM user. That credential lives in Firebase's own config. The IAM grant is scoped by an exact ses:FromAddress condition to no-reply@mail.cslewis.com, so a leaked credential cannot spoof other local parts on the domain.
  • Django uses the ECS task role, no SMTP. It sends over the SES v2 API (django-ses) authenticated by the task role, scoped to the same identity, configuration set, and exact from-address. No static creds, nothing in Secrets Manager to rotate.

A single SES configuration set is set as the identity default, so every send from the identity emits reputation metrics and routes bounce and complaint events to SNS, with CloudWatch alarms at bounce > 2% and complaints > 0.08%. Firebase's SMTP path sets no per-message configuration-set header, so the identity default is what keeps that traffic inside the monitoring.

The stack is per-environment and opt-in via config/<env>/email.yaml: prod uses mail.cslewis.com; dev leaves sender_domain empty, so the email stack is skipped and Django defaults to the console backend.

Consequences

Positive

  • Sign-in mail is DKIM/DMARC-aligned from a domain we own; the spam classification and the untappable-link symptom (CSL-155) are addressed at the source.
  • Exactly one static secret across the system, and it is forced by Firebase; the backend path holds none.
  • Bounce and complaint monitoring covers both send paths through the identity default.

Negative

  • The cslewis.com DNS records are manual and live in a client-controlled Cloudflare zone; CDK cannot write them, so it only prints the values to publish.
  • Reputation on mail.cslewis.com is ours to protect; a spam-complaint spike hurts our own sending, though it stays isolated from the client apex.

Risks & Mitigations

  • Risk: the reputation alarms notify nobody until an address is subscribed to the SNS topic. → Mitigation: set alert_email in config/prod/email.yaml before launch (open launch item).
  • Risk: Firebase's built-in email-link quota returns quota-exceeded (seen in testing, before CUSTOM_SMTP was active). This is a Firebase limit, not SES. → Mitigation: keep the launch gate blocked until a post-CUSTOM_SMTP send reaches a fresh inbox. Whether custom SMTP fully lifts the quota is unconfirmed; if it persists after SMTP is live, Identity Platform is the documented escalation for a higher email-link quota.
  • Risk: the sender domain and the /finishSignIn action-link domain (cslewis.fueled.engineering) do not share an org-domain. → Mitigation: not required for inbox delivery (alignment is on the From); tracked as CSL-65.
  • ADR-0002: Infrastructure-as-Code Tooling and Compute Platform (CDK, AWS account and region)
  • ADR-0003: Authentication via Firebase (magic-link sign-in, the first consumer)
  • CSL-401 (this work), CSL-155 (blocked-by), CSL-383 (blocked-by), CSL-65 (related)
  • Production launch checklist — the SES deployment state and manual runbook (DNS, sandbox exit, Firebase SMTP, verification)