From df4bd700e67f4043677bdfdd7e8b71b63b3362b4 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 25 Jul 2026 13:40:27 +0000 Subject: [PATCH] Switch newsletter signup to Brevo double opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 30 ++++++++++++++++++++-------- app/lib/brevo.ts | 52 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 835838d..af45095 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/lib/brevo.ts b/app/lib/brevo.ts index 9855ed7..b15244a 100644 --- a/app/lib/brevo.ts +++ b/app/lib/brevo.ts @@ -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 { 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) {