Construire un système de newsletter multilingue zéro-coût avec Cloudflare : Workers, D1, Resend et authentification HMAC
Chaque créateur indépendant et blogueur fait face au même problème : comment permettre aux visiteurs de s'abonner à vos mises à jour sans payer 20 $/mois pour un service tiers ni confier vos données utilisateur à quelqu'un d'autre ?
Quand j'ai décidé d'ajouter une newsletter à mon blog, j'avais trois exigences non négociables :
- Zéro coût — tout sur les plans gratuits ou auto-hébergé
- Contrôle total — pas de dépendance envers des boîtes noires tierces ; les données des abonnés résident dans ma propre base de données
- Sécurité de niveau production — liens de désinscription signés HMAC pour empêcher les visiteurs de se désabonner les uns les autres
- Support multilingue — e-mails de bienvenue et pages de désinscription localisés en 7 langues
J'ai construit l'intégralité de ce système avec la pile Cloudflare et je vais vous montrer tous les détails.
Architecture : pile gratuite, sans compromis
La pile technologique complète :
- Cloudflare Workers (
support-api.elvisli.ca) : backend sans état, gère l'abonnement, la désinscription, les API administrateur - Cloudflare D1 : base de données SQLite stockant les abonnés et métadonnées géographiques
- Cloudflare Pages : frontend pour le dashboard administrateur et pages de désinscription dans 7 langues
- Resend : API e-mail gratuite (280 e-mails/jour) pour l'envoi transactionnel
- Web Crypto API : HMAC-SHA256 natif du navigateur et de Workers pour la signature de tokens
Coût total : 0 $/mois.(Si vous dépassez le plan gratuit de Resend, mettez à niveau vers la version payante ; aucun coût opérationnel.)
Conception de la base de données : abonnés + géolocalisation
Le cœur est une seule table subscribers dans D1 :
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'home', -- source d'abonnement
lang TEXT, -- préférence linguistique de l'utilisateur
country TEXT, region TEXT, city TEXT, -- géolocalisation injectée par Cloudflare
timezone TEXT, -- rempli automatiquement par CF
ip TEXT, user_agent TEXT, -- pour l'audit
status TEXT DEFAULT 'active', -- 'active' ou 'unsubscribed'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_status ON subscribers(status);
Pourquoi cette conception ?
- Email UNIQUE : empêche les doublons ; les ré-abonnements échouent silencieusement avec un faux succès (anti-spam)
- Colonnes géographiques : Cloudflare Workers peut accéder à
request.cfà la périphérie, donc vous obtenez ville/pays/fuseau horaire gratuitement — pas besoin d'API de recherche IP tierce - Champ Status : modèle de soft-delete ; la désinscription définit simplement
status='unsubscribed', ne supprime pas la ligne (conservation de l'audit) - Champ source : suit la source d'abonnement (page d'accueil, transfert par e-mail, etc.)
Backend : API Workers
Le backend est un seul fichier Worker gérant trois endpoints :
1. POST /api/subscribe — Nouvel abonnement
Corps de la requête :
{
"email": "user@example.com",
"lang": "fr",
"source": "home",
"website": "", // piège (champ caché)
"elapsedMs": 5000 // millisecondes entre le chargement et l'envoi du formulaire
}
Flux :
async function handleSubscribe(request, env) {
const body = await request.json();
const { email, lang, source, website, elapsedMs } = body;
// 1. Anti-spam : rejet silencieux des bots
if (website || elapsedMs < 1200) {
return fakeSuccess(); // les bots obtiennent un faux 200 sans savoir
}
// 2. Validation d'e-mail
if (!isValidEmail(email)) return error400("E-mail invalide");
// 3. Capturer la géolocalisation depuis Cloudflare
const cf = request.cf || {};
const country = cf.country || request.headers.get("CF-IPCountry");
const timezone = cf.timezone;
const ip = request.headers.get("CF-Connecting-IP");
// 4. Insérer dans D1 (la contrainte UNIQUE prévient les doublons)
const result = await env.DB.prepare(
`INSERT OR IGNORE INTO subscribers (email, lang, source, country, timezone, ip)
VALUES (?, ?, ?, ?, ?, ?)`
).bind(email, lang, source, country, timezone, ip).run();
const isNew = result.meta.changes > 0;
// 5. Envoyer l'e-mail de bienvenue (localisé, seulement pour les nouveaux)
if (isNew) {
const unsubToken = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
const unsubUrl = `https://elvisli.ca/${localePrefix(lang)}/unsubscribe?email=${email}&token=${unsubToken}`;
await sendResendEmail({
to: email,
subject: welcomeCopy(lang).subject,
html: welcomeEmailHtml(lang, unsubUrl),
});
// Aussi notifier l'administrateur du site
await sendResendEmail({
to: env.NOTIFY_EMAIL,
subject: `📨 Nouvel abonné : ${email}`,
html: adminNotificationHtml(email, country, timezone, lang),
});
}
return ok({ success: true, isNew, subscribed: true });
}
Points clés :
- Piège + gardien temporel : les bots remplissent soit le champ
websitecaché, soit soumettent en < 1,2 secondes. Les deux retournent un faux succès (200 mais pas d'insertion), donc les bots ne essaient pas à nouveau. - Géolocalisation depuis
request.cf: Cloudflare remplit cela automatiquement à la périphérie ; pas d'appel API tiers. - Génération de token HMAC : générée au moment de l'abonnement et intégrée à l'URL de désinscription, envoyée par e-mail.
2. POST /api/unsubscribe — Désinscription libre-service
Corps de la requête :
{
"email": "user@example.com",
"token": "a3f8d2c6..." // HMAC-SHA256(email, secret)
}
Flux :
async function handleUnsubscribe(request, env) {
const { email, token } = await request.json();
// Vérifier la signature HMAC
const expected = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
if (token !== expected) {
return error403("Lien de désinscription invalide ou expiré");
}
// Marquer comme désinscrit
await env.DB.prepare(
`UPDATE subscribers SET status = 'unsubscribed' WHERE email = ?`
).bind(email).run();
return ok({ success: true });
}
Pourquoi HMAC plutôt qu'un token à usage unique ?
- Sans état : pas besoin de stocker une table de "tokens de désinscription" avec expiration. La signature elle-même est la preuve.
- Lié à l'e-mail :
HMAC(email, secret)produit toujours la même signature pour cet e-mail. Vous ne pouvez pas désabonnerattacker@evil.comavec la signature pourvictim@example.com. - Comparaison en temps constant : compare les signatures via XOR pour prévenir les attaques par chronométrage.
E-mail de bienvenue multilingue
L'e-mail de bienvenue est critique pour l'intégration et doit :
- Établir la confiance — professionnel, de marque, honnête
- Définir les attentes — quoi recevoir et à quelle fréquence
- Offrir une sortie — lien de désinscription bien visible
const WELCOME_I18N = {
fr: {
subject: "Inscription confirmée — elvisli.ca",
heading: "C'est fait.",
body: "Merci de votre inscription. Vous recevrez mes nouveaux articles sur l'IA, la création de projets et quelques expériences — directement dans votre boîte mail. Aucun spam.",
cta: "Lire les derniers articles →",
footer: "Vous recevez cet e-mail car cette adresse s'est inscrite sur elvisli.ca.",
notyou: "Ce n'était pas vous ?",
unsub: "Se désabonner immédiatement",
},
// ... autres langues
};
Notes de conception :
- Fond clair : les e-mails sombres sont souvent marqués comme spam. Fond clair, texte noir, bon contraste.
- Couleur d'accent citron vert (
#A3E635) : correspond au langage de conception du site. - Ligne "Ce n'était pas vous ?" bien visible : rouge, gras, en bas — si quelqu'un d'autre abuse en s'inscrivant avec l'adresse d'un visiteur, le visiteur voit immédiatement qu'il peut se désabonner.
Page de désinscription (7 langues)
Chaque page de désinscription (/[locale]/unsubscribe) est en lecture seule :
export default function UnsubscribePage() {
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
const [status, setStatus] = useState('init');
useEffect(() => {
// L'e-mail et le token sont en lecture SEULE depuis le lien signé
const params = new URLSearchParams(window.location.search);
setEmail(params.get('email'));
setToken(params.get('token'));
}, []);
const handleUnsubscribe = async () => {
const res = await fetch('https://support-api.elvisli.ca/api/unsubscribe', {
method: 'POST',
body: JSON.stringify({ email, token }),
});
if (res.ok) setStatus('done');
else if (res.status === 403) setStatus('invalid');
};
return (
<div>
<p>Vous êtes sur le point de vous désabonner :</p>
{/* L'e-mail est verrouillé et en lecture seule */}
<div style={{ background: '#f5f5f5', padding: '11px', borderRadius: '8px', fontFamily: 'monospace' }}>
{email}
</div>
<button onClick={handleUnsubscribe}>Confirmer la désinscription</button>
</div>
);
}
Pourquoi en lecture seule ?
Le token HMAC est lié à cet e-mail exact. Si l'utilisateur le change en attacker@evil.com, la signature ne correspond plus, et le backend retourne 403. Donc un champ éditable était :
- Déroutant — les utilisateurs pensaient pouvoir changer d'e-mail
- Inutile — le backend le rejetterait
Verrouillez-le, réduisez la charge cognitive.
Dashboard administrateur
L'onglet "Abonnés" du dashboard permet :
- Voir tous : e-mail, lieu (ville/région/pays), fuseau horaire, langue, source, date d'inscription
- Chercher & filtrer : par e-mail, lieu, langue
- Mode édition : cliquez "Éditer" pour révéler le bouton supprimer (double-bouton pour prévenir les suppressions accidentelles)
- Supprimer : abonnés uniques
<table>
<thead>
<tr>
<th>E-mail</th>
<th>Lieu</th>
<th>Langue</th>
<th>Source</th>
<th>S'inscrit</th>
<th></th>
</tr>
</thead>
<tbody>
<tr id="sub-row-5">
<td>elvis@example.com</td>
<td>Toronto, Ontario, CA</td>
<td><span class="badge">fr</span></td>
<td>home</td>
<td>2026-06-18 12:34:56</td>
<td>
<button onclick="toggleSubEdit(5)">Éditer</button>
<span id="sub-actions-5" style="display:none;">
<button onclick="deleteSubscriber(5)">Supprimer</button>
<button onclick="toggleSubEdit(5)">Annuler</button>
</span>
</td>
</tr>
</tbody>
</table>
Le bouton supprimer est caché par défaut (n'affiche que après "Éditer"), prévenant les erreurs.
Liste de vérification de déploiement
-
Créer la base de données D1 :
npx wrangler d1 create newsletter-db
npx wrangler d1 execute newsletter-db --remote --file=schema.sql
Définir les secrets :
npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put UNSUBSCRIBE_SECRET
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put NOTIFY_EMAIL
Déployer le Worker :
npx wrangler deploy
Tester :
curl -X POST https://support-api.elvisli.ca/api/subscribe \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","lang":"fr","source":"test"}'
Démo en direct : Abonnez-vous sur elvisli.ca/fr et consultez l'e-mail de bienvenue.

