VERSION + frontend/package.json -> 0.18.0. CHANGELOG entry with
the binding Upgrade-steps block per SPEC.md §20.4.
This release lands all five slices of the v0.18.0 proposal at
~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md:
Slice 1: build_envelope helper + unit tests.
Slice 2: migrate send paths; add POST /api/email/unsubscribe
for RFC 8058 one-click.
Slice 3: mandatory GITEA_WEBHOOK_SECRET (+ dev-bypass) +
unknown-repo logging.
Slice 4: outbound_emails audit table + admin endpoint.
Slice 5: bounce correlation via Message-ID.
Full suite: 295 passed (was 252 pre-release).
Upgrade-steps (RFC 2119) block in CHANGELOG covers:
* MUST: GITEA_WEBHOOK_SECRET non-empty at startup.
* MAY: RFC_APP_INSECURE_WEBHOOKS=1 for local dev only.
* MAY: EMAIL_UNSUBSCRIBE_MAILTO to route opt-out courtesy mail
to a different mailbox than EMAIL_FROM.
* MUST: apply migration 020_outbound_emails.sql (auto-applied
on next start; no operator action).
* MUST: rebuild frontend + restart backend.
* SHOULD: run mail-tester.com probe post-upgrade.
This commit is contained in:
+184
@@ -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: <mailto:…>` 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 <deployment>` 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: <mailto:…>` 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.**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user