Skip to content

feat(email): unsubscribe from digest emails without signing in - #1262

Merged
Makisuo merged 6 commits into
mainfrom
feat/email-unsubscribe-without-login
Oct 5, 2026
Merged

Makisuo merged 6 commits into
mainfrom
feat/email-unsubscribe-without-login

Conversation

@Makisuo

@Makisuo Makisuo commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

Recipients of the weekly digest and the weekly web analytics email can now unsubscribe without logging in.

  • Signed per-recipient tokens (packages/backend/src/services/digest/unsubscribe-token.ts): <kind>.<subscriptionId>.<hmac>, where kind is digest or web-analytics. Stateless (no migration), no expiry so links in old emails keep working. The key is derived with a domain-separation label from the existing MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY, so no new secret is needed.
  • Public endpoint POST /api/email/unsubscribe?token=... (emailPublic group on MapleApi). Idempotent; sets enabled=false + optedOutAt (or the web analytics pair), the same columns the settings toggle writes, so the Clerk reconciliation keeps the opt-out. Bad tokens are a 400.
  • RFC 8058 one-click headers: every send carries List-Unsubscribe (pointing at the endpoint) and List-Unsubscribe-Post: List-Unsubscribe=One-Click. EmailService.send now takes an options object with replyTo and headers.
  • Web /unsubscribe page (public route): the footer link lands here and requires a click, since mail security scanners prefetch GET links.

Also

  • The footer link previously pointed at /settings/notifications, which is not a route. Previews now use /settings?tab=notifications.
  • MAPLE_API_BASE_URL moved from the api-only env into the shared appUrlsEnv, because the alerting worker sends the digests and did not have it.
  • Each recipient's email is rendered separately (cheap string splice) so it can carry its own link.

Reviewer notes

  • Not yet verified that the email sending binding accepts the List-Unsubscribe headers. If it rejects them, digest sends would fail, so watch the first digest tick after deploy.
  • Not exercised end to end in a browser; covered by unit tests for the token and a DigestService test that runs a tick, extracts a recipient's token, unsubscribes, and checks only that row and only that email kind flipped.

Testing

  • vitest for packages/backend/src/services/digest/, packages/infra/src/env.test.ts, packages/email
  • tsc --noEmit for domain, backend, infra, api, alerting, web

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Devin Review

Summary by CodeRabbit

  • New Features
    • Added a public unsubscribe page for digest and web analytics emails. Recipients can review and confirm their choice without signing in.
    • Added one-click unsubscribe support from compatible email clients.
  • Improvements
    • Unsubscribe links now lead to the relevant notification settings page.
    • Unsubscribe requests only affect the subscription associated with the link. Reusing a valid link is supported.
  • Bug Fixes
    • Unsubscribe pages can now load without organization-specific access.

Digest and web analytics emails now carry a per-recipient signed
unsubscribe link plus RFC 8058 one-click List-Unsubscribe headers. A new
public POST /api/email/unsubscribe endpoint verifies the token and
records the opt-out the same way the settings toggle does, so the member
sync keeps it. The footer link opens a public /unsubscribe confirm page
(a click is required because mail scanners prefetch links).

Also fixes the footer link, which pointed at a nonexistent
/settings/notifications route, and moves MAPLE_API_BASE_URL into the
shared app URL env so the alerting worker that sends digests has it.
@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced 1c2f222 before its review finished. The latest commit is reviewed in a new comment.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4a9f2913-d4ae-4474-8eaf-14f7b92f30a3
📥 Commits

Reviewing files that changed from the base of the PR and between a9d45c7 and 82a8c69.

