Switch newsletter signup to Brevo double opt-in

Was a plain POST /v3/contacts upsert (single opt-in — straight onto the
list, no confirmation required). Now calls doubleOptinConfirmation
instead, so a signup only requests subscription; Brevo sends its own
confirmation email and adds the contact to the real list only once they
click through. Needs BREVO_DOUBLE_OPTIN_TEMPLATE_ID set in Coolify
before this works — not yet configured, signups will fail closed with a
logged reason until it is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 13:40:27 +00:00
parent bab2c916be
commit df4bd700e6
2 changed files with 58 additions and 24 deletions
+22 -8
View File
@@ -53,8 +53,12 @@ on the Payload side — this app's SMTP connection is deliberately
independent, see the "Monitoring & alerting" section). `STRIPE_SECRET_KEY`,
`STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`,
`PAYMENT_WEBHOOK_SECRET`, `PAYMENT_TEST_MODE` — see "Payment processing
(Stripe)" below. Set in Coolify's app settings for production, not in a
committed `.env`.
(Stripe)" below. `BREVO_API_KEY`, `BREVO_LIST_ID`,
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — required for
newsletter signups to trigger a confirmation email at all),
`BREVO_DOI_REDIRECT_URL` (optional, defaults to the homepage) — see
"Newsletter signup" below. Set in Coolify's app settings for production,
not in a committed `.env`.
## Pages
@@ -1057,15 +1061,22 @@ unsynced.
- **`app/lib/brevo.ts`** — the only thing that talks to Brevo.
`upsertNewsletterContact(email, source)` calls Brevo's
`POST /v3/contacts` with `updateEnabled: true` (204 for both a new and
an existing contact — no special-casing needed) and a
`BREVO_LIST_ID`-scoped list membership. `source` (`"checkout"` |
**double opt-in** endpoint, `POST /contacts/doubleOptinConfirmation`
(switched 2026-07-25 from the plain `POST /v3/contacts` single-opt-in
upsert this originally shipped with) — this only ever *requests* a
subscription; Brevo sends a confirmation email (the template at
`BREVO_DOUBLE_OPTIN_TEMPLATE_ID`, configured as the list's Double Opt-in
template in Brevo's own UI) and only actually adds the contact to
`BREVO_LIST_ID` once they click through. `source` (`"checkout"` |
`"newsletter-page"` | `"newsletter-modal"` | `"newsletter-hero"` |
`"challenge"`) is stored as the contact's `OPT_IN_SOURCE` attribute for
segmentation — that attribute has to already exist on the Brevo account
(`POST /v3/contacts/attributes/normal/OPT_IN_SOURCE`) or Brevo silently
drops it on every upsert (no error at all, just never stored) rather
than rejecting the request.
drops it on every request (no error at all, just never stored) rather
than rejecting the request. `redirectionUrl` (where Brevo sends the
contact after they click confirm) defaults to the homepage via
`BREVO_DOI_REDIRECT_URL` — no dedicated "danke, bestätigt" landing page
exists yet.
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
state + on-blur validation + refocus-on-invalid-submit behind all four
forms (same "state of the art, simple" input-quality bar as checkout's
@@ -1091,7 +1102,10 @@ unsynced.
at all, so that piece can only be built/inspected in Brevo's own UI, not
from this codebase.
- Needs `BREVO_API_KEY`/`BREVO_LIST_ID` set in the deployment environment
— confirmed live end-to-end 2026-07-23.
— confirmed live end-to-end 2026-07-23. As of the double-opt-in switch,
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
signup silently no-ops without it) and optionally
`BREVO_DOI_REDIRECT_URL`.
## Orders & customer accounts
+36 -16
View File
@@ -1,10 +1,20 @@
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API. Brevo
// owns everything downstream of that (list membership, unsubscribe links,
// and whatever Welcome Flow automation is configured on the list in
// Brevo's own UI — that automation isn't manageable via their public API
// at all, only contacts/lists are). This app never sends marketing mail
// itself; it only ever hands Brevo the contact + consent.
const BREVO_API_URL = "https://api.brevo.com/v3/contacts";
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API via the
// double-opt-in endpoint: this only ever *requests* a subscription, it
// does not add the contact to the real list itself — Brevo sends the
// confirmation email (the template at BREVO_DOUBLE_OPTIN_TEMPLATE_ID,
// configured as this list's Double Opt-in template in Brevo's own UI) and
// only adds the contact to BREVO_LIST_ID once they click through. This
// app never sends marketing mail itself, and — as of this switch — never
// even directly grants list membership; it only ever hands Brevo the
// contact + consent-to-be-asked. Everything after that (the confirmation
// email itself, the post-confirmation Welcome Flow automation) is
// configured in Brevo's own UI, not manageable via their public API.
//
// Previously called the plain `POST /v3/contacts` upsert (single
// opt-in — added straight to the list, no confirmation click required).
// Switched 2026-07-25 per explicit request once the confirmation-email
// template existed to point templateId at.
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
@@ -19,12 +29,17 @@ export async function upsertNewsletterContact(
): Promise<BrevoSyncResult> {
const apiKey = process.env.BREVO_API_KEY;
const listId = process.env.BREVO_LIST_ID;
if (!apiKey || !listId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID nicht konfiguriert." };
const templateId = process.env.BREVO_DOUBLE_OPTIN_TEMPLATE_ID;
if (!apiKey || !listId || !templateId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
}
// No dedicated "danke, bestätigt" landing page exists yet — falls back
// to the homepage rather than blocking double opt-in on that page
// existing first. Revisit once one exists.
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/";
try {
const res = await fetch(BREVO_API_URL, {
const res = await fetch(BREVO_DOUBLE_OPTIN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -32,16 +47,21 @@ export async function upsertNewsletterContact(
},
body: JSON.stringify({
email,
listIds: [Number(listId)],
updateEnabled: true,
includeListIds: [Number(listId)],
templateId: Number(templateId),
redirectionUrl,
attributes: { OPT_IN_SOURCE: source },
}),
signal: AbortSignal.timeout(8000),
});
// 204 for both a fresh contact and an existing one (updateEnabled
// above merges the list membership onto the existing contact instead
// of erroring).
if (res.ok || res.status === 204) return { ok: true };
// 201 Created is this endpoint's success status (unlike the plain
// contacts upsert this replaced, which used 204). A contact who's
// already confirmed-and-subscribed re-submitting the form is not
// treated as an error either — Brevo resends the confirmation email
// in that case rather than erroring, which is an acceptable no-op
// resend from this app's point of view (matches the previous
// endpoint's "always succeeds for an existing contact too" behavior).
if (res.ok || res.status === 201) return { ok: true };
const body = await res.json().catch(() => null);
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
} catch (err) {