diff --git a/apps/api/src/http/api-observability.ts b/apps/api/src/http/api-observability.ts index ac6814a9e..607252256 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.`. diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 9064cdd31..20fb5cb5e 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 000000000..5b12e7cff --- /dev/null +++ b/apps/api/src/routes/v1/email-public.http.ts @@ -0,0 +1,32 @@ +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 + + // 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)) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index e964a9ea9..a4c10c7c8 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.test.ts b/apps/web/src/lib/public-routes.test.ts index 27ae1b49e..ebe4ffeb3 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 ae22e7544..ffdade3fa 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. @@ -31,6 +31,16 @@ const EXACT_PUBLIC_PATHS = new Set(["/sign-in", "/sign-up", "/org-required"]) */ 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/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 8fe396989..5e14e1383 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/__root.tsx b/apps/web/src/routes/__root.tsx index acbca1aa4..ff23f5500 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 new file mode 100644 index 000000000..6694ab112 --- /dev/null +++ b/apps/web/src/routes/unsubscribe.tsx @@ -0,0 +1,103 @@ +/** + * 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() + + if (!token) { + return ( + +

Invalid unsubscribe link

+

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

+
+ ) + } + + // 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 () => { + 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 030b7cdc3..ad1ff722e 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 529d2a615..189bd8403 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 79a5cc8cf..c49850224 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 efa80a22b..d1ed8918e 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 3a908a29f..de8948ce3 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 83619e3d2..e8d543fe6 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 000000000..910e89877 --- /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 000000000..ae12542df --- /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/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index cf549d34f..d99a76c1a 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", diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts index b4fde3aae..a19817633 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 6f6fa8ba9..b05074914 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 8e507b98a..f79822ca6 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.test.ts b/packages/infra/src/env.test.ts index f6374413a..f576c69d8 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", () => { diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index 57378a58d..39d635462 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 "), )