// 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"; const BREVO_CONTACTS_URL = "https://api.brevo.com/v3/contacts"; export type BrevoSyncResult = { ok: true; alreadySubscribed?: boolean } | { ok: false; reason: string }; export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge"; // Checked before calling doubleOptinConfirmation — that endpoint gives // no way to tell "brand new signup" apart from "already confirmed, // resending the same mail again" (verified directly: calling it a // second time for an already-subscribed contact still returns a plain // 201, same as the first time). `listIds` on a Brevo contact is only // populated once double opt-in actually confirms (never for a merely // *requested*, still-pending one), so its presence here is a reliable // "already subscribed to this list" signal. Fails open on any error — // this check is a UX nicety (skip an unnecessary resend, show a // friendlier message), never a reason to block a real signup attempt. async function isAlreadySubscribed(email: string, apiKey: string, listId: string): Promise { try { const res = await fetch(`${BREVO_CONTACTS_URL}/${encodeURIComponent(email)}`, { headers: { "api-key": apiKey }, signal: AbortSignal.timeout(5000), }); if (!res.ok) return false; // 404 (never signed up before) or any transient error const contact: { listIds?: number[] } = await res.json(); return (contact.listIds ?? []).includes(Number(listId)); } catch { return false; } } // `source` becomes a Brevo contact attribute so campaigns/segments can // tell a checkout opt-in apart from the standalone signup forms without // needing separate lists. export async function upsertNewsletterContact( email: string, source: NewsletterOptInSource, ): Promise { const apiKey = process.env.BREVO_API_KEY; const listId = process.env.BREVO_LIST_ID; 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." }; } if (await isAlreadySubscribed(email, apiKey, listId)) { return { ok: true, alreadySubscribed: true }; } const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed"; try { const res = await fetch(BREVO_DOUBLE_OPTIN_URL, { method: "POST", headers: { "Content-Type": "application/json", "api-key": apiKey, }, body: JSON.stringify({ email, includeListIds: [Number(listId)], templateId: Number(templateId), redirectionUrl, attributes: { OPT_IN_SOURCE: source }, }), signal: AbortSignal.timeout(8000), }); // 201 Created is this endpoint's success status (unlike the plain // contacts upsert this replaced, which used 204). The already- // subscribed case is handled above, before this call ever fires. 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) { return { ok: false, reason: err instanceof Error ? err.message : "Brevo ist gerade nicht erreichbar." }; } }