Anti-spam: only register HubSpot contact AFTER email confirmation

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) <noreply@anthropic.com>
This commit is contained in:
MVA Global Fret 2026-05-06 13:50:32 +02:00
parent 82bc8ba358
commit 07ccec0808
2 changed files with 143 additions and 73 deletions

View File

@ -130,10 +130,10 @@ export default {
} }
// ── action: requestVerification ────────────────────────── // ── action: requestVerification ──────────────────────────
// Génère un token unique, le stocke en KV avec les infos du contact, // Génère un token unique, stocke TOUTES les données du formulaire en KV,
// et envoie un email de validation via Brevo (avec un lien de // et envoie un email de validation via Brevo. Le contact n'est créé
// confirmation). Le contact ne reçoit la référence et l'adresse // dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
// Paris qu'APRÈS avoir cliqué sur le lien. // les inscriptions non vérifiées ne polluent pas le CRM).
// Anti-bot : Turnstile vérifié d'abord. // Anti-bot : Turnstile vérifié d'abord.
if (action === 'requestVerification') { if (action === 'requestVerification') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400); if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
@ -147,8 +147,10 @@ export default {
const verToken = crypto.randomUUID().replace(/-/g, ''); const verToken = crypto.randomUUID().replace(/-/g, '');
const tokenData = { const tokenData = {
firstname : body.firstname || '', firstname : body.firstname || '',
lastname : body.lastname || '',
phone : body.phone || '',
email : body.email.toLowerCase().trim(), email : body.email.toLowerCase().trim(),
reference_client : body.reference_client || '', address : body.address || '',
createdAt : new Date().toISOString(), createdAt : new Date().toISOString(),
}; };
@ -169,9 +171,10 @@ export default {
// ── action: verifyToken ────────────────────────────────── // ── action: verifyToken ──────────────────────────────────
// Appelé par confirmation.html quand l'utilisateur clique sur // Appelé par confirmation.html quand l'utilisateur clique sur
// le lien dans l'email de validation. Lit le token en KV, // le lien dans l'email de validation. C'est ICI que le contact
// envoie le welcome (avec ref + adresse Paris) via Brevo, // est CRÉÉ dans HubSpot (avec une référence générée à la volée),
// puis supprime le token (one-time use). // 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 (action === 'verifyToken') {
if (!body.token) return jsonResponse({ error: 'token requis' }, 400); if (!body.token) return jsonResponse({ error: 'token requis' }, 400);
if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500); if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
@ -183,17 +186,67 @@ export default {
} }
const tokenData = JSON.parse(raw); const tokenData = JSON.parse(raw);
try { // Idempotence : si déjà consommé, retourne le résultat précédent
await sendWelcomeViaBrevo(env, tokenData); // sans recréer le contact ni renvoyer d'email.
// Marque le token consommé (gardé 7j pour idempotence en cas de if (tokenData.used) {
// 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,
});
return jsonResponse({ return jsonResponse({
ok: true, ok: true,
firstname : tokenData.firstname, 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) { } catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500); 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) { async function sendVerificationEmail(env, contact, verToken) {
const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret'; const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret';
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`; const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || ''); const firstname = escapeHtml(contact.firstname || '');
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;"> <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="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:30px;text-align:center;"> <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:#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> <div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div> </div>
<div style="padding:40px;"> <div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p> <p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
@ -521,18 +584,42 @@ async function sendVerificationEmail(env, contact, verToken) {
} }
async function sendWelcomeViaBrevo(env, contact) { 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 firstname = escapeHtml(contact.firstname || '');
const ref = escapeHtml(contact.reference_client || ''); 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, '<br>'); const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '<br>');
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;"> <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="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:30px;text-align:center;"> <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:#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> <div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div> </div>
<div style="padding:40px;"> <div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p> <p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>

View File

@ -98,40 +98,50 @@ function setupContactForm(form) {
const email = form.email.value.trim(); const email = form.email.value.trim();
// ── VÉRIFICATION DOUBLON ────────────────────────────────────────────────── // ── VÉRIFICATION DOUBLON ──────────────────────────────────────────────────
// Si le client existe déjà, on affiche uniquement un message informatif. // Vérifie HubSpot. Comme les contacts ne sont créés QU'APRÈS confirmation
// On ne soumet RIEN à HubSpot — la référence existante n'est JAMAIS modifiée. // email, ce check ne retourne que les vrais clients déjà inscrits (pas
// les inscriptions en attente de confirmation).
const existing = await checkExistingContact(email); const existing = await checkExistingContact(email);
if (existing) { if (existing) {
setLoading(false); setLoading(false);
showAlreadyRegistered(existing); 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 = { const data = {
firstname: form.firstname.value.trim(), firstname: form.firstname.value.trim(),
lastname: form.lastname.value.trim(), lastname: form.lastname.value.trim(),
phone: form.phone.value.trim(), phone: form.phone.value.trim(),
email: email, email: email,
address: form.address.value.trim(), address: form.address.value.trim(),
reference_client: refNumber,
}; };
const results = await Promise.allSettled([ // ── ENVOI VERS LE WORKER ──────────────────────────────────────────────────
submitToHubSpot(data), // Le Worker stocke les données en KV (24h), envoie un email de validation
submitToFormspree(data), // 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;
const hubspotOk = results[0].status === 'fulfilled'; try {
const formspreeOk = results[1].status === 'fulfilled'; 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); setLoading(false);
if (hubspotOk || formspreeOk) { if (ok) {
showSuccess(refNumber, data); showSuccess(null, data);
} else { } else {
showError(); showError();
} }
@ -285,7 +295,10 @@ function setLoading(isLoading) {
form?.classList.toggle('form-loading', 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 successEl = document.getElementById('formSuccess');
const form = document.getElementById('contactForm'); const form = document.getElementById('contactForm');
if (successEl) { if (successEl) {
@ -293,36 +306,6 @@ function showSuccess(refNumber, clientData) {
successEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); successEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
if (form) form.style.display = 'none'; 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) ────────────────────────── // ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ──────────────────────────