diff --git a/CHANGELOG.md b/CHANGELOG.md index 00babe2..836bc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,190 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.18.0 — 2026-05-28 + +**Minor — schema migration required; one env var now mandatory; no +new secrets.** This release lands the framework-side half of OHM +roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20 +(Email deliverability). It is the framework counterpart to the +operator-side SMTP / DNS hardening covered in the +`EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` companion doc. + +The release is shipped in five atomic slices per the v0.18.0 proposal: + +1. **`build_envelope` shared helper.** A single place where every + outbound `EmailMessage` is constructed. Lands the + deliverability-critical headers (`Date`, `Message-ID`, + `Auto-Submitted`) uniformly across OTC, invite, watcher + notification, bundle, and digest paths; exposes per-kind + unsubscribe semantics (none for OTC, mailto: for invites, full + one-click for bulk-adjacent paths) as explicit kwargs. +2. **Migrated send paths.** `email_otc.py`, `email_invite.py`, + `email._deliver`, `email._send_bundle`, and `digest.py` now + build their envelopes through the helper. Per-RFC invite + (v0.16.0) rides through `email_invite.py`'s helper and picks + up the change for free. The shared `_SENT` test buffer also + carries the constructed `EmailMessage` under `envelope["message"]` + so tests can assert on the header surface directly. +3. **Webhook handler tightening.** `GITEA_WEBHOOK_SECRET` is now + mandatory at startup — the framework refuses to load_config() + if it's empty, unless the operator explicitly opts into the + dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`. The + `/api/webhooks/gitea` receiver carries defense-in-depth checks + that surface the misconfiguration loudly at the request layer + too. Mis-targeted webhooks (a hook on a fork or a stale Gitea + binding) now log at INFO instead of silently 200-OK'ing. +4. **`outbound_emails` audit table + admin endpoint.** Every send + helper writes one row to `outbound_emails` before returning, + capturing the send attempt regardless of outcome + (status='sent' / 'failed' / 'deferred'). The new admin endpoint + `GET /api/admin/outbound-emails` (filterable by kind / status / + to_address) lets the operator answer "did this person ever get + their invite?" without grepping VM logs. No admin UI ships + with v0.18.0; operator queries via curl + jq for now. +5. **Bounce correlation.** The `POST /api/webhooks/email-bounce` + body accepts a new optional `message_id` field; when supplied, + the handler stamps status='bounced' on the matching + `outbound_emails` row and returns the row id as + `correlated_id`. The pre-existing hard-bounce -> + `email_opt_out_all = 1` flow still fires. + +The `POST /api/email/unsubscribe` endpoint also lands in Slice 2 +as the matching receiver for the new `List-Unsubscribe-Post: +List-Unsubscribe=One-Click` header (Gmail and Yahoo POST that +payload on the user's one-click action per RFC 8058 — the GET +endpoint alone is no longer sufficient for senders at OHM's tier). + +### Added + +- **`backend/app/email_envelope.py`** — the `build_envelope` helper. + Single source of truth for every outbound `EmailMessage`'s + headers + body shape. Standalone module so tests can exercise it + without booting the FastAPI app. +- **`backend/migrations/020_outbound_emails.sql`** — the audit + table. Single new table with three indexes (to_address, sent_at, + message_id); no changes to existing tables. +- **`email.record_outbound(...)`** — best-effort write helper every + send path calls. Catches `RuntimeError` (so pure-helper unit + tests where `db.init()` was never called don't break) and any + other exception (so the audit write never breaks a send). +- **`GET /api/admin/outbound-emails`** in `api_admin.py` — + admin-only listing of `outbound_emails`. Newest-first, filterable + by kind, status, and to_address (case-insensitive). Returns + `{items: [...], has_more}` per the rest of the admin endpoints' + shape. +- **`POST /api/email/unsubscribe`** in `api_notifications.py` — + RFC 8058 one-click receiver. Accepts the same `?t=` token as the + GET handler; idempotent; returns 200 + `{ok, category}` on + success. +- **`all` synthetic category** for unsubscribe URLs. Used by the + bundle + digest paths (which can't honor per-category opt-outs + because they span multiple categories); flips + `email_opt_out_all = 1` rather than a per-category column. + +### Changed + +- **`backend/app/email.py`** — `EmailConfig` gains + `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`, default + falls back to `EMAIL_FROM`). `_deliver` and `_send_bundle` + build envelopes through `build_envelope` and thread `kind` + + `notification_id` into the audit write. +- **`backend/app/email_otc.py`** + **`email_invite.py`** — both + call `build_envelope` and `record_outbound`. OTC carries no + `List-Unsubscribe` (recipient explicitly requested the code); + invite carries `List-Unsubscribe: ` only (no signed + URL — the invitee isn't a user yet, no per-user opt-out row + exists). +- **`backend/app/digest.py`** — calls `_deliver` with `kind='digest'` + + the new `all`-category one-click unsubscribe. +- **`backend/app/webhooks.py`** — refuses 500 at request time if + the secret is empty + bypass isn't set; logs a loud warning + per-request when running under the bypass; logs INFO when a + hook targets a repo not in `cached_rfcs`. +- **`backend/app/config.py`** — `load_config()` raises + RuntimeError if `GITEA_WEBHOOK_SECRET` is empty unless + `RFC_APP_INSECURE_WEBHOOKS=1`. +- **`backend/app/api_notifications.py`** — GET unsubscribe handler + accepts the `all` category (sets `email_opt_out_all = 1`). + Bounce webhook body adds optional `message_id` field; response + shape adds `correlated_id` field. (Tests that read the exact + response shape — currently just + `test_bounce_webhook_refuses_unsigned_when_secret_configured` + in test_e2e_smoke.py — updated to assert on the new shape.) +- **`backend/tests/test_propose_vertical.py`** — the shared + `tmp_env` fixture binds a fake `GITEA_WEBHOOK_SECRET` so all + 252 pre-v0.18.0 tests boot cleanly under the new mandatory + secret. Tests that want to exercise the dev-bypass path + monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1` explicitly. + +### Tests + +- 15 new unit tests in `test_email_envelope.py` for the helper. +- 10 new integration tests across `test_otc_vertical`, + `test_admin_create_user_invite_vertical`, and + `test_notifications_vertical` covering: OTC has no + List-Unsubscribe; invite has mailto: only; notification has + full one-click; POST one-click flips per-category; `all` flips + global; respects `EMAIL_UNSUBSCRIBE_MAILTO` override. +- 7 new integration tests in `test_webhooks_vertical.py` covering + the startup-time mandatory-secret check, the dev-bypass, and + the request-time signature verification including the unknown- + repo log line. +- 11 new integration tests in `test_outbound_emails_vertical.py` + covering the audit table write path (OTC / invite / notification), + the admin endpoint (list, filter by kind, filter by to_address, + non-admin refusal), and the bounce correlation (matched + message_id stamps status='bounced'; unknown message_id is + logged; absent message_id falls back to legacy behavior; bounced + rows surface in admin endpoint with `?status=bounced`). +- One test updated for intentional response-shape change: + `test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured`. + +Full suite: 295 passed (was 252 pre-v0.18.0). + +### Migration + +- **`backend/migrations/020_outbound_emails.sql`** — auto-applied + on next backend start. Single new table with three indexes; no + changes to existing tables. + +### Upgrade steps (from 0.17.0) + +- Operators **MUST** ensure `GITEA_WEBHOOK_SECRET` is set in the + deployment's env. The framework now refuses to start if it's + empty. (For OHM-flotilla deployments, + `flotilla secret list ` confirms the binding; OHM + has carried this binding since v0.14.0, so the upgrade is + gesture-free for OHM specifically.) +- Operators **MAY** set `RFC_APP_INSECURE_WEBHOOKS=1` to bypass + the requirement in local-dev environments. Production + deployments **MUST NOT** set this; if they do, every webhook + POST logs a loud warning line per request. +- Operators **MAY** set `EMAIL_UNSUBSCRIBE_MAILTO` to route + `List-Unsubscribe: ` opt-out courtesy mail to a + humans-monitored mailbox distinct from the no-reply + `EMAIL_FROM` sender. Default falls back to `EMAIL_FROM`. +- You **MUST** apply schema migration + `020_outbound_emails.sql`. The migration creates a single new + table with three indexes; the framework runs migrations + automatically at process start, so no manual step is required + beyond restarting the backend so the migration runner picks + the file up. Existing deployments pick it up on first start + after upgrade with no operator action required. +- You **MUST** rebuild the frontend and restart the backend + after upgrading. `frontend/package.json#version` and `VERSION` + both move to `0.18.0`. No new secrets (the `outbound_emails` + table writes synchronously to the same SQLite file as every + other write). +- Operators **SHOULD** run a `mail-tester.com` probe against the + upgraded deployment to confirm the new envelope headers + (`Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`, + `List-Unsubscribe-Post`) land cleanly with the upstream SMTP + relay's DKIM signing. The expected delta from pre-v0.18.0 is + +2-3 points on the spam-score axis (typical 5-6/10 baseline + → 9+/10 post-upgrade). + + ## 0.17.0 — 2026-05-28 **Minor — schema migration required; no new env vars; no new secrets.** diff --git a/VERSION b/VERSION index c5523bd..6633391 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.17.0 +0.18.0 diff --git a/frontend/package.json b/frontend/package.json index 0668198..2396528 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite",