diff --git a/apps/web/package.json b/apps/web/package.json index 46e6a3f3..4cc2ab49 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@lingui/conf": "^5.3.2", "@lingui/macro": "^5.3.2", "@lingui/react": "^5.3.2", + "@novu/api": "^3.11.0", "@t3-oss/env-nextjs": "^0.11.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "catalog:", @@ -46,6 +47,7 @@ "aws-sdk": "^2.1692.0", "date-fns": "^4.1.0", "geist": "^1.3.1", + "jose": "^6.1.2", "next": "^15.3.4", "next-logger": "^5.0.1", "next-runtime-env": "^1.7.2", diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index e4f00149..5404519f 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -51,6 +51,8 @@ export const env = createEnv({ VK_CLIENT_SECRET: z.string().optional(), LINKEDIN_CLIENT_ID: z.string().optional(), LINKEDIN_CLIENT_SECRET: z.string().optional(), + NOVU_API_KEY: z.string().optional(), + EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(), // Generic OIDC Provider OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(), diff --git a/apps/web/src/pages/api/unsubscribe.ts b/apps/web/src/pages/api/unsubscribe.ts new file mode 100644 index 00000000..1631266c --- /dev/null +++ b/apps/web/src/pages/api/unsubscribe.ts @@ -0,0 +1,104 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { Novu } from "@novu/api"; +import { jwtVerify } from "jose"; +import { z } from "zod"; + +import { env } from "~/env"; + +const requestSchema = z.object({ + token: z.string().min(1), +}); + +const tokenPayloadSchema = z.object({ + subscriberId: z.string(), +}); + +type ResponseData = + | { success: true } + | { success: false; error: string; code?: string }; + +const textEncoder = new TextEncoder(); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") { + return res.status(404).json({ + success: false, + error: "Unsubscribe endpoint is not available.", + code: "UNAVAILABLE", + }); + } + + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ + success: false, + error: "Method not allowed.", + code: "METHOD_NOT_ALLOWED", + }); + } + + const parsedBody = requestSchema.safeParse(req.body); + + if (!parsedBody.success) { + return res.status(400).json({ + success: false, + error: "Invalid request payload.", + code: "BAD_REQUEST", + }); + } + + if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) { + return res.status(500).json({ + success: false, + error: "Unsubscribe service is not configured.", + code: "NOT_CONFIGURED", + }); + } + + let payload: z.infer; + + try { + const verified = await jwtVerify( + parsedBody.data.token, + textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET), + { + // We intentionally do not use exp/iat claims – + // tokens are long-lived and validated only by signature + payload. + clockTolerance: "0s", + }, + ); + payload = tokenPayloadSchema.parse(verified.payload); + } catch { + return res.status(401).json({ + success: false, + error: "Your unsubscribe link is invalid or has expired.", + code: "INVALID_TOKEN", + }); + } + + const novu = new Novu({ secretKey: env.NOVU_API_KEY }); + + try { + await novu.subscribers.preferences.update( + { + channels: { + email: false, + }, + }, + payload.subscriberId, + ); + } catch (error) { + console.error("Failed to update Novu preferences", error); + return res.status(502).json({ + success: false, + error: + "We could not update your email preferences right now. Please try again later.", + code: "NOVU_ERROR", + }); + } + + return res.status(200).json({ success: true }); +} diff --git a/apps/web/src/pages/unsubscribe/index.tsx b/apps/web/src/pages/unsubscribe/index.tsx new file mode 100644 index 00000000..1d75581d --- /dev/null +++ b/apps/web/src/pages/unsubscribe/index.tsx @@ -0,0 +1,110 @@ +import { useRouter } from "next/router"; +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; + +import Button from "~/components/Button"; +import { PageHead } from "~/components/PageHead"; +import PatternedBackground from "~/components/PatternedBackground"; + +type UnsubscribeStatus = "idle" | "processing" | "success" | "error"; + +export default function UnsubscribePage() { + const router = useRouter(); + const [token, setToken] = useState(""); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!router.isReady) return; + const value = router.query.token; + if (typeof value === "string") { + setToken(value); + } else if (Array.isArray(value)) { + setToken(value[0] ?? ""); + } else { + setToken(""); + } + }, [router.isReady, router.query.token]); + + const handleUnsubscribe = async () => { + if (!token) { + setStatus("error"); + setErrorMessage( + t`Your unsubscribe link is missing a token. Please open the latest email and try again.`, + ); + return; + } + + setStatus("processing"); + setErrorMessage(null); + + try { + const response = await fetch("/api/unsubscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; + + throw new Error( + payload?.error ?? + "We couldn't update your preferences. Please try again.", + ); + } + + setStatus("success"); + } catch (error) { + setStatus("error"); + setErrorMessage( + t`We couldn't update your preferences. Please try again.`, + ); + } + }; + + const title = t`Unsubscribe`; + + return ( + <> + +
+ +
+
+

+ {t`Do you want to unsubscribe?`} +

+

+ {t`Confirm your email preferences:`} +

+
+ +
+ +
+ {status === "success" && ( +

+ {t`You have been unsubscribed!`} +

+ )} + {status === "error" && ( +

+ {errorMessage} +

+ )} +
+
+ + ); +} diff --git a/cloud/docker-compose.yml b/cloud/docker-compose.yml index 64fd155f..d13205ea 100644 --- a/cloud/docker-compose.yml +++ b/cloud/docker-compose.yml @@ -39,6 +39,7 @@ services: # Notifications - NOVU_API_KEY=${NOVU_API_KEY} - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL} + - EMAIL_UNSUBSCRIBE_SECRET=${EMAIL_UNSUBSCRIBE_SECRET} # S3 storage - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 36ae442c..c6b737eb 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -18,6 +18,7 @@ import * as userRepo from "@kan/db/repository/user.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as schema from "@kan/db/schema"; import { notificationClient, sendEmail } from "@kan/email"; +import { createEmailUnsubscribeLink } from "@kan/shared"; import { createStripeClient } from "@kan/stripe"; export const configuredProviders = socialProviderList.reduce< @@ -401,6 +402,10 @@ export const initAuth = (db: dbClient) => { ? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}` : undefined; + const unsubscribeUrl = await createEmailUnsubscribeLink( + user.id, + ); + await notificationClient.trigger({ to: { subscriberId: user.id, @@ -415,6 +420,9 @@ export const initAuth = (db: dbClient) => { updatedAt: user.updatedAt, }, }, + payload: { + emailUnsubscribeUrl: unsubscribeUrl, + }, workflowId: "user-signup", }); @@ -489,6 +497,8 @@ async function triggerWorkflow( if (!user || !notificationClient) return; + const unsubscribeUrl = await createEmailUnsubscribeLink(user.id); + await notificationClient.trigger({ to: { subscriberId: user.id, @@ -496,6 +506,7 @@ async function triggerWorkflow( payload: { ...subscription, cancellationDetails, + emailUnsubscribeUrl: unsubscribeUrl, }, workflowId, }); diff --git a/packages/shared/src/utils/email.ts b/packages/shared/src/utils/email.ts new file mode 100644 index 00000000..78666655 --- /dev/null +++ b/packages/shared/src/utils/email.ts @@ -0,0 +1,31 @@ +import { SignJWT } from "jose"; + +const encoder = new TextEncoder(); + +/** + * Creates a long‑lived unsubscribe link for a given user/subscriber. + * + * `${NEXT_PUBLIC_BASE_URL}/unsubscribe?token=` + * + * The JWT payload only contains the subscriberId. There is no expiry + * on purpose – unsubscribe links should remain valid indefinitely. + * + */ +export async function createEmailUnsubscribeLink( + userId: string, +): Promise { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; + const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET; + + if (!baseUrl || !secret) { + // Environment not configured for unsubscribe links. + return null; + } + + const token = await new SignJWT({ subscriberId: userId }) + .setProtectedHeader({ alg: "HS256" }) + // No expiration on purpose; unsubscribe links are long‑lived. + .sign(encoder.encode(secret)); + + return `${baseUrl}/unsubscribe?token=${encodeURIComponent(token)}`; +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 87404978..4d1f8470 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./generateUID"; export * from "./generateSlug"; export * from "./subscriptions"; +export * from "./email"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81cf710e..c6d9bbe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@lingui/react': specifier: ^5.3.2 version: 5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))(react@18.3.1) + '@novu/api': + specifier: ^3.11.0 + version: 3.11.0 '@t3-oss/env-nextjs': specifier: ^0.11.1 version: 0.11.1(typescript@5.9.2)(zod@3.25.76) @@ -166,6 +169,9 @@ importers: geist: specifier: ^1.3.1 version: 1.4.2(next@15.5.2(@babel/core@7.28.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + jose: + specifier: ^6.1.2 + version: 6.1.2 next: specifier: ^15.3.4 version: 15.5.2(@babel/core@7.28.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5554,6 +5560,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.2: + resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -13649,6 +13658,8 @@ snapshots: jose@5.10.0: {} + jose@6.1.2: {} + joycon@3.1.1: {} js-beautify@1.15.4: diff --git a/turbo.json b/turbo.json index ee293875..e0a56510 100644 --- a/turbo.json +++ b/turbo.json @@ -121,7 +121,8 @@ "PORT", "BETTER_AUTH_SECRET", "BETTER_AUTH_TRUSTED_ORIGINS", - "NOVU_API_KEY" + "NOVU_API_KEY", + "EMAIL_UNSUBSCRIBE_SECRET" ], "globalPassThroughEnv": [ "NODE_ENV",