// ============================================================ // MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend // ============================================================ // Ce Worker gère le formulaire de contact via un flow double opt-in : // // 1) requestVerification : génère un token, stocke les données du formulaire en KV, // envoie un email de validation (lien de confirmation) via Resend. // // 2) verifyToken : appelé quand le client clique sur le lien de confirmation. // Crée le contact dans HubSpot (avec une référence générée à la volée), // puis envoie le welcome email avec la référence + l'adresse du dépôt Paris. // Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email. // // La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant // validation de l'email — protection anti-bot et anti-cartons-vides. // // ============================================================ // DÉPLOIEMENT (Phase D du plan WordPress → static) // ============================================================ // // Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète. // // Secrets requis (`wrangler secret put `) : // • HUBSPOT_TOKEN = pat-eu1-... (read+write contacts) // • RESEND_API_KEY = re_... (compte Resend partagé avec m4s-auth) // • RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend) // • RESEND_FROM_NAME = nom affiché à l'expéditeur // • PARIS_DEPOT_ADDRESS = adresse complète du dépôt Paris // • TURNSTILE_SECRET = secret Cloudflare Turnstile (anti-bot) // • SITE_URL = base URL du site (ex: "https://mva-globalfret.com") // // Bindings KV requis : // • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken) // // ============================================================ const HUBSPOT_API = 'https://api.hubapi.com'; const HUBSPOT_PORTAL_ID = '148163754'; const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a'; 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: requestVerification ────────────────────────── // Génère un token unique, stocke TOUTES les données du formulaire en KV, // et envoie un email de validation via Resend. 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) Création directe via CRM API (= more deterministic que Forms API // qui peut accepter une submission sans réellement créer le contact // à cause des filtres anti-spam ou de la config du Form HubSpot). // Requires scope crm.objects.contacts.write. // En cas de 409 (contact déjà existant), fallback sur PATCH par ID // pour update les propriétés (= notamment reference_client). const crmRes = await fetch( `${HUBSPOT_API}/crm/v3/objects/contacts`, { method: 'POST', headers: { 'Content-Type' : 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ properties: { firstname : tokenData.firstname || '', lastname : tokenData.lastname || '', phone : tokenData.phone || '', email : tokenData.email, address : tokenData.address || '', reference_client : refNumber, }, }), } ); if (crmRes.status === 409) { // Contact existe déjà — update via PATCH par ID const search = await searchContactByEmail(token, tokenData.email); const existing = (search.results || [])[0]; if (existing?.id) { const patchRes = await fetch( `${HUBSPOT_API}/crm/v3/objects/contacts/${existing.id}`, { method: 'PATCH', headers: { 'Content-Type' : 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ properties: { firstname : tokenData.firstname || existing.properties?.firstname || '', lastname : tokenData.lastname || existing.properties?.lastname || '', phone : tokenData.phone || existing.properties?.phone || '', address : tokenData.address || '', reference_client : refNumber, }, }), } ); if (!patchRes.ok) { const errTxt = await patchRes.text(); throw new Error(`HubSpot CRM patch failed ${patchRes.status}: ${errTxt.slice(0, 200)}`); } } } else if (!crmRes.ok) { const errTxt = await crmRes.text(); throw new Error(`HubSpot CRM create failed ${crmRes.status}: ${errTxt.slice(0, 200)}`); } // 3) Envoie le welcome email avec ref + adresse Paris const welcomeContact = { ...tokenData, reference_client: refNumber }; await sendWelcomeViaResend(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: sendWelcomeBack ───────────────────────────── // Envoie un email "Vous êtes déjà inscrit" au client qui tente // une ré-inscription. Idempotent côté HubSpot (= aucune création // ni update de contact). Anti-bot via Turnstile + sanity check // que l'email existe vraiment dans HubSpot avant d'envoyer. if (action === 'sendWelcomeBack') { 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 { // Vérification : le contact existe bien (= prevent spam vers // emails inconnus en passant un faux turnstile) const search = await searchContactByEmail(token, body.email); const existing = (search.results || [])[0]; if (!existing) { return jsonResponse({ ok: false, error: 'Contact not found' }, 404); } await sendWelcomeBackViaResend(env, { firstname : body.firstname || existing.properties?.firstname || '', email : body.email, reference_client : existing.properties?.reference_client || '', }); return jsonResponse({ ok: true }); } 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); } }, }; // ============================================================= // HubSpot : recherches & lectures // ============================================================= 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, }), }); if (!res.ok) { throw new Error(`HubSpot search failed: ${res.status}`); } 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'); } // ============================================================= // Resend : envoi d'emails transactionnels (verification + welcome) // ============================================================= // Aligné avec m4s-auth (Phase 2.1) qui utilise déjà Resend en production. // Le compte Resend (et le domaine vérifié) sont partagés entre m4s-auth et // ce Worker — un seul fournisseur SMTP pour tout Mind4Solutions. // // Setup requis (`wrangler secret put `) : // - env.RESEND_API_KEY = clé API Resend (re_...) // - env.RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend) // - env.RESEND_FROM_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret") // - env.SITE_URL = base URL du site (ex: "https://mva-globalfret.com") // // API doc : https://resend.com/docs/api-reference/emails/send-email const RESEND_API = 'https://api.resend.com/emails'; async function resendSend(env, { to, subject, html }) { if (!env.RESEND_API_KEY) { throw new Error('RESEND_API_KEY env var not set'); } const fromEmail = env.RESEND_FROM_EMAIL || 'noreply@mva-globalfret.com'; const fromName = env.RESEND_FROM_NAME || 'MVA Global Fret'; const res = await fetch(RESEND_API, { method: 'POST', headers: { 'Content-Type' : 'application/json', 'Authorization': `Bearer ${env.RESEND_API_KEY}`, }, body: JSON.stringify({ from : `${fromName} <${fromEmail}>`, to : [to], subject: subject, html : html, }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Resend ${res.status}: ${text}`); } return res.json(); } async function sendVerificationEmail(env, contact, verToken) { const siteUrl = env.SITE_URL || 'https://mva-globalfret.com'; const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`; const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`; const firstname = escapeHtml(contact.firstname || ''); const html = `
MVA Global Fret
MVA GLOBAL FRET
Fret Aérien Paris — Antananarivo

