Back to all posts
Tutorial

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 :

  1. Zéro coût — tout sur les plans gratuits ou auto-hébergé
  2. 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
  3. 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
  4. 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 website caché, 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ésabonner attacker@evil.com avec la signature pour victim@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 :

  1. Établir la confiance — professionnel, de marque, honnête
  2. Définir les attentes — quoi recevoir et à quelle fréquence
  3. 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 :

  1. Déroutant — les utilisateurs pensaient pouvoir changer d'e-mail
  2. 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

  1. 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.