# ADR-0117 — M6.6 Per-Domain Mail TLS

**Status:** Accepted (2026-06-07)
**Supersedes:** —
**Amends:** ADR-0048 (M6.4 panel-hostname-only mail) — panel-hostname stays the default, per-domain SAN is now an opt-in addition.

## Context

GH #133 surfaced a real UX gap. Today a tenant with mailboxes at `you@brand.com` has to point their mail client at `mail.<panel-hostname>` because that's the only name on the cert Stalwart serves. Connecting to `mail.brand.com` :465 returns Stalwart's bootstrap self-signed (SNI miss).

M6.4 (ADR-0048) made `mail.<panel-hostname>` the canonical default. That choice was right for shared-host quickstart (no per-domain cert juggling, no rate-limit math). It's wrong as the **only** option: cPanel, DirectAdmin, Hestia, Mailcow all auto-issue per-domain mail certs and users expect that to work.

## Decision

1. **Opt-in per-domain cert via `mail_certificate` table.** Migration 000161 adds the table. One row per opted-in domain, unique on `(domain_id)`. Cascade on domain delete.
2. **Cert covers four SAN hostnames per domain.** In stable order:
   - `mail.<domain>`
   - `autoconfig.<domain>` (Thunderbird autodiscover)
   - `autodiscover.<domain>` (Outlook autodiscover)
   - `mta-sts.<domain>` (MTA-STS HTTPS policy fetch)
3. **HTTP-01 via the existing panel ACME webroot.** `/var/www/jabali-acme` is the nginx `:80` location install.sh already provisions for the panel-cert flow; per-domain mail certs reuse it. Each SAN hostname must have an A record pointing at the panel public IP; DNS pre-check rejects with `dns_missing` if any of the four miss.
4. **Stalwart `x:Certificate` registry per domain.** Naming scheme `domain-<domain-ulid>`. The deploy-hook fires `jabali-stalwart-push-cert` once per renewal with `JABALI_STALWART_CERT_NAME` / `_CERT_PATH` / `_KEY_PATH` env vars pointing at the per-domain lineage. Stalwart's SNI matcher routes inbound 25/465/587/993 to the right cert by extracted SAN.
5. **Default cert stays `#panel-mail` from M6.4.** Connections without a matching SNI cert fall back to the panel-hostname cert, preserving existing client setups.
6. **Reconciler state machine** mirrors panel-cert (ADR-0066). States: `pending` → `dns_missing` (1h backoff) → `issuing` → `issued` (renew when <30d) / `failed` (3h backoff) / `disabled` (operator opt-out).
7. **LE new-order soft cap = 40/week.** First-issuance attempts are gated by `CountFirstIssueAttemptsLastWeek` in the reconciler; renewals are exempt because LE doesn't count them against new-order quota. Hitting the cap parks each new attempt for 24h with a clear `last_error`. Operator raises the cap by editing the constant after requesting an LE rate-limit increase.
8. **REST shape:**
   - `GET    /api/v1/domains/:id/mail-certificate` — status + SAN list + last_error.
   - `POST   /api/v1/domains/:id/mail-certificate/enable` — opt in.
   - `POST   /api/v1/domains/:id/mail-certificate/disable` — opt out.
   - `POST   /api/v1/domains/:id/mail-certificate/reissue` — clear backoff + flip to pending.

## Consequences

**Wins.**

- Tenants get a valid LE cert on `mail.<their-domain>` :465 / :993, matching cPanel/DA/Hestia/Mailcow expectations.
- Renewal piggybacks on certbot's existing timer; no new cron.
- M6.4 default unchanged → existing clients keep working without action.
- Per-domain failure is isolated; one bad row doesn't break mail for other domains because Stalwart falls back to the default cert on SNI miss.

**Costs.**

- Every opt-in domain consumes one LE new-order slot per registered-domain per week. Operators at scale (>40 first-time opt-ins / week) must request a rate-limit increase from Let's Encrypt.
- DNS for `mail/autoconfig/autodiscover/mta-sts.<domain>` must point at the panel before opt-in succeeds. Failing that, the row parks in `dns_missing` and re-checks hourly until A records appear.
- Per-domain `mta-sts` SAN sets the stage for M6.7 (per-domain `mta-sts.<domain>` HTTPS server block serving the policy file). M6.6 only gets the cert; the actual MTA-STS policy endpoint is out of scope.

## Alternatives Considered

- **DNS-01 challenges.** Lets the panel cover wildcards (`*.<domain>`) without requiring per-SAN A records. Rejected for v1 because every operator would need to either grant the panel API tokens for their DNS provider or run our own auth-DNS proxy. HTTP-01 via the existing webroot has zero new operator setup.
- **One wildcard cert per panel.** `*.<panel-hostname>` would cover `mail.X.<panel-hostname>` synonyms; doesn't help tenants who want `mail.<their-own-domain>`. Out of scope.
- **Per-mailbox Stalwart Domain/set.** Was considered. Would require a JMAP-level per-tenant TLS config Stalwart doesn't expose; cert routing in Stalwart is global via SNI, not per-Domain.

## Related

- Blueprint: `plans/m6.6-per-domain-mail-tls.md`
- ADR-0048 (M6.4 panel-hostname primary mail domain)
- ADR-0066 (M32 panel-cert state machine)
- ADR-0105 (panel hostname + mail kinds split)
- GH #133 (johnnyq's mail cert bug report)