📒 Files selected for processing (21)
  • apps/api/src/http/api-observability.ts
  • apps/api/src/resources/env.ts
  • apps/api/src/routes/v1/email-public.http.ts
  • apps/api/src/runtime/http-graph.ts
  • apps/web/src/lib/public-routes.test.ts
  • apps/web/src/lib/public-routes.ts
  • apps/web/src/routeTree.gen.ts
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/unsubscribe.tsx
  • packages/backend/src/platform/EmailService.ts
  • packages/backend/src/platform/bindings.ts
  • packages/backend/src/platform/email-sender.ts
  • packages/backend/src/services/digest/DigestService.test.ts
  • packages/backend/src/services/digest/DigestService.ts
  • packages/backend/src/services/digest/WebAnalyticsDigestService.ts
  • packages/backend/src/services/digest/unsubscribe-token.test.ts
  • packages/backend/src/services/digest/unsubscribe-token.ts
  • packages/domain/src/http/api.ts
  • packages/domain/src/http/digest.ts
  • packages/email/src/samples.ts
  • packages/infra/src/env.ts
 __________________________________________________________________________________________________________________________
< Unix was not designed to stop its users from doing stupid things, as that would also stop them from doing clever things. >
 --------------------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread apps/web/src/lib/public-routes.ts
Comment thread apps/web/src/routes/unsubscribe.tsx Outdated
…token

A signed-in recipient whose org has no plan was redirected to onboarding
before reaching the unsubscribe page. The confirm state is now keyed by
token so navigating to another email's link starts fresh.
@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 3/5 · needs attention
A long-lived bearer token now rides in a query string on the api's server span; the rest of the change is contained and well tested.
quality 88/100 · 1 warning · 1 note · tests partial · risk medium · 2/2 new units observable

Digest and web-analytics emails now carry per-recipient signed unsubscribe links and RFC 8058 one-click headers, served by a new unauthenticated POST /api/email/unsubscribe plus a public /unsubscribe confirm page. The logic is sound, but the token is left in telemetry.

  • POST /api/email/unsubscribe flips enabled/webAnalyticsEnabled from a signed token
  • Digest sends render per recipient and carry one-click List-Unsubscribe headers
  • MAPLE_API_BASE_URL moved into shared appUrlsEnv for the alerting worker
  • Public /unsubscribe page skips the region and plan gates

Findings

🟠 Warning · F1 · Unsubscribe token is retained in url.query on the api server span

security · apps/api/src/routes/v1/email-public.http.ts:12

The credential for this endpoint is the token query parameter, and api-observability.ts:6-13 documents that HttpMiddleware.tracer stamps url.full and url.query verbatim on the server span — the reason the OAuth callbacks are added to TracerDisabledWhen. Nothing suppresses this path, so every unsubscribe writes a working, never-expiring token into Maple's telemetry, where anyone who can read traces can replay it to unsubscribe a subscriber. Add /api/email/unsubscribe to TracerDisabledWhen (or emit a handler-owned span, as the callbacks do), in apps/api/src/http/api-observability.ts.

Extend the `TracerDisabledWhen` predicate in `apps/api/src/http/api-observability.ts` with the unsubscribe path, then log the outcome from the handler's own span, as the OAuth callbacks do.
🔵 Note · F2 · appUrlsEnv's new MAPLE_API_BASE_URL branch is untested

tests · packages/infra/src/env.ts:121-123

packages/infra/src/env.test.ts:127-141 asserts the deploy-time default and the environment override for MAPLE_APP_BASE_URL and MAPLE_INGEST_PUBLIC_URL, but the new MAPLE_API_BASE_URL key — including the derived branch that refuses an environment override when domains.api is set — is not covered, and env.test.ts is not part of this pull request. Add the same two assertions for MAPLE_API_BASE_URL.

