Per the v0.18.0 email + webhook hygiene proposal §3:
* `backend/migrations/020_outbound_emails.sql` — new table
capturing every send attempt: to_address, from_address,
subject, kind, sent_at, status ('sent' | 'failed' | 'deferred'
| 'bounced'), error, notification_id (FK), message_id (for
Slice 5 bounce correlation).
* `email.record_outbound()` — best-effort write helper every
send path calls. Status='sent' on SMTP success, 'failed' on
exception (with class + message in `error`), 'deferred' on
the dev-fallback path where SMTP_HOST is unset (the send
didn't happen but the row records the attempt).
* `email._deliver` (watcher notifications + bundles), `digest.py`,
`email_otc.py`, `email_invite.py` — every send path now records.
* `GET /api/admin/outbound-emails` — admin-only listing,
filterable by kind / status / to_address. Answers "did this
person ever get their invite?" without grepping VM logs. No
admin UI in v0.18.0; operator queries via curl + jq for now.
7 new integration tests covering: OTC / invite / notification all
write rows with matching Message-ID; admin endpoint lists,
filters by kind, filters by to_address (case-insensitive),
refuses non-admins.
Full suite: 291 passed.
Five send sites now construct their envelopes through
`email_envelope.build_envelope` instead of building `EmailMessage`
ad hoc:
* `email_otc.py` — OTC mail (no List-Unsubscribe per the
proposal's tradeoff: the recipient explicitly requested the
code, so a list semantic would be wrong).
* `email_invite.py` — admin/per-RFC invite mail (mailto:-only
List-Unsubscribe — the invitee isn't a user yet, so no
per-user opt-out URL exists).
* `email._deliver` — watcher notifications (full one-click
unsubscribe: mailto + signed URL + List-Unsubscribe-Post per
RFC 8058, required by Gmail/Yahoo).
* `email._send_bundle` — the "while you were away" bundle,
one-click to the new `all` synthetic category (which lands
`email_opt_out_all = 1` because the bundle spans multiple
per-category flags).
* `digest.py` — same as the bundle: bulk-adjacent, one-click to
`all`.
`api_notifications.py` gains the POST `/api/email/unsubscribe`
endpoint (the matching receiver for `List-Unsubscribe-Post:
List-Unsubscribe=One-Click`) and accepts the `all` category in
both GET and POST handlers.
`EmailConfig` adds `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`,
default = `EMAIL_FROM`) so deployments can route unsubscribe
courtesy mail to a humans-monitored mailbox distinct from the
no-reply sender.
The `_SENT` test buffer now also carries `envelope["message"]`
(the `EmailMessage` itself) so new tests can assert on headers
directly. Legacy `to`/`from`/`subject`/`body` keys remain for
backward compatibility.
10 new integration tests across test_otc_vertical /
test_admin_create_user_invite_vertical / test_notifications_vertical
covering: OTC has no List-Unsubscribe; invite has mailto: only;
notification has full one-click; POST one-click flips the
category; `all` category sets global opt-out via both GET and
POST.
Full suite: 277 passed.
Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>