Architecture finale :
1. User remplit formulaire + passe Turnstile CAPTCHA → form-handler.js
2. form-handler.js POST au Worker avec action 'requestVerification'
3. Worker valide Turnstile, génère un token UUID, le stocke en KV (TTL 24h)
avec firstname/email/reference_client, puis envoie un email via Resend
avec un lien : confirmation.html?token=XXX
4. User reçoit email, clique 'Confirmer mon email'
5. confirmation.html lit le token de l'URL, POST au Worker avec action
'verifyToken'
6. Worker valide le token, envoie le welcome email via Resend (avec ref +
adresse Paris depuis env var), marque le token comme utilisé
7. confirmation.html affiche 'Inscription confirmée !'
Ainsi : ref + adresse Paris ne sortent JAMAIS avant validation email,
et les bots sont bloqués à l'étape 1 par Turnstile.
Setup Cloudflare requis (côté user) :
- RESEND_API_KEY : clé API Resend (re_...)
- RESEND_FROM : adresse expéditrice ('onboarding@resend.dev' pour test,
ou domain vérifié pour prod)
- SITE_URL : optionnel, défaut https://mva-global-fret.github.io/site-mva-global-fret
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
618 lines
25 KiB
JavaScript
618 lines
25 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: requestVerification ──────────────────────────
|
|
// Génère un token unique, le stocke en KV avec les infos du contact,
|
|
// et envoie un email de validation via Resend (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.
|
|
// 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 || '',
|
|
email : body.email.toLowerCase().trim(),
|
|
reference_client : body.reference_client || '',
|
|
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. Lit le token en KV,
|
|
// envoie le welcome (avec ref + adresse Paris) via Resend,
|
|
// puis supprime le token (one-time use).
|
|
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);
|
|
|
|
try {
|
|
await sendWelcomeViaResend(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,
|
|
});
|
|
return jsonResponse({
|
|
ok: true,
|
|
firstname : tokenData.firstname,
|
|
reference_client : tokenData.reference_client,
|
|
});
|
|
} 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}`);
|
|
}
|
|
}
|
|
|
|
// =============================================================
|
|
// Resend : envoi d'emails (verification + welcome)
|
|
// =============================================================
|
|
// Resend est utilisé pour envoyer les emails car il ne nécessite
|
|
// pas de template séparé (on construit le HTML directement dans le
|
|
// Worker). Free tier : 100 emails/jour, 3000/mois.
|
|
//
|
|
// Setup requis :
|
|
// - env.RESEND_API_KEY = clé API Resend (re_...)
|
|
// - env.RESEND_FROM = adresse expéditrice vérifiée chez Resend
|
|
// (ex: "MVA Global Fret <noreply@mvaglobalfret.com>")
|
|
// Pour test : "onboarding@resend.dev"
|
|
// - env.SITE_URL = base URL du site (ex: "https://mva-global-fret.github.io/site-mva-global-fret")
|
|
|
|
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 from = env.RESEND_FROM || 'MVA Global Fret <onboarding@resend.dev>';
|
|
|
|
const res = await fetch(RESEND_API, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ from, to: [to], subject, 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-global-fret.github.io/site-mva-global-fret';
|
|
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
|
|
const firstname = escapeHtml(contact.firstname || '');
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<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="background:#1a1a3e;padding:30px;text-align:center;">
|
|
<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>
|
|
<div style="padding:40px;">
|
|
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
|
|
<p style="color:#333;line-height:1.6;">
|
|
Merci pour votre inscription chez <strong>MVA Global Fret</strong> !
|
|
</p>
|
|
<p style="color:#333;line-height:1.6;">
|
|
Pour finaliser votre inscription et recevoir votre <strong>numéro de référence client</strong>
|
|
ainsi que <strong>l'adresse de notre dépôt à Paris</strong>, cliquez sur le bouton ci-dessous :
|
|
</p>
|
|
<div style="text-align:center;margin:32px 0;">
|
|
<a href="${verifyUrl}" style="display:inline-block;background:#c5a55a;color:#1a1a3e;padding:16px 40px;border-radius:50px;text-decoration:none;font-weight:700;font-size:16px;letter-spacing:0.5px;">
|
|
✓ Confirmer mon email
|
|
</a>
|
|
</div>
|
|
<p style="color:#666;font-size:13px;line-height:1.6;">
|
|
Ce lien est valable <strong>24 heures</strong>. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email.
|
|
</p>
|
|
<p style="color:#666;font-size:12px;line-height:1.6;border-top:1px solid #eee;padding-top:18px;margin-top:30px;">
|
|
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
|
<span style="color:#c5a55a;word-break:break-all;">${verifyUrl}</span>
|
|
</p>
|
|
</div>
|
|
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
|
|
© 2026 MVA Global Fret — Tous droits réservés
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
return resendSend(env, {
|
|
to: contact.email,
|
|
subject: 'Confirmez votre inscription chez MVA Global Fret',
|
|
html,
|
|
});
|
|
}
|
|
|
|
async function sendWelcomeViaResend(env, contact) {
|
|
const firstname = escapeHtml(contact.firstname || '');
|
|
const ref = escapeHtml(contact.reference_client || '');
|
|
const parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
|
|
const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '<br>');
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<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="background:#1a1a3e;padding:30px;text-align:center;">
|
|
<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>
|
|
<div style="padding:40px;">
|
|
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
|
|
<p style="color:#333;line-height:1.6;">
|
|
Bienvenu(e) chez <strong>MVA Global Fret</strong> ! Votre email est confirmé,
|
|
votre inscription est désormais active.
|
|
</p>
|
|
<div style="background:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;">
|
|
<p style="margin:0;color:#1a1a3e;font-size:14px;">Votre numéro de référence client :</p>
|
|
<p style="margin:8px 0 0;color:#1a1a3e;font-size:22px;font-weight:bold;letter-spacing:2px;">${ref}</p>
|
|
<p style="margin:6px 0 0;color:#666;font-size:12px;">Conservez ce numéro précieusement.</p>
|
|
</div>
|
|
<p style="color:#333;margin-top:28px;"><strong>L'adresse à Paris pour l'envoi de vos colis :</strong></p>
|
|
<div style="background:#f9f9f9;border:1px solid #ddd;padding:20px 24px;border-radius:6px;margin:12px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e;">
|
|
${parisAddr}
|
|
</div>
|
|
<div style="background:#fff3cd;border:1px solid #ffc107;padding:14px 18px;border-radius:6px;margin:16px 0;">
|
|
<p style="margin:0;color:#856404;font-size:14px;">
|
|
📌 <strong>Sur le colis</strong>, indiquez votre numéro de référence <strong>${ref}</strong>
|
|
juste après le nom du destinataire entre parenthèses.
|
|
</p>
|
|
</div>
|
|
<p style="color:#333;line-height:1.6;">
|
|
Pour toute question, contactez-nous :<br>
|
|
📧 mvaglobalfret@gmail.com<br>
|
|
📞 +33 7 80 97 08 25 (France) — +261 38 49 737 51 (Madagascar)
|
|
</p>
|
|
</div>
|
|
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
|
|
© 2026 MVA Global Fret — Tous droits réservés
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
return resendSend(env, {
|
|
to: contact.email,
|
|
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
|
|
html,
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&').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' },
|
|
});
|
|
}
|