In `packages/infra/src/env.test.ts`'s `appUrlsEnv` suite, assert `run(appUrlsEnv({ api: "api.eu.maple.dev" }), {})` yields `https://api.eu.maple.dev` and that `run(appUrlsEnv({}), { MAPLE_API_BASE_URL: "https://api.example.test" })` keeps the override.
🤖 Prompt to fix all 2 findings with an AI agent
Findings from an automated review of commit 8daf33f066372b249be7eda7794757962881d84f. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · security · apps/api/src/routes/v1/email-public.http.ts:12
Unsubscribe token is retained in `url.query` on the api server span
The credential for this endpoint is the `token` query parameter, and `api-observability.ts:6-13` documents that `HttpMiddleware.tracer` stamps `url.full` and `url.query` verbatim on the server span — the reason the OAuth callbacks are added to `TracerDisabledWhen`. Nothing suppresses this path, so every unsubscribe writes a working, never-expiring token into Maple's telemetry, where anyone who can read traces can replay it to unsubscribe a subscriber. Add `/api/email/unsubscribe` to `TracerDisabledWhen` (or emit a handler-owned span, as the callbacks do), in `apps/api/src/http/api-observability.ts`.
Suggested fix: Extend the `TracerDisabledWhen` predicate in `apps/api/src/http/api-observability.ts` with the unsubscribe path, then log the outcome from the handler's own span, as the OAuth callbacks do.

---

F2 · Note · tests · packages/infra/src/env.ts:121-123
`appUrlsEnv`'s new `MAPLE_API_BASE_URL` branch is untested
`packages/infra/src/env.test.ts:127-141` asserts the deploy-time default and the environment override for `MAPLE_APP_BASE_URL` and `MAPLE_INGEST_PUBLIC_URL`, but the new `MAPLE_API_BASE_URL` key — including the `derived` branch that refuses an environment override when `domains.api` is set — is not covered, and `env.test.ts` is not part of this pull request. Add the same two assertions for `MAPLE_API_BASE_URL`.
Suggested fix: In `packages/infra/src/env.test.ts`'s `appUrlsEnv` suite, assert `run(appUrlsEnv({ api: "api.eu.maple.dev" }), {})` yields `https://api.eu.maple.dev` and that `run(appUrlsEnv({}), { MAPLE_API_BASE_URL: "https://api.example.test" })` keeps the override.
What was checked
  • verifyUnsubscribeToken fails closed on swapped kind, tampered signature, empty id and extra dot segments (unsubscribe-token.ts:29)
  • The token write sets the same columns as the settings toggle (DigestService.ts:454), which the Clerk sweep preserves (DigestService.ts:1005)
  • Both alerting and api carry MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY and appUrlsEnv, so mint and verify share one secret
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
POST /api/email/unsubscribe (emailPublic group) http yes Auto server span via HttpMiddleware.tracer (apps/api/src/http/api-observability.ts:24); DigestService.unsubscribeByToken is an Effect.fn with maple.email.unsubscribe_kind (DigestService.ts:434)
Digest send loop per recipient outbound yes EmailService.send annotates email.subject/email.provider/email.message_id (EmailService.ts:53)

8daf33f · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread apps/api/src/routes/v1/email-public.http.ts Outdated
The auto server span stamps url.query verbatim, which would retain a
working, non-expiring unsubscribe token in telemetry. Suppress it for
/api/email/unsubscribe; DigestService.unsubscribeByToken owns the span.
@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Maple review

🔴 Confidence 2/5 · risky as written
Only the tracer-filter file changed since the last review; it hides the token but leaves the new endpoint with no Server span.
quality 88/100 · 1 warning · 1 note · tests partial · risk medium · 0/1 new units observable

Adds login-free digest unsubscribe: signed per-recipient tokens, a public POST endpoint, one-click List-Unsubscribe headers and a web confirm page. Only the api tracer filter changed since the last review; it hides the token but leaves the endpoint with no Server span.

  • POST /api/email/unsubscribe opts a recipient out from a signed token, no session
  • unsubscribe-token.ts mints and verifies <kind>.<subscriptionId>.<hmac> links
  • Every digest send carries List-Unsubscribe and List-Unsubscribe-Post headers
  • MAPLE_API_BASE_URL moved into appUrlsEnv so the alerting worker has it

Findings

🟠 Warning · F3 · EMAIL_UNSUBSCRIBE_PATH leaves the new endpoint with no Server span

