Merge pull request 'chore: remove decommissioned cloudflare-worker' (#15) from chore/remove-decommissioned-cloudflare-worker into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled

This commit is contained in:
serge 2026-05-16 17:41:54 +03:00
commit b6b492f224
4 changed files with 0 additions and 844 deletions

View File

@ -1 +0,0 @@
.wrangler/

View File

@ -1,149 +0,0 @@
# Déploiement Cloudflare Worker — Double opt-in MVA via Resend
Ce Worker gère le flow double opt-in du formulaire de contact :
1. **`requestVerification`** — génère un token, stocke les données du formulaire en KV, envoie un email de validation via Resend.
2. **`verifyToken`** — appelé quand le client clique sur le lien dans l'email. Crée le contact dans HubSpot (avec une référence à la volée) et envoie le welcome email avec la référence + l'adresse du dépôt Paris.
Le Worker est aussi un proxy HubSpot pour la vérification de doublon par email et la génération du prochain numéro de référence séquentiel.
L'email de bienvenue contient le **numéro de référence client** ET **l'adresse du dépôt à Paris**. Ces infos ne sont **jamais** envoyées avant validation de l'email — protection contre les bots et les expéditions de cartons vides.
---
## Étapes de déploiement (Phase D du plan WordPress → static)
### 1. Mettre à jour le code du Worker
Préférer `wrangler` :
```bash
cd cloudflare-worker
wrangler deploy
```
Ou via le dashboard Cloudflare :
1. Aller sur https://dash.cloudflare.com/
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
3. Onglet **Modifier le code** (ou *Quick edit*)
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js`
5. Cliquer **Déployer / Save and deploy**
### 2. Secrets
Dans **Paramètres → Variables et secrets** (ou via CLI : `wrangler secret put <name>`) :
| Nom | Valeur |
|-----|--------|
| `HUBSPOT_TOKEN` | `pat-eu1-...` (scope 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 (ex: `MVA Global Fret`) |
| `PARIS_DEPOT_ADDRESS` | **Ton adresse exacte à Paris** (rue, code postal, ville, étage, nom à indiquer sur le carton…) |
| `TURNSTILE_SECRET` | secret Cloudflare Turnstile (anti-bot) |
| `SITE_URL` | base URL du site (ex: `https://mva-globalfret.com`) |
> `PARIS_DEPOT_ADDRESS` est l'info la plus sensible — c'est l'adresse à protéger. Elle ne quitte jamais Cloudflare/Resend et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email.
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
Dans **Paramètres → Stockage et bases de données → Bindings KV** :
1. Cliquer **Ajouter un binding**
2. Variable name : **`WELCOME_KV`**
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
4. Sauvegarder
Si déployé via `wrangler` : `wrangler.toml` contient déjà l'ID du namespace KV (`c02656ba22064923ab1c6db06b0f4a56` sur le compte CF `sergemind4s@gmail.com`). Pour un autre compte, recréer le namespace via `wrangler kv namespace create WELCOME_KV` puis remplacer l'ID dans `wrangler.toml`.
### 4. Vérifier le scope HubSpot
Le token HubSpot doit avoir :
- `crm.objects.contacts.read`
- `crm.objects.contacts.write`
- `crm.lists.read`
Si tu obtiens des erreurs 403, regénère le token sur https://app-eu1.hubspot.com/private-apps/148163754/
### 5. Resend — vérifier le domaine expéditeur
Le compte Resend (partagé avec m4s-auth) doit avoir le domaine de `RESEND_FROM_EMAIL` vérifié (DNS records SPF + DKIM). Voir https://resend.com/domains.
---
## Test manuel
Une fois tout déployé, tu peux tester le flow `requestVerification` (token de test, ne pas réutiliser en prod) :
```bash
curl -X POST https://mva-hubspot-proxy.<account>.workers.dev \
-H "Content-Type: application/json" \
-d '{
"action": "requestVerification",
"firstname": "Test",
"lastname": "User",
"email": "test@example.com",
"phone": "+33000000000",
"address": "Test address",
"turnstile_token": "<token from Turnstile widget>"
}'
```
Réponse attendue :
```json
{ "ok": true }
```
Le client reçoit alors un email de validation. En cliquant sur le lien, il déclenche `verifyToken`, qui crée le contact dans HubSpot et envoie le welcome email avec la référence + l'adresse Paris.
---
## Logs en production
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution du Worker.
---
## En cas de problème
| Symptôme | Cause probable | Fix |
|---|---|---|
| `Resend 401: API key invalid` | Mauvaise valeur dans `RESEND_API_KEY` | Re-vérifier la clé sur https://resend.com/api-keys |
| `Resend 422: domain is not verified` | Domaine de `RESEND_FROM_EMAIL` pas vérifié | Vérifier le domaine sur https://resend.com/domains |
| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope `contacts.write` |
| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token |
| `KV not bound` | Binding `WELCOME_KV` pas créé | Vérifier *Storage & Databases → KV bindings* |
| `Turnstile validation failed` | Secret côté Worker ne match pas la sitekey côté front | Re-vérifier `TURNSTILE_SECRET` et la sitekey HTML |
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ 1. User remplit le formulaire sur contact.html │
│ ↓ │
│ 2. form-handler.js → POST action: requestVerification │
│ ↓ │
│ 3. Worker valide Turnstile, génère un token, stocke les │
│ données du formulaire en KV (TTL 24h), envoie l'email │
│ de validation via Resend │
│ ↓ │
│ ─────── User clique sur « Confirmer » ─────── │
│ ↓ │
│ 4. confirmation.html → POST action: verifyToken │
│ ↓ │
│ 5. Worker récupère les données du KV, génère la prochaine │
│ référence client, soumet via HubSpot Forms API (= contact │
│ créé dans le CRM), envoie le welcome email via Resend │
│ (avec la référence + adresse Paris), marque le token │
│ consommé en KV (TTL 7j pour idempotence). │
│ ↓ │
│ 6. Le client reçoit son welcome email avec sa référence ET │
│ l'adresse de dépôt à Paris. │
└──────────────────────────────────────────────────────────────────┘
```
**Conclusion** : il est impossible pour un bot de récupérer la référence client ou l'adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé.

View File

@ -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;">
</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:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</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 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;">
</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:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</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 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</strong>,
sans rien retirer ni ajouter. Votre numéro de référence <strong>${ref}</strong>
fait partie intégrante de l'adresse — c'est ce qui garantit que votre colis nous arrive bien.
</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,
});
}
// 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;"></td><td style="text-align:center;padding:15px 75px 15px 0;vertical-align:middle"><div style="color:#c5a55a;font-size:22px;font-weight:700;letter-spacing:2px;font-family:Arial,sans-serif">MVA GLOBAL FRET</div><div style="color:#ffffff;font-size:12px;margin-top:4px;font-family:Arial,sans-serif">Fret Aerien Paris - Antananarivo</div></td></tr></table>
</div>
<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</strong> chez MVA Global Fret !</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>
</div>
<p style="color:#333333;margin-top:28px"><strong>L&#39;adresse a Paris pour l&#39;envoi de vos colis est :</strong></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}
</div>
<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 :</strong> Cette adresse ne doit etre changee sous aucun pretexte. Toute modification empecherait la bonne transmission de votre colis a notre depot a Paris.</p>
</div>
<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></li>
<li><a href="tel:+33780970825" style="color:#c5a55a">+33 7 80 97 08 25</a> (France)</li>
<li><a href="tel:+261384973751" style="color:#c5a55a">+261 38 49 737 51</a> (Madagascar)</li>
</ul>
<p style="color:#333333;margin-top:32px">A tres bientot pour votre prochain envoi,<br><strong>L&#39;equipe MVA Global Fret</strong></p>
</div>
<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
</div>
</div>
</div></body></html>`;
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// =============================================================
// 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' },
});
}

View File

@ -1,19 +0,0 @@
name = "mva-hubspot-proxy"
main = "hubspot-proxy.js"
compatibility_date = "2026-04-01"
workers_dev = true
# KV namespace — placeholder ID, populated at deploy time (Phase D3)
[[kv_namespaces]]
binding = "WELCOME_KV"
id = "c02656ba22064923ab1c6db06b0f4a56"
# Required secrets to configure via `wrangler secret put` :
# - HUBSPOT_TOKEN
# - RESEND_API_KEY
# - RESEND_FROM_EMAIL
# - RESEND_FROM_NAME
# - PARIS_DEPOT_ADDRESS
# - TURNSTILE_SECRET
# - SITE_URL
# (BREVO_* and EMAILJS_* deprecated — removed in this version)