@ -1,675 +0,0 @@
// ============================================================
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend
// ============================================================
// Ce Worker gère le formulaire de contact via un flow double opt-in :
//
// 1) requestVerification : génère un token, stocke les données du formulaire en KV,
// envoie un email de validation (lien de confirmation) via Resend.
//
// 2) verifyToken : appelé quand le client clique sur le lien de confirmation.
// Crée le contact dans HubSpot (avec une référence générée à la volée),
// puis envoie le welcome email avec la référence + l'adresse du dépôt Paris.
// Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email.
//
// La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant
// validation de l'email — protection anti-bot et anti-cartons-vides.
//
// ============================================================
// DÉPLOIEMENT (Phase D du plan WordPress → static)
// ============================================================
//
// Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète.
//
// Secrets requis (`wrangler secret put <name>`) :
// • HUBSPOT_TOKEN = pat-eu1-... (read+write contacts)
// • RESEND_API_KEY = re_... (compte Resend partagé avec m4s-auth)
// • RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// • RESEND_FROM_NAME = nom affiché à l'expéditeur
// • PARIS_DEPOT_ADDRESS = adresse complète du dépôt Paris
// • TURNSTILE_SECRET = secret Cloudflare Turnstile (anti-bot)
// • SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// Bindings KV requis :
// • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken)
//
// ============================================================
const HUBSPOT _API = 'https://api.hubapi.com' ;
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: requestVerification ──────────────────────────
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
// et envoie un email de validation via Resend. 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 ) ;
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 || '' ,
lastname : body . lastname || '' ,
phone : body . phone || '' ,
email : body . email . toLowerCase ( ) . trim ( ) ,
address : body . address || '' ,
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. 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 ) ;
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 ) ;
// 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 || '' ,
} ) ;
}
try {
// 1) Récupère la ref existante si le contact est déjà dans HubSpot
// (réinscription après suppression d'un test, ou création via
// l'ancien flow Forms API). Sinon génère la prochaine ref.
let refNumber ;
try {
const existing = await searchContactByEmail ( token , tokenData . email ) ;
const existingResult = ( existing . results || [ ] ) [ 0 ] ;
const existingRef = existingResult ? . properties ? . reference _client ;
refNumber = existingRef || await getNextRef ( token ) ;
} catch ( _ ) {
// Si la search échoue (scope manquant, etc.), fallback : génère
// une nouvelle ref. Le Forms API gérera la dédup côté HubSpot.
refNumber = await getNextRef ( token ) ;
}
// 2) Création directe via CRM API (= more deterministic que Forms API
// qui peut accepter une submission sans réellement créer le contact
// à cause des filtres anti-spam ou de la config du Form HubSpot).
// Requires scope crm.objects.contacts.write.
// En cas de 409 (contact déjà existant), fallback sur PATCH par ID
// pour update les propriétés (= notamment reference_client).
const crmRes = 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 ( crmRes . status === 409 ) {
// Contact existe déjà — update via PATCH par ID
const search = await searchContactByEmail ( token , tokenData . email ) ;
const existing = ( search . results || [ ] ) [ 0 ] ;
if ( existing ? . id ) {
const patchRes = await fetch (
` ${ HUBSPOT _API } /crm/v3/objects/contacts/ ${ existing . id } ` ,
{
method : 'PATCH' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ token } ` ,
} ,
body : JSON . stringify ( {
properties : {
firstname : tokenData . firstname || existing . properties ? . firstname || '' ,
lastname : tokenData . lastname || existing . properties ? . lastname || '' ,
phone : tokenData . phone || existing . properties ? . phone || '' ,
address : tokenData . address || '' ,
reference _client : refNumber ,
} ,
} ) ,
}
) ;
if ( ! patchRes . ok ) {
const errTxt = await patchRes . text ( ) ;
throw new Error ( ` HubSpot CRM patch failed ${ patchRes . status } : ${ errTxt . slice ( 0 , 200 ) } ` ) ;
}
}
} else if ( ! crmRes . ok ) {
const errTxt = await crmRes . text ( ) ;
throw new Error ( ` HubSpot CRM create failed ${ crmRes . status } : ${ errTxt . slice ( 0 , 200 ) } ` ) ;
}
// 3) Envoie le welcome email avec ref + adresse Paris
const welcomeContact = { ... tokenData , reference _client : refNumber } ;
await sendWelcomeViaResend ( 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 ) ;
}
}
// ── action: sendWelcomeBack ─────────────────────────────
// Envoie un email "Vous êtes déjà inscrit" au client qui tente
// une ré-inscription. Idempotent côté HubSpot (= aucune création
// ni update de contact). Anti-bot via Turnstile + sanity check
// que l'email existe vraiment dans HubSpot avant d'envoyer.
if ( action === 'sendWelcomeBack' ) {
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 {
// Vérification : le contact existe bien (= prevent spam vers
// emails inconnus en passant un faux turnstile)
const search = await searchContactByEmail ( token , body . email ) ;
const existing = ( search . results || [ ] ) [ 0 ] ;
if ( ! existing ) {
return jsonResponse ( { ok : false , error : 'Contact not found' } , 404 ) ;
}
await sendWelcomeBackViaResend ( env , {
firstname : body . firstname || existing . properties ? . firstname || '' ,
email : body . email ,
reference _client : existing . properties ? . reference _client || '' ,
} ) ;
return jsonResponse ( { ok : true } ) ;
} catch ( err ) {
return jsonResponse ( { ok : false , error : err . message } , 500 ) ;
}
}
// ── 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 ) ;
}
} ,
} ;
// =============================================================
// HubSpot : recherches & lectures
// =============================================================
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 ) {
// Paginate through ALL HubSpot contacts with `reference_client` property to
// find the true numeric maximum. Previous version used `limit: 100` without
// pagination — produced collisions once the contact count exceeded 100
// because HubSpot search results don't guarantee ordering by ref. With
// pagination, we walk the full set: 100 per page × N pages until no more.
// For ~1000 contacts = 10 API calls. Acceptable cost given that this runs
// once per signup confirmation (= rare path).
let maxNum = 0 ;
let after ; // undefined on first iteration
do {
const body = {
filterGroups : [ {
filters : [ { propertyName : 'reference_client' , operator : 'HAS_PROPERTY' } ] ,
} ] ,
properties : [ 'reference_client' ] ,
limit : 100 ,
} ;
if ( after ) body . after = after ;
const res = await fetch ( ` ${ HUBSPOT _API } /crm/v3/objects/contacts/search ` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ token } ` ,
} ,
body : JSON . stringify ( body ) ,
} ) ;
if ( ! res . ok ) {
throw new Error ( ` HubSpot search failed: ${ res . status } ` ) ;
}
const data = await res . json ( ) ;
( 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 ;
}
} ) ;
after = data . paging ? . next ? . after ;
} while ( after ) ;
return 'MVA-' + String ( maxNum + 1 ) . padStart ( 3 , '0' ) ;
}
// =============================================================
// Resend : envoi d'emails transactionnels (verification + welcome)
// =============================================================
// Aligné avec m4s-auth (Phase 2.1) qui utilise déjà Resend en production.
// Le compte Resend (et le domaine vérifié) sont partagés entre m4s-auth et
// ce Worker — un seul fournisseur SMTP pour tout Mind4Solutions.
//
// Setup requis (`wrangler secret put <name>`) :
// - env.RESEND_API_KEY = clé API Resend (re_...)
// - env.RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// - env.RESEND_FROM_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
// - env.SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// API doc : https://resend.com/docs/api-reference/emails/send-email
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 fromEmail = env . RESEND _FROM _EMAIL || 'noreply@mva-globalfret.com' ;
const fromName = env . RESEND _FROM _NAME || 'MVA Global Fret' ;
const res = await fetch ( RESEND _API , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ env . RESEND _API _KEY } ` ,
} ,
body : JSON . stringify ( {
from : ` ${ fromName } < ${ fromEmail } > ` ,
to : [ to ] ,
subject : subject ,
html : 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-globalfret.com' ;
const verifyUrl = ` ${ siteUrl } /confirmation.html?token= ${ verToken } ` ;
const logoUrl = ` ${ siteUrl } /PNG%20MVA%20GLOBAL%20FRET.png ` ;
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: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;" >
< / t d >
< 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 < / d i v >
< div style = "color:#fff;font-size:13px;margin-top:6px;" > Fret Aérien Paris — Antananarivo < / d i v >
< / t d >
< / t r >
< / t a b l e >
< / d i v >
< 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 < / s t r o n g > !
< / p >
< p style = "color:#333;line-height:1.6;" >
Pour finaliser votre inscription et recevoir votre < strong > numéro de référence client < / s t r o n g >
ainsi que < strong > l ' adresse de notre dépôt à Paris < / s t r o n g > , c l i q u e z s u r l e b o u t o n c i - d e s s o u s :
< / 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 >
< / d i v >
< p style = "color:#666;font-size:13px;line-height:1.6;" >
Ce lien est valable < strong > 24 heures < / s t r o n g > . S i v o u s n ' ê t e s p a s à l ' o r i g i n e d e c e t t e i n s c r i p t i o n , i g n o r e z s i m p l e m e n t c e t e m a i l .
< / 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 } < / s p a n >
< / p >
< / d i v >
< div style = "background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;" >
© 2026 MVA Global Fret — Tous droits réservés
< / d i v >
< / d i v >
< / b o d y >
< / h t m l > ` ;
return resendSend ( env , {
to : contact . email ,
subject : 'Confirmez votre inscription chez MVA Global Fret' ,
html ,
} ) ;
}
async function sendWelcomeViaResend ( env , contact ) {
const siteUrl = env . SITE _URL || 'https://mva-globalfret.com' ;
const logoUrl = ` ${ siteUrl } /PNG%20MVA%20GLOBAL%20FRET.png ` ;
const firstname = escapeHtml ( contact . firstname || '' ) ;
const ref = escapeHtml ( contact . reference _client || '' ) ;
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 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: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;" >
< / t d >
< 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 < / d i v >
< div style = "color:#fff;font-size:13px;margin-top:6px;" > Fret Aérien Paris — Antananarivo < / d i v >
< / t d >
< / t r >
< / t a b l e >
< / d i v >
< 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 < / s t r o n g > ! V o t r e e m a i l e s t c o n f i r m é ,
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 >
< / d i v >
< p style = "color:#333;margin-top:28px;" > < strong > L 'adresse à Paris pour l' envoi de vos colis : < / s t r o n g > < / 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 }
< / d i v >
< div style = "background:#fff3cd;border:1px solid #ffc107;padding:14px 18px;border-radius:6px;margin:16px 0;" >
< p style = "margin:0 0 8px;color:#856404;font-size:14px;font-weight:bold;" >
⚠ ️ Important : ne modifiez rien à ces informations .
< / p >
< p style = "margin:0;color:#856404;font-size:14px;line-height:1.5;" >
Recopiez l 'adresse <strong>exactement telle qu' elle est indiquée ci - dessus < / s t r o n g > ,
sans rien retirer ni ajouter . Votre numéro de référence < strong > $ { ref } < / s t r o n g >
fait partie intégrante de l 'adresse — c' est ce qui garantit que votre colis nous arrive bien .
< / p >
< / d i v >
< 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 >
< / d i v >
< div style = "background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;" >
© 2026 MVA Global Fret — Tous droits réservés
< / d i v >
< / d i v >
< / b o d y >
< / h t m l > ` ;
return resendSend ( env , {
to : contact . email ,
subject : ` Bienvenue chez MVA Global Fret — Votre référence ${ ref } ` ,
html ,
} ) ;
}
// Email "Ravis de vous revoir" pour les clients déjà inscrits qui retentent
// le formulaire de contact. Reprend EXACTEMENT le template original (= avant
// migration EmailJS \xe2\x86\x92 Resend) car son contenu est strat\xc3\xa9gique \xe2\x80\x94 rappel
// adresse Paris + warning anti-modification + r\xe9f\xe9rence client. Seules
// modifications : footer (c) 2025 \xe2\x86\x92 \xc2\xa9 2026, suppression du tag
// "Email sent via EmailJS.com" (obsol\xe8te depuis Resend), URL logo
// pointe vers le nouveau domaine, et adresse Paris injecte la ref via
// le placeholder {{ref}} de PARIS_DEPOT_ADDRESS.
//
// Idempotent c\xf4t\xe9 HubSpot (= z\xe9ro write).
async function sendWelcomeBackViaResend ( env , contact ) {
const siteUrl = env . SITE _URL || 'https://mva-globalfret.com' ;
const logoUrl = ` ${ siteUrl } /PNG%20MVA%20GLOBAL%20FRET.png ` ;
const firstnameRaw = contact . firstname || '' ;
const firstname = escapeHtml ( firstnameRaw ) ;
const refRaw = contact . reference _client || '' ;
const ref = escapeHtml ( refRaw ) ;
// Construction adresse Paris (= m\xeame logique que sendWelcomeViaResend) :
// injecte la ref client soit via placeholder {{ref}}, soit en l'ajoutant
// entre parenth\xe8ses sur la 1\xe8re ligne (= pattern original "VASTA Mélissa (MVA-XXX)").
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' ) ;
}
// 1\xe8re ligne en gras (= match original `<strong>VASTA Melissa (MVA-XXX)</strong>`)
const addrLines = escapeHtml ( parisAddrRaw ) . split ( '\n' ) ;
const parisAddrHtml = addrLines . length > 1
? ` <strong> ${ addrLines [ 0 ] } </strong><br> ${ addrLines . slice ( 1 ) . join ( '<br>' ) } `
: escapeHtml ( parisAddrRaw ) ;
const greetingTitle = firstnameRaw
? ` Ravis de vous revoir, ${ firstname } ! `
: 'Ravis de vous revoir !' ;
const html = ` <html lang=""><body><div style="font-family:Arial,sans-serif;font-size:16px;background-color:#f5f5f5;padding:20px">
< div style = "max-width:600px;margin:auto;background-color:#ffffff;border-radius:8px;overflow:hidden" >
< div style = "background-color:#1a1a3e;padding:30px 40px;text-align:center" >
< table width = "100%" cellpadding = "0" cellspacing = "0" role = "presentation" > < tr > < td width = "145" style = "padding:15px 0 15px 20px;vertical-align:middle" > < img src = "${logoUrl}" width = "130" height = "130" alt = "MVA" style = "display:block;" > < / t d > < t d s t y l e = " t e x t - a l i g n : c e n t e r ; p a d d i n g : 1 5 p x 7 5 p x 1 5 p x 0 ; v e r t i c a l - a l i g n : m i d d l e " > < d i v s t y l e = " c o l o r : # c 5 a 5 5 a ; f o n t - s i z e : 2 2 p x ; f o n t - w e i g h t : 7 0 0 ; l e t t e r - s p a c i n g : 2 p x ; f o n t - f a m i l y : A r i a l , s a n s - s e r i f " > M V A G L O B A L F R E T < / d i v > < d i v s t y l e = " c o l o r : # f f f f f f ; f o n t - s i z e : 1 2 p x ; m a r g i n - t o p : 4 p x ; f o n t - f a m i l y : A r i a l , s a n s - s e r i f " > F r e t A e r i e n P a r i s - A n t a n a n a r i v o < / d i v > < / t d > < / t r > < / t a b l e >
< / d i v >
< div style = "padding:40px" >
< p style = "color:#1a1a3e;font-size:22px;font-weight:bold;margin-top:0" > $ { greetingTitle } < / p >
< p style = "color:#333333" > Nous avons bien recu votre nouvelle tentative d & # 39 ; inscription . Pas d & # 39 ; inquietude : vous etes < strong > deja client < / s t r o n g > c h e z M V A G l o b a l F r e t ! < / p >
< p style = "color:#333333" > Voici un rappel de votre numero de reference client : < / p >
< div style = "background-color:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;text-align:center" >
< p style = "margin:0;color:#1a1a3e;font-size:14px;letter-spacing:1px" > VOTRE NUMERO DE REFERENCE CLIENT < / p >
< p style = "margin:8px 0 0 0;color:#1a1a3e;font-size:28px;font-weight:bold;letter-spacing:2px" > $ { ref } < / p >
< p style = "margin:6px 0 0 0;color:#666666;font-size:12px" > Conservez ce numero precieusement . < / p >
< / d i v >
< p style = "color:#333333;margin-top:28px" > < strong > L & # 39 ; adresse a Paris pour l & # 39 ; envoi de vos colis est : < / s t r o n g > < / p >
< div style = "background-color:#f9f9f9;border:1px solid #dddddd;padding:20px 24px;border-radius:6px;margin:12px 0 24px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e" >
$ { parisAddrHtml }
< / d i v >
< div style = "background-color:#fff3cd;border:1px solid #ffc107;padding:16px 20px;border-radius:6px;margin:24px 0" >
< p style = "margin:0;color:#856404;font-size:14px" > < strong > IMPORTANT : < / s t r o n g > C e t t e a d r e s s e n e d o i t e t r e c h a n g e e s o u s a u c u n p r e t e x t e . T o u t e m o d i f i c a t i o n e m p e c h e r a i t l a b o n n e t r a n s m i s s i o n d e v o t r e c o l i s a n o t r e d e p o t a P a r i s . < / p >
< / d i v >
< p style = "color:#333333" > Pour toute question , n & # 39 ; hesitez pas a nous contacter : < / p >
< ul style = "color:#333333;line-height:2" >
< li > < a href = "mailto:mvaglobalfret@gmail.com" style = "color:#c5a55a" > mvaglobalfret @ gmail . com < / a > < / l i >
< li > < a href = "tel:+33780970825" style = "color:#c5a55a" > + 33 7 80 97 08 25 < / a > ( F r a n c e ) < / l i >
< li > < a href = "tel:+261384973751" style = "color:#c5a55a" > + 261 38 49 737 51 < / a > ( M a d a g a s c a r ) < / l i >
< / u l >
< p style = "color:#333333;margin-top:32px" > A tres bientot pour votre prochain envoi , < br > < strong > L & # 39 ; equipe MVA Global Fret < / s t r o n g > < / p >
< / d i v >
< div style = "background-color:#1a1a3e;color:rgba(255,255,255,0.6);padding:16px;text-align:center;font-size:12px" >
( c ) 2026 MVA Global Fret - Antananarivo 101 , Madagascar
< / d i v >
< / d i v >
< / d i v > < / b o d y > < / h t m l > ` ;
// Subject : reprend strictement le sujet original "Ravis de vous revoir, [firstname] !"
// (= en cas de firstname vide, fallback sans virgule).
const subjectFirstname = firstnameRaw . replace ( /[\r\n]/g , '' ) . trim ( ) ;
return resendSend ( env , {
to : contact . email ,
subject : subjectFirstname
? ` Ravis de vous revoir, ${ subjectFirstname } ! `
: 'Ravis de vous revoir !' ,
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' } ,
} ) ;
}