observability · SPAN-01 · apps/api/src/http/api-observability.ts:35

Disabling the auto server span here means POST /api/email/unsubscribe reaches the warehouse only as the internal DigestService.unsubscribeByToken span (packages/backend/src/services/digest/DigestService.ts:434, kind internal by default): no http.route, no http.request.method, no response status, so the new public endpoint is not an entry-point operation and scans for its failures in the service map find nothing. The OAuth callbacks this same filter covers avoid that by opening their own span with kind: "server" and http.route (apps/api/src/routes/v1/integrations.http.ts:1089); give unsubscribeByToken the same options.

Annotate the handler span like the suppressed OAuth callbacks do: `Effect.fn("DigestService.unsubscribeByToken", { kind: "server", attributes: { "http.route": "/api/email/unsubscribe", "http.request.method": "POST" } })`.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 82a8c69995c6acadbfe04d2bd3989eb58eb9710d. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F3 · Warning · observability · SPAN-01 · apps/api/src/http/api-observability.ts:35
`EMAIL_UNSUBSCRIBE_PATH` leaves the new endpoint with no Server span
Disabling the auto server span here means `POST /api/email/unsubscribe` reaches the warehouse only as the internal `DigestService.unsubscribeByToken` span (`packages/backend/src/services/digest/DigestService.ts:434`, kind `internal` by default): no `http.route`, no `http.request.method`, no response status, so the new public endpoint is not an entry-point operation and scans for its failures in the service map find nothing. The OAuth callbacks this same filter covers avoid that by opening their own span with `kind: "server"` and `http.route` (`apps/api/src/routes/v1/integrations.http.ts:1089`); give `unsubscribeByToken` the same options.
Suggested fix: Annotate the handler span like the suppressed OAuth callbacks do: `Effect.fn("DigestService.unsubscribeByToken", { kind: "server", attributes: { "http.route": "/api/email/unsubscribe", "http.request.method": "POST" } })`.

Still open from earlier reviews

What was checked
  • EMAIL_UNSUBSCRIBE_PATH anchors on a path, matching the /health comparison at api-observability.ts:32
  • Repeat clicks and a deleted row still return success: the UPDATE at DigestService.ts:448 is unconditional
  • Token check uses timingSafeEqual on decoded bytes with a length guard (unsubscribe-token.ts:31)
Observability coverage: 0 of 1 changes observable
Change Kind Observable Evidence
POST /api/email/unsubscribe (emailPublic group) inbound entrypoint no server span suppressed by EMAIL_UNSUBSCRIBE_PATH (apps/api/src/http/api-observability.ts:35); only the internal DigestService span remains

82a8c69 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one. Check ids refer to Maple's instrumentation audit.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread apps/api/src/http/api-observability.ts
With the auto server span suppressed, the request had no entry-point
span. The handler now opens a server-kind span with http.route, method
and response status, as the OAuth callbacks do.
@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 3/5 · needs attention
The endpoint, its server span and its status mapping read correctly; the one gap is the stale anticipated-4xx list, which no test the PR runs regenerates.
quality 88/100 · 1 warning · 1 note · tests partial · risk medium

Adds stateless signed unsubscribe tokens, a public idempotent POST /api/email/unsubscribe with RFC 8058 one-click headers and a web confirm page, and moves MAPLE_API_BASE_URL into appUrlsEnv. The endpoint and its hand-rolled server span are sound; the new 4xx error still needs its anticipated-error identifier regenerated.

  • DigestService.unsubscribeByToken opts a subscriber out from a signed token
  • POST /api/email/unsubscribe is public, idempotent, and answers 400 on a bad token
  • Sends now carry List-Unsubscribe / List-Unsubscribe-Post one-click headers
  • MAPLE_API_BASE_URL moved from the api-only env into the shared appUrlsEnv

Findings

🟠 Warning · F4 · DigestUnsubscribeTokenInvalidError missing from the anticipated 4xx list

