site-mva-global-fret/cloudflare-worker/hubspot-proxy.js
MVA Global Fret 2b148a8682 Worker: use Forms API for contact creation (no scope required)
Direct CRM API needed crm.objects.contacts.write scope which the
existing Personal Access Key doesn't have. Using HubSpot Forms API
instead — same endpoint the browser used to call directly, but now
called server-side from the Worker after email verification.

This means HubSpot contact creation works without granting any
additional scopes to the token: anyone can submit a public form.

Also updates the welcome email warning to tell the customer to copy
the address EXACTLY as shown (the reference is now baked into line 1
of the address) and not to modify anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:54:58 +02:00

731 lines
30 KiB
JavaScript

// ============================================================
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + Welcome cron
// ============================================================
// Ce Worker fait deux choses :
//
// 1) Proxy HubSpot (via fetch handler, appelé par le navigateur)
// - Vérification doublon par email
// - Génération du prochain numéro de référence séquentiel
//
// 2) Cron post-confirmation (via scheduled handler, appelé par
// Cloudflare toutes les 5 min) :
// - Cherche les contacts qui ont CONFIRMÉ leur double opt-in
// - Pour chacun, envoie l'email de bienvenue (avec sa référence)
// - Marque le contact dans Cloudflare KV pour ne pas re-envoyer
//
// L'email de bienvenue n'arrive donc QU'APRÈS que le client a cliqué
// sur "Confirmer" dans le mail HubSpot. La référence client + l'adresse
// du dépôt à Paris ne fuitent jamais avant validation.
//
// ============================================================
// DÉPLOIEMENT
// ============================================================
//
// Sur https://dash.cloudflare.com/ :
//
// 1. Workers & Pages → ton Worker mva-hubspot-proxy → Modifier le code
// → coller ce fichier → Déployer
//
// 2. Workers & Pages → ton Worker → Paramètres → Variables et secrets :
// • HUBSPOT_TOKEN = pat-eu1-... (déjà existant, lecture+écriture contacts)
// • EMAILJS_PUBLIC_KEY = 8KUlaQ7BDVIbkZRyP
// • EMAILJS_SERVICE_ID = service_aeamo3x
// • EMAILJS_TEMPLATE_ID = template_s1kr2et
//
// 3. Workers & Pages → ton Worker → Paramètres → Stockage et bases
// → Bindings KV → Ajouter :
// Variable name : WELCOME_KV
// Namespace : créer "mva-welcome-tracker"
//
// 4. Workers & Pages → ton Worker → Paramètres → Déclencheurs (Triggers)
// → Cron Triggers → Ajouter :
// */5 * * * * (toutes les 5 minutes)
//
// 5. ⚠️ EmailJS : sur https://dashboard.emailjs.com/admin/account →
// Security → décocher "Allow EmailJS API for non-browser applications"
// → la décocher (= autoriser les appels serveur). Sinon le Worker ne
// pourra pas envoyer les emails.
//
// 6. ⚠️ Le token HubSpot doit avoir le scope crm.objects.contacts.write
// en plus du read (pour mettre à jour les propriétés contact).
// → si erreur 403 sur la mise à jour KV/contact, regénérer le token
// avec ce scope sur https://app-eu1.hubspot.com/private-apps/...
//
// ============================================================
// Fallbacks pour les valeurs publiques d'EmailJS (déjà visibles dans le
// JavaScript du site). Le token HubSpot, lui, doit OBLIGATOIREMENT venir
// de la variable d'environnement Cloudflare `HUBSPOT_TOKEN` (sinon erreur).
const FALLBACK_EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP';
const FALLBACK_EMAILJS_SERVICE_ID = 'service_aeamo3x';
const FALLBACK_EMAILJS_TEMPLATE_ID= 'template_s1kr2et';
const HUBSPOT_API = 'https://api.hubapi.com';
const HUBSPOT_PORTAL_ID = '148163754';
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
const EMAILJS_API = 'https://api.emailjs.com/api/v1.0/email/send';
const corsHeaders = {
'Access-Control-Allow-Origin' : '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export default {
// -----------------------------------------------------------
// 1) Handler navigateur (POST depuis le formulaire / page de confirmation)
// -----------------------------------------------------------
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const token = env.HUBSPOT_TOKEN;
if (!token) {
return jsonResponse({ error: 'HUBSPOT_TOKEN env var not set' }, 500);
}
try {
const body = await request.json();
const { email, action } = body;
// ── action: nextRef ─────────────────────────────────────
if (action === 'nextRef') {
return jsonResponse({ nextRef: await getNextRef(token) });
}
// ── action: triggerWelcome (admin/debug, optionnel) ─────
if (action === 'triggerWelcomeQueue') {
const stats = await processWelcomeQueue(env);
return jsonResponse({ ok: true, stats });
}
// ── action: sendWelcomeNow ───────────────────────────────
// Envoi immédiat du welcome email via EmailJS (avec l'adresse
// Paris depuis env var). Appelé par form-handler.js après
// soumission du formulaire. L'adresse n'apparaît jamais dans
// le code JS public — elle vient des secrets Cloudflare.
// Anti-bot : on vérifie d'abord le token Cloudflare Turnstile.
if (action === 'sendWelcomeNow') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
// Validation Turnstile (anti-bot)
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
if (!turnstileOk) {
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
}
try {
await sendWelcomeEmail(env, {
firstname : body.firstname || '',
email : body.email,
reference_client : body.reference_client || '',
});
return jsonResponse({ ok: true });
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: requestVerification ──────────────────────────
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
// et envoie un email de validation via Brevo. Le contact n'est créé
// dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
// les inscriptions non vérifiées ne polluent pas le CRM).
// Anti-bot : Turnstile vérifié d'abord.
if (action === 'requestVerification') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
if (!turnstileOk) {
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
}
try {
const verToken = crypto.randomUUID().replace(/-/g, '');
const tokenData = {
firstname : body.firstname || '',
lastname : body.lastname || '',
phone : body.phone || '',
email : body.email.toLowerCase().trim(),
address : body.address || '',
createdAt : new Date().toISOString(),
};
if (!env.WELCOME_KV) {
return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
}
// Token valide 24h
await env.WELCOME_KV.put(`verify:${verToken}`, JSON.stringify(tokenData), {
expirationTtl: 60 * 60 * 24,
});
await sendVerificationEmail(env, tokenData, verToken);
return jsonResponse({ ok: true });
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: verifyToken ──────────────────────────────────
// Appelé par confirmation.html quand l'utilisateur clique sur
// le lien dans l'email de validation. C'est ICI que le contact
// est CRÉÉ dans HubSpot (avec une référence générée à la volée),
// puis le welcome email est envoyé (ref + adresse Paris).
// Idempotent : un 2ème clic ne re-crée pas de contact.
if (action === 'verifyToken') {
if (!body.token) return jsonResponse({ error: 'token requis' }, 400);
if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
const key = `verify:${body.token}`;
const raw = await env.WELCOME_KV.get(key);
if (!raw) {
return jsonResponse({ ok: false, error: 'Token invalide ou expiré' }, 404);
}
const tokenData = JSON.parse(raw);
// Idempotence : si déjà consommé, retourne le résultat précédent
// sans recréer le contact ni renvoyer d'email.
if (tokenData.used) {
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : tokenData.reference_client || '',
});
}
try {
// 1) Récupère la ref existante si le contact est déjà dans HubSpot
// (réinscription après suppression d'un test, ou création via
// l'ancien flow Forms API). Sinon génère la prochaine ref.
let refNumber;
try {
const existing = await searchContactByEmail(token, tokenData.email);
const existingResult = (existing.results || [])[0];
const existingRef = existingResult?.properties?.reference_client;
refNumber = existingRef || await getNextRef(token);
} catch (_) {
// Si la search échoue (scope manquant, etc.), fallback : génère
// une nouvelle ref. Le Forms API gérera la dédup côté HubSpot.
refNumber = await getNextRef(token);
}
// 2) Soumet via Forms API HubSpot (sans auth, pas besoin de scope
// write). Forms API crée le contact OU met à jour s'il existe.
const formRes = await fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_GUID}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fields: [
{ name: 'firstname', value: tokenData.firstname || '' },
{ name: 'lastname', value: tokenData.lastname || '' },
{ name: 'phone', value: tokenData.phone || '' },
{ name: 'email', value: tokenData.email },
{ name: 'address', value: tokenData.address || '' },
{ name: 'reference_client', value: refNumber },
],
context: {
pageUri : 'https://mva-global-fret.github.io/site-mva-global-fret/contact.html',
pageName: 'Verified signup (MVA Global Fret)',
},
}),
}
);
if (!formRes.ok) {
const errTxt = await formRes.text();
throw new Error(`HubSpot Forms API failed ${formRes.status}: ${errTxt}`);
}
// 3) Envoie le welcome email avec ref + adresse Paris
const welcomeContact = { ...tokenData, reference_client: refNumber };
await sendWelcomeViaBrevo(env, welcomeContact);
// 4) Marque le token consommé (gardé 7j pour idempotence)
await env.WELCOME_KV.put(key, JSON.stringify({
...tokenData,
used : true,
usedAt : new Date().toISOString(),
reference_client : refNumber,
}), {
expirationTtl: 60 * 60 * 24 * 7,
});
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : refNumber,
});
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: listSubscriptions (debug : trouver les IDs) ──
if (action === 'listSubscriptions') {
// Endpoint legacy email/public/v1 nécessite scope content au lieu de
// communication_preferences (que notre token n'a pas)
const r = await fetch(`${HUBSPOT_API}/email/public/v1/subscriptions`, {
headers: { 'Authorization': `Bearer ${token}` },
});
return jsonResponse(await r.json());
}
// ── action: subscribe ────────────────────────────────────
// Inscrit un contact à un type d'abonnement marketing (déclenche
// l'envoi du mail de double opt-in si DOI activé au niveau compte).
if (action === 'subscribe') {
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const subId = body.subscriptionId;
if (!subId) return jsonResponse({ error: 'subscriptionId requis' }, 400);
const r = await fetch(`${HUBSPOT_API}/communication-preferences/v3/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
emailAddress: email.toLowerCase().trim(),
subscriptionId: subId,
legalBasis: 'LEGITIMATE_INTEREST_CLIENT',
legalBasisExplanation: 'Soumission du formulaire MVA Global Fret',
}),
});
const data = await r.text();
return jsonResponse({ status: r.status, body: data });
}
// ── action par défaut : vérification doublon par email ──
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const data = await searchContactByEmail(token, email);
return jsonResponse(data);
} catch (err) {
return jsonResponse({ error: err.message }, 500);
}
},
// -----------------------------------------------------------
// 2) Handler cron (Cloudflare scheduler, toutes les 5 min)
// -----------------------------------------------------------
async scheduled(event, env, ctx) {
ctx.waitUntil(processWelcomeQueue(env));
},
};
// =============================================================
// File d'attente : envoi du welcome aux contacts confirmés
// =============================================================
async function processWelcomeQueue(env) {
const token = env.HUBSPOT_TOKEN;
const stats = { scanned: 0, sent: 0, skipped: 0, errors: 0 };
// Liste des contacts qui ont CONFIRMÉ leur opt-in marketing
// Filtre HubSpot : hs_emailconfirmationstatus EQ "CONFIRMED"
const confirmed = await searchConfirmedContacts(token);
for (const contact of confirmed) {
stats.scanned++;
const props = contact.properties || {};
const email = (props.email || '').toLowerCase();
if (!email) { stats.skipped++; continue; }
// Idempotence : si on a déjà envoyé, on saute
const kvKey = `welcomed:${email}`;
const already = env.WELCOME_KV ? await env.WELCOME_KV.get(kvKey) : null;
if (already) { stats.skipped++; continue; }
try {
await sendWelcomeEmail(env, {
firstname : props.firstname || '',
email : email,
reference_client : props.reference_client || '',
});
// Marquer comme envoyé dans KV (TTL 1 an pour éviter de garder
// indéfiniment des entrées si quelqu'un se désabonne et se réabonne)
if (env.WELCOME_KV) {
await env.WELCOME_KV.put(kvKey, new Date().toISOString(), {
expirationTtl: 60 * 60 * 24 * 365,
});
}
stats.sent++;
} catch (err) {
stats.errors++;
console.warn('[welcome]', email, err.message);
}
}
return stats;
}
// =============================================================
// HubSpot : recherches & lectures
// =============================================================
// Date avant laquelle les contacts CONFIRMÉS ne déclenchent PAS de welcome.
// Évite de spammer les contacts déjà existants au moment du déploiement
// du nouveau cron. Tout contact créé APRÈS cette date (et qui confirme
// son email) recevra son email de bienvenue normalement.
const WELCOME_CUTOFF_ISO = '2026-05-05T00:00:00Z';
async function searchConfirmedContacts(token) {
// On limite à 100 contacts par cron run (largement suffisant pour 1 PME)
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [
{ propertyName: 'hs_emailconfirmationstatus', operator: 'EQ', value: 'CONFIRMED' },
{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' },
{ propertyName: 'createdate', operator: 'GTE', value: WELCOME_CUTOFF_ISO },
],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client', 'hs_emailconfirmationstatus'],
sorts: [{ propertyName: 'lastmodifieddate', direction: 'DESCENDING' }],
limit: 100,
}),
});
if (!res.ok) {
throw new Error(`HubSpot search failed: ${res.status}`);
}
const data = await res.json();
return data.results || [];
}
async function searchContactByEmail(token, email) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'email', operator: 'EQ', value: email.toLowerCase().trim() }],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client'],
}),
});
if (!res.ok) {
throw new Error(`HubSpot lookup failed: ${res.status}`);
}
return res.json();
}
async function getNextRef(token) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' }],
}],
properties: ['reference_client'],
limit: 100,
}),
});
const data = await res.json();
let maxNum = 0;
(data.results || []).forEach(c => {
const m = (c.properties?.reference_client || '').match(/^MVA-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxNum) maxNum = n;
}
});
return 'MVA-' + String(maxNum + 1).padStart(3, '0');
}
// =============================================================
// EmailJS : envoi serveur via REST API
// =============================================================
async function sendWelcomeEmail(env, params) {
const payload = {
service_id : env.EMAILJS_SERVICE_ID || FALLBACK_EMAILJS_SERVICE_ID,
template_id: env.EMAILJS_TEMPLATE_ID || FALLBACK_EMAILJS_TEMPLATE_ID,
user_id : env.EMAILJS_PUBLIC_KEY || FALLBACK_EMAILJS_PUBLIC_KEY,
template_params: {
firstname : params.firstname,
email : params.email,
reference_client : params.reference_client,
// Adresse du dépôt Paris — définie via l'env var PARIS_DEPOT_ADDRESS
// dans Cloudflare. Si non définie, on envoie un placeholder visible
// pour signaler à l'admin qu'il faut la configurer.
paris_address : env.PARIS_DEPOT_ADDRESS || '[À configurer dans Cloudflare]',
},
};
const res = await fetch(EMAILJS_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`EmailJS ${res.status}: ${text}`);
}
}
// =============================================================
// Brevo (ex-Sendinblue) : envoi d'emails (verification + welcome)
// =============================================================
// Brevo est utilisé pour l'envoi car il accepte la "single-sender
// verification" : on valide juste une adresse email (mvaglobalfret@gmail.com)
// au lieu de devoir vérifier tout un domaine. Free tier : 300 emails/jour.
//
// Setup requis :
// - env.BREVO_API_KEY = clé API Brevo (xkeysib-...)
// - env.BREVO_SENDER_EMAIL = adresse expéditrice validée chez Brevo
// (ex: "mvaglobalfret@gmail.com")
// - env.BREVO_SENDER_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
// - env.SITE_URL = base URL du site (ex: "https://mva-global-fret.github.io/site-mva-global-fret")
//
// API doc : https://developers.brevo.com/reference/sendtransacemail
const BREVO_API = 'https://api.brevo.com/v3/smtp/email';
async function brevoSend(env, { to, subject, html }) {
if (!env.BREVO_API_KEY) {
throw new Error('BREVO_API_KEY env var not set');
}
const senderEmail = env.BREVO_SENDER_EMAIL || 'mvaglobalfret@gmail.com';
const senderName = env.BREVO_SENDER_NAME || 'MVA Global Fret';
const res = await fetch(BREVO_API, {
method: 'POST',
headers: {
'api-key' : env.BREVO_API_KEY,
'accept' : 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
sender : { name: senderName, email: senderEmail },
to : [{ email: to }],
subject : subject,
htmlContent: html,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Brevo ${res.status}: ${text}`);
}
return res.json();
}
async function sendVerificationEmail(env, contact, verToken) {
const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret';
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris — Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Merci pour votre inscription chez <strong>MVA Global Fret</strong> !
</p>
<p style="color:#333;line-height:1.6;">
Pour finaliser votre inscription et recevoir votre <strong>numéro de référence client</strong>
ainsi que <strong>l'adresse de notre dépôt à Paris</strong>, cliquez sur le bouton ci-dessous :
</p>
<div style="text-align:center;margin:32px 0;">
<a href="${verifyUrl}" style="display:inline-block;background:#c5a55a;color:#1a1a3e;padding:16px 40px;border-radius:50px;text-decoration:none;font-weight:700;font-size:16px;letter-spacing:0.5px;">
✓ Confirmer mon email
</a>
</div>
<p style="color:#666;font-size:13px;line-height:1.6;">
Ce lien est valable <strong>24 heures</strong>. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email.
</p>
<p style="color:#666;font-size:12px;line-height:1.6;border-top:1px solid #eee;padding-top:18px;margin-top:30px;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="color:#c5a55a;word-break:break-all;">${verifyUrl}</span>
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret — Tous droits réservés
</div>
</div>
</body>
</html>`;
return brevoSend(env, {
to: contact.email,
subject: 'Confirmez votre inscription chez MVA Global Fret',
html,
});
}
async function sendWelcomeViaBrevo(env, contact) {
const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret';
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const ref = escapeHtml(contact.reference_client || '');
const refRaw = contact.reference_client || '';
// Format adresse Paris : la 1ère ligne (nom du destinataire) reçoit
// automatiquement la référence client entre parenthèses, comme ça
// le client a directement la bonne forme à recopier sur son colis.
// Support aussi un placeholder {{ref}} si présent dans l'env var.
let parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
if (parisAddrRaw.includes('{{ref}}')) {
parisAddrRaw = parisAddrRaw.replace(/\{\{ref\}\}/g, refRaw);
} else if (refRaw && parisAddrRaw) {
const lines = parisAddrRaw.split('\n');
lines[0] = `${lines[0]} (${refRaw})`;
parisAddrRaw = lines.join('\n');
}
const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '<br>');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris — Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Bienvenu(e) chez <strong>MVA Global Fret</strong> ! Votre email est confirmé,
votre inscription est désormais active.
</p>
<div style="background:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;">
<p style="margin:0;color:#1a1a3e;font-size:14px;">Votre numéro de référence client :</p>
<p style="margin:8px 0 0;color:#1a1a3e;font-size:22px;font-weight:bold;letter-spacing:2px;">${ref}</p>
<p style="margin:6px 0 0;color:#666;font-size:12px;">Conservez ce numéro précieusement.</p>
</div>
<p style="color:#333;margin-top:28px;"><strong>L'adresse à Paris pour l'envoi de vos colis :</strong></p>
<div style="background:#f9f9f9;border:1px solid #ddd;padding:20px 24px;border-radius:6px;margin:12px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e;">
${parisAddr}
</div>
<div style="background:#fff3cd;border:1px solid #ffc107;padding:14px 18px;border-radius:6px;margin:16px 0;">
<p style="margin:0 0 8px;color:#856404;font-size:14px;font-weight:bold;">
⚠️ Important : ne modifiez rien à ces informations.
</p>
<p style="margin:0;color:#856404;font-size:14px;line-height:1.5;">
Recopiez l'adresse <strong>exactement telle qu'elle est indiquée ci-dessus</strong>,
sans rien retirer ni ajouter. Votre numéro de référence <strong>${ref}</strong>
fait partie intégrante de l'adresse — c'est ce qui garantit que votre colis nous arrive bien.
</p>
</div>
<p style="color:#333;line-height:1.6;">
Pour toute question, contactez-nous :<br>
📧 mvaglobalfret@gmail.com<br>
📞 +33 7 80 97 08 25 (France) — +261 38 49 737 51 (Madagascar)
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret — Tous droits réservés
</div>
</div>
</body>
</html>`;
return brevoSend(env, {
to: contact.email,
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
html,
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// =============================================================
// Cloudflare Turnstile : validation anti-bot
// =============================================================
// Reçoit le token généré côté client (window.turnstileToken) et
// l'envoie à l'API Cloudflare avec le secret pour validation.
// Renvoie true uniquement si Cloudflare confirme que c'est un
// utilisateur humain.
async function verifyTurnstile(env, token, request) {
if (!token) return false;
if (!env.TURNSTILE_SECRET) {
// En dev / si pas configuré, on laisse passer (à durcir en prod)
console.warn('TURNSTILE_SECRET not set, skipping validation');
return true;
}
const ip = request.headers.get('CF-Connecting-IP') || '';
const formData = new FormData();
formData.append('secret', env.TURNSTILE_SECRET);
formData.append('response', token);
if (ip) formData.append('remoteip', ip);
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data.success === true;
} catch (err) {
console.warn('Turnstile verification error:', err);
return false;
}
}
// =============================================================
// Helpers
// =============================================================
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}