Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Stull 0562d53f86 Release 0.18.0: email + webhook hygiene (roadmap items #18 + #20)
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.
2026-05-28 07:39:33 -07:00
Ben Stull 4666c4abe7 v0.18.0 Slice 5: bounce correlation hook
The `POST /api/webhooks/email-bounce` body accepts a new optional
`message_id` field. When supplied, the handler looks up the
matching row in `outbound_emails` and stamps status='bounced' +
appends "bounce (<kind>)" to the error column. The response
gains a `correlated_id` field (nullable: null when no
message_id was provided or no row matched).

Hard-bounce -> `email_opt_out_all = 1` still fires regardless
(the v1 contract from api_notifications.py:486-498); Slice 5
just adds the per-message audit trail on top.

Providers that don't surface Message-ID get the legacy v1
behavior — match by email, flip the global opt-out, leave
correlated_id null. Providers that replay an old bounce with a
message_id the framework no longer has log an INFO line but
still 200 (the bounce path can't refuse just because a row was
pruned).

Test update: `test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py was asserting on the exact response shape
{ok, matched}; v0.18.0 adds `correlated_id`. The test now asserts
on the new shape; documented in CHANGELOG.

4 new tests covering: bounce with message_id stamps the row;
unknown message_id is logged + does not crash; bounce without
message_id still flips opt-out (backward compat); bounced rows
surface in the admin endpoint with `?status=bounced`.

Full suite: 295 passed.
2026-05-28 07:36:02 -07:00
Ben Stull 281a844513 v0.18.0 Slice 4: outbound_emails audit table + admin endpoint
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.
2026-05-28 07:32:31 -07:00
Ben Stull d3daa97264 v0.18.0 Slice 3: webhook tightening — mandatory secret + dev-bypass
`GITEA_WEBHOOK_SECRET` is now mandatory at startup. The framework
refuses to load_config() when the env var is empty unless the
operator opts into the dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`.
This is the v0.18.0 startup-loud-failure shape — the pre-v0.18.0
"silently accept unsigned POSTs when the secret is empty" path is
the bug the proposal targets.

`webhooks.receive`:
  * Defense in depth: refuses 500 if the secret is empty at request
    time and the dev-bypass is not set (catches the case where
    something mutates env after startup).
  * Logs a loud warning every time a webhook lands under the
    bypass — so a misconfigured production deployment shows up in
    the logs even if the operator missed the warning at boot.
  * Adds an INFO log at the unknown-repo branch (previously
    silently 200-OK'd a hook on a fork or a stale Gitea binding).

`tmp_env` fixture binds a fake secret so the existing 277 tests
boot cleanly; the new test_webhooks_vertical.py exercises both
the production-secret path (valid signature, invalid signature,
missing signature) and the dev-bypass path (config loads with
empty secret when bypass set, refuses without it).

7 new tests; full suite: 284 passed.
2026-05-28 07:27:23 -07:00
Ben Stull e9fdc478f6 v0.18.0 Slice 2: migrate send paths to build_envelope + POST unsubscribe
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.
2026-05-28 07:24:03 -07:00
Ben Stull 92059f319e v0.18.0 Slice 1: build_envelope helper + unit tests
Per the v0.18.0 email + webhook hygiene proposal
(~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md §1), this is the
shared envelope builder every send path will call in Slice 2. No
call-site changes yet — Slice 2 migrates email_otc / email_invite /
email._deliver / email._send_bundle to use it.

The helper centralizes the deliverability-critical headers (Date,
Message-ID, Auto-Submitted) and exposes per-kind unsubscribe
semantics (none for OTC, mailto: for invites, full one-click for
watcher notifications) as explicit kwargs rather than buried in
each call site.

15 new unit tests covering: always-present headers, Auto-Submitted
toggle, the three List-Unsubscribe shapes, plain-only vs
multipart/alternative body. Full suite: 267 passed (was 252).
2026-05-28 07:15:06 -07:00
Ben Stull 1456c8b73f Release 0.17.0: admin-create user + invite email (+ #21 Part C Amplitude wiring)
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.

From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).

Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.

Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.

Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).

Amplitude wiring (inline, #21 Part C):
  - USER_INVITED from CreateUserInviteModal { target_user_id,
    initial_role, custom_message_chars }
  - INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
    initial_role, needs_passcode, trust_device }
  - identify() BEFORE the claim event with properties:
    claim_method 'admin-invite', invited_at (setOnce),
    invited_by_admin_id (setOnce), initial_role (setOnce) —
    so the Amplitude user record is created with the OHM
    user_id from the very first event the invitee fires

Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:10:52 -07:00
28 changed files with 4164 additions and 39 deletions
+382
View File
@@ -23,6 +23,388 @@ 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.**
This release lands admin-create user with role assignment + invite
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
an admin can now type first name, last name, email, role, and an
optional custom message; the framework provisions the `users` row with
the chosen role and `permission_state='granted'` (the admin's hand is
the grant) and sends an invite email carrying a single-use claim link.
The invitee clicks through to `/invites/claim?token=…`, the token is
verified and consumed, the session is established, and the user is
routed to the passcode-set screen (per v0.10.0) on first sign-in.
Distinct from #12 (which ships in parallel in this wave): #12 is
per-RFC contribution/discussion membership and uses
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
provisioning by an admin and uses `user_invite_tokens` (slot 019).
Both can coexist; both surface in the same SMTP relay but with
distinct email templates.
Design decisions documented inline (see `backend/app/invites.py`'s
module docstring + the migration's header comment):
* **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
Opaque chosen over JWT because revocation is then a single SQL
UPDATE — admin-issued invites are exactly the kind of thing an
admin should be able to yank back without rotating a signing
key. The raw token only ever lives in the outbound email link
and the inbound claim body.
* **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
in `backend/app/invites.py`). Env-var configurability is a
§19.2 candidate; the constant is exposed as a single point of
edit if a deployment wants to override.
* **Immediate send, no admin-review-then-send queue.** Matches
how the v0.9.0 beta-request admin notification works (single
SMTP path). Admin-preview-before-send is a future enhancement.
* **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
a follow-up release can layer bulk on top of the same
`POST /api/admin/users` body shape with minimal disruption.
* **OTC skipped on first sign-in.** Per the roadmap: clicking the
unique token in the email is itself proof of email control, so
the claim flow signs the invitee in directly. Subsequent
sign-ins go through the standard OTC / passcode paths.
* **No `users` table changes.** The brief floated
`first_sign_in_at` / `last_seen_at IS NULL` as the
"(pending invite)" discriminator, but the existing
`users.last_seen_at` column is NOT NULL with a `datetime('now')`
default (migrations/001) and no `first_sign_in_at` column
exists. Rather than land a schema migration to introduce one,
the discriminator is the existence of an active (not-claimed,
not-expired) row in `user_invite_tokens` joined on
`invited_user_id`. The admin user-listing carries a
`pending_invite` field populated via that join; on claim, the
badge clears naturally as the invite row's `claimed_at`
populates.
* **Admin-create vs. self-flip refusals.** Self-invite is refused
422 (use the role-change channel for self-edits). Duplicate
email is refused 409 (use the existing role / grant gestures on
the existing user). Owner-grant by a non-owner admin is refused
422 (§6.1's owner-zero is the only owner bootstrap path; the
sitting owner must issue the invite).
### Added
- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
Body: `{ email, first_name?, last_name?, role, custom_message? }`.
Provisions the `users` row with the chosen role and writes the
`user_invite_tokens` row + dispatches the invite email + writes a
`permission_events` row with `event_kind='user_invited'`. Returns
`{ ok, invite_id, invited_user_id, email, role }` on success;
surfaces the four refusals (403/422/409/422) per their distinct
paths.
- **`GET /api/admin/users/invites`** — admin-only. Lists active
(not claimed, not expired) invites with the admin who created them
joined through for display. Powers the "I sent these but they
haven't been claimed yet" admin view.
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
`/auth/otc/verify` and `/auth/device-trust/start` since it shares
the device-trust cookie helpers). Anonymous-reachable. Body:
`{ token, trust_device? }`. Validates the token, consumes the
invite row, signs the user in, optionally mints a device-trust
cookie, and returns `{ ok, user, needs_passcode }`. The
`needs_passcode` hint drives the frontend's route-to-passcode-set
vs. route-to-home decision. Maps token-failure modes to distinct
HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
- **`backend/app/invites.py`** — the create + claim + list module.
Mirrors the `device_trust.py` shape: opaque-token issuance with
bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
`CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
`EmailConfig.from_env()` for the SMTP plumbing + From identity;
composes a separate template (subject "You're invited to <app> by
<admin>"; body names the inviter, embeds the optional custom
message in a clearly-delimited indented block if present, and
carries the claim URL). Dev / no-SMTP path logs the envelope to
the shared `_SENT` buffer so backend tests can assert on the
outbound shape.
- **Schema migration `019_user_invite_tokens.sql`** — new
`user_invite_tokens` table (id, email, role, first_name, last_name,
custom_message, token_hash, expires_at, created_at,
created_by_admin_id, claimed_at, claimed_by_user_id,
invited_user_id). Three indexes: unique on `token_hash`
(documents the no-collision invariant); `(email, claimed_at)` for
the "is this email already invited?" pre-check; and
`(created_by_admin_id, created_at DESC)` for the per-admin
invites listing. Slot 018 is reserved for the parallel #12
release shipping in the same wave; slot 016 stays
reserved-and-skipped per Session K's v0.9.0 integration.
- **`frontend/src/components/InviteClaim.jsx`** — the
`/invites/claim?token=…` landing page. Reads the token from the
URL, renders a "Claim my account" CTA with an optional
"trust this device for 30 days" checkbox, calls
`POST /api/invites/claim` on submit, and routes onward
(`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
on success. Anonymous-reachable.
- **"Create user + invite" affordance** on `/admin/users`
(`frontend/src/components/Admin.jsx`). A header button opens a
modal with email / first / last / role / custom-message inputs
(the textarea shows a "chars left" counter against the 500-char
ceiling). On submit, the modal calls
`POST /api/admin/users` and refreshes the user listing.
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
inline with v0.17.0): `USER_INVITED` event fires from
`CreateUserInviteModal` on successful invite-send with
`{ target_user_id, initial_role, custom_message_chars }` — the
OHM `invited_user_id` returned by `POST /api/admin/users`
becomes the dashboard's binding for the future Amplitude user
record; `custom_message_chars` is a coarse signal of admin
effort (0 = template-only, 1+ = personalized) and carries no
PII. `INVITE_CLAIMED` event fires from `InviteClaim.jsx` on
successful claim with `{ invited_by_admin_id, initial_role,
needs_passcode, trust_device }` — BUT the claim handler first
calls `identify({ user_id, properties: { claim_method:
'admin-invite', invited_at (setOnce), invited_by_admin_id
(setOnce), initial_role (setOnce) } })` so the Amplitude user
record is created with the OHM user_id from the very first
event the invitee fires, never as an anonymous device that
retroactively links. setOnce semantics preserve the original
invite context even if the invitee later changes roles.
- **"(pending invite)" badge** inline on the user-listing's
per-row handle (rendered when the row's `pending_invite` field
is populated by the backend's join through `user_invite_tokens`).
Clears automatically on claim as the invite row's `claimed_at`
populates.
### Changed
- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
+ `EmailConfig`; adds the two new endpoints alongside the existing
`set_role` / `set_permission` neighbors; extends `list_users` to
join through `user_invite_tokens` and emit the `pending_invite`
field on each row. The new `CreateUserInviteBody` pydantic model
carries the body bounds (320-char email, 120-char first/last,
regex-pinned role, 500-char custom_message) so malformed input
fails at the body bound (422) instead of at the SQL layer.
- **`backend/app/main.py`** — imports `invites as invites_mod`;
adds the `InviteClaimBody` pydantic model alongside
`PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
endpoint in the OAuth router so it can reuse the
`_set_device_trust_cookie` helper.
- **`frontend/src/api.js`** — exports `createUserInvite()`,
`listUserInvites()`, `claimInvite()`. Same fetch shape as the
rest of the v0.9.0 / v0.10.0 admin neighborhood.
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
`/invites/claim` route alongside `/beta-pending` (both are
anonymous-reachable auth-shape landings).
### Migration
- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
on next backend start. Single new table with three indexes; no
changes to existing tables.
### Upgrade steps (from 0.14.0)
- You **MUST** apply schema migration `019_user_invite_tokens.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.
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both
move to `0.17.0`. No new env vars; no new secrets (the invite
email rides the existing SMTP relay configured for v0.7.0's OTC
mail).
- You **MAY** announce the new admin-create gesture to existing
admins. Existing user rows are unaffected — the
`user_invite_tokens` table is empty post-migration, and the
user-listing's new `pending_invite` field is null on every
existing row. The bootstrap shape for the very first admin
account stays the v0.9.0 path (DB-level role flip on an OTC-
provisioned row); the v0.17.0 admin-create gesture works
end-to-end once at least one admin exists.
## 0.16.0 — 2026-05-28
**Minor — schema migration auto-applied; no operator action.** This
+1 -1
View File
@@ -1 +1 @@
0.16.0
0.18.0
+332 -1
View File
@@ -11,6 +11,8 @@ The endpoints in this module:
- `GET /api/admin/users` — list users with role + mute
- `POST /api/admin/users/<id>/role` — set role per §6.1
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
- `POST /api/admin/users` — v0.17.0: create user + invite
- `GET /api/admin/users/invites` — v0.17.0: pending invites
- `GET /api/admin/audit` — paged `actions` log
- `GET /api/admin/permission-events` — paged `permission_events` log
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
@@ -33,8 +35,9 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from . import auth, db
from . import auth, db, email_invite, invites
from .config import Config
from .email import EmailConfig
# ---------------------------------------------------------------------------
@@ -65,6 +68,32 @@ class AllowlistAddBody(BaseModel):
note: str | None = Field(default=None, max_length=200)
class CreateUserInviteBody(BaseModel):
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
The admin types these fields on the "Create user + invite" modal on
`/admin/users`. The email + role are required; first/last name and
the optional custom message round out the body.
Bounds mirror the rest of the codebase:
* `email`: 320 chars — RFC 5321 envelope limit, same as
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
* `first_name` / `last_name`: 120 chars — same as the v0.8.0
`BetaRequestBody` capture form.
* `role`: pydantic regex pinned to the §6.1 set so an unknown
role fails at the body bound (422) instead of landing as a
CHECK constraint violation in the migration.
* `custom_message`: 500 chars — the brief calls this out as
the max. The frontend modal shows a "remaining chars"
counter to match.
"""
email: str = Field(min_length=3, max_length=320)
first_name: str = Field(default="", max_length=120)
last_name: str = Field(default="", max_length=120)
role: str = Field(pattern="^(owner|admin|contributor)$")
custom_message: str = Field(default="", max_length=500)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -156,6 +185,32 @@ def make_router(config: Config) -> APIRouter:
"inviter_login": ir["inviter_login"],
"inviter_display": ir["inviter_display"],
})
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
# is NULL is one of two things — a brand-new row that was just
# provisioned (rare, and the v0.7.0 OTC verify path stamps
# last_seen_at on the same call that creates the row), or an
# admin-created invite-pending row (v0.17.0 — created by
# `POST /api/admin/users`). We surface a `pending_invite_id`
# field by joining through `user_invite_tokens` so the
# Users tab can render a "(pending invite)" badge alongside
# the role/state controls. Filters to invites that are
# neither expired nor claimed — once the invitee clicks
# through, the badge clears (and `last_seen_at` populates).
pending_invite_rows = db.conn().execute(
"""
SELECT invited_user_id, id AS invite_id, expires_at
FROM user_invite_tokens
WHERE claimed_at IS NULL
AND datetime(expires_at) > datetime('now')
"""
).fetchall()
pending_invites = {
r["invited_user_id"]: {
"invite_id": r["invite_id"],
"expires_at": r["expires_at"],
}
for r in pending_invite_rows
}
return {
"items": [
{
@@ -176,6 +231,215 @@ def make_router(config: Config) -> APIRouter:
"permission_decided_by_display": r["decided_by_display"],
# v0.16.0 additive — never null, always an array.
"rfc_invitations": per_user_invites.get(r["id"], []),
# v0.17.0: present iff the row is invited-but-not-
# claimed-yet. The frontend renders a "(pending
# invite)" badge when this is non-null.
"pending_invite": pending_invites.get(r["id"]),
}
for r in rows
]
}
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
@router.post("/api/admin/users")
async def create_user_with_invite(
body: CreateUserInviteBody, request: Request,
) -> dict[str, Any]:
"""Provision a fresh `users` row with a pre-assigned role + send
an invite email carrying a claim link.
Refusals:
* `422` — the admin tries to invite their own email (no
self-invite; symmetric to `set_permission`'s self-flip
refusal and `set_role`'s self-downgrade refusal). Use
the existing role-change channel for self-edits.
* `422` — the admin tries to grant `owner` without being
owner themselves. §6.1: owner-zero is the only owner
bootstrap path; new owners come from a sitting owner's
hand. A 422 here matches the message shape; a 403 would
also be defensible, but staying with 422 keeps the
"your input is bad" framing.
* `409` — the email already maps to a `users` row. The
admin should use the existing role / grant gestures on
the existing user, not create a duplicate.
* `422` — pydantic-level: malformed email, role outside
the §6.1 set, custom_message over 500 chars.
On success:
1. The invitee `users` row lands with the chosen role and
`permission_state='granted'` (admin's hand is the grant)
and `last_seen_at IS NULL` (the "(pending invite)"
discriminator the listing surface joins through).
2. The `user_invite_tokens` row lands with the bcrypt-
hashed opaque token; the raw token rides only in the
email link.
3. The invite email dispatches with subject "You're
invited to <app> by <admin>" and the custom message
embedded in a clearly-delimited block if present.
4. A `permission_events` row records the admin-create
gesture so the §6.5 / `permissions` admin tab carries
the audit trail alongside the existing grant/revoke
flips.
"""
viewer = auth.require_admin(request)
email_clean = body.email.strip().lower()
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
raise HTTPException(422, "Email looks malformed")
# Self-invite refusal. Compare the admin's own email
# case-insensitively against the invite target.
viewer_row = db.conn().execute(
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
).fetchone()
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
if viewer_email and viewer_email == email_clean:
raise HTTPException(
422,
"You cannot invite yourself — use the role-change channel "
"if you need to edit your own row",
)
# Owner-grant refusal: §6.1 says only a sitting owner can mint
# a new owner. An admin trying to invite-as-owner is refused
# at 422; the admin should ask the owner to issue the invite,
# or invite as `admin` and let the owner promote later.
if body.role == "owner" and viewer.role != "owner":
raise HTTPException(
422,
"Only an owner can invite a new owner — invite as admin and "
"ask the owner to promote, or have the owner issue this invite",
)
# Duplicate-email refusal. A pre-existing row (regardless of
# permission_state) means the admin should use the existing
# role / grant gestures, not create a parallel user.
existing = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
(email_clean,),
).fetchone()
if existing is not None:
raise HTTPException(409, "A user with this email already exists")
# Create the invitee row + token row + send the email.
outcome = invites.create_invite(
email=email_clean,
first_name=body.first_name,
last_name=body.last_name,
role=body.role,
custom_message=body.custom_message,
created_by_admin_id=viewer.user_id,
)
# Audit row in permission_events so the admin Permissions tab
# carries the gesture. The before-state is "n/a" (the row
# did not exist); the after-state is the granted role. We
# use a new `event_kind='user_invited'` so the existing
# grant/revoke kinds stay scoped to their flip surface.
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'user_invited', ?)
""",
(
viewer.user_id,
outcome.invited_user_id,
json.dumps({
"email": email_clean,
"role": body.role,
"invite_id": outcome.invite_id,
"custom_message_chars": len(body.custom_message or ""),
}),
),
)
# Build the claim URL using the same APP_URL the email module
# reads. The token rides as a query-string param to the
# frontend route `/invites/claim?token=…`; the frontend POSTs
# it back to `/api/invites/claim` which consumes the row.
cfg = EmailConfig.from_env()
from urllib.parse import urlencode
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
# Fetch the inviter display so the email body can render
# "Ben Stull (ben@example.com) has invited you to …". We
# read off the row fresh rather than trusting the session
# cookie's cached display_name.
inviter_row = db.conn().execute(
"SELECT display_name, email FROM users WHERE id = ?",
(viewer.user_id,),
).fetchone()
inviter_display = (
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
)
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
email_invite.send_invite_email(
to_address=email_clean,
claim_url=claim_url,
inviter_display=inviter_display,
inviter_email=inviter_email_for_body,
custom_message=body.custom_message,
)
return {
"ok": True,
"invite_id": outcome.invite_id,
"invited_user_id": outcome.invited_user_id,
"email": email_clean,
"role": body.role,
}
@router.get("/api/admin/users/invites")
async def list_user_invites(request: Request) -> dict[str, Any]:
"""List active (not claimed, not expired) admin-issued invites.
Powers the admin's "I sent these but they haven't been claimed
yet" view. The frontend uses this alongside `list_users` —
the user-listing's `pending_invite` field carries the per-row
flag; this endpoint carries the full invite shape for a
dedicated drill-in surface.
"""
auth.require_admin(request)
rows = invites.list_pending_invites()
# Join through to the admin display names so the surface can
# render "invited by @ben" without a second client call.
admin_ids = {r.created_by_admin_id for r in rows}
admin_lookup: dict[int, dict[str, str]] = {}
if admin_ids:
placeholders = ",".join("?" * len(admin_ids))
admin_rows = db.conn().execute(
f"SELECT id, gitea_login, display_name FROM users "
f"WHERE id IN ({placeholders})",
tuple(admin_ids),
).fetchall()
admin_lookup = {
ar["id"]: {
"gitea_login": ar["gitea_login"] or "",
"display_name": ar["display_name"] or "",
}
for ar in admin_rows
}
return {
"items": [
{
"id": r.id,
"email": r.email,
"role": r.role,
"first_name": r.first_name,
"last_name": r.last_name,
"custom_message": r.custom_message,
"created_at": r.created_at,
"expires_at": r.expires_at,
"invited_user_id": r.invited_user_id,
"created_by_admin_id": r.created_by_admin_id,
"created_by_login": admin_lookup.get(
r.created_by_admin_id, {}
).get("gitea_login", ""),
"created_by_display": admin_lookup.get(
r.created_by_admin_id, {}
).get("display_name", ""),
}
for r in rows
]
@@ -408,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
"has_more": len(rows) == limit,
}
@router.get("/api/admin/outbound-emails")
async def list_outbound_emails(
request: Request,
kind: str | None = None,
status: str | None = None,
to_address: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
"""v0.18.0 Slice 4: read-only inspection of the
`outbound_emails` audit table.
Answers questions like "did this person ever get their
invite?" without grepping VM logs. Filterable by kind
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
to_address; the latter is exact-match because the audit
question is usually "the specific person who said they
didn't receive it." Per the proposal, no admin UI ships
with v0.18.0 — operator queries via curl + jq for now.
"""
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if kind:
clauses.append("kind = ?")
args.append(kind)
if status:
clauses.append("status = ?")
args.append(status)
if to_address:
clauses.append("LOWER(to_address) = LOWER(?)")
args.append(to_address)
if before_id is not None:
clauses.append("id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT id, to_address, from_address, subject, kind, sent_at,
status, error, notification_id, message_id
FROM outbound_emails
{where}
ORDER BY id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
return {
"items": [
{
"id": r["id"],
"to_address": r["to_address"],
"from_address": r["from_address"],
"subject": r["subject"],
"kind": r["kind"],
"sent_at": r["sent_at"],
"status": r["status"],
"error": r["error"],
"notification_id": r["notification_id"],
"message_id": r["message_id"],
}
for r in rows
],
"has_more": len(rows) == limit,
}
@router.get("/api/admin/permission-events")
async def list_permission_events(
request: Request,
+115 -13
View File
@@ -73,6 +73,13 @@ class MarkReadBody(BaseModel):
class BounceBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
kind: str = Field(default="hard") # 'hard' or 'complaint'
# v0.18.0 Slice 5: when the bounce provider includes the
# original Message-ID, the framework correlates it back to
# the matching `outbound_emails` row and stamps
# `status='bounced'`. Optional — providers that don't surface
# the Message-ID still flip the global opt-out via the email
# match, but lose the per-message attribution.
message_id: str | None = Field(default=None, max_length=1000)
class CookieConsentBody(BaseModel):
@@ -443,6 +450,40 @@ def make_router(config: Config) -> APIRouter:
# ----- Email: one-click unsubscribe + bounce webhook -----
# v0.18.0: the category → column map. The `all` synthetic
# category lands the bundle's one-click on the global opt-out
# flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
# spans multiple categories, so a per-category flip wouldn't
# honor the user's intent).
_CATEGORY_COLUMN: dict[str, str] = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
"all": "email_opt_out_all",
}
def _apply_unsubscribe(user_id: int, category: str) -> bool:
"""Flip the matching column. Returns True on success, False
if the category is unknown. Idempotent — running twice on
the same (user, category) is harmless (it sets the column
to its current value)."""
column = _CATEGORY_COLUMN.get(category)
if column is None:
return False
# `all` sets the flag to 1 (opt out); per-category sets to 0
# (turn that category off). The column semantic is "1 means
# don't send"; the per-category booleans are "1 means do
# send". Different polarities, hence the case split.
if category == "all":
db.conn().execute(
f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
)
else:
db.conn().execute(
f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
)
return True
@router.get("/api/email/unsubscribe")
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
try:
@@ -453,20 +494,51 @@ def make_router(config: Config) -> APIRouter:
"<p>Open the app to manage your notification preferences directly.</p>",
status_code=400,
)
column = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
}.get(category)
if column is None:
if not _apply_unsubscribe(user_id, category):
return HTMLResponse(
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
)
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
return HTMLResponse(
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
if category == "all":
body = (
"<h1>Unsubscribed</h1><p>You will no longer receive any email "
"from this app. You can re-enable individual categories from "
"your notification preferences after signing in.</p>"
)
else:
body = (
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
return HTMLResponse(body)
@router.post("/api/email/unsubscribe")
async def email_unsubscribe_post(
request: Request,
t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
) -> dict[str, Any]:
"""v0.18.0: RFC 8058 one-click endpoint.
Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
form-encoded body) to the URL in the `List-Unsubscribe`
header when the user clicks their MUA's "Unsubscribe"
button. The endpoint MUST accept POST (per the
`List-Unsubscribe-Post` header we advertise) and MUST be
idempotent.
The body content is checked loosely — RFC 8058 says it
SHOULD be exactly `List-Unsubscribe=One-Click`, but some
intermediaries strip / re-encode the body, so the
framework accepts any POST to the URL once the token
verifies. The bar is that the token signature carries the
authority; the body is hint-only.
"""
try:
user_id, category = email_mod.verify_unsubscribe_token(t)
except BadSignature:
raise HTTPException(400, "Invalid or expired token")
if not _apply_unsubscribe(user_id, category):
raise HTTPException(400, f"Unknown category: {category}")
return {"ok": True, "category": category}
@router.post("/api/webhooks/email-bounce")
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
@@ -490,16 +562,46 @@ def make_router(config: Config) -> APIRouter:
import hmac as _hmac
if not received or not _hmac.compare_digest(expected, received):
raise HTTPException(401, "Invalid webhook signature")
# v0.18.0 Slice 5: correlate the bounce back to the
# matching outbound_emails row if the provider supplied
# the Message-ID. The hard-bounce -> global-opt-out
# logic below still fires regardless; this is an
# additional audit signal.
correlated_row_id: int | None = None
if body.message_id:
correlated = db.conn().execute(
"SELECT id FROM outbound_emails WHERE message_id = ?",
(body.message_id,),
).fetchone()
if correlated is not None:
correlated_row_id = correlated["id"]
db.conn().execute(
"UPDATE outbound_emails SET status = 'bounced', "
"error = COALESCE(error, '') || ? WHERE id = ?",
(f"bounce ({body.kind})", correlated_row_id),
)
log.info(
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
body.message_id, correlated_row_id,
)
else:
log.info(
"email-bounce: message_id=%s did not match any "
"outbound_emails row (provider may be replaying an old bounce, "
"or the row was pruned)",
body.message_id,
)
row = db.conn().execute(
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
).fetchone()
if row is None:
return {"ok": True, "matched": False}
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
db.conn().execute(
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
)
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
return {"ok": True, "matched": True}
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
return router
+15 -1
View File
@@ -60,6 +60,20 @@ def load_config() -> Config:
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the
# email + webhook hygiene proposal). An empty value used to
# silently accept unsigned webhook POSTs — that was the
# invisible-failure shape the proposal targets. Now the
# framework refuses to start when the secret is empty unless
# the operator opts into the dev-bypass with
# `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without
# a wired Gitea hook set the bypass; production MUST NOT.
insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if insecure_webhooks:
webhook_secret = _optional("GITEA_WEBHOOK_SECRET")
else:
webhook_secret = _required("GITEA_WEBHOOK_SECRET")
return Config(
gitea_url=_required("GITEA_URL").rstrip("/"),
gitea_bot_user=_required("GITEA_BOT_USER"),
@@ -72,7 +86,7 @@ def load_config() -> Config:
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
webhook_secret=webhook_secret,
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
+13 -1
View File
@@ -180,7 +180,19 @@ def assemble_for_user(
subject = _subject(eligible, cadence)
body = _body(eligible, cadence, cfg)
sent = email_mod._deliver(cfg, email, subject, body)
# v0.18.0: the digest is the bulk-adjacent surface par excellence
# (it can carry weeks of accumulated activity), so it gets the
# full one-click unsubscribe to the global opt-out. Per-category
# opt-outs are managed from the preferences page; this footer is
# the "stop sending me anything" escape hatch Gmail and Yahoo
# expect for senders at this tier.
unsubscribe_url = email_mod.make_unsubscribe_url(user_id, "all")
sent = email_mod._deliver(
cfg, email, subject, body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="digest",
)
if not sent:
return False
ids = [r["id"] for r, _ in eligible]
+165 -10
View File
@@ -24,7 +24,6 @@ import os
import smtplib
from dataclasses import dataclass
from datetime import datetime, time, timezone
from email.message import EmailMessage
from email.utils import formataddr
from itertools import groupby
from typing import Any
@@ -33,6 +32,7 @@ from urllib.parse import urlencode
from itsdangerous import BadSignature, URLSafeSerializer
from . import db
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -69,6 +69,7 @@ class EmailConfig:
app_url: str
bundle_threshold: int
enabled: bool
unsubscribe_mailto: str
@classmethod
def from_env(cls) -> "EmailConfig":
@@ -84,6 +85,16 @@ class EmailConfig:
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
# v0.18.0: the `List-Unsubscribe: <mailto:…>` target on
# invite + notification mail. Defaults to the From
# address when unset; a deployment can route opt-out
# mail to a separate mailbox (e.g., a humans-monitored
# account distinct from the no-reply notifications
# sender) by setting this explicitly.
unsubscribe_mailto=os.environ.get(
"EMAIL_UNSUBSCRIBE_MAILTO",
os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local"),
).strip(),
)
@@ -98,6 +109,14 @@ def _signer() -> URLSafeSerializer:
def make_unsubscribe_url(user_id: int, category: str) -> str:
"""Build the §15.4 per-category one-click URL.
`category` is one of `personal-direct`, `structural`,
`admin-actionable` (the three per-category flags) or `all`
(v0.18.0: the bundle path, which sets `email_opt_out_all = 1`
because a bundle covers multiple categories and a per-category
opt-out wouldn't honor the user's intent).
"""
cfg = EmailConfig.from_env()
token = _signer().dumps({"u": user_id, "c": category})
qs = urlencode({"t": token})
@@ -250,7 +269,21 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
return
subject = _subject(payload)
body = _body(payload, user["id"], category, cfg)
sent = _deliver(cfg, user["email"], subject, body)
# v0.18.0: notification mail is bulk-adjacent (a watcher can
# accumulate dozens of structural events on a busy RFC), so it
# carries the full one-click unsubscribe — Gmail and Yahoo
# require this for senders at OHM's volume tier per RFC 8058.
unsubscribe_url = make_unsubscribe_url(user["id"], category)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="notification",
notification_id=notif_id,
)
if not sent:
return
db.conn().execute(
@@ -305,23 +338,65 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
return cfg.app_url
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
def _deliver(
cfg: EmailConfig,
to_address: str,
subject: str,
body: str,
*,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
kind: str = "notification",
notification_id: int | None = None,
) -> bool:
"""Build the envelope via the shared `build_envelope` helper and
hand it to SMTP.
The `_SENT` buffer carries the helper's `EmailMessage` under
`message` plus the legacy `to`/`from`/`subject`/`body` keys for
backward-compatibility with tests that read those directly.
Newer tests can assert on the header surface by inspecting
`envelope["message"]`.
v0.18.0 Slice 4: also writes one row to `outbound_emails`
capturing the attempt. status='sent' on success, 'failed' on
SMTP exception, 'deferred' on the dev-fallback path (no
SMTP_HOST configured — the send didn't happen, but the row
records the attempt so the admin endpoint can answer "did the
framework try?").
"""
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"message": msg,
"kind": kind,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.smtp_host:
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="deferred",
message_id=message_id,
notification_id=notification_id,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
@@ -331,12 +406,78 @@ def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="sent",
message_id=message_id,
notification_id=notification_id,
)
return True
except Exception:
except Exception as exc:
log.exception("email send failed: to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="failed",
error=f"{type(exc).__name__}: {exc}",
message_id=message_id,
notification_id=notification_id,
)
return False
def record_outbound(
*,
to_address: str,
from_address: str,
subject: str,
kind: str,
status: str,
error: str | None = None,
notification_id: int | None = None,
message_id: str | None = None,
) -> int | None:
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
Returns the inserted row's id, or `None` if the DB connection
isn't initialized (which happens in unit tests that don't boot
the full app — the write is best-effort and never raises).
"""
try:
cur = db.conn().execute(
"""
INSERT INTO outbound_emails
(to_address, from_address, subject, kind, sent_at, status,
error, notification_id, message_id)
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
""",
(
to_address,
from_address,
subject,
kind,
status,
error,
notification_id,
message_id,
),
)
return cur.lastrowid
except RuntimeError:
# db.conn() raises RuntimeError if init() hasn't been called.
# Pure-helper unit tests for build_envelope hit this path; the
# audit row is best-effort and not part of the contract.
return None
except Exception:
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
return None
# ---------------------------------------------------------------------------
# Quiet-hours release pass — called from the digest job
# ---------------------------------------------------------------------------
@@ -440,13 +581,27 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
for r, _cat, extras in group_rows:
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
sections.append(f" · {summary}")
# v0.18.0: the bundle covers multiple categories, so a
# per-category opt-out can't honor the user's intent. The
# `all` category lands at the §15.4 endpoint and sets
# `email_opt_out_all = 1`.
unsubscribe_url = make_unsubscribe_url(user["id"], "all")
body = (
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
+ "\n".join(sections)
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
+ f"Unsubscribe from all email: {unsubscribe_url}\n"
)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="bundle",
)
sent = _deliver(cfg, user["email"], subject, body)
if not sent:
return 0
ids = [r["id"] for r, _, _ in emailable]
+143
View File
@@ -0,0 +1,143 @@
"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder.
Every outbound mail in rfc-app today (OTC, admin-invite, watcher
notification, "while you were away" bundle, per-RFC invite) constructs
its own `email.message.EmailMessage` ad-hoc. The four sites diverged
just enough to be a deliverability hazard: missing `Date`, missing
`Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the
bulk-adjacent paths, no `multipart/alternative` body.
This module is the one place an `EmailMessage` is constructed. Every
send path imports `build_envelope` and calls it; the headers that
matter for inbox placement (Date, Message-ID, Auto-Submitted) land
uniformly, and the per-kind variations (unsubscribe semantics,
HTML alternative) are explicit arguments rather than buried in
each call site.
Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`,
the per-kind unsubscribe matrix is:
* OTC: no `List-Unsubscribe` (the recipient explicitly requested
the code; advertising an unsubscribe header would imply OHM has
them on a list, which it doesn't).
* Admin invite / per-RFC invite: `mailto:` form only (the
recipient isn't a user yet, so there's no per-user opt-out row
to flip; the operator handles ad-hoc opt-outs manually).
* Watcher notification / bundle: full `mailto:` + signed-URL
`List-Unsubscribe` plus `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo
enforce this for bulk-adjacent senders).
The `is_transactional` flag governs `Auto-Submitted: auto-generated`,
which prevents auto-responder loops on every kind of mail we send.
All five mail kinds today are transactional in the SMTP sense (no
human is at the From mailbox watching for replies), so the default
is True; the argument is exposed for future symmetry.
"""
from __future__ import annotations
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
def build_envelope(
*,
to_address: str,
from_address: str,
from_name: str,
subject: str,
body_plain: str,
body_html: str | None = None,
reply_to: str | None = None,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
is_transactional: bool = True,
msgid_domain: str | None = None,
) -> EmailMessage:
"""Compose an `EmailMessage` with hardened headers.
`to_address` / `from_address` are bare RFC 5322 addresses;
`from_name` is the display label that goes through `formataddr`
so spaces / commas in the display string are encoded correctly.
`body_plain` is mandatory. `body_html`, if supplied, lands as the
second part of a `multipart/alternative` body — mail clients
that prefer HTML render it; clients that don't fall back to the
plain part. The text/plain part comes first per RFC 2046, so a
plain-text client that picks the first body gets the readable
text.
`reply_to`, when set, lets a send path point replies at a
different mailbox than the From line (e.g., a watcher
notification with From=notifications@... but Reply-To=
ohm@... so a confused recipient who hits Reply lands at a
monitored mailbox).
`unsubscribe_mailto` / `unsubscribe_url` populate
`List-Unsubscribe`. If `unsubscribe_url` is set, the helper also
emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per
RFC 8058 — Gmail and Yahoo POST that payload on the user's
one-click action. (Send paths that wire `unsubscribe_url`
therefore MUST also expose a matching POST endpoint that accepts
the same token; see `api_notifications.py:email_unsubscribe`.)
`msgid_domain` defaults to the @-domain of `from_address` so
Message-IDs are aligned with the sending domain by default. A
deployment that wants the Message-ID domain to track a different
surface (e.g., a tracking-domain that's separate from the From
domain) can override.
`Date` is RFC 5322 formatted via `email.utils.formatdate`; the
`localtime=True` setting picks the running process's local
timezone, which is what every popular MUA does too. (A
deployment running in UTC stamps UTC; that's correct, not a
bug.)
"""
msg = EmailMessage()
msg["From"] = formataddr((from_name, from_address))
msg["To"] = to_address
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
# If the caller didn't pin a Message-ID domain, derive it from the
# From address. `make_msgid` accepts None and falls back to the
# local hostname, which is the wrong shape for a deliverable
# message (the hostname might be `gke-pool-xxx`); a deployment
# without a configured From would surface that as a build-time
# config error elsewhere, so the fallback here is just defensive.
if msgid_domain is None:
if "@" in from_address:
msgid_domain = from_address.split("@", 1)[1]
else:
msgid_domain = "localhost"
msg["Message-ID"] = make_msgid(domain=msgid_domain)
if reply_to:
msg["Reply-To"] = reply_to
if is_transactional:
# RFC 3834: prevents auto-responders (vacation replies, etc.)
# from triggering on this message. Every kind of mail rfc-app
# sends today is transactional in this sense.
msg["Auto-Submitted"] = "auto-generated"
if unsubscribe_mailto or unsubscribe_url:
parts: list[str] = []
if unsubscribe_mailto:
parts.append(f"<mailto:{unsubscribe_mailto}>")
if unsubscribe_url:
parts.append(f"<{unsubscribe_url}>")
msg["List-Unsubscribe"] = ", ".join(parts)
if unsubscribe_url:
# RFC 8058 one-click. Gmail and Yahoo POST the payload
# `List-Unsubscribe=One-Click` to the URL on the user's
# one-click action; the matching POST endpoint must be
# idempotent and not require auth. See
# `api_notifications.py` for the receiver.
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
if body_html:
# multipart/alternative: text/plain first, text/html second.
# `set_content` sets the first part (and the message's main
# body); `add_alternative` adds the second part and
# restructures the message as multipart/alternative.
msg.set_content(body_plain)
msg.add_alternative(body_html, subtype="html")
else:
msg.set_content(body_plain)
return msg
+166
View File
@@ -0,0 +1,166 @@
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
create-with-invite, this module composes and sends the invite envelope.
Structurally distinct from:
* `email_otc.py` (v0.7.0) — that one carries a credential the user
just requested; this one carries a credential the admin is sending
unsolicited.
* `email.py` (§15.4 notification mailer) — that one is inbox-driven,
bundled, with category opt-outs; this one is a single transactional
outbound to a person who does not yet have an inbox.
* v0.9.0's `new_beta_request` admin notification — that one is
invitee-to-admin (an existing pending user asking to be let in);
this one is admin-to-invitee (an admin reaching out to seed access).
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
and the From identity, but writes its own envelope. In dev (no
SMTP_HOST set), the envelope is logged at INFO level and pushed to
the same `_SENT` buffer the notification mailer uses, so the
integration tests can assert on the outbound shape without standing
up an SMTP server.
The send is synchronous. The admin endpoint returns 200 on the
create-row half regardless of send outcome — a transient SMTP
failure should not roll back the invite (an admin can re-send via a
future "resend invite" gesture, deferred to a follow-up release).
"""
from __future__ import annotations
import logging
import smtplib
from email.utils import formataddr
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
def send_invite_email(
*,
to_address: str,
claim_url: str,
inviter_display: str,
inviter_email: str,
custom_message: str = "",
) -> bool:
"""Compose and send the admin-invite email. Returns True on the
happy path; False on SMTP failure. The notifier-side buffer
`_SENT` is appended either way so tests can assert on content.
The body names the inviting admin, embeds the optional custom
message in a clearly delimited block if present, and ships the
claim link. The subject names the inviter so the recipient can
recognize the sender at a glance in their inbox preview.
"""
cfg = EmailConfig.from_env()
subject = _subject(inviter_display, cfg)
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
# v0.18.0: invite mail carries a `List-Unsubscribe: <mailto:…>`
# only (no signed URL) — the invitee isn't a user yet, so there
# is no per-user opt-out row to flip. The operator handles
# ad-hoc opt-outs from the mailto: target. Per the proposal's
# "Tradeoff discussion": the invite was unsolicited from the
# recipient's perspective, so the courtesy header is right;
# but it can't be a one-click URL because the row doesn't
# exist yet.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "invite",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the claim URL at INFO so the operator can
# complete a claim flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
try:
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
smtp.starttls()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, cfg.smtp_password)
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="sent", message_id=message_id,
)
return True
except Exception as exc:
log.exception("invite email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
inviter = inviter_display or "an admin"
return f"You're invited to {cfg.from_name} by {inviter}"
def _body(
claim_url: str,
inviter_display: str,
inviter_email: str,
custom_message: str,
cfg: EmailConfig,
) -> str:
inviter = inviter_display or "An admin"
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
message_block = ""
if custom_message.strip():
# Indent the custom message so it reads as a clearly-delimited
# quote rather than running together with the framework's
# framing text. Per-line indent keeps multi-line messages
# visually grouped in plain-text mail clients.
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
return (
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
f"{message_block}\n"
f"Click the link below to claim your account and sign in.\n"
f"This link is single-use and expires in 7 days.\n\n"
f" {claim_url}\n\n"
f"If you weren't expecting this invitation, you can ignore this\n"
f"email — no account becomes active until you click the link.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
+34 -8
View File
@@ -23,10 +23,10 @@ from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -44,31 +44,48 @@ def send_otc_email(to_address: str, code: str) -> bool:
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
# v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient
# explicitly requested the code; advertising an unsubscribe
# header would imply OHM has them on a list, which it doesn't.
# See the proposal's "Tradeoff discussion" for the binding
# rationale.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
@@ -78,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="sent", message_id=message_id,
)
return True
except Exception:
except Exception as exc:
log.exception("otc email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
+425
View File
@@ -0,0 +1,425 @@
"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
Distinguishes from the v0.8.0 self-serve beta-access flow:
* **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
a fresh `users` row lands in `permission_state='pending'`; an admin
grants or revokes via the v0.9.0 user-management page.
* **Admin-create (v0.17.0)** — an admin types first/last/email/role
*before* the invitee has signed in. The framework provisions the
`users` row with the chosen role and `permission_state='granted'`
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
"invited but not yet arrived" discriminator. An invite-token row
lands in `user_invite_tokens`; the admin's chosen `custom_message`
(if any) rides in the email body alongside the claim link.
* **Claim flow** — the invitee clicks the link, which lands them at
`/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
the token. The framework verifies the token (not expired, not
claimed, hash matches), marks the row claimed, signs the user in,
and returns a payload telling the frontend whether to route to
passcode-set (if v0.10.0 passcode flow is in play and the user has
no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
unique token in the email is itself proof of email control, per
the roadmap. This is the intentional UX shortcut for first
sign-in; subsequent sign-ins use the standard OTC / passcode
paths.
The shape:
* `create_invite(...)` — provision the invitee `users` row + the
`user_invite_tokens` row, return the raw token for the admin
endpoint to put in the outbound email link.
* `claim(raw_token)` — validate the token, mark it claimed, return
the `SessionUser` the endpoint signs in. Distinguishes the failure
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
can map them to HTTP 410 vs HTTP 404 cleanly.
* `list_pending_invites()` — return active invites for the admin
listing surface. Filters out claimed + expired rows so the surface
only shows live invites.
Token shape: opaque DB token (256 bits of CSPRNG entropy via
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
over JWT because revocation is then a single SQL UPDATE — a JWT
would be stateless but harder to invalidate, and admin-issued
invites are exactly the kind of thing an admin should be able to
yank back. The raw token only ever lives in the outbound email link
and the inbound claim body; server-side storage is the hash.
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
configurability is a §19.2 candidate — the constant is exposed
here as a single point of edit if a deployment wants to override.
The 500-char ceiling on `custom_message` is enforced at the
Pydantic body level in `api_admin.py`; this module trusts what
the endpoint hands it.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
# ---------------------------------------------------------------------------
INVITE_TOKEN_TTL_DAYS = 7
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the outbound email link.
TOKEN_BYTES = 32
# Free-text ceiling for the admin's optional custom message. Matched
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
# bound is documented in one place.
CUSTOM_MESSAGE_MAX_LENGTH = 500
# ---------------------------------------------------------------------------
# Token + hash helpers (mirror device_trust.py shape)
# ---------------------------------------------------------------------------
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
@dataclass
class CreateOutcome:
"""The shape returned from `create_invite`.
`raw_token` is what the admin endpoint puts in the outbound email
link; it never appears in storage. `invite_id` is the surrogate
key for the admin's "invites I've sent" listing. `invited_user_id`
is the freshly-provisioned `users` row id so the admin surface can
join through to the user-management page.
"""
raw_token: str
invite_id: int
invited_user_id: int
def create_invite(
*,
email: str,
first_name: str,
last_name: str,
role: str,
custom_message: str,
created_by_admin_id: int,
) -> CreateOutcome:
"""Provision the invitee `users` row + the `user_invite_tokens` row.
Caller (`api_admin.py`) is responsible for the admin-only auth check,
the self-email refusal (422), and the duplicate-email refusal (409).
This function trusts what it's handed and writes both rows
transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
helpers follow the same separation-of-concerns pattern.
The invitee `users` row is provisioned with:
* `permission_state='granted'` — the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` — the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
* `display_name` defaults to "<first> <last>" (or local-part of
email if both are empty) so the user-management page reads a
sensible label before the user has signed in.
* `first_name` / `last_name` / `beta_request_reason` — the
first two from the admin's typed values; reason stays blank
(this user did not self-request access).
"""
email_clean = email.strip()
first_clean = (first_name or "").strip()
last_clean = (last_name or "").strip()
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
if not display:
display = email_clean.split("@", 1)[0] or email_clean
# 1. Provision the invitee users row. The grant is the admin's
# hand; no permission_events row is necessary for the grant itself
# (we are not transitioning from pending → granted, we are landing
# a fresh row directly into granted).
#
# Note on the "pending invite" discriminator: the brief floated
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
# admin user-management page reads off the row to render the
# "(pending invite)" badge. The schema didn't cooperate — the
# existing `users.last_seen_at` column is NOT NULL with a
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
# and there is no `first_sign_in_at` column. Rather than introduce
# a schema migration to add one (the brief explicitly said "likely
# no `users` table changes"), the discriminator is the existence of
# an active row in `user_invite_tokens` joined on `invited_user_id`.
# The admin listing's `pending_invite` field joins through that
# table; the claim flow stamps `claimed_at` on the invite row,
# which clears the badge naturally. This shape keeps the
# discriminator scoped to the v0.17.0 surface and avoids
# double-tracking against an existing column.
cur = db.conn().execute(
"""
INSERT INTO users (
gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state, first_name, last_name
)
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
""",
(email_clean, display, role, first_clean, last_clean),
)
invited_user_id = cur.lastrowid
# 2. Mint the token, hash it, write the invite row.
raw = _new_token()
h = _hash(raw)
cur = db.conn().execute(
f"""
INSERT INTO user_invite_tokens (
email, role, first_name, last_name, custom_message,
token_hash, expires_at, created_by_admin_id, invited_user_id
)
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
""",
(
email_clean,
role,
first_clean,
last_clean,
(custom_message or "").strip(),
h,
created_by_admin_id,
invited_user_id,
),
)
invite_id = cur.lastrowid
return CreateOutcome(
raw_token=raw,
invite_id=invite_id,
invited_user_id=invited_user_id,
)
# ---------------------------------------------------------------------------
# Claim
# ---------------------------------------------------------------------------
@dataclass
class ClaimOutcome:
"""The result of `claim`.
`user` is populated only on success. `reason` distinguishes the
failure modes so the endpoint can return distinct HTTP statuses
(HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
unknown/invalid — the request shape is wrong).
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
invite_id: int | None = None
def claim(raw_token: str) -> ClaimOutcome:
"""Validate the presented token and consume it.
Walks the active invite rows looking for a bcrypt hash match.
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
cannot SELECT by hash, but the set is small (a deployment's
outstanding invites at any moment) and bcrypt is cheap on the
order of milliseconds.
On a hit:
* Mark the row claimed (stamp `claimed_at = now`,
`claimed_by_user_id = invited_user_id` — the admin's
pre-provisioned row is the claimant).
* Stamp `last_seen_at = now` on the user row so the v0.9.0
admin user-management page no longer shows "(pending invite)".
* Return a populated `SessionUser` for the endpoint to sign in.
On a miss:
* `unknown` — no row matched. The token may have been forged or
the invite was admin-revoked.
* `expired` — row matched but `expires_at` is in the past.
* `claimed` — row matched but `claimed_at` is non-NULL. The
token was already consumed; the user must contact the admin
for a fresh invite.
* `invalid` — the token string itself was empty or unparseable.
"""
raw = (raw_token or "").strip()
if not raw:
return ClaimOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
FROM user_invite_tokens
ORDER BY id DESC
"""
).fetchall()
matched = None
for row in rows:
if _check(raw, row["token_hash"]):
matched = row
break
if matched is None:
return ClaimOutcome(ok=False, user=None, reason="unknown")
if matched["claimed_at"] is not None:
return ClaimOutcome(
ok=False, user=None, reason="claimed", invite_id=matched["id"],
)
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return ClaimOutcome(
ok=False, user=None, reason="expired", invite_id=matched["id"],
)
# Consume the row before signing in so a parallel claim of the same
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
# before-provision shape.)
db.conn().execute(
"""
UPDATE user_invite_tokens
SET claimed_at = datetime('now'),
claimed_by_user_id = invited_user_id
WHERE id = ?
""",
(matched["id"],),
)
# Stamp last_seen_at on the user row so the user's activity stamp
# is current after the claim (mirroring otc.verify_code's
# last-seen update on the provision path). The "(pending invite)"
# badge's clear is driven by the invite row's `claimed_at`
# transition above; this update is for the general user-listing's
# recency ordering.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["invited_user_id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state
FROM users
WHERE id = ?
""",
(matched["invited_user_id"],),
).fetchone()
if user_row is None:
# The invitee user row was deleted between create_invite and
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
# cascade would drop the invite row too — be defensive anyway).
return ClaimOutcome(
ok=False, user=None, reason="unknown", invite_id=matched["id"],
)
return ClaimOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
invite_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List pending invites — for the admin's review surface
# ---------------------------------------------------------------------------
@dataclass
class PendingInviteRow:
"""The shape the `GET /api/admin/users/invites` endpoint returns.
Note the absence of `token_hash` — the hash is structurally private,
and the surface has no use for it. The raw token is also not on
the listing; it lives only in the email link.
"""
id: int
email: str
role: str
first_name: str
last_name: str
custom_message: str
created_at: str
expires_at: str
created_by_admin_id: int
invited_user_id: int
def list_pending_invites() -> list[PendingInviteRow]:
"""Active invites (not claimed, not expired), freshest first.
The admin's "I sent these but they haven't been claimed yet" view.
Filters mirror the `device_trust.list_for_user` shape: the surface
only shows live records the framework would actually accept on a
presented token.
"""
rows = db.conn().execute(
"""
SELECT id, email, role, first_name, last_name, custom_message,
created_at, expires_at, created_by_admin_id, invited_user_id
FROM user_invite_tokens
WHERE claimed_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [
PendingInviteRow(
id=row["id"],
email=row["email"],
role=row["role"],
first_name=row["first_name"] or "",
last_name=row["last_name"] or "",
custom_message=row["custom_message"] or "",
created_at=row["created_at"],
expires_at=row["expires_at"],
created_by_admin_id=row["created_by_admin_id"],
invited_user_id=row["invited_user_id"],
)
for row in rows
]
+109
View File
@@ -24,6 +24,7 @@ from . import (
digest,
email_otc,
hygiene,
invites as invites_mod,
otc,
passcode as passcode_mod,
providers as providers_mod,
@@ -72,6 +73,25 @@ class PasscodeVerifyBody(BaseModel):
trust_device: bool = False
class InviteClaimBody(BaseModel):
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
The frontend `/invites/claim?token=…` page reads the token from
the URL and POSTs it here. The body bound matches the
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
the upper bound stays generous in case `TOKEN_BYTES` is ever
raised. The token-shape is opaque to this layer — `invites.claim`
bcrypt-checks it against the active candidate set.
"""
token: str = Field(min_length=1, max_length=512)
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
# is bundled here so the invitee can land trusted on first sign-in
# without an extra roundtrip. Defaults to false so the gesture is
# explicit (the frontend modal renders a checkbox alongside the
# claim CTA).
trust_device: bool = False
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -382,6 +402,95 @@ def _oauth_router(config) -> APIRouter:
},
}
# ---------------------------------------------------------------
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
#
# The admin-create surface lives at POST /api/admin/users (see
# api_admin.py); this endpoint is the corresponding claim path the
# invitee hits when they click the link in their invite email.
# The frontend route `/invites/claim?token=…` reads the token from
# the URL and POSTs it here.
#
# The claim itself is the first-sign-in for the invitee: clicking
# the unique token in the email is proof of email control per the
# roadmap, so this endpoint skips the OTC step entirely on first
# sign-in. The session cookie lands; the response tells the
# frontend whether to route to passcode-set (if v0.10.0 passcode
# flow is in play and the user has not yet set a passcode) or to
# home.
#
# The endpoint is anonymous-reachable: the entire point is to
# establish the session, so we do not gate it on `require_user`.
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
# contract (the body's `trust_device` flag, when true, mints a
# fresh device-trust row on the same response so the invitee
# lands trusted on their first device).
# ---------------------------------------------------------------
@router.post("/api/invites/claim")
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
result = invites_mod.claim(body.token)
if result.reason == "expired":
# The token's TTL window passed without a claim. HTTP 410
# (Gone) so the frontend can render a "this invite has
# expired — please contact the admin for a fresh one"
# message distinct from the generic invalid-token shape.
raise HTTPException(410, "This invite has expired")
if result.reason == "claimed":
# The token was already consumed. HTTP 410 for the same
# reason — the row is dead either way.
raise HTTPException(410, "This invite has already been claimed")
if not result.ok or result.user is None:
# 'unknown' / 'invalid' — the token does not match any
# active invite row. HTTP 400 so it reads distinct from
# the dead-token shape above.
raise HTTPException(400, "Invalid invite token")
# Establish the session. From here on the invitee is signed
# in as the pre-provisioned user row carrying their
# pre-assigned role.
auth.store_session(request, result.user)
# v0.11.0 — opt-in device trust on the claim response. Same
# contract as OTC/passcode verify: when the body's flag is
# true, the server mints a fresh device-trust row and sets
# the long-lived cookie, so the invitee skips the email step
# on subsequent visits to the same browser.
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
# Has the user already set a passcode? (Could only happen via
# an admin pre-population path that doesn't exist yet, but
# the response shape mirrors `/api/auth/me` so the frontend
# can read it without a second call.) If `needs_passcode` is
# true and v0.10.0 passcode flow is in play, the frontend
# routes to /settings/notifications#sign-in to set a passcode
# immediately; otherwise it routes to /.
row = db.conn().execute(
"SELECT passcode_hash FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
has_passcode = bool(row and row["passcode_hash"])
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
# Roadmap §16: the claim flow skips OTC entirely; the
# natural next step is passcode-set (so the invitee can
# sign back in without needing an email roundtrip on their
# second visit). The frontend uses this hint to decide
# whether to route to the passcode-set screen or to home.
"needs_passcode": not has_passcode,
}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
+33 -1
View File
@@ -12,6 +12,7 @@ import hashlib
import hmac
import json
import logging
import os
from fastapi import APIRouter, Header, HTTPException, Request
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
# v0.18.0: defense in depth. config.py refuses to start
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
# is set; this branch catches the dev-bypass case (the only
# path where `config.webhook_secret` can be empty) and surfaces
# it loudly to the client. A POST that lands here with an
# empty secret on a production deployment indicates a
# mis-configuration (somebody flipped the bypass in prod),
# and the loud 500 is the proposal's whole point.
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if not config.webhook_secret:
if not insecure:
log.error(
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
)
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
log.warning(
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
"signature verification is DISABLED. Production deployments MUST NOT set this."
)
else:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -68,6 +89,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
slug = _slug_for_repo(repo_full)
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
else:
# v0.18.0: the proposal's "unknown-repo logging"
# gesture — a hook on a fork or a stale repo binding
# used to silently 200-OK here, hiding the
# misconfiguration. Now the operator sees it in
# the log.
log.info(
"webhook received for unknown repo: repo_full=%s event=%s "
"(no cached_rfcs row matched; hook may be on a fork or stale)",
repo_full, event,
)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
@@ -0,0 +1,105 @@
-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
--
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
-- here an *admin* creates a `users` row *before* the invited person has
-- ever signed in, assigns them a role at creation time, and sends them
-- an invite email carrying a claim link. The invitee clicks the link,
-- the claim flow consumes the token (which is itself proof of email
-- control), the row is marked claimed, and the user is signed in
-- inheriting the pre-set role.
--
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
-- in the same wave; the two features live in distinct tables
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
--
-- Open-question decisions settled in this release (see CHANGELOG):
-- * No `users` table changes — the brief floated `first_sign_in_at`
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
-- but the existing `users.last_seen_at` is NOT NULL with a
-- `datetime('now')` default (migrations/001) and there is no
-- `first_sign_in_at` column. Rather than land a schema migration to
-- introduce one, the discriminator is the existence of an active
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
-- `invited_user_id`. The admin user-listing carries a
-- `pending_invite` field populated via that join; on claim, the
-- invite row's `claimed_at` populates and the badge clears.
-- No new `permission_state` value is introduced either.
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
-- rest), not a JWT, so admin revocation by row UPDATE works
-- without distributing a key-rotation gesture.
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
-- `backend/app/invites.py`); env-var configurability is a follow-up.
-- * Immediate-send (no admin-review-then-send queue) ships in this
-- release; admin-preview is a future enhancement.
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
-- v0.17.0 is one-at-a-time.
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the admin "pending invites" listing
-- address a row without leaking the token shape.
-- * `email` — the address the invite was sent to (case-insensitive
-- match at claim time, persisted verbatim for the audit trail).
-- * `role` — the role the invitee inherits on first sign-in. Pinned
-- via CHECK to the same set the §6.1 role flip accepts
-- (`owner` / `admin` / `contributor`) so a future role-set drift
-- fails loudly at insert rather than provisioning a ghost role.
-- * `first_name` / `last_name` — captured at create time so the
-- invitee skips the v0.8.0 capture-form step on first sign-in.
-- * `custom_message` — optional free-text from the admin (max 500
-- chars enforced at the API layer); embedded verbatim in the
-- email body if present.
-- * `token_hash` — bcrypt hash of the random opaque token. The
-- raw token only ever lives in the outbound email link and the
-- inbound claim body; server-side storage is the hash.
-- * `expires_at` — `created_at + 7 days` (default at the app layer
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
-- the claim path refuses with HTTP 410.
-- * `created_at` — when the admin issued the invite.
-- * `created_by_admin_id` — FK into users(id) for the admin who
-- created the invite (no cascade; if the admin's row is deleted
-- the invite history stays so the audit trail survives).
-- * `claimed_at` — non-NULL once the invitee successfully claims.
-- A second claim attempt against an already-claimed row returns
-- HTTP 410.
-- * `claimed_by_user_id` — FK into users(id) for the user row
-- that consumed the token. In the common case this equals the
-- freshly-provisioned row that was created at invite time; the
-- FK lets the admin's "claimed" list join through.
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
-- row. Created at invite time with `last_seen_at IS NULL` so the
-- v0.9.0 admin user-management page can render a "(pending
-- invite)" badge alongside existing users.
--
-- Indexing:
-- * Unique index on `token_hash` documents the no-collision
-- invariant (256 bits of CSPRNG entropy; collision is
-- structurally impossible, the unique constraint catches a
-- bug at insert time).
-- * Index on `(email, claimed_at)` so the "is this email already
-- invited?" pre-check the admin endpoint runs is a covering walk.
-- * Index on `(created_by_admin_id, created_at DESC)` for the
-- admin's "invites I've sent" listing.
CREATE TABLE user_invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
custom_message TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
claimed_at TEXT,
claimed_by_user_id INTEGER REFERENCES users(id),
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
@@ -0,0 +1,30 @@
-- v0.18.0 Slice 4: outbound_emails audit table.
--
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
-- helper writes a row to this table before returning, regardless
-- of outcome. status='sent' on success, 'failed' on exception,
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
--
-- The table is queried by `GET /api/admin/outbound-emails` to
-- answer "did this person ever get their invite?" without having
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
-- hook (which looks up message_id when a POST lands at
-- /api/webhooks/email-bounce and marks the matching row
-- status='bounced').
CREATE TABLE IF NOT EXISTS outbound_emails (
id INTEGER PRIMARY KEY,
to_address TEXT NOT NULL,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
error TEXT, -- exception class + message if status='failed'
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
message_id TEXT -- the Message-ID header value, for bounce correlation
);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
@@ -0,0 +1,728 @@
"""End-to-end integration tests for v0.17.0's admin-create user +
invite-email + claim-flow vertical (roadmap item #16, §6.1).
The release lands three halves of the same surface:
* **Admin-create user** at `POST /api/admin/users`. The admin types
email, first/last name, role, and an optional custom message. The
framework provisions the invitee `users` row (granted, with the
chosen role) and writes a `user_invite_tokens` row carrying the
bcrypt-hashed opaque token. The "pending invite" discriminator is
the active `user_invite_tokens` row joined on `invited_user_id`,
not a NULL column on `users` (the existing `last_seen_at` column
is NOT NULL). An invite email dispatches via the existing SMTP
relay.
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
Lists active (not claimed, not expired) invites for the admin's
"I sent these but they haven't been claimed yet" view.
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
they got via email; the framework verifies, marks the row claimed,
signs them in (skipping OTC on first sign-in per the roadmap), and
returns a `needs_passcode` hint for the frontend to route to the
passcode-set screen.
The tests prove:
* The happy path: admin creates → invite row + email envelope land →
invitee claims with the token → session is established.
* Non-admin caller is refused 403.
* Self-invite is refused 422.
* Duplicate email is refused 409.
* Owner-grant by non-owner is refused 422.
* Malformed role is refused 422 (pydantic regex).
* Custom message over 500 chars is refused 422 (pydantic max_length).
* Claim with valid token: signs in + marks row claimed.
* Claim with expired token: HTTP 410.
* Claim with already-claimed token: HTTP 410.
* Claim with unknown token: HTTP 400.
* The admin-create gesture writes a `permission_events` row with
event_kind='user_invited'.
* The user listing surfaces the `pending_invite` field for invited-
but-not-yet-claimed users, and clears it after claim.
"""
from __future__ import annotations
import json
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_invite_envelopes(to_address: str | None = None) -> list[dict]:
"""Pull the invite-kind envelopes off the shared notifier buffer.
Mirrors the OTC code-extraction helper in
test_admin_users_vertical.py — invite emails land in the same
`_SENT` buffer with `kind='invite'`.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "invite":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _extract_claim_url(envelope: dict) -> str:
"""Pull the claim URL out of the invite email body."""
for line in envelope["body"].splitlines():
line = line.strip()
if line.startswith("http") and "/invites/claim" in line:
return line
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
def _extract_claim_token(envelope: dict) -> str:
"""Pull the `token` query-string param out of the claim URL."""
from urllib.parse import urlparse, parse_qs
url = _extract_claim_url(envelope)
qs = parse_qs(urlparse(url).query)
return qs["token"][0]
# ---------------------------------------------------------------------------
# Admin create + invite — happy path
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
"""Admin creates → user row + invite-token row + email envelope all
land; the response carries the created ids and the inviter is the
admin who issued the gesture."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=100, login="adminzero", role="admin")
sign_in_as(
client, user_id=100, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
email="adminzero@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "invitee@example.com",
"first_name": "Inv",
"last_name": "Tee",
"role": "contributor",
"custom_message": "We chatted at the conference — welcome!",
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["email"] == "invitee@example.com"
assert body["role"] == "contributor"
assert body["invite_id"] > 0
assert body["invited_user_id"] > 0
# User row exists with the chosen role + granted. The "pending
# invite" discriminator is the active `user_invite_tokens` row,
# not a NULL column on `users` — see the invites.create_invite
# docstring for the reasoning.
row = db.conn().execute(
"SELECT role, permission_state, first_name, last_name "
"FROM users WHERE email = ? COLLATE NOCASE",
("invitee@example.com",),
).fetchone()
assert row is not None
assert row["role"] == "contributor"
assert row["permission_state"] == "granted"
assert row["first_name"] == "Inv"
assert row["last_name"] == "Tee"
# Invite-token row exists with the matching ids and the custom
# message persisted verbatim.
invite = db.conn().execute(
"SELECT email, role, custom_message, created_by_admin_id, "
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
(body["invite_id"],),
).fetchone()
assert invite is not None
assert invite["email"] == "invitee@example.com"
assert invite["role"] == "contributor"
assert invite["custom_message"] == "We chatted at the conference — welcome!"
assert invite["created_by_admin_id"] == 100
assert invite["invited_user_id"] == body["invited_user_id"]
assert invite["claimed_at"] is None
# Email envelope landed with the invite kind and embeds the
# custom message + claim URL. The inviter display name comes
# off the DB row (which provision_user_row sets to
# login.capitalize()), not the sign_in_as cookie payload.
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
assert len(envelopes) == 1
env = envelopes[0]
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
assert "We chatted at the conference — welcome!" in env["body"]
# Claim URL is well-formed.
url = _extract_claim_url(env)
assert "/invites/claim?token=" in url
# `permission_events` row landed with event_kind='user_invited'.
ev = db.conn().execute(
"SELECT actor_user_id, subject_user_id, event_kind, details "
"FROM permission_events WHERE event_kind = 'user_invited'"
).fetchall()
assert len(ev) == 1
assert ev[0]["actor_user_id"] == 100
assert ev[0]["subject_user_id"] == body["invited_user_id"]
details = json.loads(ev[0]["details"])
assert details["email"] == "invitee@example.com"
assert details["role"] == "contributor"
# ---------------------------------------------------------------------------
# Refusals on the admin-create endpoint
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
"""A contributor caller is refused 403; an anonymous caller 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=110, login="contrib", role="contributor")
sign_in_as(
client, user_id=110, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 403, r.text
client.cookies.clear()
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 401
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
"""An admin trying to invite their own email is refused 422 —
self-invite is the wrong channel; the role-change endpoint exists
for self-edits."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=120, login="adm", role="admin")
# Manually set the admin's email since provision_user_row's
# fixture uses login@test; this is what we'll try to self-invite.
from app import db
db.conn().execute(
"UPDATE users SET email = ? WHERE id = ?",
("selfinviter@example.com", 120),
)
sign_in_as(
client, user_id=120, gitea_login="adm",
display_name="Adm", role="admin",
email="selfinviter@example.com",
)
r = client.post(
"/api/admin/users",
json={
"email": "selfinviter@example.com",
"role": "contributor",
},
)
assert r.status_code == 422, r.text
assert "yourself" in r.json()["detail"].lower()
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
"""An admin trying to invite an email that already maps to a users
row is refused 409 — the existing role / grant gestures are the
right surface for an existing user."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=130, login="adminD", role="admin")
provision_user_row(user_id=131, login="existingone", role="contributor")
sign_in_as(
client, user_id=130, gitea_login="adminD",
display_name="Admin D", role="admin",
)
# provision_user_row sets email to <login>@test, so:
r = client.post(
"/api/admin/users",
json={
"email": "existingone@test",
"role": "contributor",
},
)
assert r.status_code == 409, r.text
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
"""An admin (not owner) trying to invite a fresh user as `owner` is
refused 422 — §6.1's owner-zero is the only bootstrap path."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
sign_in_as(
client, user_id=140, gitea_login="adminNoOwner",
display_name="Admin", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "wouldbeowner@example.com",
"role": "owner",
},
)
assert r.status_code == 422, r.text
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
"""A sitting owner can invite a fresh user as `owner` — the §6.1
role-grant channel. Sanity check that the owner-grant path itself
works, paired with the refusal above."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=150, login="ownerzero", role="owner")
sign_in_as(
client, user_id=150, gitea_login="ownerzero",
display_name="Owner Zero", role="owner",
)
r = client.post(
"/api/admin/users",
json={
"email": "newowner@example.com",
"role": "owner",
},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
("newowner@example.com",),
).fetchone()
assert row["role"] == "owner"
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
"""The pydantic regex refuses any role outside the §6.1 set."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=160, login="adminR", role="admin")
sign_in_as(
client, user_id=160, gitea_login="adminR",
display_name="Admin R", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "superuser",
},
)
assert r.status_code == 422
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
"""Custom message over the 500-char ceiling is refused 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=170, login="adminM", role="admin")
sign_in_as(
client, user_id=170, gitea_login="adminM",
display_name="Admin M", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "contributor",
"custom_message": "x" * 501,
},
)
assert r.status_code == 422
# ---------------------------------------------------------------------------
# Claim flow
# ---------------------------------------------------------------------------
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
"""End-to-end: admin creates → invitee posts the token to
/api/invites/claim → session lands + row marked claimed +
last_seen_at stamps on the user row (the pending-invite
discriminator clears)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=200, login="adminC", role="admin")
sign_in_as(
client, user_id=200, gitea_login="adminC",
display_name="Admin C", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "claimant@example.com",
"first_name": "Clai",
"last_name": "Mant",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
invited_user_id = r.json()["invited_user_id"]
env = _outbound_invite_envelopes("claimant@example.com")[0]
token = _extract_claim_token(env)
# The invitee's request is anonymous (they have no session
# yet). We clear the admin's session cookie to simulate this.
client.cookies.clear()
r = client.post(
"/api/invites/claim",
json={"token": token},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["user"]["id"] == invited_user_id
assert body["user"]["role"] == "contributor"
assert body["user"]["permission_state"] == "granted"
# The user has no passcode set yet → frontend should route to
# passcode-set per the roadmap.
assert body["needs_passcode"] is True
# Row marked claimed; last_seen_at populated.
invite = db.conn().execute(
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
"WHERE id = ?",
(invite_id,),
).fetchone()
assert invite["claimed_at"] is not None
assert invite["claimed_by_user_id"] == invited_user_id
user_row = db.conn().execute(
"SELECT last_seen_at FROM users WHERE id = ?",
(invited_user_id,),
).fetchone()
assert user_row["last_seen_at"] is not None
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
from fastapi.testclient import TestClient
from app import db, invites
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=210, login="adminE", role="admin")
sign_in_as(
client, user_id=210, gitea_login="adminE",
display_name="Admin E", role="admin",
)
_reset_outbound()
# Create the invite, then back-date the expires_at to the past.
r = client.post(
"/api/admin/users",
json={
"email": "expired@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE id = ?",
(invite_id,),
)
env = _outbound_invite_envelopes("expired@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "expired" in r.json()["detail"].lower()
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=220, login="adminA", role="admin")
sign_in_as(
client, user_id=220, gitea_login="adminA",
display_name="Admin A", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "twice@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
env = _outbound_invite_envelopes("twice@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
# First claim succeeds.
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
# Second claim, with the same token, refuses with 410.
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "already" in r.json()["detail"].lower()
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
"""A token that doesn't match any active invite is HTTP 400."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# No invite ever created; the token is whatever the attacker
# types in. The endpoint should refuse without disclosing
# whether the token "looked" right.
r = client.post(
"/api/invites/claim",
json={"token": "totally-made-up-token-string-that-is-not-real"},
)
assert r.status_code == 400, r.text
# ---------------------------------------------------------------------------
# Pending-invite admin listing
# ---------------------------------------------------------------------------
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
"""The `GET /api/admin/users/invites` listing filters to active
invites — claimed and expired rows do not surface here (the admin
user-listing carries the per-row pending-invite badge for the
living rows; once claimed, the badge clears)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=300, login="adminL", role="admin")
sign_in_as(
client, user_id=300, gitea_login="adminL",
display_name="Admin L", role="admin",
)
_reset_outbound()
# Create three invites: one stays pending, one we'll claim, one
# we'll back-date to expired.
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
r = client.post(
"/api/admin/users",
json={"email": email, "role": "contributor"},
)
assert r.status_code == 200
# Claim the middle one.
env = _outbound_invite_envelopes("claimed@ex.co")[0]
token_claim = _extract_claim_token(env)
# Expire the third one.
from app import db
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE email = 'expired@ex.co'"
)
# The admin's session is still on the cookie. Claim works
# anonymously; we clear and restore.
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token_claim})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
r = client.get("/api/admin/users/invites")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["email"] for i in items)
assert emails == ["alive@ex.co"]
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
"""The `/api/admin/users` listing surfaces `pending_invite` while
the invite is unclaimed; after the invitee claims, the row's
pending_invite is null."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=310, login="adminB", role="admin")
sign_in_as(
client, user_id=310, gitea_login="adminB",
display_name="Admin B", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={"email": "badgey@ex.co", "role": "contributor"},
)
assert r.status_code == 200
invited_id = r.json()["invited_user_id"]
# Before claim — pending_invite is populated.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is not None
assert row["pending_invite"]["invite_id"] > 0
# Claim.
env = _outbound_invite_envelopes("badgey@ex.co")[0]
token = _extract_claim_token(env)
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
# After claim — pending_invite is null.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is None
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
"""The listing requires admin/owner; contributor gets 403."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=320, login="contribL", role="contributor")
sign_in_as(
client, user_id=320, gitea_login="contribL",
display_name="Contrib L", role="contributor",
)
r = client.get("/api/admin/users/invites")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# v0.18.0: invite-envelope header shape — Slice 2
#
# Invite mail goes through `build_envelope` and MUST land Date,
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
# (no URL — the invitee isn't a user yet, so no per-user opt-out
# row exists). The mailto: target is the operator's `EMAIL_FROM`
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
# ---------------------------------------------------------------------------
def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
provision_user_row(user_id=400, login="adminH", role="admin")
sign_in_as(
client, user_id=400, gitea_login="adminH",
display_name="Admin H", role="admin",
email="adminh@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": to,
"first_name": "Header",
"last_name": "Test",
"role": "contributor",
"custom_message": "",
},
)
assert r.status_code == 200, r.text
return _outbound_invite_envelopes(to)[-1]
def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert parsedate_to_datetime(msg["Date"]) is not None
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
assert msg["Auto-Submitted"] == "auto-generated"
def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
"""The invitee isn't a user yet — no per-user opt-out URL is
available. The `List-Unsubscribe` MUST be a mailto: form, and
the `List-Unsubscribe-Post` header MUST be absent (the
one-click semantic requires a URL the MUA can POST to)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
lu = msg["List-Unsubscribe"]
assert lu is not None and lu.startswith("<mailto:")
# No URL part — invite is mailto-only.
assert "https://" not in lu and "http://" not in lu
assert msg["List-Unsubscribe-Post"] is None
def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
"""When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
`List-Unsubscribe` honors it (lets a deployment route opt-outs
to a humans-monitored mailbox distinct from the no-reply
sender)."""
from fastapi.testclient import TestClient
monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
+5 -1
View File
@@ -231,13 +231,17 @@ def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gi
# With the right header, the call passes the guard. (No matching
# user exists, so we get {matched: False} — that's the v1 contract.)
# v0.18.0 Slice 5: the response now includes `correlated_id`
# (the outbound_emails row id that matched the bounce's
# `message_id`, if one was supplied). The body didn't pass a
# message_id, so correlated_id is None.
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
headers={"X-Webhook-Secret": "shhh"},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "matched": False}
assert r.json() == {"ok": True, "matched": False, "correlated_id": None}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
+173
View File
@@ -0,0 +1,173 @@
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
These tests don't spin up the FastAPI app or touch the DB — they
exercise the helper directly. The integration tests in
test_otc_vertical / test_admin_create_user_invite_vertical /
test_notifications_vertical exercise the helper's *use* via the
shared `_SENT` buffer (the send path appends the envelope dict
before invoking the helper).
"""
from __future__ import annotations
from email.utils import parsedate_to_datetime
from app.email_envelope import build_envelope
def _base_kwargs(**overrides):
base = dict(
to_address="recipient@example.com",
from_address="notifications@ohm.wiggleverse.org",
from_name="OHM",
subject="A test subject",
body_plain="Hello, world.\n",
)
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Always-present headers
# ---------------------------------------------------------------------------
def test_envelope_sets_from_to_subject():
msg = build_envelope(**_base_kwargs())
assert msg["To"] == "recipient@example.com"
assert msg["Subject"] == "A test subject"
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
assert "OHM" in msg["From"]
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
def test_envelope_sets_date_header_parseable():
msg = build_envelope(**_base_kwargs())
raw = msg["Date"]
assert raw, "Date header must be set"
# parsedate_to_datetime raises ValueError on malformed input.
dt = parsedate_to_datetime(raw)
assert dt is not None
def test_envelope_sets_message_id_with_from_domain_by_default():
msg = build_envelope(**_base_kwargs())
mid = msg["Message-ID"]
assert mid, "Message-ID must be set"
# Shape per RFC 5322 / make_msgid: <random@domain>
assert mid.startswith("<") and mid.endswith(">")
assert "@ohm.wiggleverse.org>" in mid
def test_envelope_message_id_domain_override():
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
assert "@example.test>" in msg["Message-ID"]
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
# Defensive: a malformed from_address shouldn't crash the helper.
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
assert "@localhost>" in msg["Message-ID"]
# ---------------------------------------------------------------------------
# Auto-Submitted (RFC 3834)
# ---------------------------------------------------------------------------
def test_envelope_sets_auto_submitted_for_transactional_default():
msg = build_envelope(**_base_kwargs())
assert msg["Auto-Submitted"] == "auto-generated"
def test_envelope_omits_auto_submitted_when_transactional_is_false():
msg = build_envelope(**_base_kwargs(is_transactional=False))
assert msg["Auto-Submitted"] is None
# ---------------------------------------------------------------------------
# Reply-To
# ---------------------------------------------------------------------------
def test_envelope_sets_reply_to_when_provided():
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
assert msg["Reply-To"] == "ohm@wiggleverse.org"
def test_envelope_omits_reply_to_when_absent():
msg = build_envelope(**_base_kwargs())
assert msg["Reply-To"] is None
# ---------------------------------------------------------------------------
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
# ---------------------------------------------------------------------------
def test_envelope_no_list_unsubscribe_when_neither_given():
"""OTC mail: the recipient explicitly requested the code; no
unsubscribe semantics. The header MUST be absent (presence would
imply OHM has the recipient on a list, which it doesn't)."""
msg = build_envelope(**_base_kwargs())
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_mailto_only_list_unsubscribe():
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
The recipient isn't a user yet, so there's no per-user opt-out
URL to flip; the operator handles ad-hoc opt-outs manually."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
))
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
# NO List-Unsubscribe-Post when only a mailto is present — the
# one-click semantic requires a URL the MUA can POST to.
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_full_one_click_list_unsubscribe():
"""Watcher notification / bundle: `mailto:` + signed-URL +
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
lu = msg["List-Unsubscribe"]
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
def test_envelope_url_only_list_unsubscribe_still_sets_post():
msg = build_envelope(**_base_kwargs(
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
# ---------------------------------------------------------------------------
# Body shape — plain-only vs multipart/alternative
# ---------------------------------------------------------------------------
def test_envelope_plain_only_body_is_text_plain():
msg = build_envelope(**_base_kwargs())
# No HTML alternative -> single-part text/plain.
assert msg.get_content_type() == "text/plain"
assert msg.get_content().strip() == "Hello, world."
def test_envelope_with_html_is_multipart_alternative():
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
assert msg.get_content_type() == "multipart/alternative"
# Two parts: text/plain first (so plain-text clients picking the
# first part get the readable text), text/html second.
parts = list(msg.iter_parts())
assert len(parts) == 2
assert parts[0].get_content_type() == "text/plain"
assert parts[1].get_content_type() == "text/html"
assert "Hello, world." in parts[0].get_content()
assert "<b>world</b>" in parts[1].get_content()
@@ -612,3 +612,127 @@ def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
# the user put them.
assert row["set_by"] == "explicit"
assert row["state"] == "following"
# ---------------------------------------------------------------------------
# v0.18.0 — envelope headers + RFC 8058 one-click POST endpoint
#
# Watcher notifications are bulk-adjacent (a busy RFC can produce
# dozens of structural events); per the proposal, they MUST carry
# `Date`, `Message-ID`, `Auto-Submitted`, full `List-Unsubscribe`
# (mailto + signed URL), AND `List-Unsubscribe-Post:
# List-Unsubscribe=One-Click` per RFC 8058. Gmail and Yahoo
# enforce this for senders at OHM's volume tier.
# ---------------------------------------------------------------------------
def test_notification_envelope_carries_full_one_click_headers(app_with_fake_gitea):
"""A `proposal_merged` event lands a watcher notification email
with the full one-click unsubscribe shape."""
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
from app import db, email as email_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
assert r.status_code == 200
email_mod.reset_sent_envelopes()
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
assert merge_r.status_code == 200, merge_r.text
envelopes = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
assert envelopes, "watcher notification did not fire"
msg = envelopes[-1]["message"]
# Always-present headers from the helper.
assert parsedate_to_datetime(msg["Date"]) is not None
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
assert msg["Auto-Submitted"] == "auto-generated"
# Full one-click unsubscribe.
lu = msg["List-Unsubscribe"]
assert lu is not None
assert "<mailto:" in lu
# URL part carries the signed token per make_unsubscribe_url.
assert "/api/email/unsubscribe?t=" in lu
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
def test_email_unsubscribe_post_one_click_flips_category_off(app_with_fake_gitea):
"""RFC 8058: Gmail/Yahoo POST `List-Unsubscribe=One-Click` to the
URL in the List-Unsubscribe header. The endpoint MUST accept POST
+ the same token shape as the GET handler + return 200 + flip the
flag."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
r = client.post(
f"/api/email/unsubscribe?t={token}",
data={"List-Unsubscribe": "One-Click"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["category"] == "personal-direct"
row = db.conn().execute(
"SELECT email_personal_direct FROM users WHERE id = 2"
).fetchone()
assert row["email_personal_direct"] == 0
def test_email_unsubscribe_post_all_sets_global_opt_out(app_with_fake_gitea):
"""The v0.18.0 `all` synthetic category (used by the bundle +
digest paths) MUST set `email_opt_out_all = 1`."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
r = client.post(f"/api/email/unsubscribe?t={token}")
assert r.status_code == 200
assert r.json() == {"ok": True, "category": "all"}
row = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 2"
).fetchone()
assert row["email_opt_out_all"] == 1
def test_email_unsubscribe_get_all_sets_global_opt_out(app_with_fake_gitea):
"""GET handler also accepts the `all` category and lands the
global opt-out (so an MUA that doesn't honor RFC 8058 POST and
just opens the URL in a browser still works)."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
r = client.get(f"/api/email/unsubscribe?t={token}")
assert r.status_code == 200
assert "Unsubscribed" in r.text
row = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 2"
).fetchone()
assert row["email_opt_out_all"] == 1
def test_email_unsubscribe_post_refuses_invalid_token(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/email/unsubscribe?t=not-a-valid-token")
assert r.status_code == 400
+50
View File
@@ -347,3 +347,53 @@ def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monke
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
# ---------------------------------------------------------------------------
# v0.18.0: envelope headers — Slice 2
#
# OTC mail goes through `build_envelope` and MUST land Date,
# Message-ID, and Auto-Submitted but MUST NOT carry a
# List-Unsubscribe header (the recipient explicitly requested the
# code; advertising a list semantic would be wrong).
# ---------------------------------------------------------------------------
def _last_otc_envelope():
from app import email as email_mod
otc = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
assert otc, "no OTC envelope in the buffer"
return otc[-1]
def test_otc_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
# Date is RFC 5322 parseable.
assert parsedate_to_datetime(msg["Date"]) is not None
# Message-ID is bracketed and carries the From-address @-domain.
mid = msg["Message-ID"]
assert mid.startswith("<") and mid.endswith(">")
# Auto-Submitted prevents auto-responder loops.
assert msg["Auto-Submitted"] == "auto-generated"
def test_otc_envelope_has_no_list_unsubscribe(app_with_fake_gitea):
"""The recipient explicitly typed their email and asked for a
code; the framework MUST NOT advertise a list semantic on this
mail. Per the v0.18.0 proposal's tradeoff discussion."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None
@@ -0,0 +1,368 @@
"""End-to-end integration tests for the v0.18.0 Slice 4
outbound_emails audit table + admin endpoint.
The release adds:
* `backend/migrations/020_outbound_emails.sql` — the audit table.
* `record_outbound()` in `email.py` — the write helper every send
path calls before returning, capturing status='sent' / 'failed'
/ 'deferred' (the dev-fallback path when SMTP_HOST is unset).
* `GET /api/admin/outbound-emails` — admin-only listing, filterable
by kind / status / to_address.
These tests prove:
* Sending OTC / invite / notification mail writes one row per send
(status='deferred' under tests since SMTP_HOST is unset).
* The Message-ID on the row matches the envelope's Message-ID
header (the seam Slice 5 uses for bounce correlation).
* `kind` is populated per send path.
* `GET /api/admin/outbound-emails` lists rows newest-first,
accepts filters, refuses non-admins.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Write-on-send wiring
# ---------------------------------------------------------------------------
def test_otc_send_writes_outbound_row_with_message_id(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
email_mod.reset_sent_envelopes()
r = client.post("/auth/otc/request", json={"email": "newcomer@ex.co"})
assert r.status_code == 200
# Audit row landed.
rows = db.conn().execute(
"SELECT id, to_address, kind, status, message_id, error "
"FROM outbound_emails WHERE to_address = 'newcomer@ex.co'"
).fetchall()
assert len(rows) == 1
row = rows[0]
assert row["kind"] == "otc"
# No SMTP_HOST in tests -> 'deferred', not 'sent'.
assert row["status"] == "deferred"
assert row["error"] is None
# Message-ID matches the envelope's header (the seam Slice 5 uses).
envelopes = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
assert envelopes
envelope_mid = envelopes[-1]["message"]["Message-ID"]
assert row["message_id"] == envelope_mid
def test_invite_send_writes_outbound_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=500, login="adminQ", role="admin")
sign_in_as(
client, user_id=500, gitea_login="adminQ",
display_name="Admin Q", role="admin",
email="adminq@test",
)
r = client.post(
"/api/admin/users",
json={
"email": "invitee@ex.co",
"first_name": "Inv", "last_name": "Itee",
"role": "contributor", "custom_message": "",
},
)
assert r.status_code == 200, r.text
rows = db.conn().execute(
"SELECT kind, status, message_id FROM outbound_emails "
"WHERE to_address = 'invitee@ex.co'"
).fetchall()
assert len(rows) == 1
assert rows[0]["kind"] == "invite"
assert rows[0]["status"] == "deferred"
assert rows[0]["message_id"] is not None
def test_notification_send_writes_outbound_row_with_notification_id(app_with_fake_gitea):
"""Watcher notifications carry a `notification_id` FK so the
admin can join through to the notifications table to see what
triggered the send."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
from test_notifications_vertical import PITCH
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(
client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test",
)
r = client.post("/api/rfcs/propose", json={
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
})
email_mod.reset_sent_envelopes()
# Wipe pre-merge audit rows so the assertion below is unambiguous.
db.conn().execute("DELETE FROM outbound_emails")
sign_in_as(
client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test",
)
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
assert merge_r.status_code == 200, merge_r.text
rows = db.conn().execute(
"SELECT kind, status, notification_id, message_id "
"FROM outbound_emails WHERE to_address = 'alice@test'"
).fetchall()
assert rows, "no outbound_emails row for alice@test"
# At least one notification kind, with a populated FK.
notif_rows = [r for r in rows if r["kind"] == "notification"]
assert notif_rows
for nr in notif_rows:
assert nr["status"] == "deferred"
assert nr["notification_id"] is not None
assert nr["message_id"] is not None
# ---------------------------------------------------------------------------
# Admin endpoint
# ---------------------------------------------------------------------------
def test_admin_outbound_emails_lists_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Generate a few rows.
client.post("/auth/otc/request", json={"email": "one@ex.co"})
provision_user_row(user_id=600, login="adminR", role="admin")
sign_in_as(
client, user_id=600, gitea_login="adminR",
display_name="Admin R", role="admin", email="adminr@test",
)
client.post("/api/admin/users", json={
"email": "two@ex.co", "first_name": "T", "last_name": "Wo",
"role": "contributor", "custom_message": "",
})
r = client.get("/api/admin/outbound-emails")
assert r.status_code == 200, r.text
items = r.json()["items"]
kinds = {it["kind"] for it in items}
assert "otc" in kinds
assert "invite" in kinds
# Newest-first.
ids = [it["id"] for it in items]
assert ids == sorted(ids, reverse=True)
def test_admin_outbound_emails_filters_by_kind(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.post("/auth/otc/request", json={"email": "filter1@ex.co"})
provision_user_row(user_id=601, login="adminS", role="admin")
sign_in_as(
client, user_id=601, gitea_login="adminS",
display_name="Admin S", role="admin", email="admins@test",
)
client.post("/api/admin/users", json={
"email": "filter2@ex.co", "first_name": "F", "last_name": "Two",
"role": "contributor", "custom_message": "",
})
r = client.get("/api/admin/outbound-emails?kind=otc")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["kind"] == "otc" for it in items)
def test_admin_outbound_emails_filters_by_to_address(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.post("/auth/otc/request", json={"email": "TARGET@ex.co"})
client.post("/auth/otc/request", json={"email": "other@ex.co"})
provision_user_row(user_id=602, login="adminT", role="admin")
sign_in_as(
client, user_id=602, gitea_login="adminT",
display_name="Admin T", role="admin", email="admint@test",
)
# to_address filter is case-insensitive.
r = client.get("/api/admin/outbound-emails?to_address=target@ex.co")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["to_address"].lower() == "target@ex.co" for it in items)
def test_admin_outbound_emails_refuses_non_admin(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=700, login="contribU", role="contributor")
sign_in_as(
client, user_id=700, gitea_login="contribU",
display_name="Contrib U", role="contributor",
)
r = client.get("/api/admin/outbound-emails")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# v0.18.0 Slice 5: bounce correlation
# ---------------------------------------------------------------------------
def test_bounce_with_message_id_marks_outbound_row_bounced(app_with_fake_gitea):
"""When the bounce body includes the original `message_id`, the
framework looks it up in outbound_emails and stamps
status='bounced' on the matching row. The hard-bounce ->
global-opt-out logic still fires."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=800, login="bouncey", role="contributor")
db.conn().execute("UPDATE users SET email = 'bouncey@ex.co' WHERE id = 800")
# Send something to bouncey to land an outbound_emails row.
email_mod.reset_sent_envelopes()
client.post("/auth/otc/request", json={"email": "bouncey@ex.co"})
row = db.conn().execute(
"SELECT id, message_id, status FROM outbound_emails "
"WHERE to_address = 'bouncey@ex.co'"
).fetchone()
assert row is not None
original_id = row["id"]
message_id = row["message_id"]
assert row["status"] == "deferred" # pre-bounce baseline
# Bounce comes in carrying that message_id.
r = client.post(
"/api/webhooks/email-bounce",
json={
"email": "bouncey@ex.co",
"kind": "hard",
"message_id": message_id,
},
)
assert r.status_code == 200
body = r.json()
assert body["matched"] is True
assert body["correlated_id"] == original_id
# Audit row stamped.
post = db.conn().execute(
"SELECT status, error FROM outbound_emails WHERE id = ?",
(original_id,),
).fetchone()
assert post["status"] == "bounced"
assert "bounce (hard)" in (post["error"] or "")
# Hard-bounce global opt-out still fires.
urow = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 800"
).fetchone()
assert urow["email_opt_out_all"] == 1
def test_bounce_with_unknown_message_id_does_not_crash(app_with_fake_gitea):
"""A message_id the framework doesn't recognize logs but does
NOT 5xx — bounce providers replay old bounces, and the
framework can't refuse just because the row was pruned."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={
"email": "nobody@ex.co",
"kind": "hard",
"message_id": "<not-in-our-db@ex.co>",
},
)
assert r.status_code == 200
assert r.json()["correlated_id"] is None
def test_bounce_without_message_id_still_flips_opt_out(app_with_fake_gitea):
"""Backward compat: providers that don't surface Message-ID
still get the legacy v1 behavior — match by email + flip the
global opt-out."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=801, login="legacybounce", role="contributor")
db.conn().execute("UPDATE users SET email = 'legacy@ex.co' WHERE id = 801")
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "legacy@ex.co", "kind": "hard"},
)
assert r.status_code == 200
body = r.json()
assert body["matched"] is True
assert body["correlated_id"] is None
urow = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 801"
).fetchone()
assert urow["email_opt_out_all"] == 1
def test_bounced_rows_show_in_admin_endpoint(app_with_fake_gitea):
"""The admin endpoint surfaces bounced rows alongside the rest;
filtering by `status=bounced` isolates them."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=802, login="adminB", role="admin")
sign_in_as(
client, user_id=802, gitea_login="adminB",
display_name="Admin B", role="admin", email="adminb@test",
)
email_mod.reset_sent_envelopes()
client.post("/auth/otc/request", json={"email": "willbounce@ex.co"})
row = db.conn().execute(
"SELECT message_id FROM outbound_emails WHERE to_address = 'willbounce@ex.co'"
).fetchone()
client.post(
"/api/webhooks/email-bounce",
json={"email": "willbounce@ex.co", "kind": "hard", "message_id": row["message_id"]},
)
r = client.get("/api/admin/outbound-emails?status=bounced")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["status"] == "bounced" for it in items)
assert any(it["to_address"] == "willbounce@ex.co" for it in items)
+5 -1
View File
@@ -438,7 +438,11 @@ def tmp_env(monkeypatch):
"SECRET_KEY": "test-secret-key-for-cookies",
"DATABASE_PATH": str(db_path),
"OWNER_GITEA_LOGIN": "ben",
"GITEA_WEBHOOK_SECRET": "",
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
# per the email + webhook hygiene proposal. Tests bind a fake
# value so the framework boots; tests that want to exercise
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
"ENABLED_MODELS": "claude",
}
for k, v in env.items():
+205
View File
@@ -0,0 +1,205 @@
"""End-to-end integration tests for the Gitea webhook receiver
(v0.18.0 Slice 3 — webhook tightening per the email + webhook
hygiene proposal).
The release changes the receiver from "verifies the signature only
when a secret is configured; silently accepts unsigned POSTs
otherwise" to "requires the secret unless `RFC_APP_INSECURE_WEBHOOKS=1`
is set as an explicit dev-bypass." The startup-time check lives in
`config.load_config()`; the request-time check lives in
`webhooks.receive`.
These tests prove:
* The framework refuses to start when `GITEA_WEBHOOK_SECRET` is
empty and the dev-bypass is not set.
* The dev-bypass (`RFC_APP_INSECURE_WEBHOOKS=1`) lets the
framework boot with an empty secret AND lets webhook POSTs
land without signature verification (a loud-warning log line
surfaces, but the request is accepted).
* Default path (secret bound): a POST with a valid signature
lands; a POST with an invalid signature gets 401; a POST with
no signature gets 401.
* Unknown-repo POSTs surface in the log (the "stale Gitea hook"
case the proposal targets).
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Startup-time secret check (config.load_config)
# ---------------------------------------------------------------------------
def test_config_refuses_to_load_with_empty_secret_and_no_bypass(monkeypatch, tmp_path):
"""The framework MUST refuse to start when `GITEA_WEBHOOK_SECRET`
is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` is set. This is
the v0.18.0 startup-loud-failure shape — silent acceptance was
the bug."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
from app.config import load_config
with pytest.raises(RuntimeError, match="GITEA_WEBHOOK_SECRET"):
load_config()
def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path):
"""The explicit `RFC_APP_INSECURE_WEBHOOKS=1` opt-in lets the
framework boot with an empty webhook secret. This is the
local-dev escape hatch."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
monkeypatch.setenv("RFC_APP_INSECURE_WEBHOOKS", "1")
from app.config import load_config
cfg = load_config() # MUST NOT raise
assert cfg.webhook_secret == ""
def test_config_loads_with_secret_set(monkeypatch, tmp_path):
"""Sanity: the happy path (secret bound, bypass not set) loads
cleanly."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "my-real-secret")
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
from app.config import load_config
cfg = load_config()
assert cfg.webhook_secret == "my-real-secret"
# ---------------------------------------------------------------------------
# Request-time signature verification (webhooks.receive)
#
# The default `app_with_fake_gitea` fixture binds
# `GITEA_WEBHOOK_SECRET=test-webhook-secret-for-signature-verification`,
# so these tests exercise the production path.
# ---------------------------------------------------------------------------
_SECRET = "test-webhook-secret-for-signature-verification"
def _sign(body: bytes) -> str:
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
def test_webhook_post_with_valid_signature_accepted(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
sig = _sign(body)
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": sig,
"Content-Type": "application/json",
},
)
assert r.status_code == 200, r.text
def test_webhook_post_with_invalid_signature_refused_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": "0" * 64, # wrong signature
"Content-Type": "application/json",
},
)
assert r.status_code == 401
def test_webhook_post_with_missing_signature_refused_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"Content-Type": "application/json",
},
)
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Unknown-repo logging (the "stale hook on a fork" surface)
# ---------------------------------------------------------------------------
def test_webhook_unknown_repo_logs_at_info(app_with_fake_gitea, caplog):
"""Per the proposal: a hook on a fork or a stale Gitea binding
used to silently 200-OK. v0.18.0 surfaces it as an INFO log."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "someone-else/unrelated"}}).encode()
sig = _sign(body)
with caplog.at_level(logging.INFO, logger="app.webhooks"):
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": sig,
"Content-Type": "application/json",
},
)
assert r.status_code == 200 # the handler still 200s; surface is the log line
assert any(
"unknown repo" in rec.message and "someone-else/unrelated" in rec.message
for rec in caplog.records
), f"expected unknown-repo log line; got: {[r.message for r in caplog.records]}"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.16.0",
"version": "0.18.0",
"type": "module",
"scripts": {
"dev": "vite",
+5
View File
@@ -16,6 +16,7 @@ import Docs from './components/Docs.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import AcceptInvitation from './components/AcceptInvitation.jsx'
import InviteClaim from './components/InviteClaim.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
@@ -227,6 +228,10 @@ export default function App() {
<Route path="/invitations/accept" element={
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
} />
{/* v0.17.0 — roadmap item #16. The claim landing page for
admin-issued invites. Anonymous-reachable; the call
itself establishes the session on success. */}
<Route path="/invites/claim" element={<InviteClaim />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
+47
View File
@@ -799,6 +799,53 @@ export async function removeAllowlistEmail(email) {
}))
}
// v0.17.0 — roadmap item #16. Admin-create user + invite email with
// optional custom message. The frontend modal on /admin/users wires
// these two helpers; the claim helper drives the /invites/claim page
// that the invitee lands on when they click the email link.
//
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
// owner-grant-by-non-owner, malformed input) surface as thrown errors
// via `jsonOrThrow` so the modal can render the server's message.
//
// `listUserInvites` returns the active-invites list for the admin's
// "I sent these but they haven't been claimed yet" view. Active means
// not claimed and not expired; once the invitee clicks through, the
// row clears here and the user-listing's `pending_invite` badge
// vanishes alongside.
export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
return jsonOrThrow(await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
first_name: first_name || '',
last_name: last_name || '',
role,
custom_message: custom_message || '',
}),
}))
}
export async function listUserInvites() {
return jsonOrThrow(await fetch('/api/admin/users/invites'))
}
// Claim an admin-issued invite token. Anonymous endpoint — the invitee
// is not yet signed in; this call establishes the session on success.
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
// the server mints a fresh device-trust row + sets the long-lived
// cookie so the invitee skips OTC on their next visit.
export async function claimInvite(token, { trustDevice = false } = {}) {
return jsonOrThrow(await fetch('/api/invites/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, trust_device: !!trustDevice }),
}))
}
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
+212
View File
@@ -23,9 +23,16 @@ import {
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
createUserInvite,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
// v0.17.0 roadmap item #16. The max length the backend enforces
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
// here so the modal's "remaining chars" counter stays in lockstep
// with the server-side bound.
const CUSTOM_MESSAGE_MAX_LENGTH = 500
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
@@ -90,6 +97,10 @@ function UsersTab() {
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
// v0.17.0 roadmap item #16. The "Create user + invite" modal's
// open/closed state. The modal is local to UsersTab (it only opens
// from the header button) and refreshes the listing on success.
const [inviteModalOpen, setInviteModalOpen] = useState(false)
async function refresh() {
setError(null)
@@ -179,8 +190,28 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header>
{error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && (
<CreateUserInviteModal
onClose={() => setInviteModalOpen(false)}
onSuccess={async () => {
setInviteModalOpen(false)
await refresh()
}}
/>
)}
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
@@ -231,12 +262,26 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
const state = u.permission_state || 'granted'
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
// v0.17.0 roadmap item #16. The user's row may also be the
// "(pending invite)" shape: admin-created via POST /api/admin/users,
// not yet claimed via /api/invites/claim. The backend's user-listing
// surfaces this via `pending_invite` (object with invite_id +
// expires_at) or null. The badge sits inline next to the handle so
// the admin sees at a glance which rows are real users vs. unclaimed
// invites.
const pendingInvite = u.pending_invite
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>(pending invite)</span>
)}
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
@@ -326,6 +371,173 @@ function PermissionCell({ user: u, busy, onFlipPermission }) {
)
}
// Create user + invite modal (v0.17.0 / roadmap item #16)
//
// The "Create user + invite" affordance on the Users tab opens this
// modal. Admin types email, first name, last name, role, and (optionally)
// a custom message to embed in the invite email. On submit, calls
// `POST /api/admin/users` which provisions the row + sends the email.
// The 409 path (duplicate email) and 422 path (self-invite, owner-
// grant-by-non-owner, malformed input) surface the server's message
// inline; the success path closes the modal and refreshes the listing.
//
// The modal lives in this file rather than a separate component
// because it has one caller (UsersTab), reuses the existing modal
// stylesheet from /admin's chrome, and shares the
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
function CreateUserInviteModal({ onClose, onSuccess }) {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [role, setRole] = useState('contributor')
const [customMessage, setCustomMessage] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
async function handleSubmit(event) {
event.preventDefault()
const trimmedEmail = email.trim()
if (!trimmedEmail) {
setError('Email is required')
return
}
setBusy(true)
setError(null)
setSuccess(null)
try {
const result = await createUserInvite({
email: trimmedEmail,
first_name: firstName.trim(),
last_name: lastName.trim(),
role,
custom_message: customMessage,
})
// v0.17.0 + #21 Part C Amplitude wiring. target_user_id is
// the OHM user id the invite-create gesture provisioned;
// initial_role is what the invitee inherits on claim.
// custom_message_chars is a coarse signal of admin effort
// (0 = template-only, 1+ = personalized). No PII.
track(EVENTS.USER_INVITED, {
target_user_id: result.invited_user_id != null
? String(result.invited_user_id) : null,
initial_role: result.role,
custom_message_chars: (customMessage || '').length,
})
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
// Brief delay so the admin sees the success state, then close
// and let the parent refresh the listing.
setTimeout(() => { onSuccess?.() }, 600)
} catch (e) {
setError(e.message || 'Unable to send invite')
} finally {
setBusy(false)
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-panel" onClick={e => e.stopPropagation()}>
<header className="modal-header">
<h3>Create user + invite</h3>
<button
type="button"
className="btn-link-quiet"
onClick={onClose}
disabled={busy}
aria-label="Close"
>×</button>
</header>
<p className="muted">
Provisions a fresh user row with the chosen role and sends an
invite email carrying a single-use claim link. The link
expires in 7 days. The invitee clicks through to claim
their account no OTC roundtrip is required on first sign-in.
</p>
<form onSubmit={handleSubmit} className="create-user-invite-form">
<label>
<span>Email</span>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={busy}
autoFocus
maxLength={320}
/>
</label>
<div className="form-row">
<label>
<span>First name</span>
<input
type="text"
value={firstName}
onChange={e => setFirstName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
<label>
<span>Last name</span>
<input
type="text"
value={lastName}
onChange={e => setLastName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
</div>
<label>
<span>Role</span>
<select
value={role}
onChange={e => setRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner (owner-only)</option>
</select>
</label>
<label>
<span>
Custom message (optional){' '}
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
{remaining} chars left
</span>
</span>
<textarea
value={customMessage}
onChange={e => setCustomMessage(e.target.value)}
disabled={busy}
rows={4}
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
placeholder="Optional — embedded in the invite email."
/>
</label>
{error && <p className="settings-note warning">{error}</p>}
{success && <p className="settings-note success">{success}</p>}
<div className="modal-actions">
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={busy || !email.trim() || remaining < 0}
>
{busy ? 'Sending…' : 'Send invite'}
</button>
</div>
</form>
</div>
</div>
)
}
// Private-beta allowlist (`migrations/011_allowlist.sql`)
function AllowlistTab() {
+173
View File
@@ -0,0 +1,173 @@
// v0.17.0 roadmap item #16. The claim flow's landing page.
//
// The admin's invite email carries a link to /invites/claim?token=;
// the invitee clicks through and lands here. The page reads the
// token from the URL, posts it to /api/invites/claim, and on success
// routes either to the passcode-set screen (if v0.10.0 passcode flow
// is in play and the user has no passcode yet) or to home.
//
// Anonymous-reachable: the entire point of the call is to establish
// the session; we do not pre-check authentication.
//
// Failure modes the backend distinguishes:
// * 410 token is expired or already claimed (the row is dead).
// * 400 token doesn't match any active invite (forged, revoked,
// or wiped).
//
// We surface both as the same "this invite link isn't valid" shape
// for the invitee the detail message from the server reads
// distinctively enough that the admin can debug from logs, and the
// invitee just needs to know they should contact the admin for a
// fresh link.
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { claimInvite } from '../api.js'
import { EVENTS, identify, track } from '../lib/analytics.js'
export default function InviteClaim() {
const location = useLocation()
const navigate = useNavigate()
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
const [error, setError] = useState(null)
const [trustDevice, setTrustDevice] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [user, setUser] = useState(null)
const [needsPasscode, setNeedsPasscode] = useState(false)
const params = new URLSearchParams(location.search)
const token = params.get('token') || ''
async function performClaim() {
if (!token) {
setStatus('failed')
setError('No invite token in the URL.')
return
}
setSubmitted(true)
setStatus('working')
setError(null)
try {
const result = await claimInvite(token, { trustDevice })
setUser(result.user)
setNeedsPasscode(!!result.needs_passcode)
// v0.17.0 + #21 Part C identify the new user with their OHM
// user_id BEFORE firing any track() event, so the Amplitude
// user record is created with the OHM id from the first event
// rather than as an anonymous device that retroactively links.
// setOnce on invited_at + invited_by_admin_id + initial_role so
// these are immutable user-history markers on the Amplitude
// record.
if (result.user?.id != null) {
const setOnceProps = {
claim_method: 'admin-invite',
}
if (result.invited_at) setOnceProps.invited_at = ['__setOnce__', result.invited_at]
if (result.invited_by_admin_id != null) {
setOnceProps.invited_by_admin_id = ['__setOnce__', String(result.invited_by_admin_id)]
}
if (result.user.role) setOnceProps.initial_role = ['__setOnce__', result.user.role]
identify({
user_id: String(result.user.id),
properties: setOnceProps,
})
}
track(EVENTS.INVITE_CLAIMED, {
invited_by_admin_id: result.invited_by_admin_id != null
? String(result.invited_by_admin_id) : null,
initial_role: result.user?.role,
needs_passcode: !!result.needs_passcode,
trust_device: trustDevice,
})
setStatus('ok')
} catch (e) {
setStatus('failed')
setError(e.message || 'Unable to claim invite')
}
}
// Pre-flight: if the URL has no token at all, fail fast so the
// invitee sees the missing-token shape immediately rather than
// an empty form.
useEffect(() => {
if (!token) {
setStatus('failed')
setError('This claim link is missing its token.')
}
}, [token])
// On a successful claim, route the user onward. The brief calls
// this out: route to passcode-set if v0.10.0 passcode flow is in
// play and the user has no passcode yet; otherwise route to home.
useEffect(() => {
if (status !== 'ok') return
const timeout = setTimeout(() => {
if (needsPasscode) {
navigate('/settings/notifications#sign-in', { replace: true })
} else {
navigate('/', { replace: true })
}
}, 1200)
return () => clearTimeout(timeout)
}, [status, needsPasscode, navigate])
return (
<div className="invite-claim-page">
<div className="invite-claim-panel">
<h1>Claim your account</h1>
{!submitted && status === 'working' && token && (
<>
<p>
You've been invited to this deployment. Click the button below
to claim your account and sign in. This link is single-use and
expires 7 days after it was sent.
</p>
<label className="claim-trust-toggle">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
/>
{' '}Trust this device for 30 days (skip the email step on
your next visit from this browser).
</label>
<div className="claim-actions">
<button
type="button"
className="btn-primary"
onClick={performClaim}
>Claim my account</button>
</div>
</>
)}
{submitted && status === 'working' && (
<p>Claiming</p>
)}
{status === 'ok' && (
<>
<p className="settings-note success">
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
You're signed in.
</p>
<p className="muted">
{needsPasscode
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
: 'Redirecting you to the home page…'}
</p>
</>
)}
{status === 'failed' && (
<>
<p className="settings-note warning">
{error || "This invite link isn't valid."}
</p>
<p className="muted">
If you believe this is a mistake, contact the admin who
sent you the invite they can issue a fresh link.
</p>
</>
)}
</div>
</div>
)
}