refactor(worker): migrate Brevo+EmailJS → Resend + remove cron + fix github.io URLs
- Replace Brevo HTTP API calls with Resend (api.resend.com/emails) — aligns with m4s-auth Phase 2.1 SMTP provider - Remove scheduled handler and processWelcomeQueue (EmailJS cron */5 * * * *) → eliminates double-send race window identified in Phase 2 audit - Rename sendWelcomeViaBrevo → sendWelcomeViaResend - Fix 3 hardcoded GH Pages URLs (lines ~234, ~541, ~600) → mva-globalfret.com fallback - Add wrangler.toml: KV binding placeholder, no [triggers] cron, secrets list - Update DEPLOIEMENT.md: env vars list (Resend in, Brevo+EmailJS out) DO NOT MERGE until new Cloudflare account + Worker deployment Phase D ready (merge écarterait l'ancien Worker Melissa actif si on revenait dessus). Refs: WordPress → static migration plan, Phase B PR #3a.
This commit is contained in:
parent
2d3526da06
commit
e4b4992e67
@ -1,38 +1,50 @@
|
|||||||
# Déploiement Cloudflare Worker — Welcome cron MVA
|
# Déploiement Cloudflare Worker — Double opt-in MVA via Resend
|
||||||
|
|
||||||
Ce Worker fait deux choses :
|
Ce Worker gère le flow double opt-in du formulaire de contact :
|
||||||
1. **Proxy HubSpot** (déjà fonctionnel — vérification doublon + numéro de référence)
|
|
||||||
2. **Cron Welcome** (NOUVEAU — envoie l'email de bienvenue **après** que le client a cliqué sur "Confirmer" dans le mail HubSpot de double opt-in)
|
|
||||||
|
|
||||||
L'email de bienvenue contient le **numéro de référence client** ET (à ajouter dans le template EmailJS) **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.
|
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 (10 min)
|
## Étapes de déploiement (Phase D du plan WordPress → static)
|
||||||
|
|
||||||
### 1. Mettre à jour le code du Worker
|
### 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/
|
1. Aller sur https://dash.cloudflare.com/
|
||||||
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
|
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
|
||||||
3. Onglet **Modifier le code** (ou *Quick edit*)
|
3. Onglet **Modifier le code** (ou *Quick edit*)
|
||||||
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js` (de ce repo)
|
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js`
|
||||||
5. Cliquer **Déployer / Save and deploy**
|
5. Cliquer **Déployer / Save and deploy**
|
||||||
|
|
||||||
### 2. Variables d'environnement
|
### 2. Secrets
|
||||||
|
|
||||||
Dans **Paramètres → Variables et secrets**, ajouter (si pas déjà là) :
|
Dans **Paramètres → Variables et secrets** (ou via CLI : `wrangler secret put <name>`) :
|
||||||
|
|
||||||
| Nom | Valeur |
|
| Nom | Valeur |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `HUBSPOT_TOKEN` | `pat-eu1-...` (existant — vérifier que le scope est read+write contacts) |
|
| `HUBSPOT_TOKEN` | `pat-eu1-...` (scope read+write contacts) |
|
||||||
| `EMAILJS_PUBLIC_KEY` | `8KUlaQ7BDVIbkZRyP` |
|
| `RESEND_API_KEY` | `re_...` (compte Resend partagé avec m4s-auth) |
|
||||||
| `EMAILJS_SERVICE_ID` | `service_aeamo3x` |
|
| `RESEND_FROM_EMAIL` | adresse expéditrice (domaine vérifié chez Resend) |
|
||||||
| `EMAILJS_TEMPLATE_ID` | `template_s1kr2et` |
|
| `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…) |
|
| `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 que tu veux protéger. Elle ne quitte jamais Cloudflare/EmailJS et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email.
|
> `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.
|
||||||
|
|
||||||
> Si une variable n'est pas définie, le Worker utilise le fallback hardcodé.
|
|
||||||
|
|
||||||
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
|
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
|
||||||
|
|
||||||
@ -43,100 +55,54 @@ Dans **Paramètres → Stockage et bases de données → Bindings KV** :
|
|||||||
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
|
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
|
||||||
4. Sauvegarder
|
4. Sauvegarder
|
||||||
|
|
||||||
### 4. Cron Trigger — toutes les 5 minutes
|
Si déployé via `wrangler` : mettre à jour `wrangler.toml` avec l'ID du namespace KV créé (remplacer `REPLACE_AT_DEPLOY_TIME`).
|
||||||
|
|
||||||
Dans **Paramètres → Déclencheurs (Triggers) → Cron Triggers** :
|
### 4. Vérifier le scope HubSpot
|
||||||
|
|
||||||
1. Cliquer **Add Cron Trigger**
|
|
||||||
2. Schedule : `*/5 * * * *`
|
|
||||||
3. Sauvegarder
|
|
||||||
|
|
||||||
### 5a. EmailJS — mettre à jour le template pour inclure l'adresse Paris
|
|
||||||
|
|
||||||
1. Aller sur https://dashboard.emailjs.com/admin/templates
|
|
||||||
2. Cliquer sur le template `template_s1kr2et`
|
|
||||||
3. Modifier le contenu pour inclure les variables suivantes :
|
|
||||||
- `{{firstname}}` — prénom du client
|
|
||||||
- `{{reference_client}}` — sa référence (ex : `MVA-001`)
|
|
||||||
- `{{paris_address}}` — l'adresse complète du dépôt à Paris
|
|
||||||
4. Exemple de corps d'email recommandé :
|
|
||||||
|
|
||||||
```
|
|
||||||
Bonjour {{firstname}},
|
|
||||||
|
|
||||||
Bienvenue chez MVA Global Fret ! Votre inscription est confirmée.
|
|
||||||
|
|
||||||
══════════════════════════════════════════════
|
|
||||||
VOTRE NUMÉRO DE RÉFÉRENCE CLIENT
|
|
||||||
{{reference_client}}
|
|
||||||
══════════════════════════════════════════════
|
|
||||||
|
|
||||||
Conservez précieusement ce numéro — il vous permet de suivre vos colis
|
|
||||||
et nous facilite la prise en charge.
|
|
||||||
|
|
||||||
📦 ADRESSE DU DÉPÔT À PARIS
|
|
||||||
|
|
||||||
{{paris_address}}
|
|
||||||
|
|
||||||
⚠️ Important : indiquez bien votre numéro de référence
|
|
||||||
({{reference_client}}) sur chaque colis que vous nous envoyez.
|
|
||||||
Cela nous permet de vous identifier rapidement.
|
|
||||||
|
|
||||||
Pour toute question, contactez-nous via Messenger ou par WhatsApp.
|
|
||||||
|
|
||||||
— L'équipe MVA Global Fret
|
|
||||||
+261 38 49 737 51
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Sauvegarder le template
|
|
||||||
|
|
||||||
### 5b. EmailJS — autoriser les appels serveur (CRITIQUE)
|
|
||||||
|
|
||||||
Sans cette étape, le Worker ne pourra pas envoyer les emails (erreur 403).
|
|
||||||
|
|
||||||
1. Aller sur https://dashboard.emailjs.com/admin/account
|
|
||||||
2. Onglet **Security**
|
|
||||||
3. Décocher **"Allow EmailJS API for non-browser applications"**
|
|
||||||
- Le terme est trompeur : décocher autorise les appels serveur
|
|
||||||
- (Cocher = bloquer les appels serveur pour anti-spam)
|
|
||||||
4. Sauvegarder
|
|
||||||
|
|
||||||
### 6. Vérifier le scope HubSpot
|
|
||||||
|
|
||||||
Le token HubSpot doit avoir :
|
Le token HubSpot doit avoir :
|
||||||
- ✅ `crm.objects.contacts.read`
|
|
||||||
- ✅ `crm.objects.contacts.write` (NOUVEAU — pour mettre à jour la propriété en cas de besoin)
|
- `crm.objects.contacts.read`
|
||||||
- ✅ `crm.lists.read` (déjà bon)
|
- `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/
|
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
|
## Test manuel
|
||||||
|
|
||||||
Une fois tout déployé, tu peux forcer une exécution du cron sans attendre 5 min :
|
Une fois tout déployé, tu peux tester le flow `requestVerification` (token de test, ne pas réutiliser en prod) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://mva-hubspot-proxy.mvaglobalfret.workers.dev \
|
curl -X POST https://mva-hubspot-proxy.<account>.workers.dev \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"action":"triggerWelcomeQueue"}'
|
-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 :
|
Réponse attendue :
|
||||||
```json
|
```json
|
||||||
{ "ok": true, "stats": { "scanned": 3, "sent": 1, "skipped": 2, "errors": 0 } }
|
{ "ok": true }
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scanned` : combien de contacts confirmés ont été inspectés
|
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.
|
||||||
- `sent` : combien d'emails de bienvenue ont été envoyés cette fois-ci
|
|
||||||
- `skipped` : déjà envoyés précédemment (KV tracking)
|
|
||||||
- `errors` : envois qui ont échoué
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Logs en production
|
## Logs en production
|
||||||
|
|
||||||
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution cron avec les stats.
|
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution du Worker.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -144,42 +110,40 @@ Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exéc
|
|||||||
|
|
||||||
| Symptôme | Cause probable | Fix |
|
| Symptôme | Cause probable | Fix |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `EmailJS 403: API access disabled` | Étape 5 pas faite | Décocher "Allow API for non-browser" sur EmailJS |
|
| `Resend 401: API key invalid` | Mauvaise valeur dans `RESEND_API_KEY` | Re-vérifier la clé sur https://resend.com/api-keys |
|
||||||
| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope contacts.write |
|
| `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 |
|
| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token |
|
||||||
| Le cron ne tourne pas | Cron trigger pas activé | Vérifier *Triggers → Cron Triggers* |
|
| `KV not bound` | Binding `WELCOME_KV` pas créé | Vérifier *Storage & Databases → KV bindings* |
|
||||||
| KV: `env.WELCOME_KV is undefined` | Binding 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 |
|
||||||
| Aucun email envoyé même après confirmation | Le filtre HubSpot ne matche pas | Inspecter dans Logs : voir si `searchConfirmedContacts` retourne des résultats |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture finale
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ 1. User remplit le formulaire sur contact.html │
|
│ 1. User remplit le formulaire sur contact.html │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ 2. form-handler.js soumet à HubSpot (Forms API) │
|
│ 2. form-handler.js → POST action: requestVerification │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ 3. HubSpot crée le contact AVEC reference_client │
|
│ 3. Worker valide Turnstile, génère un token, stocke les │
|
||||||
│ ↓ │
|
│ données du formulaire en KV (TTL 24h), envoie l'email │
|
||||||
│ 4. HubSpot envoie l'email de double opt-in (sujet + bouton │
|
│ de validation via Resend │
|
||||||
│ « Confirmer » seulement — PAS la référence ni l'adresse) │
|
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ ─────── User clique sur « Confirmer » ─────── │
|
│ ─────── User clique sur « Confirmer » ─────── │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ 5. HubSpot met à jour hs_emailconfirmationstatus = CONFIRMED │
|
│ 4. confirmation.html → POST action: verifyToken │
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ 6. Cron Cloudflare (toutes les 5 min) : │
|
│ 5. Worker récupère les données du KV, génère la prochaine │
|
||||||
│ - cherche les contacts CONFIRMED non encore welcomed │
|
│ référence client, soumet via HubSpot Forms API (= contact │
|
||||||
│ - vérifie KV : welcomed:{email} │
|
│ créé dans le CRM), envoie le welcome email via Resend │
|
||||||
│ - envoie email via EmailJS REST API │
|
│ (avec la référence + adresse Paris), marque le token │
|
||||||
│ (template contient : prénom, ref, adresse Paris) │
|
│ consommé en KV (TTL 7j pour idempotence). │
|
||||||
│ - écrit welcomed:{email} dans KV │
|
|
||||||
│ ↓ │
|
│ ↓ │
|
||||||
│ 7. Le client reçoit son welcome email avec sa référence ET │
|
│ 6. Le client reçoit son welcome email avec sa référence ET │
|
||||||
│ l'adresse de dépôt à Paris. │
|
│ l'adresse de dépôt à Paris. │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Conclusion** : il est désormais impossible pour un bot de récupérer ta référence client ou ton adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé. ✅
|
**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,69 +1,42 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + Welcome cron
|
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Ce Worker fait deux choses :
|
// Ce Worker gère le formulaire de contact via un flow double opt-in :
|
||||||
//
|
//
|
||||||
// 1) Proxy HubSpot (via fetch handler, appelé par le navigateur)
|
// 1) requestVerification : génère un token, stocke les données du formulaire en KV,
|
||||||
// - Vérification doublon par email
|
// envoie un email de validation (lien de confirmation) via Resend.
|
||||||
// - Génération du prochain numéro de référence séquentiel
|
|
||||||
//
|
//
|
||||||
// 2) Cron post-confirmation (via scheduled handler, appelé par
|
// 2) verifyToken : appelé quand le client clique sur le lien de confirmation.
|
||||||
// Cloudflare toutes les 5 min) :
|
// Crée le contact dans HubSpot (avec une référence générée à la volée),
|
||||||
// - Cherche les contacts qui ont CONFIRMÉ leur double opt-in
|
// puis envoie le welcome email avec la référence + l'adresse du dépôt Paris.
|
||||||
// - Pour chacun, envoie l'email de bienvenue (avec sa référence)
|
// Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email.
|
||||||
// - Marque le contact dans Cloudflare KV pour ne pas re-envoyer
|
|
||||||
//
|
//
|
||||||
// L'email de bienvenue n'arrive donc QU'APRÈS que le client a cliqué
|
// La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant
|
||||||
// sur "Confirmer" dans le mail HubSpot. La référence client + l'adresse
|
// validation de l'email — protection anti-bot et anti-cartons-vides.
|
||||||
// du dépôt à Paris ne fuitent jamais avant validation.
|
|
||||||
//
|
//
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// DÉPLOIEMENT
|
// DÉPLOIEMENT (Phase D du plan WordPress → static)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
//
|
//
|
||||||
// Sur https://dash.cloudflare.com/ :
|
// Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète.
|
||||||
//
|
//
|
||||||
// 1. Workers & Pages → ton Worker mva-hubspot-proxy → Modifier le code
|
// Secrets requis (`wrangler secret put <name>`) :
|
||||||
// → coller ce fichier → Déployer
|
// • 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")
|
||||||
//
|
//
|
||||||
// 2. Workers & Pages → ton Worker → Paramètres → Variables et secrets :
|
// Bindings KV requis :
|
||||||
// • HUBSPOT_TOKEN = pat-eu1-... (déjà existant, lecture+écriture contacts)
|
// • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken)
|
||||||
// • EMAILJS_PUBLIC_KEY = 8KUlaQ7BDVIbkZRyP
|
|
||||||
// • EMAILJS_SERVICE_ID = service_aeamo3x
|
|
||||||
// • EMAILJS_TEMPLATE_ID = template_s1kr2et
|
|
||||||
//
|
|
||||||
// 3. Workers & Pages → ton Worker → Paramètres → Stockage et bases
|
|
||||||
// → Bindings KV → Ajouter :
|
|
||||||
// Variable name : WELCOME_KV
|
|
||||||
// Namespace : créer "mva-welcome-tracker"
|
|
||||||
//
|
|
||||||
// 4. Workers & Pages → ton Worker → Paramètres → Déclencheurs (Triggers)
|
|
||||||
// → Cron Triggers → Ajouter :
|
|
||||||
// */5 * * * * (toutes les 5 minutes)
|
|
||||||
//
|
|
||||||
// 5. ⚠️ EmailJS : sur https://dashboard.emailjs.com/admin/account →
|
|
||||||
// Security → décocher "Allow EmailJS API for non-browser applications"
|
|
||||||
// → la décocher (= autoriser les appels serveur). Sinon le Worker ne
|
|
||||||
// pourra pas envoyer les emails.
|
|
||||||
//
|
|
||||||
// 6. ⚠️ Le token HubSpot doit avoir le scope crm.objects.contacts.write
|
|
||||||
// en plus du read (pour mettre à jour les propriétés contact).
|
|
||||||
// → si erreur 403 sur la mise à jour KV/contact, regénérer le token
|
|
||||||
// avec ce scope sur https://app-eu1.hubspot.com/private-apps/...
|
|
||||||
//
|
//
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// Fallbacks pour les valeurs publiques d'EmailJS (déjà visibles dans le
|
|
||||||
// JavaScript du site). Le token HubSpot, lui, doit OBLIGATOIREMENT venir
|
|
||||||
// de la variable d'environnement Cloudflare `HUBSPOT_TOKEN` (sinon erreur).
|
|
||||||
const FALLBACK_EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP';
|
|
||||||
const FALLBACK_EMAILJS_SERVICE_ID = 'service_aeamo3x';
|
|
||||||
const FALLBACK_EMAILJS_TEMPLATE_ID= 'template_s1kr2et';
|
|
||||||
|
|
||||||
const HUBSPOT_API = 'https://api.hubapi.com';
|
const HUBSPOT_API = 'https://api.hubapi.com';
|
||||||
const HUBSPOT_PORTAL_ID = '148163754';
|
const HUBSPOT_PORTAL_ID = '148163754';
|
||||||
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
|
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
|
||||||
const EMAILJS_API = 'https://api.emailjs.com/api/v1.0/email/send';
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin' : '*',
|
'Access-Control-Allow-Origin' : '*',
|
||||||
@ -98,42 +71,9 @@ export default {
|
|||||||
return jsonResponse({ nextRef: await getNextRef(token) });
|
return jsonResponse({ nextRef: await getNextRef(token) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── action: triggerWelcome (admin/debug, optionnel) ─────
|
|
||||||
if (action === 'triggerWelcomeQueue') {
|
|
||||||
const stats = await processWelcomeQueue(env);
|
|
||||||
return jsonResponse({ ok: true, stats });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── action: sendWelcomeNow ───────────────────────────────
|
|
||||||
// Envoi immédiat du welcome email via EmailJS (avec l'adresse
|
|
||||||
// Paris depuis env var). Appelé par form-handler.js après
|
|
||||||
// soumission du formulaire. L'adresse n'apparaît jamais dans
|
|
||||||
// le code JS public — elle vient des secrets Cloudflare.
|
|
||||||
// Anti-bot : on vérifie d'abord le token Cloudflare Turnstile.
|
|
||||||
if (action === 'sendWelcomeNow') {
|
|
||||||
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
|
|
||||||
|
|
||||||
// Validation Turnstile (anti-bot)
|
|
||||||
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
|
|
||||||
if (!turnstileOk) {
|
|
||||||
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendWelcomeEmail(env, {
|
|
||||||
firstname : body.firstname || '',
|
|
||||||
email : body.email,
|
|
||||||
reference_client : body.reference_client || '',
|
|
||||||
});
|
|
||||||
return jsonResponse({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
return jsonResponse({ ok: false, error: err.message }, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── action: requestVerification ──────────────────────────
|
// ── action: requestVerification ──────────────────────────
|
||||||
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
|
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
|
||||||
// et envoie un email de validation via Brevo. Le contact n'est créé
|
// 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 :
|
// dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
|
||||||
// les inscriptions non vérifiées ne polluent pas le CRM).
|
// les inscriptions non vérifiées ne polluent pas le CRM).
|
||||||
// Anti-bot : Turnstile vérifié d'abord.
|
// Anti-bot : Turnstile vérifié d'abord.
|
||||||
@ -231,7 +171,7 @@ export default {
|
|||||||
{ name: 'reference_client', value: refNumber },
|
{ name: 'reference_client', value: refNumber },
|
||||||
],
|
],
|
||||||
context: {
|
context: {
|
||||||
pageUri : 'https://mva-global-fret.github.io/site-mva-global-fret/contact.html',
|
pageUri : 'https://mva-globalfret.com/contact.html',
|
||||||
pageName: 'Verified signup (MVA Global Fret)',
|
pageName: 'Verified signup (MVA Global Fret)',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -244,7 +184,7 @@ export default {
|
|||||||
|
|
||||||
// 3) Envoie le welcome email avec ref + adresse Paris
|
// 3) Envoie le welcome email avec ref + adresse Paris
|
||||||
const welcomeContact = { ...tokenData, reference_client: refNumber };
|
const welcomeContact = { ...tokenData, reference_client: refNumber };
|
||||||
await sendWelcomeViaBrevo(env, welcomeContact);
|
await sendWelcomeViaResend(env, welcomeContact);
|
||||||
|
|
||||||
// 4) Marque le token consommé (gardé 7j pour idempotence)
|
// 4) Marque le token consommé (gardé 7j pour idempotence)
|
||||||
await env.WELCOME_KV.put(key, JSON.stringify({
|
await env.WELCOME_KV.put(key, JSON.stringify({
|
||||||
@ -315,102 +255,12 @@ export default {
|
|||||||
return jsonResponse({ error: err.message }, 500);
|
return jsonResponse({ error: err.message }, 500);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// 2) Handler cron (Cloudflare scheduler, toutes les 5 min)
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
async scheduled(event, env, ctx) {
|
|
||||||
ctx.waitUntil(processWelcomeQueue(env));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================
|
|
||||||
// File d'attente : envoi du welcome aux contacts confirmés
|
|
||||||
// =============================================================
|
|
||||||
async function processWelcomeQueue(env) {
|
|
||||||
const token = env.HUBSPOT_TOKEN;
|
|
||||||
const stats = { scanned: 0, sent: 0, skipped: 0, errors: 0 };
|
|
||||||
|
|
||||||
// Liste des contacts qui ont CONFIRMÉ leur opt-in marketing
|
|
||||||
// Filtre HubSpot : hs_emailconfirmationstatus EQ "CONFIRMED"
|
|
||||||
const confirmed = await searchConfirmedContacts(token);
|
|
||||||
|
|
||||||
for (const contact of confirmed) {
|
|
||||||
stats.scanned++;
|
|
||||||
const props = contact.properties || {};
|
|
||||||
const email = (props.email || '').toLowerCase();
|
|
||||||
|
|
||||||
if (!email) { stats.skipped++; continue; }
|
|
||||||
|
|
||||||
// Idempotence : si on a déjà envoyé, on saute
|
|
||||||
const kvKey = `welcomed:${email}`;
|
|
||||||
const already = env.WELCOME_KV ? await env.WELCOME_KV.get(kvKey) : null;
|
|
||||||
if (already) { stats.skipped++; continue; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendWelcomeEmail(env, {
|
|
||||||
firstname : props.firstname || '',
|
|
||||||
email : email,
|
|
||||||
reference_client : props.reference_client || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Marquer comme envoyé dans KV (TTL 1 an pour éviter de garder
|
|
||||||
// indéfiniment des entrées si quelqu'un se désabonne et se réabonne)
|
|
||||||
if (env.WELCOME_KV) {
|
|
||||||
await env.WELCOME_KV.put(kvKey, new Date().toISOString(), {
|
|
||||||
expirationTtl: 60 * 60 * 24 * 365,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
stats.sent++;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
stats.errors++;
|
|
||||||
console.warn('[welcome]', email, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================
|
// =============================================================
|
||||||
// HubSpot : recherches & lectures
|
// HubSpot : recherches & lectures
|
||||||
// =============================================================
|
// =============================================================
|
||||||
|
|
||||||
// Date avant laquelle les contacts CONFIRMÉS ne déclenchent PAS de welcome.
|
|
||||||
// Évite de spammer les contacts déjà existants au moment du déploiement
|
|
||||||
// du nouveau cron. Tout contact créé APRÈS cette date (et qui confirme
|
|
||||||
// son email) recevra son email de bienvenue normalement.
|
|
||||||
const WELCOME_CUTOFF_ISO = '2026-05-05T00:00:00Z';
|
|
||||||
|
|
||||||
async function searchConfirmedContacts(token) {
|
|
||||||
// On limite à 100 contacts par cron run (largement suffisant pour 1 PME)
|
|
||||||
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filterGroups: [{
|
|
||||||
filters: [
|
|
||||||
{ propertyName: 'hs_emailconfirmationstatus', operator: 'EQ', value: 'CONFIRMED' },
|
|
||||||
{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' },
|
|
||||||
{ propertyName: 'createdate', operator: 'GTE', value: WELCOME_CUTOFF_ISO },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
properties: ['firstname', 'lastname', 'email', 'reference_client', 'hs_emailconfirmationstatus'],
|
|
||||||
sorts: [{ propertyName: 'lastmodifieddate', direction: 'DESCENDING' }],
|
|
||||||
limit: 100,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HubSpot search failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.results || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchContactByEmail(token, email) {
|
async function searchContactByEmail(token, email) {
|
||||||
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
|
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -460,85 +310,51 @@ async function getNextRef(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================
|
// =============================================================
|
||||||
// EmailJS : envoi serveur via REST API
|
// Resend : envoi d'emails transactionnels (verification + welcome)
|
||||||
// =============================================================
|
// =============================================================
|
||||||
|
// Aligné avec m4s-auth (Phase 2.1) qui utilise déjà Resend en production.
|
||||||
async function sendWelcomeEmail(env, params) {
|
// Le compte Resend (et le domaine vérifié) sont partagés entre m4s-auth et
|
||||||
const payload = {
|
// ce Worker — un seul fournisseur SMTP pour tout Mind4Solutions.
|
||||||
service_id : env.EMAILJS_SERVICE_ID || FALLBACK_EMAILJS_SERVICE_ID,
|
|
||||||
template_id: env.EMAILJS_TEMPLATE_ID || FALLBACK_EMAILJS_TEMPLATE_ID,
|
|
||||||
user_id : env.EMAILJS_PUBLIC_KEY || FALLBACK_EMAILJS_PUBLIC_KEY,
|
|
||||||
template_params: {
|
|
||||||
firstname : params.firstname,
|
|
||||||
email : params.email,
|
|
||||||
reference_client : params.reference_client,
|
|
||||||
// Adresse du dépôt Paris — définie via l'env var PARIS_DEPOT_ADDRESS
|
|
||||||
// dans Cloudflare. Si non définie, on envoie un placeholder visible
|
|
||||||
// pour signaler à l'admin qu'il faut la configurer.
|
|
||||||
paris_address : env.PARIS_DEPOT_ADDRESS || '[À configurer dans Cloudflare]',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(EMAILJS_API, {
|
|
||||||
method : 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body : JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`EmailJS ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================
|
|
||||||
// Brevo (ex-Sendinblue) : envoi d'emails (verification + welcome)
|
|
||||||
// =============================================================
|
|
||||||
// Brevo est utilisé pour l'envoi car il accepte la "single-sender
|
|
||||||
// verification" : on valide juste une adresse email (mvaglobalfret@gmail.com)
|
|
||||||
// au lieu de devoir vérifier tout un domaine. Free tier : 300 emails/jour.
|
|
||||||
//
|
//
|
||||||
// Setup requis :
|
// Setup requis (`wrangler secret put <name>`) :
|
||||||
// - env.BREVO_API_KEY = clé API Brevo (xkeysib-...)
|
// - env.RESEND_API_KEY = clé API Resend (re_...)
|
||||||
// - env.BREVO_SENDER_EMAIL = adresse expéditrice validée chez Brevo
|
// - env.RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
|
||||||
// (ex: "mvaglobalfret@gmail.com")
|
// - env.RESEND_FROM_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
|
||||||
// - env.BREVO_SENDER_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
|
// - env.SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
|
||||||
// - env.SITE_URL = base URL du site (ex: "https://mva-global-fret.github.io/site-mva-global-fret")
|
|
||||||
//
|
//
|
||||||
// API doc : https://developers.brevo.com/reference/sendtransacemail
|
// API doc : https://resend.com/docs/api-reference/emails/send-email
|
||||||
|
|
||||||
const BREVO_API = 'https://api.brevo.com/v3/smtp/email';
|
const RESEND_API = 'https://api.resend.com/emails';
|
||||||
|
|
||||||
async function brevoSend(env, { to, subject, html }) {
|
async function resendSend(env, { to, subject, html }) {
|
||||||
if (!env.BREVO_API_KEY) {
|
if (!env.RESEND_API_KEY) {
|
||||||
throw new Error('BREVO_API_KEY env var not set');
|
throw new Error('RESEND_API_KEY env var not set');
|
||||||
}
|
}
|
||||||
const senderEmail = env.BREVO_SENDER_EMAIL || 'mvaglobalfret@gmail.com';
|
const fromEmail = env.RESEND_FROM_EMAIL || 'noreply@mva-globalfret.com';
|
||||||
const senderName = env.BREVO_SENDER_NAME || 'MVA Global Fret';
|
const fromName = env.RESEND_FROM_NAME || 'MVA Global Fret';
|
||||||
|
|
||||||
const res = await fetch(BREVO_API, {
|
const res = await fetch(RESEND_API, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'api-key' : env.BREVO_API_KEY,
|
'Content-Type' : 'application/json',
|
||||||
'accept' : 'application/json',
|
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sender : { name: senderName, email: senderEmail },
|
from : `${fromName} <${fromEmail}>`,
|
||||||
to : [{ email: to }],
|
to : [to],
|
||||||
subject : subject,
|
subject: subject,
|
||||||
htmlContent: html,
|
html : html,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(`Brevo ${res.status}: ${text}`);
|
throw new Error(`Resend ${res.status}: ${text}`);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendVerificationEmail(env, contact, verToken) {
|
async function sendVerificationEmail(env, contact, verToken) {
|
||||||
const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret';
|
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
|
||||||
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
|
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
|
||||||
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
|
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
|
||||||
const firstname = escapeHtml(contact.firstname || '');
|
const firstname = escapeHtml(contact.firstname || '');
|
||||||
@ -589,15 +405,15 @@ async function sendVerificationEmail(env, contact, verToken) {
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
return brevoSend(env, {
|
return resendSend(env, {
|
||||||
to: contact.email,
|
to: contact.email,
|
||||||
subject: 'Confirmez votre inscription chez MVA Global Fret',
|
subject: 'Confirmez votre inscription chez MVA Global Fret',
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWelcomeViaBrevo(env, contact) {
|
async function sendWelcomeViaResend(env, contact) {
|
||||||
const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret';
|
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
|
||||||
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
|
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
|
||||||
const firstname = escapeHtml(contact.firstname || '');
|
const firstname = escapeHtml(contact.firstname || '');
|
||||||
const ref = escapeHtml(contact.reference_client || '');
|
const ref = escapeHtml(contact.reference_client || '');
|
||||||
@ -672,7 +488,7 @@ async function sendWelcomeViaBrevo(env, contact) {
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
return brevoSend(env, {
|
return resendSend(env, {
|
||||||
to: contact.email,
|
to: contact.email,
|
||||||
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
|
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
|
||||||
html,
|
html,
|
||||||
|
|||||||
18
cloudflare-worker/wrangler.toml
Normal file
18
cloudflare-worker/wrangler.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
name = "mva-hubspot-proxy"
|
||||||
|
main = "hubspot-proxy.js"
|
||||||
|
compatibility_date = "2026-04-01"
|
||||||
|
|
||||||
|
# KV namespace — placeholder ID, populated at deploy time (Phase D3)
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "WELCOME_KV"
|
||||||
|
id = "REPLACE_AT_DEPLOY_TIME"
|
||||||
|
|
||||||
|
# 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