observability · STAT-01 · packages/domain/src/http/digest.ts:88-94

A rejected token fails the new email.unsubscribe server span, and the SDK only exports a failure as Ok when the tag is in ANTICIPATED_ERROR_IDENTIFIERS (packages/infra/src/cloudflare/worker-telemetry.ts:27) — packages/domain/src/generated/anticipated-error-identifiers.ts was not regenerated, so every stale or forged link turns into an Error span that opens an error issue in Maple, which is exactly what the 4xx rule exists to prevent. Run bun run --cwd packages/domain gen:anticipated-errors and commit the list.

🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 33a0d6e08f7b1c728a5787b562e7e1d67841ada6. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F4 · Warning · observability · STAT-01 · packages/domain/src/http/digest.ts:88-94
`DigestUnsubscribeTokenInvalidError` missing from the anticipated 4xx list
A rejected token fails the new `email.unsubscribe` server span, and the SDK only exports a failure as `Ok` when the tag is in `ANTICIPATED_ERROR_IDENTIFIERS` (`packages/infra/src/cloudflare/worker-telemetry.ts:27`) — `packages/domain/src/generated/anticipated-error-identifiers.ts` was not regenerated, so every stale or forged link turns into an `Error` span that opens an error issue in Maple, which is exactly what the 4xx rule exists to prevent. Run `bun run --cwd packages/domain gen:anticipated-errors` and commit the list.

Still open from earlier reviews

33a0d6e · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one. Check ids refer to Maple's instrumentation audit.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/domain/src/http/digest.ts
A forged or stale link is a 400, so its span exports as Ok instead of
opening an error issue.
@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 4/5 · likely safe to merge
F2 is still open: appUrlsEnv's MAPLE_API_BASE_URL branch has no test at this head, and the untested branch now feeds the digest unsubscribe links.
quality 98/100 · 1 note · tests partial · risk medium

The only change since the last review is the regenerated anticipated-error list, which correctly picks up DigestUnsubscribeTokenInvalidError; the rest of the diff is unchanged. Mergeable once F2 is addressed, or knowingly with that gap.

  • ANTICIPATED_ERROR_IDENTIFIER_LIST now lists @maple/http/errors/DigestUnsubscribeTokenInvalidError, so a rejected token exports Ok

Still open from earlier reviews

What was checked
  • Regenerated list is sorted and matches deriveAnticipatedIdentifiers output; nothing else new was added (packages/domain/scripts/gen-anticipated-errors.ts:10)
  • DigestUnsubscribeTokenInvalidError is a 400, so it belongs in the anticipated set (packages/domain/src/http/digest.ts:88)
  • No test for the domains.api branch of appUrlsEnv — env.test.ts:127-142 only asserts the web and ingest defaults

f0c6b6e · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot

maple-review-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 5/5 · safe to merge
The new test pins all three MAPLE_API_BASE_URL branches and env.ts itself is unchanged since the earlier review.
quality 100/100 · no findings · tests covered · risk low

This commit only adds coverage for appUrlsEnv's MAPLE_API_BASE_URL branch inside packages/infra/src/env.test.ts; the env group it tests is unchanged. Safe to merge.

  • Test pins appUrlsEnv's derived MAPLE_API_BASE_URL from domains.api, the provider override, and the api.maple.dev default

Fixed since the last review

  • ✅ F2 · appUrlsEnv's new MAPLE_API_BASE_URL branch is untested
What was checked
  • Test can fail: line 146 expects https://api.eu.maple.dev while the provider holds https://api.example.test, so a missing derived branch breaks it
  • Assertions match env.ts:121-123: derived ignores the provider (Config.succeed), plainWithDefault falls back for absent/blank values

23fc5db · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@Makisuo
Makisuo merged commit f9741bc into main Oct 5, 2026
15 checks passed
@Makisuo
Makisuo deleted the feat/email-unsubscribe-without-login branch October 5, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant