Repository navigation
feat(email): unsubscribe from digest emails without signing in #1262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1c2f222
feat(email): unsubscribe from digest emails without signing in
Makisuo 8daf33f
fix(web): let /unsubscribe skip the plan and region gates, reset per …
Makisuo 82a8c69
fix(api): keep unsubscribe tokens off the server span
Makisuo 33a0d6e
fix(api): give the unsubscribe endpoint its own server span
Makisuo f0c6b6e
chore(domain): mark the invalid unsubscribe token error as anticipated
Makisuo 23fc5db
test(infra): cover appUrlsEnv's MAPLE_API_BASE_URL branches
Makisuo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| }), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.