diff --git a/apps/blog/src/app/api/newsletter/route.ts b/apps/blog/src/app/api/newsletter/route.ts index eaf09ff3bc..4634fca86c 100644 --- a/apps/blog/src/app/api/newsletter/route.ts +++ b/apps/blog/src/app/api/newsletter/route.ts @@ -1,175 +1,10 @@ -import { NextResponse } from "next/server"; +import { createNewsletterRoute } from "@prisma-docs/ui/lib/newsletter-route"; export const dynamic = "force-dynamic"; -const allowedOrigins = new Set(["https://prisma.io", "https://www.prisma.io"]); +const route = createNewsletterRoute({ + allowedOrigins: ["https://prisma.io", "https://www.prisma.io"], + source: "blog", +}); -function getCorsHeaders(request: Request) { - const origin = request.headers.get("origin") ?? ""; - const allowOrigin = allowedOrigins.has(origin) ? origin : "https://prisma.io"; - - return { - "Access-Control-Allow-Origin": allowOrigin, - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - Vary: "Origin", - }; -} - -export async function OPTIONS(request: Request) { - return NextResponse.json({}, { headers: getCorsHeaders(request), status: 200 }); -} - -export async function POST(request: Request) { - const corsHeaders = getCorsHeaders(request); - - try { - const body = await request.json(); - const { email } = body; - - if (!email || typeof email !== "string") { - return NextResponse.json( - { error: "Email is required" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return NextResponse.json( - { error: "Invalid email address" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Check for required environment variable - const brevoApiKey = process.env.BREVO_API_KEY; - if (!brevoApiKey) { - console.error("Missing Brevo API key"); - return NextResponse.json( - { error: "Newsletter service is not configured" }, - { status: 500, headers: corsHeaders }, - ); - } - - const options = { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - "api-key": brevoApiKey, - }, - body: JSON.stringify({ - email: email, - attributes: { - EMAIL: email, - SOURCE: "website", - }, - includeListIds: [15], - templateId: 36, - redirectionUrl: "https://prisma.io", - }), - }; - - const response = await fetch( - "https://api.brevo.com/v3/contacts/doubleOptinConfirmation", - options, - ); - - // Get response text first to check if it's empty - const responseText = await response.text(); - - let data: any = null; - - // Only try to parse JSON if there's actual content - if (responseText && responseText.length > 0) { - try { - data = JSON.parse(responseText); - } catch (parseError) { - console.error("Failed to parse Brevo response:", { - text: responseText, - status: response.status, - parseError, - }); - - // If response was successful but JSON parse failed, treat as success - if (response.ok) { - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { error: "Invalid response from newsletter service" }, - { status: 500, headers: corsHeaders }, - ); - } - } - - // Handle error responses - if (!response.ok) { - console.error("Brevo error:", { - status: response.status, - statusText: response.statusText, - data, - email, - }); - - // Handle specific Brevo errors - if (data?.code === "duplicate_parameter" || data?.message?.includes("already exists")) { - return NextResponse.json( - { message: "Already subscribed", alreadySubscribed: true }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { - error: data?.message || "Failed to subscribe. Please try again later.", - debug: - process.env.NODE_ENV === "development" - ? { - status: response.status, - statusText: response.statusText, - brevoError: data, - responseText, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } - - // Success - Brevo may return empty body on success - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } catch (error) { - console.error("Newsletter subscription error:", error); - const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; - - return NextResponse.json( - { - error: errorMessage, - debug: - process.env.NODE_ENV === "development" - ? { - errorType: error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } -} - -export async function GET(request: Request) { - return NextResponse.json( - { error: "Method Not Allowed" }, - { status: 405, headers: { ...getCorsHeaders(request), Allow: "POST, OPTIONS" } }, - ); -} +export const { GET, OPTIONS, POST } = route; diff --git a/apps/docs/src/app/api/newsletter/README.md b/apps/docs/src/app/api/newsletter/README.md index c6f11d56f4..c0c7f26479 100644 --- a/apps/docs/src/app/api/newsletter/README.md +++ b/apps/docs/src/app/api/newsletter/README.md @@ -1,6 +1,7 @@ # Newsletter API -This API endpoint handles newsletter subscriptions via Brevo (formerly Sendinblue) with double opt-in. +This API endpoint subscribes public website visitors to the Prisma newsletter through Brevo. +Subscription is immediate and does not require an email confirmation. ## Setup @@ -13,9 +14,9 @@ This API endpoint handles newsletter subscriptions via Brevo (formerly Sendinblu ### 2. Configure Brevo List and Template -1. Go to **Contacts** → **Lists** and note your list ID (default is `15`) -2. Go to **Campaigns** → **Templates** and create a double opt-in template -3. Note your template ID (default is `36`) +1. In **Contacts** → **Lists**, verify that the shared helper's newsletter list ID is `15` +2. In **Transactional** → **Templates**, verify that welcome template `228` is active +3. These are verification steps; the shared server helper owns both IDs ### 3. Environment Variables @@ -59,7 +60,7 @@ export default function Page() { ### Response Codes -- **200**: Successfully added to list (confirmation email sent) or already subscribed +- **200**: Successfully subscribed (welcome email requested) or already subscribed - **400**: Invalid email or missing email - **500**: Server error or missing configuration @@ -68,7 +69,7 @@ export default function Page() { **Success (200)** ```json { - "message": "Please check your email to confirm subscription" + "message": "Subscribed to the Prisma newsletter" } ``` @@ -83,7 +84,7 @@ export default function Page() { **Error (400)** ```json { - "error": "Invalid email address" + "error": "A valid email address is required" } ``` @@ -94,16 +95,17 @@ export default function Page() { } ``` -## Double Opt-In +## Subscription Flow -This implementation uses Brevo's double opt-in feature: +Public website subscriptions use this flow: -1. User submits their email -2. Brevo sends a confirmation email using the configured template -3. User clicks the confirmation link -4. Subscription is confirmed and user is redirected to https://prisma.io +1. The route looks up the contact in Brevo. +2. New or unsubscribed contacts are added to newsletter list `15` immediately. +3. The route sends the newsletter welcome template once when the contact enters the list. +4. Existing list members receive no additional welcome email. -This ensures compliance with GDPR and other privacy regulations. +Each route supplies a fixed `NEWSLETTER_SOURCE` (`website`, `blog`, or `docs`). Console signup uses +`console-signup` in a separate silent list-sync path and never calls the welcome-email helper. ## Troubleshooting @@ -115,29 +117,10 @@ Check that the `BREVO_API_KEY` environment variable is set correctly. Check the server logs for detailed error messages from Brevo. Common issues: - Invalid API key -- Incorrect list ID (update line 60 in route.ts if different from `15`) -- Incorrect template ID (update line 61 in route.ts if different from `36`) +- Incorrect list ID in the shared newsletter helper +- Inactive or incorrect welcome template ID in the shared newsletter helper - Brevo API rate limits -### Development Mode Debug Info - -In development mode (`NODE_ENV=development`), the API will return additional debug information in the error response: - -```json -{ - "error": "Failed to subscribe. Please try again later.", - "debug": { - "status": 400, - "brevoError": { - "code": "invalid_parameter", - "message": "..." - } - } -} -``` - -Check the browser console for "API Error Debug:" logs. - ### Testing Locally Create a `.env.local` file in the app root: @@ -150,19 +133,13 @@ Restart your development server after adding environment variables. ## Customization -To customize the list ID or template ID, edit the API route: - -```typescript -// In route.ts, around line 60-61 -includeListIds: [15], // Change to your list ID -templateId: 36, // Change to your template ID -``` +List membership, source attribution, one-time welcome dispatch, and template selection live in +`packages/ui/src/lib/newsletter-subscription.ts`. ## CORS Configuration The API is configured to allow requests from: - https://prisma.io - https://www.prisma.io -- https://prisma.io/docs -To add more origins, update the `corsHeaders` in `route.ts`. \ No newline at end of file +To add more origins, update the route's `allowedOrigins`. diff --git a/apps/docs/src/app/api/newsletter/route.ts b/apps/docs/src/app/api/newsletter/route.ts index 54db479aff..66c27bf3da 100644 --- a/apps/docs/src/app/api/newsletter/route.ts +++ b/apps/docs/src/app/api/newsletter/route.ts @@ -1,166 +1,10 @@ -import { NextResponse } from "next/server"; +import { createNewsletterRoute } from "@prisma-docs/ui/lib/newsletter-route"; export const dynamic = "force-dynamic"; -// CORS headers configuration -const corsHeaders = { - "Access-Control-Allow-Origin": "https://prisma.io, https://www.prisma.io, https://prisma.io/docs", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -}; +const route = createNewsletterRoute({ + allowedOrigins: ["https://prisma.io", "https://www.prisma.io"], + source: "docs", +}); -export async function OPTIONS() { - return NextResponse.json({}, { headers: corsHeaders, status: 200 }); -} - -export async function POST(request: Request) { - try { - const body = await request.json(); - const { email } = body; - - if (!email || typeof email !== "string") { - return NextResponse.json( - { error: "Email is required" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return NextResponse.json( - { error: "Invalid email address" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Check for required environment variable - const brevoApiKey = process.env.BREVO_API_KEY; - if (!brevoApiKey) { - console.error("Missing Brevo API key"); - return NextResponse.json( - { error: "Newsletter service is not configured" }, - { status: 500, headers: corsHeaders }, - ); - } - - const options = { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - "api-key": brevoApiKey, - }, - body: JSON.stringify({ - email: email, - attributes: { - EMAIL: email, - SOURCE: "website", - }, - includeListIds: [15], - templateId: 36, - redirectionUrl: "https://prisma.io", - }), - }; - - const response = await fetch( - "https://api.brevo.com/v3/contacts/doubleOptinConfirmation", - options, - ); - - // Get response text first to check if it's empty - const responseText = await response.text(); - - let data: any = null; - - // Only try to parse JSON if there's actual content - if (responseText && responseText.length > 0) { - try { - data = JSON.parse(responseText); - } catch (parseError) { - console.error("Failed to parse Brevo response:", { - text: responseText, - status: response.status, - parseError, - }); - - // If response was successful but JSON parse failed, treat as success - if (response.ok) { - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { error: "Invalid response from newsletter service" }, - { status: 500, headers: corsHeaders }, - ); - } - } - - // Handle error responses - if (!response.ok) { - console.error("Brevo error:", { - status: response.status, - statusText: response.statusText, - data, - email, - }); - - // Handle specific Brevo errors - if (data?.code === "duplicate_parameter" || data?.message?.includes("already exists")) { - return NextResponse.json( - { message: "Already subscribed", alreadySubscribed: true }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { - error: data?.message || "Failed to subscribe. Please try again later.", - debug: - process.env.NODE_ENV === "development" - ? { - status: response.status, - statusText: response.statusText, - brevoError: data, - responseText, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } - - // Success - Brevo may return empty body on success - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } catch (error) { - console.error("Newsletter subscription error:", error); - const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; - - return NextResponse.json( - { - error: errorMessage, - debug: - process.env.NODE_ENV === "development" - ? { - errorType: error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } -} - -export async function GET() { - return NextResponse.json( - { error: "Method Not Allowed" }, - { status: 405, headers: { ...corsHeaders, Allow: "POST" } }, - ); -} +export const { GET, OPTIONS, POST } = route; diff --git a/apps/site/src/app/api/newsletter/route.ts b/apps/site/src/app/api/newsletter/route.ts index 54db479aff..c7db48da07 100644 --- a/apps/site/src/app/api/newsletter/route.ts +++ b/apps/site/src/app/api/newsletter/route.ts @@ -1,166 +1,10 @@ -import { NextResponse } from "next/server"; +import { createNewsletterRoute } from "@prisma-docs/ui/lib/newsletter-route"; export const dynamic = "force-dynamic"; -// CORS headers configuration -const corsHeaders = { - "Access-Control-Allow-Origin": "https://prisma.io, https://www.prisma.io, https://prisma.io/docs", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -}; +const route = createNewsletterRoute({ + allowedOrigins: ["https://prisma.io", "https://www.prisma.io"], + source: "website", +}); -export async function OPTIONS() { - return NextResponse.json({}, { headers: corsHeaders, status: 200 }); -} - -export async function POST(request: Request) { - try { - const body = await request.json(); - const { email } = body; - - if (!email || typeof email !== "string") { - return NextResponse.json( - { error: "Email is required" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return NextResponse.json( - { error: "Invalid email address" }, - { status: 400, headers: corsHeaders }, - ); - } - - // Check for required environment variable - const brevoApiKey = process.env.BREVO_API_KEY; - if (!brevoApiKey) { - console.error("Missing Brevo API key"); - return NextResponse.json( - { error: "Newsletter service is not configured" }, - { status: 500, headers: corsHeaders }, - ); - } - - const options = { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - "api-key": brevoApiKey, - }, - body: JSON.stringify({ - email: email, - attributes: { - EMAIL: email, - SOURCE: "website", - }, - includeListIds: [15], - templateId: 36, - redirectionUrl: "https://prisma.io", - }), - }; - - const response = await fetch( - "https://api.brevo.com/v3/contacts/doubleOptinConfirmation", - options, - ); - - // Get response text first to check if it's empty - const responseText = await response.text(); - - let data: any = null; - - // Only try to parse JSON if there's actual content - if (responseText && responseText.length > 0) { - try { - data = JSON.parse(responseText); - } catch (parseError) { - console.error("Failed to parse Brevo response:", { - text: responseText, - status: response.status, - parseError, - }); - - // If response was successful but JSON parse failed, treat as success - if (response.ok) { - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { error: "Invalid response from newsletter service" }, - { status: 500, headers: corsHeaders }, - ); - } - } - - // Handle error responses - if (!response.ok) { - console.error("Brevo error:", { - status: response.status, - statusText: response.statusText, - data, - email, - }); - - // Handle specific Brevo errors - if (data?.code === "duplicate_parameter" || data?.message?.includes("already exists")) { - return NextResponse.json( - { message: "Already subscribed", alreadySubscribed: true }, - { status: 200, headers: corsHeaders }, - ); - } - - return NextResponse.json( - { - error: data?.message || "Failed to subscribe. Please try again later.", - debug: - process.env.NODE_ENV === "development" - ? { - status: response.status, - statusText: response.statusText, - brevoError: data, - responseText, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } - - // Success - Brevo may return empty body on success - return NextResponse.json( - { message: "Please check your email to confirm subscription" }, - { status: 200, headers: corsHeaders }, - ); - } catch (error) { - console.error("Newsletter subscription error:", error); - const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; - - return NextResponse.json( - { - error: errorMessage, - debug: - process.env.NODE_ENV === "development" - ? { - errorType: error instanceof Error ? error.constructor.name : typeof error, - stack: error instanceof Error ? error.stack : undefined, - } - : undefined, - }, - { status: 500, headers: corsHeaders }, - ); - } -} - -export async function GET() { - return NextResponse.json( - { error: "Method Not Allowed" }, - { status: 405, headers: { ...corsHeaders, Allow: "POST" } }, - ); -} +export const { GET, OPTIONS, POST } = route; diff --git a/apps/site/src/app/api/newsletter/unsubscribe/route.ts b/apps/site/src/app/api/newsletter/unsubscribe/route.ts new file mode 100644 index 0000000000..0fcb4b6166 --- /dev/null +++ b/apps/site/src/app/api/newsletter/unsubscribe/route.ts @@ -0,0 +1,70 @@ +import { + NewsletterSubscriptionError, + NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME, + isValidNewsletterUnsubscribeToken, + unsubscribeFromPrismaNewsletter, +} from "@prisma-docs/ui/lib/newsletter-subscription"; +import { type NextRequest, NextResponse } from "next/server"; + +const unsubscribeCookieOptions = { + httpOnly: true, + maxAge: 10 * 60, + path: "/", + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", +}; + +function unsubscribePageUrl(request: Request, status: "error" | "invalid" | "success") { + const url = new URL("/newsletter/unsubscribe", request.url); + url.searchParams.set("status", status); + return url; +} + +function clearUnsubscribeCookie(response: NextResponse) { + response.cookies.set(NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME, "", { + ...unsubscribeCookieOptions, + maxAge: 0, + }); + return response; +} + +export function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get("token") ?? ""; + if (!isValidNewsletterUnsubscribeToken(token)) { + return clearUnsubscribeCookie( + NextResponse.redirect(unsubscribePageUrl(request, "invalid"), 303), + ); + } + + const response = NextResponse.redirect(new URL("/newsletter/unsubscribe", request.url), 303); + response.cookies.set(NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME, token, unsubscribeCookieOptions); + return response; +} + +export async function POST(request: NextRequest) { + const token = request.cookies.get(NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME)?.value ?? ""; + if (!isValidNewsletterUnsubscribeToken(token)) { + return clearUnsubscribeCookie( + NextResponse.redirect(unsubscribePageUrl(request, "invalid"), 303), + ); + } + + const apiKey = process.env.BREVO_API_KEY; + if (!apiKey) { + console.error("Newsletter unsubscribe service is missing its Brevo API key"); + return NextResponse.redirect(unsubscribePageUrl(request, "error"), 303); + } + + try { + const result = await unsubscribeFromPrismaNewsletter({ apiKey, token }); + const status = result.status === "invalid_token" ? "invalid" : "success"; + return clearUnsubscribeCookie(NextResponse.redirect(unsubscribePageUrl(request, status), 303)); + } catch (error) { + const details = + error instanceof NewsletterSubscriptionError + ? { code: error.code, status: error.status } + : { code: "unexpected_error" }; + console.error("Newsletter unsubscribe failed", details); + return NextResponse.redirect(unsubscribePageUrl(request, "error"), 303); + } +} diff --git a/apps/site/src/app/newsletter/newsletter-signup.tsx b/apps/site/src/app/newsletter/newsletter-signup.tsx index f471e39fd8..2465fa6d97 100644 --- a/apps/site/src/app/newsletter/newsletter-signup.tsx +++ b/apps/site/src/app/newsletter/newsletter-signup.tsx @@ -18,7 +18,7 @@ export function NewsletterSignup() { ? { text: error, className: "text-foreground-error" } : isSubmitted ? { - text: "Please check your email to confirm your subscription!", + text: "You're subscribed. Welcome to the Prisma newsletter!", className: "text-foreground-success", } : isAlreadySubscribed diff --git a/apps/site/src/app/newsletter/unsubscribe/page.tsx b/apps/site/src/app/newsletter/unsubscribe/page.tsx new file mode 100644 index 0000000000..6493ee5d89 --- /dev/null +++ b/apps/site/src/app/newsletter/unsubscribe/page.tsx @@ -0,0 +1,80 @@ +import { createPageMetadata } from "@/lib/page-metadata"; +import { Button } from "@prisma/eclipse"; +import { + NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME, + isValidNewsletterUnsubscribeToken, +} from "@prisma-docs/ui/lib/newsletter-subscription"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import Link from "next/link"; + +export const metadata = { + ...createPageMetadata({ + title: "Unsubscribe from the Prisma newsletter", + description: "Manage your Prisma newsletter subscription.", + path: "/newsletter/unsubscribe", + }), + referrer: "no-referrer", + robots: { follow: false, index: false }, +} satisfies Metadata; + +type UnsubscribePageProps = { + searchParams: Promise<{ status?: string }>; +}; + +export default async function UnsubscribePage({ searchParams }: UnsubscribePageProps) { + const { status } = await searchParams; + const token = (await cookies()).get(NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME)?.value ?? ""; + const canConfirm = isValidNewsletterUnsubscribeToken(token); + const isSuccess = status === "success"; + const isError = status === "error" && canConfirm; + const isInvalid = !isSuccess && !canConfirm; + + return ( +
+
+

Prisma newsletter

+

+ {isSuccess + ? "You are unsubscribed" + : isInvalid + ? "This link is not valid" + : "Unsubscribe from the newsletter?"} +

+ + {isSuccess ? ( +

+ You will no longer receive the Prisma newsletter. Transactional account and service + emails are unaffected. +

+ ) : isInvalid ? ( +

+ The unsubscribe link is missing or invalid. You can try the link from your latest Prisma + newsletter again. +

+ ) : ( + <> +

+ This stops Prisma newsletter emails only. You will still receive important account and + service messages. +

+ {isError ? ( +

+ We could not update your subscription. Please try again. +

+ ) : null} +
+ +
+ + )} + + + Return to Prisma + +
+
+ ); +} diff --git a/packages/ui/src/components/newsletter.tsx b/packages/ui/src/components/newsletter.tsx index e35dca49b9..c99c1c0315 100644 --- a/packages/ui/src/components/newsletter.tsx +++ b/packages/ui/src/components/newsletter.tsx @@ -42,7 +42,7 @@ export const FooterNewsletterForm = ({ ? { text: error, className: "text-red-500" } : isSubmitted ? { - text: "Please check your email to confirm your subscription!", + text: "You're subscribed. Welcome to the Prisma newsletter!", className: "text-green-500", } : isAlreadySubscribed diff --git a/packages/ui/src/lib/newsletter-route.ts b/packages/ui/src/lib/newsletter-route.ts new file mode 100644 index 0000000000..e292654fc7 --- /dev/null +++ b/packages/ui/src/lib/newsletter-route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { + NewsletterSubscriptionError, + isValidNewsletterEmail, + subscribeToPrismaNewsletter, + type NewsletterSource, +} from "./newsletter-subscription"; + +type NewsletterRouteOptions = { + allowedOrigins: readonly string[]; + source: NewsletterSource; +}; + +function getCorsHeaders(request: Request, allowedOrigins: readonly string[]) { + const origin = request.headers.get("origin") ?? ""; + const allowOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0]; + + return { + "Access-Control-Allow-Origin": allowOrigin, + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + Vary: "Origin", + }; +} + +export function createNewsletterRoute({ allowedOrigins, source }: NewsletterRouteOptions) { + if (allowedOrigins.length === 0) { + throw new Error("At least one newsletter CORS origin is required"); + } + + return { + OPTIONS(request: Request) { + return NextResponse.json( + {}, + { headers: getCorsHeaders(request, allowedOrigins), status: 200 }, + ); + }, + + async POST(request: Request) { + const corsHeaders = getCorsHeaders(request, allowedOrigins); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "A valid JSON body is required" }, + { status: 400, headers: corsHeaders }, + ); + } + + const email = + typeof body === "object" && body !== null && "email" in body ? body.email : undefined; + + if (typeof email !== "string" || !isValidNewsletterEmail(email.trim())) { + return NextResponse.json( + { error: "A valid email address is required" }, + { status: 400, headers: corsHeaders }, + ); + } + + const apiKey = process.env.BREVO_API_KEY; + if (!apiKey) { + console.error("Newsletter service is missing its Brevo API key"); + return NextResponse.json( + { error: "Newsletter service is not configured" }, + { status: 500, headers: corsHeaders }, + ); + } + + try { + const result = await subscribeToPrismaNewsletter({ apiKey, email, source }); + + if (!result.welcomeSent && result.status === "subscribed") { + console.warn("Newsletter subscription completed without welcome email", { source }); + } + + if (result.status === "already_subscribed") { + return NextResponse.json( + { message: "Already subscribed", alreadySubscribed: true }, + { status: 200, headers: corsHeaders }, + ); + } + + return NextResponse.json( + { message: "Subscribed to the Prisma newsletter" }, + { status: 200, headers: corsHeaders }, + ); + } catch (error) { + const details = + error instanceof NewsletterSubscriptionError + ? { code: error.code, status: error.status, source } + : { code: "unexpected_error", source }; + console.error("Newsletter subscription failed", details); + + return NextResponse.json( + { error: "Failed to subscribe. Please try again later." }, + { status: 500, headers: corsHeaders }, + ); + } + }, + + GET(request: Request) { + return NextResponse.json( + { error: "Method Not Allowed" }, + { + status: 405, + headers: { ...getCorsHeaders(request, allowedOrigins), Allow: "POST, OPTIONS" }, + }, + ); + }, + }; +} diff --git a/packages/ui/src/lib/newsletter-subscription.test.ts b/packages/ui/src/lib/newsletter-subscription.test.ts new file mode 100644 index 0000000000..06b23d60df --- /dev/null +++ b/packages/ui/src/lib/newsletter-subscription.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + NEWSLETTER_SOURCE_ATTRIBUTE, + NEWSLETTER_WELCOME_TEMPLATE_ID, + NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE, + PRISMA_NEWSLETTER_LIST_ID, + NewsletterSubscriptionError, + isValidNewsletterEmail, + subscribeToPrismaNewsletter, + unsubscribeFromPrismaNewsletter, +} from "./newsletter-subscription"; + +describe("isValidNewsletterEmail", () => { + it("accepts a normal address and rejects malformed or oversized input", () => { + assert.equal(isValidNewsletterEmail("dev@example.com"), true); + assert.equal(isValidNewsletterEmail("dev@@example.com"), false); + assert.equal(isValidNewsletterEmail("dev@example"), false); + assert.equal(isValidNewsletterEmail(`dev@${"a".repeat(250)}.com`), false); + }); + + it("handles repeated punctuation without a backtracking expression", () => { + assert.equal(isValidNewsletterEmail(`!@!${"!.".repeat(10_000)}`), false); + }); +}); + +type FetchCall = { + input: string; + init?: RequestInit; +}; + +function jsonResponse(body: unknown, status = 200) { + return new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function createFetch(responses: Response[]) { + const calls: FetchCall[] = []; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ input: String(input), init }); + const response = responses.shift(); + assert.ok(response, "Unexpected Brevo request"); + return response; + }) as typeof fetch; + + return { calls, fetcher }; +} + +describe("subscribeToPrismaNewsletter", () => { + it("subscribes a new website contact and sends one welcome email", async () => { + const { calls, fetcher } = createFetch([ + jsonResponse({}, 404), + jsonResponse({ id: 123 }, 201), + jsonResponse({ messageId: "welcome-1" }, 201), + ]); + + const result = await subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: " Dev@Example.com ", + fetcher, + source: "website", + }); + + assert.deepEqual(result, { status: "subscribed", welcomeSent: true }); + assert.equal(calls.length, 3); + + const contactBody = JSON.parse(String(calls[1]?.init?.body)); + assert.equal(contactBody.email, "dev@example.com"); + assert.equal(contactBody.updateEnabled, true); + assert.equal(contactBody.attributes.EMAIL, "dev@example.com"); + assert.equal(contactBody.attributes[NEWSLETTER_SOURCE_ATTRIBUTE], "website"); + assert.equal(contactBody.attributes.SOURCE, "website"); + assert.match(contactBody.attributes[NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE], /^[0-9a-f]{64}$/); + assert.equal(contactBody.emailBlacklisted, false); + assert.deepEqual(contactBody.listIds, [PRISMA_NEWSLETTER_LIST_ID]); + + const emailBody = JSON.parse(String(calls[2]?.init?.body)); + assert.equal(emailBody.templateId, NEWSLETTER_WELCOME_TEMPLATE_ID); + assert.deepEqual(emailBody.to, [{ email: "dev@example.com" }]); + assert.equal( + emailBody.params.unsubscribeUrl, + `https://www.prisma.io/api/newsletter/unsubscribe?token=${contactBody.attributes[NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE]}`, + ); + assert.match( + emailBody.headers["Idempotency-Key"], + /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("keeps an existing unsubscribe token when a contact resubscribes", async () => { + const token = "a".repeat(64); + const { calls, fetcher } = createFetch([ + jsonResponse({ + attributes: { + [NEWSLETTER_SOURCE_ATTRIBUTE]: "console-signup", + [NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE]: token, + SOURCE: "console-signup", + }, + emailBlacklisted: false, + listIds: [], + }), + jsonResponse({}, 204), + jsonResponse({ messageId: "welcome-2" }, 201), + ]); + + await subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: "returning@example.com", + fetcher, + source: "blog", + }); + + const contactBody = JSON.parse(String(calls[1]?.init?.body)); + const emailBody = JSON.parse(String(calls[2]?.init?.body)); + assert.equal(contactBody.attributes[NEWSLETTER_SOURCE_ATTRIBUTE], "blog"); + assert.equal(contactBody.attributes[NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE], token); + assert.equal(contactBody.attributes.SOURCE, "console-signup"); + assert.equal( + emailBody.params.unsubscribeUrl, + `https://www.prisma.io/api/newsletter/unsubscribe?token=${token}`, + ); + }); + + it("does not send a welcome when the contact is already subscribed", async () => { + const { calls, fetcher } = createFetch([ + jsonResponse({ emailBlacklisted: false, listIds: [PRISMA_NEWSLETTER_LIST_ID] }), + ]); + + const result = await subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: "console-user@example.com", + fetcher, + source: "blog", + }); + + assert.deepEqual(result, { status: "already_subscribed", welcomeSent: false }); + assert.equal(calls.length, 1); + }); + + it("resubscribes a blacklisted contact before sending the welcome", async () => { + const { calls, fetcher } = createFetch([ + jsonResponse({ emailBlacklisted: true, listIds: [PRISMA_NEWSLETTER_LIST_ID] }), + jsonResponse({}, 204), + jsonResponse({ messageId: "welcome-2" }, 201), + ]); + + const result = await subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: "returning@example.com", + fetcher, + source: "docs", + }); + + assert.deepEqual(result, { status: "subscribed", welcomeSent: true }); + assert.equal(calls[1]?.init?.method, "PUT"); + assert.equal(calls.length, 3); + }); + + it("keeps a completed subscription when the welcome send fails", async () => { + const { fetcher } = createFetch([ + jsonResponse({}, 404), + jsonResponse({ id: 123 }, 201), + jsonResponse({ code: "temporary_failure" }, 503), + ]); + + const result = await subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: "dev@example.com", + fetcher, + source: "website", + }); + + assert.deepEqual(result, { status: "subscribed", welcomeSent: false }); + }); + + it("does not include the subscriber email in subscription errors", async () => { + const { fetcher } = createFetch([jsonResponse({ code: "unauthorized" }, 401)]); + + await assert.rejects( + subscribeToPrismaNewsletter({ + apiKey: "test-key", + email: "private@example.com", + fetcher, + source: "website", + }), + (error: unknown) => { + assert.ok(error instanceof NewsletterSubscriptionError); + assert.equal(error.code, "unauthorized"); + assert.doesNotMatch(error.message, /private@example\.com/); + return true; + }, + ); + }); +}); + +describe("unsubscribeFromPrismaNewsletter", () => { + it("removes the contact from only the newsletter list", async () => { + const token = "b".repeat(64); + const { calls, fetcher } = createFetch([ + jsonResponse({ + contacts: [ + { + email: "dev@example.com", + listIds: [PRISMA_NEWSLETTER_LIST_ID, 99], + }, + ], + }), + jsonResponse({}, 204), + ]); + + const result = await unsubscribeFromPrismaNewsletter({ + apiKey: "test-key", + fetcher, + token, + }); + + assert.deepEqual(result, { status: "unsubscribed" }); + assert.match(calls[0].input, /filter=equals%28NEWSLETTER_UNSUBSCRIBE_TOKEN%2C%22b{64}%22%29/); + assert.match(calls[1].input, /contacts\/dev%40example\.com\?identifierType=email_id$/); + assert.deepEqual(JSON.parse(String(calls[1].init?.body)), { + unlinkListIds: [PRISMA_NEWSLETTER_LIST_ID], + }); + }); + + it("does not update a contact that already left the newsletter list", async () => { + const { calls, fetcher } = createFetch([ + jsonResponse({ contacts: [{ email: "dev@example.com", listIds: [99] }] }), + ]); + + const result = await unsubscribeFromPrismaNewsletter({ + apiKey: "test-key", + fetcher, + token: "c".repeat(64), + }); + + assert.deepEqual(result, { status: "already_unsubscribed" }); + assert.equal(calls.length, 1); + }); + + it("rejects an invalid token without calling Brevo", async () => { + const { calls, fetcher } = createFetch([]); + + const result = await unsubscribeFromPrismaNewsletter({ + apiKey: "test-key", + fetcher, + token: "not-a-token", + }); + + assert.deepEqual(result, { status: "invalid_token" }); + assert.equal(calls.length, 0); + }); +}); diff --git a/packages/ui/src/lib/newsletter-subscription.ts b/packages/ui/src/lib/newsletter-subscription.ts new file mode 100644 index 0000000000..999ea1db98 --- /dev/null +++ b/packages/ui/src/lib/newsletter-subscription.ts @@ -0,0 +1,329 @@ +const BREVO_API_BASE_URL = "https://api.brevo.com/v3"; +const BREVO_REQUEST_TIMEOUT_MS = 8_000; +const NEWSLETTER_UNSUBSCRIBE_URL = "https://www.prisma.io/api/newsletter/unsubscribe"; + +export const PRISMA_NEWSLETTER_LIST_ID = 15; +export const NEWSLETTER_WELCOME_TEMPLATE_ID = 228; +export const NEWSLETTER_UNSUBSCRIBE_COOKIE_NAME = "prisma_newsletter_unsubscribe"; +export const NEWSLETTER_SOURCE_ATTRIBUTE = "NEWSLETTER_SOURCE"; +export const NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE = "NEWSLETTER_UNSUBSCRIBE_TOKEN"; + +export type NewsletterSource = "blog" | "docs" | "website"; + +type BrevoContact = { + attributes: Record; + email: string | null; + emailBlacklisted: boolean; + listIds: number[]; +}; + +type BrevoErrorBody = { + code?: string; + message?: string; +}; + +export type NewsletterSubscriptionResult = { + status: "already_subscribed" | "subscribed"; + welcomeSent: boolean; +}; + +export type NewsletterUnsubscribeResult = { + status: "already_unsubscribed" | "invalid_token" | "unsubscribed"; +}; + +export class NewsletterSubscriptionError extends Error { + readonly code: string; + readonly status?: number; + + constructor(message: string, options: { code: string; status?: number; cause?: unknown }) { + super(message, { cause: options.cause }); + this.name = "NewsletterSubscriptionError"; + this.code = options.code; + this.status = options.status; + } +} + +type SubscribeToNewsletterOptions = { + apiKey: string; + email: string; + fetcher?: typeof fetch; + source: NewsletterSource; +}; + +type UnsubscribeFromNewsletterOptions = { + apiKey: string; + fetcher?: typeof fetch; + token: string; +}; + +const unsubscribeTokenPattern = /^[0-9a-f]{64}$/; + +export function isValidNewsletterEmail(email: string): boolean { + if (email.length === 0 || email.length > 254 || /\s/.test(email)) return false; + + const atIndex = email.indexOf("@"); + if (atIndex <= 0 || atIndex !== email.lastIndexOf("@")) return false; + + const localPart = email.slice(0, atIndex); + const domainParts = email.slice(atIndex + 1).split("."); + return ( + localPart.length <= 64 && + domainParts.length >= 2 && + domainParts.every((part) => part.length > 0 && part.length <= 63) + ); +} + +function getBrevoHeaders(apiKey: string) { + return { + accept: "application/json", + "api-key": apiKey, + "content-type": "application/json", + }; +} + +async function readJson(response: Response): Promise> { + const responseText = await response.text(); + if (!responseText) return {}; + + try { + return JSON.parse(responseText) as BrevoErrorBody & Record; + } catch (cause) { + throw new NewsletterSubscriptionError("Brevo returned an invalid response", { + code: "invalid_brevo_response", + status: response.status, + cause, + }); + } +} + +function toBrevoError( + operation: string, + response: Response, + body: BrevoErrorBody, +): NewsletterSubscriptionError { + return new NewsletterSubscriptionError(`Brevo ${operation} failed`, { + code: body.code ?? "brevo_request_failed", + status: response.status, + }); +} + +function parseContact(body: Record): BrevoContact { + const listIds = Array.isArray(body.listIds) + ? body.listIds.filter((listId): listId is number => typeof listId === "number") + : []; + + return { + attributes: + typeof body.attributes === "object" && body.attributes !== null + ? (body.attributes as Record) + : {}, + email: typeof body.email === "string" ? body.email : null, + emailBlacklisted: body.emailBlacklisted === true, + listIds, + }; +} + +export function isValidNewsletterUnsubscribeToken(token: string): boolean { + return unsubscribeTokenPattern.test(token); +} + +function getExistingUnsubscribeToken(contact: BrevoContact | null): string | null { + const token = contact?.attributes[NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE]; + return typeof token === "string" && isValidNewsletterUnsubscribeToken(token) ? token : null; +} + +async function createUnsubscribeToken(apiKey: string, email: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(apiKey), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = new Uint8Array( + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`prisma-newsletter-unsubscribe:${email}`), + ), + ); + + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function getContact( + fetcher: typeof fetch, + apiKey: string, + email: string, + signal: AbortSignal, +): Promise { + const response = await fetcher( + `${BREVO_API_BASE_URL}/contacts/${encodeURIComponent(email)}?identifierType=email_id`, + { headers: getBrevoHeaders(apiKey), signal }, + ); + + if (response.status === 404) return null; + + const body = await readJson(response); + if (!response.ok) throw toBrevoError("contact lookup", response, body); + + return parseContact(body); +} + +async function upsertContact( + fetcher: typeof fetch, + apiKey: string, + email: string, + source: NewsletterSource, + contact: BrevoContact | null, + unsubscribeToken: string, + signal: AbortSignal, +): Promise { + const originalSource = contact?.attributes.SOURCE; + const body = { + attributes: { + EMAIL: email, + [NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE]: unsubscribeToken, + [NEWSLETTER_SOURCE_ATTRIBUTE]: source, + SOURCE: typeof originalSource === "string" && originalSource ? originalSource : source, + }, + emailBlacklisted: false, + listIds: [PRISMA_NEWSLETTER_LIST_ID], + }; + + const response = contact + ? await fetcher(`${BREVO_API_BASE_URL}/contacts/${encodeURIComponent(email)}`, { + method: "PUT", + headers: getBrevoHeaders(apiKey), + body: JSON.stringify(body), + signal, + }) + : await fetcher(`${BREVO_API_BASE_URL}/contacts`, { + method: "POST", + headers: getBrevoHeaders(apiKey), + body: JSON.stringify({ email, updateEnabled: true, ...body }), + signal, + }); + + const responseBody = await readJson(response); + if (!response.ok) throw toBrevoError("contact subscription", response, responseBody); +} + +async function createWelcomeIdempotencyKey(email: string): Promise { + const input = new TextEncoder().encode(`prisma-newsletter-welcome:${email}`); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)).slice(0, 16); + + // UUID v8 reserves the version for application-defined deterministic identifiers. + digest[6] = (digest[6] & 0x0f) | 0x80; + digest[8] = (digest[8] & 0x3f) | 0x80; + + const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +async function sendWelcomeEmail( + fetcher: typeof fetch, + apiKey: string, + email: string, + unsubscribeToken: string, + signal: AbortSignal, +): Promise { + const response = await fetcher(`${BREVO_API_BASE_URL}/smtp/email`, { + method: "POST", + headers: getBrevoHeaders(apiKey), + body: JSON.stringify({ + to: [{ email }], + templateId: NEWSLETTER_WELCOME_TEMPLATE_ID, + params: { + unsubscribeUrl: `${NEWSLETTER_UNSUBSCRIBE_URL}?token=${unsubscribeToken}`, + }, + tags: ["newsletter-welcome"], + headers: { + "Idempotency-Key": await createWelcomeIdempotencyKey(email), + }, + }), + signal, + }); + + const body = await readJson(response); + if (!response.ok) throw toBrevoError("welcome email send", response, body); +} + +export async function subscribeToPrismaNewsletter({ + apiKey, + email, + fetcher = fetch, + source, +}: SubscribeToNewsletterOptions): Promise { + const normalizedEmail = email.trim().toLowerCase(); + const signal = AbortSignal.timeout(BREVO_REQUEST_TIMEOUT_MS); + const contact = await getContact(fetcher, apiKey, normalizedEmail, signal); + + if (contact?.listIds.includes(PRISMA_NEWSLETTER_LIST_ID) && !contact.emailBlacklisted) { + return { status: "already_subscribed", welcomeSent: false }; + } + + const unsubscribeToken = + getExistingUnsubscribeToken(contact) ?? (await createUnsubscribeToken(apiKey, normalizedEmail)); + + await upsertContact(fetcher, apiKey, normalizedEmail, source, contact, unsubscribeToken, signal); + + try { + await sendWelcomeEmail(fetcher, apiKey, normalizedEmail, unsubscribeToken, signal); + return { status: "subscribed", welcomeSent: true }; + } catch { + // The subscription is complete even when the non-essential welcome email fails. + return { status: "subscribed", welcomeSent: false }; + } +} + +export async function unsubscribeFromPrismaNewsletter({ + apiKey, + fetcher = fetch, + token, +}: UnsubscribeFromNewsletterOptions): Promise { + if (!isValidNewsletterUnsubscribeToken(token)) return { status: "invalid_token" }; + const signal = AbortSignal.timeout(BREVO_REQUEST_TIMEOUT_MS); + + const contactsUrl = new URL(`${BREVO_API_BASE_URL}/contacts`); + contactsUrl.searchParams.set("limit", "2"); + contactsUrl.searchParams.set( + "filter", + `equals(${NEWSLETTER_UNSUBSCRIBE_TOKEN_ATTRIBUTE},"${token}")`, + ); + + const lookupResponse = await fetcher(contactsUrl, { + headers: getBrevoHeaders(apiKey), + signal, + }); + const lookupBody = await readJson(lookupResponse); + if (!lookupResponse.ok) throw toBrevoError("unsubscribe lookup", lookupResponse, lookupBody); + + const contacts = Array.isArray(lookupBody.contacts) + ? lookupBody.contacts.filter( + (contact): contact is Record => + typeof contact === "object" && contact !== null, + ) + : []; + if (contacts.length !== 1) return { status: "invalid_token" }; + + const contact = parseContact(contacts[0]); + if (!contact.email) return { status: "invalid_token" }; + if (!contact.listIds.includes(PRISMA_NEWSLETTER_LIST_ID)) { + return { status: "already_unsubscribed" }; + } + + const updateResponse = await fetcher( + `${BREVO_API_BASE_URL}/contacts/${encodeURIComponent(contact.email)}?identifierType=email_id`, + { + method: "PUT", + headers: getBrevoHeaders(apiKey), + body: JSON.stringify({ unlinkListIds: [PRISMA_NEWSLETTER_LIST_ID] }), + signal, + }, + ); + const updateBody = await readJson(updateResponse); + if (!updateResponse.ok) throw toBrevoError("newsletter unsubscribe", updateResponse, updateBody); + + return { status: "unsubscribed" }; +}