// ============================================================ // 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 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: 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}`); } } // ============================================================= // 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' }, }); }