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.
This commit is contained in:
@@ -443,6 +443,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 +487,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]:
|
||||
|
||||
Reference in New Issue
Block a user