Skip to content
Open
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
177 changes: 6 additions & 171 deletions apps/blog/src/app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
@@ -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;
65 changes: 21 additions & 44 deletions apps/docs/src/app/api/newsletter/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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"
}
```

Expand All @@ -83,7 +84,7 @@ export default function Page() {
**Error (400)**
```json
{
"error": "Invalid email address"
"error": "A valid email address is required"
}
```

Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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`.
To add more origins, update the route's `allowedOrigins`.
Loading
Loading