feat(email): unsubscribe from digest emails without signing in - #1262
Conversation
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.
|
Note A newer push replaced |
|
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
📒 Files selected for processing (21)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Devin Review found 2 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
…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🟡 Confidence 3/5 · needs attention Digest and web-analytics emails now carry per-recipient signed unsubscribe links and RFC 8058 one-click headers, served by a new unauthenticated
Findings🟠 Warning · F1 · Unsubscribe token is retained in
|
| 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.
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🔴 Confidence 2/5 · risky as written Adds login-free digest unsubscribe: signed per-recipient tokens, a public POST endpoint, one-click
Findings🟠 Warning · F3 ·
|
| 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.
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🟡 Confidence 3/5 · needs attention Adds stateless signed unsubscribe tokens, a public idempotent
Findings🟠 Warning · F4 ·
|
A forged or stale link is a 400, so its span exports as Ok instead of opening an error issue.
Maple review🟢 Confidence 4/5 · likely safe to merge The only change since the last review is the regenerated anticipated-error list, which correctly picks up
Still open from earlier reviews
What was checked
|
Maple review🟢 Confidence 5/5 · safe to merge This commit only adds coverage for
Fixed since the last review
What was checked
|
What
Recipients of the weekly digest and the weekly web analytics email can now unsubscribe without logging in.
packages/backend/src/services/digest/unsubscribe-token.ts):<kind>.<subscriptionId>.<hmac>, where kind isdigestorweb-analytics. Stateless (no migration), no expiry so links in old emails keep working. The key is derived with a domain-separation label from the existingMAPLE_INGEST_KEY_LOOKUP_HMAC_KEY, so no new secret is needed.POST /api/email/unsubscribe?token=...(emailPublicgroup onMapleApi). Idempotent; setsenabled=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.List-Unsubscribe(pointing at the endpoint) andList-Unsubscribe-Post: List-Unsubscribe=One-Click.EmailService.sendnow takes an options object withreplyToandheaders./unsubscribepage (public route): the footer link lands here and requires a click, since mail security scanners prefetch GET links.Also
/settings/notifications, which is not a route. Previews now use/settings?tab=notifications.MAPLE_API_BASE_URLmoved from the api-only env into the sharedappUrlsEnv, because the alerting worker sends the digests and did not have it.Reviewer notes
List-Unsubscribeheaders. If it rejects them, digest sends would fail, so watch the first digest tick after deploy.Testing
vitestforpackages/backend/src/services/digest/,packages/infra/src/env.test.ts,packages/emailtsc --noEmitfor domain, backend, infra, api, alerting, web🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by CodeRabbit