Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/http/api-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ||
Comment thread
Makisuo marked this conversation as resolved.
/\.(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.<name>`.
Expand Down
5 changes: 0 additions & 5 deletions apps/api/src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
appUrlsEnv,
authEnv,
cloudflareOAuthEnv,
derived,
githubAppSourceEnv,
ingestKeyCryptoEnv,
merge,
Expand Down Expand Up @@ -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"),
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/routes/v1/email-public.http.ts
Original file line number Diff line number Diff line change
@@ -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))
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/lib/public-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/lib/public-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Comment thread
Makisuo marked this conversation as resolved.

/**
* Prefixes whose entire subtree is public.
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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/',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -977,6 +986,7 @@ export interface FileRouteTypes {
| '/settings'
| '/sign-in'
| '/sign-up'
| '/unsubscribe'
| '/agent-sessions/$sessionId'
| '/alerts/$ruleId'
| '/alerts/create'
Expand Down Expand Up @@ -1081,6 +1091,7 @@ export interface FileRouteTypes {
| '/settings'
| '/sign-in'
| '/sign-up'
| '/unsubscribe'
| '/agent-sessions/$sessionId'
| '/alerts/$ruleId'
| '/alerts/create'
Expand Down Expand Up @@ -1186,6 +1197,7 @@ export interface FileRouteTypes {
| '/settings'
| '/sign-in'
| '/sign-up'
| '/unsubscribe'
| '/agent-sessions/$sessionId'
| '/alerts/$ruleId'
| '/alerts/create'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -2160,6 +2180,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsRoute: SettingsRoute,
SignInRoute: SignInRoute,
SignUpRoute: SignUpRoute,
UnsubscribeRoute: UnsubscribeRoute,
AgentSessionsSessionIdRoute: AgentSessionsSessionIdRoute,
AlertsRuleIdRoute: AlertsRuleIdRoute,
AlertsCreateRoute: AlertsCreateRoute,
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -201,10 +201,10 @@ function ClerkReverseRedirects() {
return <Navigate to={target.pathname} search={target.search} replace />
}

// 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 <AppFrame />
}

Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/routes/unsubscribe.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthLayout maxWidth="max-w-md">
<h1 className="text-xl font-semibold">Invalid unsubscribe link</h1>
<p className="mt-2 text-sm text-muted-foreground">
This link is incomplete. Use the unsubscribe link from the email itself.
</p>
</AuthLayout>
)
}

// Keyed so a same-route navigation to another email's link starts fresh.
return <UnsubscribeConfirm key={token} token={token} />
}

function UnsubscribeConfirm({ token }: { token: string }) {
const [state, setState] = useState<State>({ 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 (
<AuthLayout maxWidth="max-w-md">
<h1 className="text-xl font-semibold">You're unsubscribed</h1>
<p className="mt-2 text-sm text-muted-foreground">
You won't receive {label} anymore. You can turn it back on any time in your notification
settings.
</p>
<div className="mt-4">
<Button
variant="outline"
render={<Link to="/settings" search={{ tab: "notifications" }} />}
>
Notification settings
</Button>
</div>
</AuthLayout>
)
}

return (
<AuthLayout maxWidth="max-w-md">
<h1 className="text-xl font-semibold">Unsubscribe</h1>
<p className="mt-2 text-sm text-muted-foreground">Stop receiving {label} from Maple?</p>
{state.kind === "error" && <p className="mt-3 text-sm text-destructive">{state.message}</p>}
<div className="mt-4">
<Button onClick={unsubscribe} disabled={state.kind === "pending"}>
{state.kind === "pending" ? "Unsubscribing..." : "Unsubscribe"}
</Button>
</div>
</AuthLayout>
)
}
Loading
Loading