From 07ccec0808f0b4a41463981fd1bc495a007d9a8c Mon Sep 17 00:00:00 2001 From: MVA Global Fret Date: Wed, 6 May 2026 13:50:32 +0200 Subject: [PATCH] Anti-spam: only register HubSpot contact AFTER email confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Forms API created the contact at form submission time — which meant unverified signups (bots that pass Turnstile, typos, fake emails) polluted HubSpot. Now: - Form submit → Worker stores all data in KV (24h TTL) + sends Brevo verification email (no HubSpot write) - User clicks email link → Worker generates ref + creates HubSpot contact via CRM API + sends welcome email with ref + Paris address Plus this commit: - Email header gets the MVA logo on the left of the dark blue banner - Welcome email's first address line auto-injects (MVA-XXX) so the customer can copy it directly onto their package Also handles idempotency — clicking the verification link a second time returns the existing ref without creating a duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- cloudflare-worker/hubspot-proxy.js | 139 +++++++++++++++++++++++------ js/form-handler.js | 77 +++++++--------- 2 files changed, 143 insertions(+), 73 deletions(-) diff --git a/cloudflare-worker/hubspot-proxy.js b/cloudflare-worker/hubspot-proxy.js index 8eddb81..3d9bf5a 100644 --- a/cloudflare-worker/hubspot-proxy.js +++ b/cloudflare-worker/hubspot-proxy.js @@ -130,10 +130,10 @@ export default { } // ── action: requestVerification ────────────────────────── - // Génère un token unique, le stocke en KV avec les infos du contact, - // et envoie un email de validation via Brevo (avec un lien de - // confirmation). Le contact ne reçoit la référence et l'adresse - // Paris qu'APRÈS avoir cliqué sur le lien. + // 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); @@ -146,10 +146,12 @@ export default { try { const verToken = crypto.randomUUID().replace(/-/g, ''); const tokenData = { - firstname : body.firstname || '', - email : body.email.toLowerCase().trim(), - reference_client : body.reference_client || '', - createdAt : new Date().toISOString(), + 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) { @@ -169,9 +171,10 @@ export default { // ── action: verifyToken ────────────────────────────────── // Appelé par confirmation.html quand l'utilisateur clique sur - // le lien dans l'email de validation. Lit le token en KV, - // envoie le welcome (avec ref + adresse Paris) via Brevo, - // puis supprime le token (one-time use). + // 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); @@ -183,17 +186,67 @@ export default { } const tokenData = JSON.parse(raw); - try { - await sendWelcomeViaBrevo(env, tokenData); - // Marque le token consommé (gardé 7j pour idempotence en cas de - // double clic du lien, mais avec flag "used") - await env.WELCOME_KV.put(key, JSON.stringify({ ...tokenData, used: true, usedAt: new Date().toISOString() }), { - expirationTtl: 60 * 60 * 24 * 7, - }); + // 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, + reference_client : tokenData.reference_client || '', + }); + } + + try { + // 1) Génère la prochaine référence client (MVA-XXX) + const refNumber = await getNextRef(token); + + // 2) Crée le contact dans HubSpot avec toutes les propriétés + const createRes = 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 (!createRes.ok) { + const errTxt = await createRes.text(); + // Si l'email existe déjà (409 conflict), on récupère le contact + // existant et on lui envoie quand même son welcome (cas très rare : + // doublon créé via une autre source pendant la fenêtre de 24h) + if (createRes.status === 409) { + throw new Error(`Contact existe déjà dans HubSpot (409). Contactez l'admin.`); + } + throw new Error(`HubSpot create failed ${createRes.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); @@ -474,15 +527,25 @@ async function brevoSend(env, { to, subject, html }) { 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 = `
-
-
MVA GLOBAL FRET
-
Fret Aérien Paris — Antananarivo
+
+ + + + + +
+ MVA Global Fret + +
MVA GLOBAL FRET
+
Fret Aérien Paris — Antananarivo
+

Bonjour ${firstname},

@@ -521,18 +584,42 @@ async function sendVerificationEmail(env, contact, verToken) { } 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 parisAddrRaw = env.PARIS_DEPOT_ADDRESS || ''; + 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
-
Fret Aérien Paris — Antananarivo
+
+ + + + + +
+ MVA Global Fret + +
MVA GLOBAL FRET
+
Fret Aérien Paris — Antananarivo
+

