# Session 0013.3 — Transcript > Parent: SESSION-0013.0-TRANSCRIPT-2026-05-28T05-26--.md > > Date: 2026-05-28 (PST) > Start: 2026-05-28T05-30 PST > End: 2026-05-28T05-58 PST > Goal: Inventory OHM's email + Gitea-webhook posture (roadmap items > #18 + #20, bundled) and draft a hardening runbook plus a > framework-side template-hygiene proposal. Read-only only — > no DNS edits, no SMTP-provider changes, no rfc-app code, > no secret rotations, no test emails. > > Outcome: **Two docs written and left on disk for the driver at > `~/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` and > `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`. Five §19.2 > candidates surfaced. Three high-impact deployment-side gestures > and three high-impact framework-side changes prioritized for the > operator. Two surprising findings: SMTP_HOST is missing from the > live flotilla overlay (gap to disambiguate), and a stale Gitea > webhook on `wiggleverse/meta` still points at the deprovisioned > `http://rfc.wiggleverse.org`.** --- ## Pre-session state - Parent: Session 0013.0 driver, dispatched M.3 with scope = inventory for items #18 + #20. - `~/git/ohm-infra/` is a plain directory (not a git repo) — driver noted this; M.3 leaves docs on disk for the driver to handle publishing. - `rfc-app` at v0.17.0 on disk; live OHM running v0.17.0 per `flotilla pin check ohm-rfc-app` (deploys.id=21). - Hard constraints from dispatch: no operator-led gestures, no secret bytes in transcript, read-only inventory only. --- ## Turn-by-turn arc ### Arc 1 — DNS inventory + roadmap read Ran `dig +short TXT wiggleverse.org`, `dig +short TXT _dmarc.wiggleverse.org`, `dig +short MX wiggleverse.org`, and probed common DKIM selectors (`default`, `mail`, `s1`, `s2`, `selector1`, `k1`, `mandrill`, `smtp`, `google`, etc.). Findings: - **SPF:** `"v=spf1 include:_spf.google.com ~all"` (soft-fail). - **DMARC:** `"v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org"` (monitoring-only, no enforcement, no `pct=`, no `sp=`). - **DKIM:** present at `google._domainkey.wiggleverse.org` — a 2048-bit RSA Workspace key. All other probed selectors empty. - **MX:** `aspmx.l.google.com` family — Google Workspace mailbox. Read roadmap items #18 (l.362-403) and #20 (l.458-547) from `~/projects/wiggleverse/ohm-rfc/ROADMAP.md`. Both confirm they should ship together because the same DNS edits serve both. #20 names BIMI as a follow-on after DMARC reaches `p=reject`. #18 names "refresh webhook-shared-secret + replay-resistance + ingress restriction" as framework-side work. ### Arc 2 — Flotilla overlay + secret inventory ``` .venv/bin/ohm-rfc-app-flotilla overlay show ohm-rfc-app .venv/bin/ohm-rfc-app-flotilla secret list ohm-rfc-app ``` Overlay reveals: - `SMTP_PORT=587`, `SMTP_STARTTLS=1`, `EMAIL_FROM=notifications@wiggleverse.org`, `EMAIL_FROM_NAME=Wiggleverse` — present. - `SMTP_HOST` — **missing**. `SMTP_USER` — **missing**. - Secret refs include `GITEA_WEBHOOK_SECRET` bound to `wiggleverse-ohm/ohm-rfc-app-gitea-webhook-secret@latest`. Greps: - `~/projects/wiggleverse/ohm-rfc-app-flotilla/scripts/register-ohm.sh:40` sets `SMTP_HOST=smtp-relay.gmail.com` — but the live overlay doesn't have it. Either the bootstrap script wasn't re-run after a reshape, or SMTP env lives out-of-band on the VM (e.g., systemd-unit override). This is a finding for the runbook — operator must disambiguate via `gcloud ssh` + `systemctl show ohm-app.service`. Confirmed provider = **Google Workspace SMTP relay** (the bootstrap script + the `google._domainkey` DKIM + the MX records align). ### Arc 3 — Framework email-helper read Read all of: - `~/git/rfc-app/backend/app/email.py` — §15.4 notification mailer (`_deliver`, `_send_one`, `_send_bundle`, `make_unsubscribe_url`). - `~/git/rfc-app/backend/app/email_otc.py` — v0.7.0 OTC helper. - `~/git/rfc-app/backend/app/email_invite.py` — v0.17.0 admin-create invite helper. Built header-presence matrix across all four kinds (OTC / invite / watcher-single / watcher-bundle): - `Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`, `List-Unsubscribe-Post`, `multipart/alternative` — **all absent** on all four paths. Google's SMTP relay adds `Date` / `Message-ID` during ingestion, but the framework doesn't control the format. - Plain-text bodies only. No HTML variant anywhere. - `_deliver` (`email.py:308`) embeds unsubscribe URL in body footer but doesn't set the header. Stdlib spot-check: `python3 -c "from email.message import EmailMessage; …"` confirms `set_content` doesn't auto-populate `Date` or `Message-ID`. Inspected `smtplib.SMTP.send_message` source — also doesn't set them. ### Arc 4 — Gitea-webhook inventory Found the handler at `~/git/rfc-app/backend/app/webhooks.py` (78 lines). HMAC-SHA-256 over the raw body, `hmac.compare_digest` for constant-time compare. **But:** `webhooks.py:43` gates verification behind `if config.webhook_secret:` — empty secret = no auth. Per `config.py:75`, `webhook_secret` is `_optional`. Insecure default. Pulled `gitea-bot-token` from GCP Secret Manager into a shell var (`GITEA_TOKEN=$(gcloud secrets versions access latest …)`), used it for read-only `curl` calls against the Gitea API, immediately `unset` after each call. Never echoed the value to stdout or any file. Hook inventory across the wiggleverse org: | Repo | Hook | Notes | |---|---|---| | `wiggleverse/ohm-meta` | `https://ohm.wiggleverse.org/api/webhooks/gitea` | active, 12 events — correct | | `wiggleverse/meta` | `http://rfc.wiggleverse.org/api/webhooks/gitea` | **active, plain HTTP, deprovisioned domain** | | `wiggleverse/rfc-0001-human` | (none) | the only published RFC has no hook | | `ben.stull/ohm-rfc` | (bot 403) | can't inventory, operator self-checks | The org-level hooks endpoint (`/api/v1/orgs/wiggleverse/hooks`) returned `[]` — no org-wide hooks, all are repo-level. Gitea's API exposes hook `config` keys = `['url', 'content_type']` — it does NOT return the configured `secret` field, so I can't confirm from the API side that the secret matches what OHM has. But OHM's cache is current and webhook receiver verifies, so the secrets must align (otherwise everything would 401). ### Arc 5 — Bounce-handling + observability gap Read `~/git/rfc-app/backend/app/api_notifications.py:471-502` — `/api/webhooks/email-bounce` exists, gated on optional `WEBHOOK_EMAIL_BOUNCE_SECRET` per SPEC.md §3667 Slice 8 settlement. But **no provider is wired to POST here** — Google Workspace SMTP relay doesn't natively webhook bounces (it bounces back to the sender mailbox). So today: hard bounces silently retry every `flush_pending` pass. All four send helpers catch `Exception`, log via `log.exception`, return `False`. No DB write of the failure, no admin notification. The operator has zero visibility into send failures without grepping VM logs. ### Arc 6 — Draft the two docs Wrote `EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` (~700 lines) with sections: current posture / hardened shape / operator-execution sequence / framework-vs-deployment split / §19.2 candidates. Wrote `RFC-APP-EMAIL-HYGIENE-PROPOSAL.md` (~280 lines) with the framework-side minor proposal: `build_envelope` shared helper, webhook handler tightening (mandatory secret), `outbound_emails` audit table, bounce-correlation, suggested 5-slice shipping plan. Cross-referenced both docs at each end. Both name files + line numbers for every framework citation. ### Arc 7 — Transcript This file. ohm-infra is not a git repo (driver's check confirmed pre-session), so no commit; driver decides whether to add a remote or just publish the transcripts via `publish-transcript.sh`. --- ## Cut state (end of subsession) | Surface | State | |---|---| | `~/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` | written, ~21 KB | | `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md` | written, ~9 KB | | `~/git/ohm-infra/SESSION-0013.3-TRANSCRIPT-…md` | this file | | OHM live | unchanged (v0.17.0, deploys.id=21) | | DNS / Gitea / SMTP provider | unchanged (read-only inventory only) | | flotilla overlay / secrets | unchanged (read-only `show` + `list` only) | --- ## §19.2 candidates surfaced 1. **rfc-app: `GITEA_WEBHOOK_SECRET` should be required, not optional.** Today the framework silently accepts unsigned POSTs when the env var is empty. Recommend `_required` with documented `RFC_APP_INSECURE_WEBHOOKS=1` dev-bypass. 2. **rfc-app: outbound-email observability seam.** Add `outbound_emails` table + write on every send. Today failures log-and-forget; the operator has no audit trail. 3. **flotilla: `overlay show` doesn't surface "expected but missing" keys.** The SMTP_HOST gap is invisible without grepping register-ohm.sh + framework source side-by-side. A per-deployment "required keys" manifest + `flotilla overlay validate` verb would catch this class of regression. 4. **flotilla: lacks a webhook-inventory verb.** Listing / diffing the webhooks on the org's Gitea repos against the deployment's expected set is a recurring operator gesture. A `flotilla webhook list / check ` verb would formalize it. Generic enough for a future `flotilla-core` extraction. 5. **rfc-app: multiple From-identity references should derive from one source.** `EMAIL_FROM` is read in `email.py:82`, `email_otc.py` via cfg, `email_invite.py` via cfg, plus implied by DMARC alignment + Workspace allowed-senders. A spec section binding all surfaces to one source would prevent skew. --- ## What lands on the driver's plate 1. **Decide where to put the two docs.** `~/git/ohm-infra/` is not a git repo. Options: (a) leave as plain files, publish only via `publish-transcript.sh` (which today only handles transcripts); (b) git-init `~/git/ohm-infra/` and add a Gitea remote so ohm-infra becomes a versioned doc repo; (c) move the two docs into `~/projects/wiggleverse/ohm-rfc/` under a `docs/` subdir since they're OHM-deployment-specific. Driver decides. 2. **Schedule the disambiguation gesture.** The SMTP_HOST gap is the most urgent finding — if the framework is actually falling to stdout-fallback, OHM's emails (OTC, invites, notifications, beta-access) aren't leaving the VM. Operator runs `gcloud ssh ohm-app` + `systemctl show ohm-app.service | grep Environment` next session. 3. **Schedule the stale-hook deletion.** Five-second gesture; the hook on `wiggleverse/meta` pointing at deprovisioned `http://rfc.wiggleverse.org/api/webhooks/gitea` should go. 4. **Decide on the From-identity question.** `ohm@wiggleverse.org` (roadmap's suggestion) or keep `notifications@wiggleverse.org`? Pinning this is a prerequisite for the framework-side proposal's `Reply-To` decision. 5. **Slot the rfc-app email-hygiene minor.** Per the proposal, v0.18.0 or v0.19.0; 5 slices; mostly backward-compatible. Operator decides whether to bundle with another minor (e.g., #19's CONTRIBUTING.md docs) or ship solo. 6. **Confirm `ben.stull/ohm-rfc`'s hooks.** Bot lacks admin so M.3 couldn't inventory. Operator self-checks via Gitea web UI. --- ## What the driver needs to know (report-back summary) **DNS posture (literal values):** - SPF: `v=spf1 include:_spf.google.com ~all` (soft-fail). - DMARC: `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` (monitoring-only). - DKIM: present at `google._domainkey.wiggleverse.org` (2048-bit Workspace key). **SMTP provider:** Google Workspace SMTP relay (`smtp-relay.gmail.com`) — inferred from bootstrap script + DKIM + MX. **But SMTP_HOST is missing from the live flotilla overlay** — either the bootstrap was reshaped and these were dropped, or they're set on the VM out-of-band. Operator must disambiguate. **Top 3 deployment-side hardening gestures (priority order):** 1. Delete stale `wiggleverse/meta` hook (5 seconds, removes plain-HTTP-to-deprovisioned-domain liability). 2. Disambiguate SMTP_HOST gap (5 minutes, confirms email actually leaves the VM). 3. DMARC Phase A: add `pct=100; sp=quarantine; adkim=s; aspf=s` to the existing `p=none` record; wait 1 week; flip to `p=quarantine`. **Top 3 framework-side changes (rfc-app minor, target v0.18.0):** 1. `build_envelope` shared helper adds `Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`, `List-Unsubscribe-Post`, and `multipart/alternative` to all four email paths. 2. Make `GITEA_WEBHOOK_SECRET` required (not silently optional). 3. `outbound_emails` audit table + bounce-correlation hook. **Webhook posture:** signature verification is correct (`hmac.compare_digest`, HMAC-SHA-256 over raw body) **when the secret is set**. On OHM the secret is bound. Two open issues: (a) framework's silent-accept-when-unset insecure default; (b) stale `wiggleverse/meta` hook on a deprovisioned domain. **§19.2 candidates surfaced:** 5 (listed above). **Doc paths:** - `/Users/benstull/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` - `/Users/benstull/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md` - `/Users/benstull/git/ohm-infra/SESSION-0013.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md`