- contact.html: ajout du widget Turnstile (site key: 0x4AAAAAADKDuc7Rmlb1svIL) - form-handler.js: blocage de la soumission si pas de token Turnstile valide - Worker: validation server-side du token via /turnstile/v0/siteverify avant chaque appel sendWelcomeNow → bloque les bots qui n'auraient pas passé le challenge côté client. Le secret Turnstile est en env var Cloudflare (TURNSTILE_SECRET). Limite humain : Turnstile détecte les bots avec très peu d'interaction côté utilisateur (mode 'Managed', le plus souvent invisible). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
400 lines
15 KiB
JavaScript
400 lines
15 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 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' },
|
|
});
|
|
}
|