Bonjour ${firstname},

diff --git a/js/form-handler.js b/js/form-handler.js index c0a6d55..537dd31 100644 --- a/js/form-handler.js +++ b/js/form-handler.js @@ -98,40 +98,50 @@ function setupContactForm(form) { const email = form.email.value.trim(); // ── VÉRIFICATION DOUBLON ────────────────────────────────────────────────── - // Si le client existe déjà, on affiche uniquement un message informatif. - // On ne soumet RIEN à HubSpot — la référence existante n'est JAMAIS modifiée. + // Vérifie HubSpot. Comme les contacts ne sont créés QU'APRÈS confirmation + // email, ce check ne retourne que les vrais clients déjà inscrits (pas + // les inscriptions en attente de confirmation). const existing = await checkExistingContact(email); if (existing) { setLoading(false); showAlreadyRegistered(existing); - return; // ← arrêt complet, aucune soumission + return; } // ───────────────────────────────────────────────────────────────────────── - // Nouveau client : génération du numéro de référence unique - const refNumber = await generateRefNumber(); - const data = { firstname: form.firstname.value.trim(), lastname: form.lastname.value.trim(), phone: form.phone.value.trim(), email: email, address: form.address.value.trim(), - reference_client: refNumber, }; - const results = await Promise.allSettled([ - submitToHubSpot(data), - submitToFormspree(data), - ]); - - const hubspotOk = results[0].status === 'fulfilled'; - const formspreeOk = results[1].status === 'fulfilled'; + // ── ENVOI VERS LE WORKER ────────────────────────────────────────────────── + // Le Worker stocke les données en KV (24h), envoie un email de validation + // via Brevo. Le contact n'est créé dans HubSpot QUE quand l'utilisateur + // clique sur le lien de confirmation (anti-pollution du CRM). + let ok = false; + try { + const res = await fetch(WORKER_PROXY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'requestVerification', + ...data, + turnstile_token: window.turnstileToken || '', + }), + }); + const result = await res.json().catch(() => ({})); + ok = res.ok && result.ok === true; + } catch (err) { + console.warn('[requestVerification]', err); + } setLoading(false); - if (hubspotOk || formspreeOk) { - showSuccess(refNumber, data); + if (ok) { + showSuccess(null, data); } else { showError(); } @@ -285,7 +295,10 @@ function setLoading(isLoading) { form?.classList.toggle('form-loading', isLoading); } -function showSuccess(refNumber, clientData) { +function showSuccess(_refNumber, _clientData) { + // L'envoi de l'email de validation est déjà fait dans setupContactForm + // via l'appel Worker requestVerification — on n'a plus rien à faire ici + // sauf afficher la confirmation à l'écran. const successEl = document.getElementById('formSuccess'); const form = document.getElementById('contactForm'); if (successEl) { @@ -293,36 +306,6 @@ function showSuccess(refNumber, clientData) { successEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } if (form) form.style.display = 'none'; - - // Envoi de l'email de bienvenue (avec la référence + l'adresse Paris) - // directement après inscription. Les bots qui soumettent avec un faux - // email ne reçoivent rien (boîte inexistante = bounce). Pour bloquer - // les bots qui utilisent de vrais emails, voir la protection honeypot - // dans validateForm() + le rate limit côté worker. - if (clientData) sendWelcomeEmail(clientData); -} - -// ── EMAIL DE VÉRIFICATION ───────────────────────────────────────────────────── -// Le Worker génère un token unique, le stocke en KV, et envoie un email -// de validation via Brevo (avec un lien de confirmation). Le contact ne -// reçoit la référence et l'adresse Paris qu'APRÈS avoir cliqué sur le lien. -async function sendWelcomeEmail(data) { - if (!WORKER_PROXY_URL) return; - try { - await fetch(WORKER_PROXY_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'requestVerification', - firstname: data.firstname, - email: data.email, - reference_client: data.reference_client, - turnstile_token: window.turnstileToken || '', - }), - }); - } catch (err) { - console.warn('Verification email failed:', err); - } } // ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ──────────────────────────