Bonjour ${firstname},

Merci pour votre inscription chez MVA Global Fret !

Pour finaliser votre inscription et recevoir votre numéro de référence client ainsi que l'adresse de notre dépôt à Paris, cliquez sur le bouton ci-dessous :

✓ Confirmer mon email

Ce lien est valable 24 heures. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email.

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
${verifyUrl}

© 2026 MVA Global Fret — Tous droits réservés
`; return resendSend(env, { to: contact.email, subject: 'Confirmez votre inscription chez MVA Global Fret', html, }); } async function sendWelcomeViaResend(env, contact) { const siteUrl = env.SITE_URL || 'https://mva-globalfret.com'; 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, '
'); const html = `
MVA Global Fret
MVA GLOBAL FRET
Fret Aérien Paris — Antananarivo

Bonjour ${firstname},

Bienvenu(e) chez MVA Global Fret ! Votre email est confirmé, votre inscription est désormais active.

Votre numéro de référence client :

${ref}

Conservez ce numéro précieusement.

L'adresse à Paris pour l'envoi de vos colis :

${parisAddr}

⚠️ Important : ne modifiez rien à ces informations.

Recopiez l'adresse exactement telle qu'elle est indiquée ci-dessus, sans rien retirer ni ajouter. Votre numéro de référence ${ref} fait partie intégrante de l'adresse — c'est ce qui garantit que votre colis nous arrive bien.

Pour toute question, contactez-nous :
📧 mvaglobalfret@gmail.com
📞 +33 7 80 97 08 25 (France) — +261 38 49 737 51 (Madagascar)

© 2026 MVA Global Fret — Tous droits réservés
`; return resendSend(env, { to: contact.email, subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`, html, }); } // Email "Ravis de vous revoir" pour les clients déjà inscrits qui retentent // le formulaire de contact. Reprend EXACTEMENT le template original (= avant // migration EmailJS \xe2\x86\x92 Resend) car son contenu est strat\xc3\xa9gique \xe2\x80\x94 rappel // adresse Paris + warning anti-modification + r\xe9f\xe9rence client. Seules // modifications : footer (c) 2025 \xe2\x86\x92 \xc2\xa9 2026, suppression du tag // "Email sent via EmailJS.com" (obsol\xe8te depuis Resend), URL logo // pointe vers le nouveau domaine, et adresse Paris injecte la ref via // le placeholder {{ref}} de PARIS_DEPOT_ADDRESS. // // Idempotent c\xf4t\xe9 HubSpot (= z\xe9ro write). async function sendWelcomeBackViaResend(env, contact) { const siteUrl = env.SITE_URL || 'https://mva-globalfret.com'; const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`; const firstnameRaw = contact.firstname || ''; const firstname = escapeHtml(firstnameRaw); const refRaw = contact.reference_client || ''; const ref = escapeHtml(refRaw); // Construction adresse Paris (= m\xeame logique que sendWelcomeViaResend) : // injecte la ref client soit via placeholder {{ref}}, soit en l'ajoutant // entre parenth\xe8ses sur la 1\xe8re ligne (= pattern original "VASTA Mélissa (MVA-XXX)"). 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'); } // 1\xe8re ligne en gras (= match original `VASTA Melissa (MVA-XXX)`) const addrLines = escapeHtml(parisAddrRaw).split('\n'); const parisAddrHtml = addrLines.length > 1 ? `${addrLines[0]}
${addrLines.slice(1).join('
')}` : escapeHtml(parisAddrRaw); const greetingTitle = firstnameRaw ? `Ravis de vous revoir, ${firstname} !` : 'Ravis de vous revoir !'; const html = `
MVA
MVA GLOBAL FRET
Fret Aerien Paris - Antananarivo

${greetingTitle}

Nous avons bien recu votre nouvelle tentative d'inscription. Pas d'inquietude : vous etes deja client chez MVA Global Fret !

Voici un rappel de votre numero de reference client :

VOTRE NUMERO DE REFERENCE CLIENT

${ref}

Conservez ce numero precieusement.

L'adresse a Paris pour l'envoi de vos colis est :

${parisAddrHtml}

IMPORTANT : Cette adresse ne doit etre changee sous aucun pretexte. Toute modification empecherait la bonne transmission de votre colis a notre depot a Paris.

Pour toute question, n'hesitez pas a nous contacter :

A tres bientot pour votre prochain envoi,
L'equipe MVA Global Fret

(c) 2026 MVA Global Fret - Antananarivo 101, Madagascar
`; // Subject : reprend strictement le sujet original "Ravis de vous revoir, [firstname] !" // (= en cas de firstname vide, fallback sans virgule). const subjectFirstname = firstnameRaw.replace(/[\r\n]/g, '').trim(); return resendSend(env, { to: contact.email, subject: subjectFirstname ? `Ravis de vous revoir, ${subjectFirstname} !` : 'Ravis de vous revoir !', html, }); } function escapeHtml(s) { return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ============================================================= // 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' }, }); }