# Consent and one-click unsubscribe

> Applies to **The Fabrica v0.1.2**. Documentation source: `1ee1bc9cb661`, including post-release documentation corrections.

`GET/POST /api/v1/email/unsubscribe?t=<token>` is the only public,
unauthenticated, side-effect-having endpoint in the factory. That's
not an oversight.

## What gates it

The endpoint takes one query param: `t` — an HMAC-SHA256 token bound
to `(clerk_user_id, issued_at)` signed with
`EMAIL_UNSUBSCRIBE_SECRET`. The handler:

1. Verifies the HMAC. Bad/missing/forged → 401.
2. Looks up the user by `clerk_user_id`.
3. Sets `User.marketing_consent_given = False`.
4. Redirects to `(marketing)/unsubscribed`.

No Clerk session required. No CSRF token. No login.

## The constraint: RFC 8058 + mailbox reality

RFC 8058 ("Signaling One-Click Functionality for List Email Headers")
mandates that the `List-Unsubscribe` URL in marketing email headers
must work via a single HTTP request — no interactive auth, no
multi-step flow. Gmail's "Unsubscribe" button uses this. Apple Mail
uses this. Outlook uses this.

If the endpoint were auth-gated, three failure modes:

1. **The mailbox-on-another-device problem.** User clicks
   "Unsubscribe" from their work Gmail. They've never logged in to your
   app on that machine. Auth-gated endpoint → "please log in to
   unsubscribe" → user gives up + clicks "Mark as spam" instead. Your
   sender reputation craters.
2. **The lost-session problem.** User receives the email 6 months
   after signup. Clicks unsubscribe. Their Clerk session expired.
   Auth-gated → login prompt → they don't remember the password.
   Spam button gets clicked. Reputation, again.
3. **RFC 8058 compliance.** Gmail won't show the inline Unsubscribe
   button in the message header unless the
   `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header is
   present AND the endpoint accepts unauthenticated POST. If you
   gate auth, Gmail demotes your delivery score.

## The HMAC token IS the auth

Cryptographically: the token is unforgeable without the secret +
binds to a single user + has a TTL (no MAX_AGE in our default — Gmail
caches links, so we tolerate replay; the worst case is "unsubscribe
gets re-applied" which is idempotent). An attacker who:

- Doesn't have the secret: can't mint a valid token.
- Has the secret: has bigger problems than unsubscribing your users.
- Has someone else's token (stolen email, phishing): can unsubscribe
  *that user*, which is annoying but not catastrophic — the user just
  re-opts-in via `POST /me/consent`.

So the threat model is: "someone can unsubscribe a user from
marketing email" — recoverable, audit-logged, low-blast-radius. Worth
trading for the deliverability + RFC compliance benefits.

## The audit-log side

The handler writes `action="email.unsubscribed"` with
`details={"via": "one_click_link"}` + the requester IP. If you ever
need to investigate "did Alice really unsubscribe or was it
malicious?", the IP + timestamp are there.

## What it doesn't gate against

- **CSRF**: the endpoint accepts both GET and POST. There is no
  cross-site form that can mint a valid token + submit it, so CSRF
  via origin-spoofing isn't viable. (The query-param HMAC is the
  CSRF defense.)
- **Replay**: an old token still works. By design — Gmail caches
  the URL in the message header for weeks. We accept the
  "unsubscribe is idempotent" trade-off to support that.

## Receipts

- Endpoint: `backend/src/api/routers/email.py:unsubscribe`
- Token primitive: `backend/src/services/unsubscribe.py`
- Header injection: `backend/src/services/email.py:send_marketing`

---

**Provenance.** Documentation source: `1ee1bc9cb6619c19f57766731e7884b37f515dc9` (`main`).

Extracted from `docs/recipes/why-unsubscribe-is-public.md`.

That commit is later than the release these pages describe: it carries documentation corrections made after `thefabrica-v0.1.2` went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.
