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:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user