From 1c2f22214e803eef9d3c0a7c7988a161ebcf5712 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 22:55:46 +0200 Subject: [PATCH 1/6] feat(email): unsubscribe from digest emails without signing in 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. --- apps/api/src/resources/env.ts | 5 - apps/api/src/routes/v1/email-public.http.ts | 14 +++ apps/api/src/runtime/http-graph.ts | 2 + apps/web/src/lib/public-routes.ts | 2 +- apps/web/src/routeTree.gen.ts | 21 ++++ apps/web/src/routes/unsubscribe.tsx | 98 +++++++++++++++++++ packages/backend/src/platform/EmailService.ts | 42 ++++---- packages/backend/src/platform/bindings.ts | 1 + packages/backend/src/platform/email-sender.ts | 10 +- .../src/services/digest/DigestService.test.ts | 51 +++++++++- .../src/services/digest/DigestService.ts | 55 ++++++++++- .../digest/WebAnalyticsDigestService.ts | 22 ++++- .../services/digest/unsubscribe-token.test.ts | 54 ++++++++++ .../src/services/digest/unsubscribe-token.ts | 53 ++++++++++ packages/domain/src/http/api.ts | 2 + packages/domain/src/http/digest.ts | 35 +++++++ packages/email/src/samples.ts | 4 +- packages/infra/src/env.ts | 5 + 18 files changed, 440 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/routes/v1/email-public.http.ts create mode 100644 apps/web/src/routes/unsubscribe.tsx create mode 100644 packages/backend/src/services/digest/unsubscribe-token.test.ts create mode 100644 packages/backend/src/services/digest/unsubscribe-token.ts diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 9064cdd319..20fb5cb5ef 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -10,7 +10,6 @@ import { appUrlsEnv, authEnv, cloudflareOAuthEnv, - derived, githubAppSourceEnv, ingestKeyCryptoEnv, merge, @@ -40,10 +39,6 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains ingestKeyCryptoEnv, requireSecretEntry("MAPLE_SHARE_TOKEN_HMAC_KEY"), appUrlsEnv(domains), - // Canonical origin for self-published URLs (MCP `server.json`), never forwarded headers. - domains.api - ? derived("MAPLE_API_BASE_URL", `https://${domains.api}`) - : plainWithDefault("MAPLE_API_BASE_URL", "https://api.maple.dev"), plainWithDefault("QE_BUCKET_CACHE_ENABLED", "true"), plainWithDefault("QE_BUCKET_CACHE_TTL_SECONDS", "86400"), plainWithDefault("QE_BUCKET_CACHE_FLUX_SECONDS", "60"), diff --git a/apps/api/src/routes/v1/email-public.http.ts b/apps/api/src/routes/v1/email-public.http.ts new file mode 100644 index 0000000000..d4d17c4830 --- /dev/null +++ b/apps/api/src/routes/v1/email-public.http.ts @@ -0,0 +1,14 @@ +import { HttpApiBuilder } from "effect/http-api" +import { Effect } from "effect" +import { MapleApi } from "@maple/domain/http" +import { DigestService } from "@maple/backend/services/digest/DigestService" + +// Unauthenticated by design: the signed token is the credential, so a recipient can +// unsubscribe without a Maple session (and mail clients can one-click POST here). +export const HttpEmailPublicLive = HttpApiBuilder.group(MapleApi, "emailPublic", (handlers) => + Effect.gen(function* () { + const digest = yield* DigestService + + return handlers.handle("unsubscribe", ({ query }) => digest.unsubscribeByToken(query.token)) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index e964a9ea99..a4c10c7c8b 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -11,6 +11,7 @@ import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http" import { HttpAuthLive, HttpAuthPublicLive } from "@/routes/v1/auth.http" import { HttpBillingLive } from "@/routes/internal/billing.http" import { HttpBillingPublicLive } from "@/routes/v1/billing-public.http" +import { HttpEmailPublicLive } from "@/routes/v1/email-public.http" import { HttpV2SharePublicLive } from "@/routes/v2/share.http" import { V1ErrorBoundaryLive } from "@maple/backend/http/error-boundary" import { HttpDemoLive } from "@/routes/internal/demo.http" @@ -112,6 +113,7 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( Layer.provide(HttpAuthLive), Layer.provide(HttpBillingPublicLive), Layer.provide(HttpCodeReviewLive), + Layer.provide(HttpEmailPublicLive), Layer.provide(HttpErrorsLive), Layer.provide(HttpIntegrationsLive), Layer.provide(HttpOrgClickHouseSettingsLive), diff --git a/apps/web/src/lib/public-routes.ts b/apps/web/src/lib/public-routes.ts index ae22e7544f..f7034d11d4 100644 --- a/apps/web/src/lib/public-routes.ts +++ b/apps/web/src/lib/public-routes.ts @@ -20,7 +20,7 @@ import { isSessionlessLabPath } from "@/lab/registry" */ export const isFixturePath = isSessionlessLabPath -const EXACT_PUBLIC_PATHS = new Set(["/sign-in", "/sign-up", "/org-required"]) +const EXACT_PUBLIC_PATHS = new Set(["/sign-in", "/sign-up", "/org-required", "/unsubscribe"]) /** * Prefixes whose entire subtree is public. diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 8fe3969891..5e14e1383c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ServiceMapRouteImport } from './routes/service-map' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SignInRouteImport } from './routes/sign-in' import { Route as SignUpRouteImport } from './routes/sign-up' +import { Route as UnsubscribeRouteImport } from './routes/unsubscribe' import { Route as AgentSessionsIndexRouteImport } from './routes/agent-sessions/index' import { Route as AgentSessionsSessionIdRouteImport } from './routes/agent-sessions/$sessionId' import { Route as AlertsIndexRouteImport } from './routes/alerts/index' @@ -198,6 +199,11 @@ const SignUpRoute = SignUpRouteImport.update({ path: '/sign-up', getParentRoute: () => rootRouteImport, } as any) +const UnsubscribeRoute = UnsubscribeRouteImport.update({ + id: '/unsubscribe', + path: '/unsubscribe', + getParentRoute: () => rootRouteImport, +} as any) const AgentSessionsIndexRoute = AgentSessionsIndexRouteImport.update({ id: '/agent-sessions/', path: '/agent-sessions/', @@ -660,6 +666,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/unsubscribe': typeof UnsubscribeRoute '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute @@ -764,6 +771,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/unsubscribe': typeof UnsubscribeRoute '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute @@ -870,6 +878,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/unsubscribe': typeof UnsubscribeRoute '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute @@ -977,6 +986,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/unsubscribe' | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' @@ -1081,6 +1091,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/unsubscribe' | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' @@ -1186,6 +1197,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/unsubscribe' | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' @@ -1292,6 +1304,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute + UnsubscribeRoute: typeof UnsubscribeRoute AgentSessionsSessionIdRoute: typeof AgentSessionsSessionIdRoute AlertsRuleIdRoute: typeof AlertsRuleIdRoute AlertsCreateRoute: typeof AlertsCreateRoute @@ -1475,6 +1488,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SignUpRouteImport parentRoute: typeof rootRouteImport } + '/unsubscribe': { + id: '/unsubscribe' + path: '/unsubscribe' + fullPath: '/unsubscribe' + preLoaderRoute: typeof UnsubscribeRouteImport + parentRoute: typeof rootRouteImport + } '/agent-sessions/': { id: '/agent-sessions/' path: '/agent-sessions' @@ -2160,6 +2180,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, + UnsubscribeRoute: UnsubscribeRoute, AgentSessionsSessionIdRoute: AgentSessionsSessionIdRoute, AlertsRuleIdRoute: AlertsRuleIdRoute, AlertsCreateRoute: AlertsCreateRoute, diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx new file mode 100644 index 0000000000..b6a5c83e84 --- /dev/null +++ b/apps/web/src/routes/unsubscribe.tsx @@ -0,0 +1,98 @@ +/** + * The footer link of every digest email. Public: the signed `token` is the + * credential, so a recipient can opt out without signing in. + * + * Unsubscribing waits for a click because mail security scanners prefetch links; + * mail clients that support one-click POST to the API directly instead. + */ +import { Link, createFileRoute } from "@tanstack/react-router" +import { Schema } from "effect" +import { useState } from "react" +import { AuthLayout } from "@/components/layout/auth-layout" +import { apiBaseUrl } from "@/lib/services/common/api-base-url" +import { Button } from "@maple/ui/components/ui/button" + +const UnsubscribeSearch = Schema.Struct({ + token: Schema.optional(Schema.String), +}) + +export const Route = createFileRoute("/unsubscribe")({ + component: UnsubscribePage, + validateSearch: Schema.toStandardSchemaV1(UnsubscribeSearch), +}) + +const labelForToken = (token: string) => { + const kind = token.split(".")[0] + if (kind === "digest") return "the weekly digest" + if (kind === "web-analytics") return "the weekly web analytics email" + return "these emails" +} + +type State = { kind: "idle" } | { kind: "pending" } | { kind: "done" } | { kind: "error"; message: string } + +function UnsubscribePage() { + const { token } = Route.useSearch() + const [state, setState] = useState({ kind: "idle" }) + + if (!token) { + return ( + +

Invalid unsubscribe link

+

+ This link is incomplete. Use the unsubscribe link from the email itself. +

+
+ ) + } + + const label = labelForToken(token) + + const unsubscribe = async () => { + setState({ kind: "pending" }) + const response = await fetch( + `${apiBaseUrl}/api/email/unsubscribe?token=${encodeURIComponent(token)}`, + { method: "POST" }, + ).catch(() => undefined) + if (response?.ok) return setState({ kind: "done" }) + setState({ + kind: "error", + message: + response?.status === 400 + ? "This unsubscribe link is invalid. Use the link from the email itself." + : "Something went wrong. Please try again.", + }) + } + + if (state.kind === "done") { + return ( + +

You're unsubscribed

+

+ You won't receive {label} anymore. You can turn it back on any time in your notification + settings. +

+
+ +
+
+ ) + } + + return ( + +

Unsubscribe

+

Stop receiving {label} from Maple?

+ {state.kind === "error" &&

{state.message}

} +
+ +
+
+ ) +} diff --git a/packages/backend/src/platform/EmailService.ts b/packages/backend/src/platform/EmailService.ts index 030b7cdc30..ad1ff722ec 100644 --- a/packages/backend/src/platform/EmailService.ts +++ b/packages/backend/src/platform/EmailService.ts @@ -7,13 +7,19 @@ class EmailDeliveryError extends Schema.TaggedError()( { message: Schema.String }, ) {} +export interface EmailSendOptions { + readonly replyTo?: string + /** Extra MIME headers, e.g. `List-Unsubscribe`. */ + readonly headers?: Readonly> +} + export interface EmailServiceApi { readonly isConfigured: boolean readonly send: ( to: string, subject: string, html: string, - replyTo?: string, + options?: EmailSendOptions, ) => Effect.Effect } @@ -41,7 +47,7 @@ export class EmailService extends Context.Service to: string, subject: string, html: string, - replyTo?: string, + options?: EmailSendOptions, ) { // PII: never stamp recipient/reply-to addresses on spans or logs yield* Effect.annotateCurrentSpan("email.subject", subject) @@ -63,23 +69,25 @@ export class EmailService extends Context.Service ) } - const result = yield* sender.value.send({ from: fromEmail, to, subject, html, replyTo }).pipe( - Effect.mapError( - (error) => - new EmailDeliveryError({ - message: `Cloudflare Email send failed: ${error.message}`, - }), - ), - Effect.timeoutOrElse({ - duration: EMAIL_TIMEOUT, - orElse: () => - Effect.fail( + const result = yield* sender.value + .send({ from: fromEmail, to, subject, html, ...options }) + .pipe( + Effect.mapError( + (error) => new EmailDeliveryError({ - message: "Cloudflare Email send timed out after 15s", + message: `Cloudflare Email send failed: ${error.message}`, }), - ), - }), - ) + ), + Effect.timeoutOrElse({ + duration: EMAIL_TIMEOUT, + orElse: () => + Effect.fail( + new EmailDeliveryError({ + message: "Cloudflare Email send timed out after 15s", + }), + ), + }), + ) yield* Effect.annotateCurrentSpan("email.message_id", result.messageId) yield* Effect.logInfo("Email sent successfully").pipe( diff --git a/packages/backend/src/platform/bindings.ts b/packages/backend/src/platform/bindings.ts index 529d2a6150..189bd84031 100644 --- a/packages/backend/src/platform/bindings.ts +++ b/packages/backend/src/platform/bindings.ts @@ -150,6 +150,7 @@ export interface EmailMessage { readonly subject: string readonly html: string readonly replyTo?: string | undefined + readonly headers?: Readonly> | undefined } export interface EmailSenderClient { diff --git a/packages/backend/src/platform/email-sender.ts b/packages/backend/src/platform/email-sender.ts index 79a5cc8cfd..c49850224d 100644 --- a/packages/backend/src/platform/email-sender.ts +++ b/packages/backend/src/platform/email-sender.ts @@ -18,8 +18,14 @@ const runtime = (effect: Effect.Effect): Effect.Effe Effect.provide(effect, RuntimeContext.phantom) const toPort = (client: Cloudflare.Email.SendClient): EmailSenderClient => ({ - send: ({ replyTo, ...message }) => - runtime(client.send(replyTo === undefined ? message : { ...message, replyTo })).pipe( + send: ({ replyTo, headers, ...message }) => + runtime( + client.send({ + ...message, + ...(replyTo === undefined ? undefined : { replyTo }), + ...(headers === undefined ? undefined : { headers: { ...headers } }), + }), + ).pipe( Effect.map((result) => ({ messageId: result.messageId })), Effect.mapError((error) => new EmailSendError({ message: error.message, cause: error.cause })), ), diff --git a/packages/backend/src/services/digest/DigestService.test.ts b/packages/backend/src/services/digest/DigestService.test.ts index efa80a22bd..d1ed8918e2 100644 --- a/packages/backend/src/services/digest/DigestService.test.ts +++ b/packages/backend/src/services/digest/DigestService.test.ts @@ -139,11 +139,13 @@ const makeHarness = ( failing?: ReadonlySet, ) => { const sends: string[] = [] + const messages: Array<{ to: string; html: string; headers: Readonly> }> = [] const emailStub = Layer.succeed(EmailService, { isConfigured: true, - send: (to) => + send: (to, _subject, html, options) => Effect.sync(() => { sends.push(to) + messages.push({ to, html, headers: options?.headers ?? {} }) }), }) const testDb = createTestDb(createdDbs) @@ -158,7 +160,7 @@ const makeHarness = ( ), Layer.provideMerge(base), ) - return { sends, layer } + return { sends, messages, layer } } const seedSub = (overrides: Partial & { email: string }) => @@ -296,6 +298,51 @@ describe("DigestService.runDigestTick", () => { }) }) +describe("DigestService.unsubscribeByToken", () => { + it.effect("each recipient gets their own link, and it opts them out without a session", () => { + const { messages, layer } = makeHarness() + return Effect.gen(function* () { + yield* TestClock.setTime(TICK_MS) + const aId = yield* seedSub({ email: "a@example.com" }) + const bId = yield* seedSub({ email: "b@example.com" }) + + const digest = yield* DigestService + yield* digest.runDigestTick() + + const toA = messages.find((m) => m.to === "a@example.com") + assert.isDefined(toA) + const header = toA.headers["List-Unsubscribe"] ?? "" + assert.strictEqual(toA.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click") + const token = decodeURIComponent(/token=([^>]+)>/.exec(header)?.[1] ?? "") + assert.include(toA.html, `/unsubscribe?token=${encodeURIComponent(token)}`) + assert.notInclude( + messages.find((m) => m.to === "b@example.com")?.html ?? "", + encodeURIComponent(token), + ) + + const result = yield* digest.unsubscribeByToken(token) + assert.strictEqual(result.kind, "digest") + // Idempotent: a mail client and a human click may both arrive. + yield* digest.unsubscribeByToken(token) + + const a = yield* getSub(aId) + assert.strictEqual(a.enabled, false) + assert.isNotNull(a.optedOutAt) + assert.strictEqual(a.webAnalyticsEnabled, true) + assert.strictEqual((yield* getSub(bId)).enabled, true) + }).pipe(Effect.provide(layer)) + }) + + it.effect("rejects a tampered token", () => { + const { layer } = makeHarness() + return Effect.gen(function* () { + const digest = yield* DigestService + const error = yield* digest.unsubscribeByToken(`digest.${randomUUID()}.forged`).pipe(Effect.flip) + assert.strictEqual(error._tag, "@maple/http/errors/DigestUnsubscribeTokenInvalidError") + }).pipe(Effect.provide(layer)) + }) +}) + const ORG_ID = OrgId.make("org_digest_test") /** diff --git a/packages/backend/src/services/digest/DigestService.ts b/packages/backend/src/services/digest/DigestService.ts index 3a908a29f4..de8948ce35 100644 --- a/packages/backend/src/services/digest/DigestService.ts +++ b/packages/backend/src/services/digest/DigestService.ts @@ -7,6 +7,8 @@ import { DigestRenderError, DigestSubscriptionId, DigestSubscriptionResponse, + DigestUnsubscribeTokenInvalidError, + EmailUnsubscribeResponse, OrgId, UserId, RoleName, @@ -40,6 +42,7 @@ import { import { formatWarehouseDateTime } from "@maple/query-engine" import { resolveOrgName } from "./resolve-org-name" +import { unsubscribeLinks, verifyUnsubscribeToken } from "./unsubscribe-token" import { summarizeCause } from "@maple/backend/platform/describe-cause" const SYSTEM_DIGEST_USER = UserId.make("system-digest") const ROOT_ROLE = RoleName.make("root") @@ -287,6 +290,11 @@ export class DigestService extends Context.Service()("@maple/api/ const env = yield* Env const warehouse = yield* WarehouseQueryService const edgeCache = yield* EdgeCacheService + const linkConfig = { + secret: Redacted.value(env.MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY), + appBaseUrl: env.MAPLE_APP_BASE_URL, + apiBaseUrl: env.MAPLE_API_BASE_URL, + } const getSubscription = Effect.fn("DigestService.getSubscription")(function* ( orgId: OrgId, @@ -419,6 +427,40 @@ export class DigestService extends Context.Service()("@maple/api/ .pipe(Effect.mapError(toPersistenceError)) }) + /** + * The login-free unsubscribe behind every digest email's link and one-click header. + * Records the opt-out the same way the settings toggle does, so the Clerk sweep keeps it. + */ + const unsubscribeByToken = Effect.fn("DigestService.unsubscribeByToken")(function* (token: string) { + const verified = verifyUnsubscribeToken( + Redacted.value(env.MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY), + token, + ) + if (verified === undefined) { + return yield* new DigestUnsubscribeTokenInvalidError({ + message: "This unsubscribe link is invalid", + }) + } + yield* Effect.annotateCurrentSpan("maple.email.unsubscribe_kind", verified.kind) + + const now = msToDate(yield* Clock.currentTimeMillis) + // Idempotent: a repeat click (or a deleted row) changes nothing and still succeeds. + yield* database + .execute((db) => + db + .update(digestSubscriptions) + .set( + verified.kind === "digest" + ? { enabled: false, optedOutAt: now, updatedAt: now } + : { webAnalyticsEnabled: false, webAnalyticsOptedOutAt: now, updatedAt: now }, + ) + .where(eq(digestSubscriptions.id, verified.subscriptionId)), + ) + .pipe(Effect.mapError(toPersistenceError)) + + return new EmailUnsubscribeResponse({ kind: verified.kind }) + }) + const generateDigestData = Effect.fn("DigestService.generateDigestData")(function* ( orgId: OrgId, scope: DigestScope = UNSCOPED, @@ -792,7 +834,7 @@ export class DigestService extends Context.Service()("@maple/api/ ingestion: { ...curUsage, approximate: isScoped }, baseUrl: env.MAPLE_APP_BASE_URL, dashboardUrl: `${env.MAPLE_APP_BASE_URL}`, - unsubscribeUrl: `${env.MAPLE_APP_BASE_URL}/settings/notifications`, + unsubscribeUrl: `${env.MAPLE_APP_BASE_URL}/settings?tab=notifications`, } yield* Effect.annotateCurrentSpan("totalRequests", totalRequests) @@ -1156,13 +1198,19 @@ export class DigestService extends Context.Service()("@maple/api/ return [] } - const html = yield* renderDigestHtml(props) const subject = deriveDigestStatus(props).subject const sendResults = yield* Effect.forEach( claimedSubs, (sub) => - email.send(sub.email, subject, html).pipe( + Effect.gen(function* () { + const links = unsubscribeLinks(linkConfig, "digest", sub.id) + const html = yield* renderDigestHtml({ + ...props, + unsubscribeUrl: links.pageUrl, + }) + yield* email.send(sub.email, subject, html, { headers: links.headers }) + }).pipe( Effect.tap(() => Effect.gen(function* () { const lastSentAt = yield* Clock.currentTimeMillis @@ -1251,6 +1299,7 @@ export class DigestService extends Context.Service()("@maple/api/ getSubscription, upsertSubscription, deleteSubscription, + unsubscribeByToken, // Exposed so the shape of a digest can be asserted directly rather than // through rendered HTML. generateDigestData, diff --git a/packages/backend/src/services/digest/WebAnalyticsDigestService.ts b/packages/backend/src/services/digest/WebAnalyticsDigestService.ts index 83619e3d20..e8d543fe60 100644 --- a/packages/backend/src/services/digest/WebAnalyticsDigestService.ts +++ b/packages/backend/src/services/digest/WebAnalyticsDigestService.ts @@ -12,7 +12,7 @@ import type { RoleName as RoleNameType } from "@maple/domain/http" import { AI_CRAWLERS, AI_PRODUCTS, aiProductById } from "@maple/domain/ai-traffic" import { WEB_ANALYTICS_UNSET } from "@maple/domain/query-engine" import { and, eq, inArray, isNull, lt, or } from "drizzle-orm" -import { Array as Arr, Cause, Clock, Context, Effect, Layer } from "effect" +import { Array as Arr, Cause, Clock, Context, Effect, Layer, Redacted } from "effect" import { aiProductIcon, computeDelta, @@ -43,6 +43,7 @@ import { quarantineOnConfigClassCause, } from "@maple/backend/services/warehouse/warehouse-org-quarantine" import { resolveOrgName } from "./resolve-org-name" +import { unsubscribeLinks } from "./unsubscribe-token" const DAY_MS = 24 * 60 * 60 * 1000 /** The email is a glance: three rows per list, the app has the rest. */ @@ -103,6 +104,11 @@ export class WebAnalyticsDigestService extends Context.Service - email.send(sub.email, subject, html).pipe( + Effect.gen(function* () { + const links = unsubscribeLinks(linkConfig, "web-analytics", sub.id) + const html = yield* render({ + ...props, + unsubscribeUrl: links.pageUrl, + }) + yield* email.send(sub.email, subject, html, { + headers: links.headers, + }) + }).pipe( Effect.tap(() => Clock.currentTimeMillis.pipe( Effect.flatMap((sentAt) => diff --git a/packages/backend/src/services/digest/unsubscribe-token.test.ts b/packages/backend/src/services/digest/unsubscribe-token.test.ts new file mode 100644 index 0000000000..910e89877c --- /dev/null +++ b/packages/backend/src/services/digest/unsubscribe-token.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { mintUnsubscribeToken, unsubscribeLinks, verifyUnsubscribeToken } from "./unsubscribe-token" + +const SECRET = "test-secret" +const SUB_ID = "4b7c1c2e-8f0a-4d7e-9b51-1f2a3b4c5d6e" + +describe("unsubscribe tokens", () => { + it("round-trips kind and subscription id", () => { + const token = mintUnsubscribeToken(SECRET, "web-analytics", SUB_ID) + expect(verifyUnsubscribeToken(SECRET, token)).toEqual({ + kind: "web-analytics", + subscriptionId: SUB_ID, + }) + }) + + it("rejects a token signed with another secret", () => { + expect( + verifyUnsubscribeToken("other", mintUnsubscribeToken(SECRET, "digest", SUB_ID)), + ).toBeUndefined() + }) + + it("rejects a token whose kind or id was swapped", () => { + const [, id, sig] = mintUnsubscribeToken(SECRET, "digest", SUB_ID).split(".") + expect(verifyUnsubscribeToken(SECRET, `web-analytics.${id}.${sig}`)).toBeUndefined() + expect( + verifyUnsubscribeToken(SECRET, `digest.00000000-0000-4000-8000-000000000000.${sig}`), + ).toBeUndefined() + }) + + it("rejects malformed tokens", () => { + for (const token of [ + "", + "digest", + `digest.${SUB_ID}`, + `bogus.${SUB_ID}.abc`, + `digest.${SUB_ID}.abc.d`, + ]) { + expect(verifyUnsubscribeToken(SECRET, token)).toBeUndefined() + } + }) + + it("builds a confirm page link and RFC 8058 one-click headers", () => { + const links = unsubscribeLinks( + { secret: SECRET, appBaseUrl: "https://app.test", apiBaseUrl: "https://api.test" }, + "digest", + SUB_ID, + ) + expect(links.pageUrl).toMatch(/^https:\/\/app\.test\/unsubscribe\?token=digest\./) + expect(links.headers["List-Unsubscribe"]).toMatch( + /^$/, + ) + expect(links.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click") + }) +}) diff --git a/packages/backend/src/services/digest/unsubscribe-token.ts b/packages/backend/src/services/digest/unsubscribe-token.ts new file mode 100644 index 0000000000..ae12542df5 --- /dev/null +++ b/packages/backend/src/services/digest/unsubscribe-token.ts @@ -0,0 +1,53 @@ +import { createHmac, timingSafeEqual } from "node:crypto" + +/** Which of a subscriber row's two emails a link turns off. */ +export type UnsubscribeKind = "digest" | "web-analytics" + +const KINDS: ReadonlyArray = ["digest", "web-analytics"] + +/** + * Subkey for unsubscribe links, derived from an existing deployment secret so the + * links need no new secret. The label keeps these signatures from ever colliding + * with the parent key's own HMACs. + */ +const deriveKey = (secret: string) => + createHmac("sha256", secret).update("maple/email-unsubscribe/v1").digest() + +const sign = (secret: string, kind: UnsubscribeKind, subscriptionId: string) => + createHmac("sha256", deriveKey(secret)).update(`${kind}:${subscriptionId}`).digest() + +/** `..`: no expiry, a link in an old email must keep working. */ +export const mintUnsubscribeToken = (secret: string, kind: UnsubscribeKind, subscriptionId: string) => + `${kind}.${subscriptionId}.${sign(secret, kind, subscriptionId).toString("base64url")}` + +export const verifyUnsubscribeToken = ( + secret: string, + token: string, +): { readonly kind: UnsubscribeKind; readonly subscriptionId: string } | undefined => { + const [rawKind, subscriptionId, rawSig, ...rest] = token.split(".") + const kind = KINDS.find((k) => k === rawKind) + if (kind === undefined || !subscriptionId || !rawSig || rest.length > 0) return undefined + const expected = sign(secret, kind, subscriptionId) + const actual = Buffer.from(rawSig, "base64url") + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return undefined + return { kind, subscriptionId } +} + +/** + * The footer link (a confirm page, since link scanners prefetch GETs) and the RFC 8058 + * one-click headers mail clients POST to directly. Both work without a session. + */ +export const unsubscribeLinks = ( + config: { readonly secret: string; readonly appBaseUrl: string; readonly apiBaseUrl: string }, + kind: UnsubscribeKind, + subscriptionId: string, +) => { + const token = encodeURIComponent(mintUnsubscribeToken(config.secret, kind, subscriptionId)) + return { + pageUrl: `${config.appBaseUrl}/unsubscribe?token=${token}`, + headers: { + "List-Unsubscribe": `<${config.apiBaseUrl}/api/email/unsubscribe?token=${token}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + } +} diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts index b4fde3aae6..a19817633d 100644 --- a/packages/domain/src/http/api.ts +++ b/packages/domain/src/http/api.ts @@ -2,6 +2,7 @@ import { HttpApi, OpenApi } from "effect/http-api" import { AuthApiGroup, AuthPublicApiGroup } from "./auth" import { BillingPublicApiGroup } from "./billing" import { CodeReviewApiGroup } from "./code-review" +import { EmailPublicApiGroup } from "./digest" import { ErrorsApiGroup } from "./errors" import { IntegrationsApiGroup } from "./integrations" import { OrgClickHouseSettingsApiGroup } from "./org-clickhouse-settings" @@ -17,6 +18,7 @@ export class MapleApi extends HttpApi.make("MapleApi") .add(AuthApiGroup) .add(BillingPublicApiGroup) .add(CodeReviewApiGroup) + .add(EmailPublicApiGroup) .add(ErrorsApiGroup) .add(IntegrationsApiGroup) .add(OrgClickHouseSettingsApiGroup) diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index 6f6fa8ba99..b05074914b 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -85,6 +85,41 @@ export class DigestRenderError extends Schema.TaggedError()( { httpApiStatus: 500 }, ) {} +export class DigestUnsubscribeTokenInvalidError extends Schema.TaggedError()( + "@maple/http/errors/DigestUnsubscribeTokenInvalidError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + +export const EmailUnsubscribeKind = Schema.Literals(["digest", "web-analytics"]) + +const EmailUnsubscribeQuery = Schema.Struct({ + token: Schema.String.check(Schema.isMaxLength(256)), +}) + +export class EmailUnsubscribeResponse extends Schema.Class( + "EmailUnsubscribeResponse", +)({ + kind: EmailUnsubscribeKind, +}) {} + +/** + * Unauthenticated: the signed token in an email's unsubscribe link is the credential. + * Mail clients POST here directly for RFC 8058 one-click (`List-Unsubscribe-Post`), + * and the web `/unsubscribe` confirm page calls it too. + */ +export class EmailPublicApiGroup extends HttpApiGroup.make("emailPublic") + .add( + HttpApiEndpoint.post("unsubscribe", "/unsubscribe", { + query: EmailUnsubscribeQuery, + success: EmailUnsubscribeResponse, + error: [DigestUnsubscribeTokenInvalidError, DigestPersistenceError], + }), + ) + .prefix("/api/email") {} + export class DigestApiGroup extends HttpApiGroup.make("digest") .add( HttpApiEndpoint.get("getSubscription", "/", { diff --git a/packages/email/src/samples.ts b/packages/email/src/samples.ts index 8e507b98a7..f79822ca64 100644 --- a/packages/email/src/samples.ts +++ b/packages/email/src/samples.ts @@ -154,7 +154,7 @@ export const healthyDigestProps: WeeklyDigestProps = { }, baseUrl: "https://app.maple.dev", dashboardUrl: "https://app.maple.dev", - unsubscribeUrl: "https://app.maple.dev/settings/notifications", + unsubscribeUrl: "https://app.maple.dev/settings?tab=notifications", } /** Elevated error rate, errors and latency climbing week over week. */ @@ -475,7 +475,7 @@ export const webAnalyticsDigestProps: WebAnalyticsDigestProps = { analyticsUrl: "https://app.maple.dev/analytics?startTime=2026-09-21+00%3A00%3A00&endTime=2026-09-27+23%3A59%3A59", aiUrl: "https://app.maple.dev/analytics?startTime=2026-09-21+00%3A00%3A00&endTime=2026-09-27+23%3A59%3A59&tab=ai", - unsubscribeUrl: "https://app.maple.dev/settings/notifications", + unsubscribeUrl: "https://app.maple.dev/settings?tab=notifications", } /** A small site with no AI traffic and a warehouse without the crawler table. */ diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index 57378a58d8..39d635462b 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -116,6 +116,11 @@ export const appUrlsEnv = (domains: MapleDomains = {}): Config.Config merge( plainWithDefault("MAPLE_INGEST_PUBLIC_URL", `https://${domains.ingest ?? "ingest.maple.dev"}`), plainWithDefault("MAPLE_APP_BASE_URL", `https://${domains.web ?? "app.maple.dev"}`), + // Canonical API origin for self-published URLs (MCP `server.json`, email one-click + // unsubscribe), never forwarded headers. + domains.api + ? derived("MAPLE_API_BASE_URL", `https://${domains.api}`) + : plainWithDefault("MAPLE_API_BASE_URL", "https://api.maple.dev"), plainWithDefault("EMAIL_FROM", "Maple "), ) From 8daf33f066372b249be7eda7794757962881d84f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:02:20 +0200 Subject: [PATCH 2/6] fix(web): let /unsubscribe skip the plan and region gates, reset per 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. --- apps/web/src/lib/public-routes.test.ts | 12 +++++++++++- apps/web/src/lib/public-routes.ts | 10 ++++++++++ apps/web/src/routes/__root.tsx | 10 +++++----- apps/web/src/routes/unsubscribe.tsx | 7 ++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/public-routes.test.ts b/apps/web/src/lib/public-routes.test.ts index 27ae1b49e1..ebe4ffeb38 100644 --- a/apps/web/src/lib/public-routes.test.ts +++ b/apps/web/src/lib/public-routes.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" import { LAB_ENTRIES } from "@/lab/registry" -import { isChromelessPath, isPublicPath } from "./public-routes" +import { isChromelessPath, isOrgIndependentPath, isPublicPath } from "./public-routes" const read = (relative: string) => readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8") @@ -52,6 +52,16 @@ describe("isPublicPath", () => { }) }) +describe("isOrgIndependentPath", () => { + it("lets an email recipient reach /unsubscribe past the plan and region gates", () => { + expect(isPublicPath("/unsubscribe")).toBe(true) + expect(isOrgIndependentPath("/unsubscribe")).toBe(true) + // Public, but the post-auth redirects still apply to it. + expect(isOrgIndependentPath("/sign-in")).toBe(false) + expect(isOrgIndependentPath("/share/abc")).toBe(false) + }) +}) + describe("the auth gate has exactly one source of truth", () => { // These two files each carried their own copy of the public-path list, and // they had already drifted — main.tsx was missing four entries. Asserted diff --git a/apps/web/src/lib/public-routes.ts b/apps/web/src/lib/public-routes.ts index f7034d11d4..ffdade3fa8 100644 --- a/apps/web/src/lib/public-routes.ts +++ b/apps/web/src/lib/public-routes.ts @@ -31,6 +31,16 @@ const EXACT_PUBLIC_PATHS = new Set(["/sign-in", "/sign-up", "/org-required", "/u */ const PUBLIC_PREFIXES = ["/share/"] +/** + * Public paths that never read org data, so a signed-in reader skips the region + * and plan gates too: an email recipient whose org has no plan must still reach them. + */ +const ORG_INDEPENDENT_PATHS = new Set(["/unsubscribe"]) + +export function isOrgIndependentPath(pathname: string): boolean { + return isFixturePath(pathname) || ORG_INDEPENDENT_PATHS.has(pathname) +} + export function isPublicPath(pathname: string): boolean { if (EXACT_PUBLIC_PATHS.has(pathname)) return true if (isFixturePath(pathname)) return true diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index acbca1aa4e..ff23f5500a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,7 +14,7 @@ import { import { selectedPlanKnownAtomFor } from "@/atoms/selected-plan-atoms" import { useAtom } from "@/lib/effect-atom" import { hasSelectedPlan, resolvePlanAccess } from "@/lib/billing/plan-gating" -import { isFixturePath, isPublicPath } from "@/lib/public-routes" +import { isOrgIndependentPath, isPublicPath } from "@/lib/public-routes" import { parseRedirectUrl } from "@/lib/redirect-utils" import { AnchoredToastProvider, ToastProvider } from "@maple/ui/components/ui/toast" import { AttributesProvider } from "@maple/ui/components/attributes/context" @@ -201,10 +201,10 @@ function ClerkReverseRedirects() { return } - // A fixture surface has no org-scoped data to gate, so it renders whatever the - // plan query is doing. Checked after the auth-page redirects above, which are - // about sending a signed-in reader somewhere better rather than gating them. - if (isFixturePath(pathname)) { + // A fixture surface (or the email unsubscribe page) has no org-scoped data to + // gate, so it renders whatever the plan query is doing. Checked after the + // auth-page redirects above, which send a signed-in reader somewhere better. + if (isOrgIndependentPath(pathname)) { return } diff --git a/apps/web/src/routes/unsubscribe.tsx b/apps/web/src/routes/unsubscribe.tsx index b6a5c83e84..6694ab112a 100644 --- a/apps/web/src/routes/unsubscribe.tsx +++ b/apps/web/src/routes/unsubscribe.tsx @@ -32,7 +32,6 @@ type State = { kind: "idle" } | { kind: "pending" } | { kind: "done" } | { kind: function UnsubscribePage() { const { token } = Route.useSearch() - const [state, setState] = useState({ kind: "idle" }) if (!token) { return ( @@ -45,6 +44,12 @@ function UnsubscribePage() { ) } + // Keyed so a same-route navigation to another email's link starts fresh. + return +} + +function UnsubscribeConfirm({ token }: { token: string }) { + const [state, setState] = useState({ kind: "idle" }) const label = labelForToken(token) const unsubscribe = async () => { From 82a8c69995c6acadbfe04d2bd3989eb58eb9710d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:07:14 +0200 Subject: [PATCH 3/6] fix(api): keep unsubscribe tokens off the server span 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. --- apps/api/src/http/api-observability.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/api/src/http/api-observability.ts b/apps/api/src/http/api-observability.ts index ac6814a9ef..6072522560 100644 --- a/apps/api/src/http/api-observability.ts +++ b/apps/api/src/http/api-observability.ts @@ -16,6 +16,10 @@ import { Headers, HttpMiddleware } from "effect/http" const OAUTH_CALLBACK_PATH = /^(?:\/api\/integrations\/[^/]+\/callback|\/oauth\/chat\/[^/]+(?:\/identity)?\/callback)(?:\?|$)/ +// The email unsubscribe link's `token` query is a signed, non-expiring credential +// for opting a subscriber out; `DigestService.unsubscribeByToken` owns the span. +const EMAIL_UNSUBSCRIBE_PATH = /^\/api\/email\/unsubscribe(?:\?|$)/ + // The `TracerDisabledWhen` filter and the header-redaction list — both // references `HttpMiddleware.tracer` reads regardless of which Tracer is // active. The Worker registers this layer with alchemy's `Telemetry.layer`, so @@ -28,6 +32,7 @@ export const ApiObservabilityLive = Layer.mergeAll( request.url === "/health" || request.method === "OPTIONS" || OAUTH_CALLBACK_PATH.test(request.url) || + EMAIL_UNSUBSCRIBE_PATH.test(request.url) || /\.(png|ico|jpg|jpeg|gif|css|js|svg|webp|woff2?)(\?.*)?$/i.test(request.url), ), // Every request header lands on the server span as `http.request.header.`. From 33a0d6e08f7b1c728a5787b562e7e1d67841ada6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:12:43 +0200 Subject: [PATCH 4/6] fix(api): give the unsubscribe endpoint its own server span 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. --- apps/api/src/routes/v1/email-public.http.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/v1/email-public.http.ts b/apps/api/src/routes/v1/email-public.http.ts index d4d17c4830..5b12e7cffc 100644 --- a/apps/api/src/routes/v1/email-public.http.ts +++ b/apps/api/src/routes/v1/email-public.http.ts @@ -9,6 +9,24 @@ export const HttpEmailPublicLive = HttpApiBuilder.group(MapleApi, "emailPublic", Effect.gen(function* () { const digest = yield* DigestService - return handlers.handle("unsubscribe", ({ query }) => digest.unsubscribeByToken(query.token)) + // Server-kind with the HTTP identity stamped by hand: the auto server span is + // suppressed for this path (it would record the token in `url.query`, see + // ApiObservabilityLive), so this span is the request's trace root. + const unsubscribe = Effect.fn("email.unsubscribe", { + kind: "server", + attributes: { "http.route": "/api/email/unsubscribe", "http.request.method": "POST" }, + })(function* (token: string) { + return yield* digest.unsubscribeByToken(token).pipe( + Effect.tap(() => Effect.annotateCurrentSpan("http.response.status_code", 200)), + Effect.tapError((error) => + Effect.annotateCurrentSpan( + "http.response.status_code", + error._tag === "@maple/http/errors/DigestUnsubscribeTokenInvalidError" ? 400 : 503, + ), + ), + ) + }) + + return handlers.handle("unsubscribe", ({ query }) => unsubscribe(query.token)) }), ) From f0c6b6e0f5b382a1cb25fb8055dd04f3803a632b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:17:16 +0200 Subject: [PATCH 5/6] chore(domain): mark the invalid unsubscribe token error as anticipated A forged or stale link is a 400, so its span exports as Ok instead of opening an error issue. --- packages/domain/src/generated/anticipated-error-identifiers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index cf549d34f9..d99a76c1a4 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -39,6 +39,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/DashboardValidationError", "@maple/http/errors/DashboardVersionNotFoundError", "@maple/http/errors/DigestNotFoundError", + "@maple/http/errors/DigestUnsubscribeTokenInvalidError", "@maple/http/errors/ErrorForbiddenError", "@maple/http/errors/ErrorIssueLeaseConflictError", "@maple/http/errors/ErrorIssueNotFoundError", From 23fc5dbc5a9022ec6713639c8d5a44bc2d96ad7d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:19:21 +0200 Subject: [PATCH 6/6] test(infra): cover appUrlsEnv's MAPLE_API_BASE_URL branches --- packages/infra/src/env.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index f6374413a4..f576c69d89 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -139,6 +139,17 @@ describe("appUrlsEnv", () => { "https://app.example.test", ) }) + + it("derives the API origin from the deploy's API host, so email links reach the right region", () => { + const env = { MAPLE_API_BASE_URL: "https://api.example.test" } + // A deploy's own API host wins over the provider, like MAPLE_ENVIRONMENT. + expect(run(appUrlsEnv({ api: "api.eu.maple.dev" }), env).MAPLE_API_BASE_URL).toBe( + "https://api.eu.maple.dev", + ) + // Without a host the provider may override, else production's. + expect(run(appUrlsEnv({}), env).MAPLE_API_BASE_URL).toBe("https://api.example.test") + expect(run(appUrlsEnv({}), {}).MAPLE_API_BASE_URL).toBe("https://api.maple.dev") + }) }) describe("selfObservabilityEnv", () => {