chore: remove decommissioned cloudflare-worker #15
1
cloudflare-worker/.gitignore
vendored
1
cloudflare-worker/.gitignore
vendored
@ -1 +0,0 @@
|
||||
.wrangler/
|
||||
@ -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é.
|
||||
@ -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'inscription. Pas d'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'adresse a Paris pour l'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'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'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, '&').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' },
|
||||
});
|
||||
}
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user