Aller au contenu principal
Tutoriel

Envoyer un email depuis un Worker Cloudflare

Créez sur Cloudflare Workers un formulaire de contact qui envoie chaque message directement dans votre boîte de réception, avec le SDK Node de mailkube.

mailkube 8 min de lecture Mis à jour le 2 novembre 2026
sdkserverless
L'email que le Worker de ce tutoriel envoie. Les noms, les adresses et l'IP sont des exemples.

Un visiteur remplit le formulaire de contact de votre site, et son message arrive dans votre boîte de réception, prêt pour un simple Répondre. Ce tutoriel construit ce formulaire sur un Worker Cloudflare avec le SDK Node de mailkube, d’un dossier vide jusqu’à l’email ci-dessus.

L’adresse IP et le pays du pied de page ne demandent aucun service de géolocalisation. Cloudflare ajoute un header CF-Connecting-IP et un header CF-IPCountry à chaque requête qui atteint votre Worker, et le handler recopie les deux dans l’email.

Prérequis

Tous les exemples utilisent example.com. Remplacez-le par le domaine auquel votre clé est rattachée.

Créer le projet

mkdir contact-form && cd contact-form
npm init -y
npm install @mailkube/mailkube-node
npm install --save-dev [email protected]
mkdir public src

Ajoutez ensuite wrangler.jsonc à la racine du projet :

{
  "name": "contact-form",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "assets": { "directory": "./public" }
}

Le SDK n’utilise que fetch et crypto.subtle, le Worker n’a donc besoin d’aucun flag nodejs_compat.

Ajouter la page du formulaire

<form method="post" action="/api/contact">
  <label>Nom <input name="name" required maxlength="100"></label>
  <label>Email <input type="email" name="email" required></label>
  <label>Message <textarea name="message" required maxlength="5000"></textarea></label>
  <p>Nous conservons votre adresse IP et votre pays avec votre message.</p>
  <button type="submit">Envoyer</button>
</form>

Ajoutez une page public/thanks.html pour la confirmation.

Écrire le handler

import { ApiError, Mailkube } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/contact' || request.method !== 'POST') {
      return new Response('Introuvable', { status: 404 });
    }

    const form = await request.formData();
    const name = String(form.get('name') ?? '');
    const email = String(form.get('email') ?? '');
    const message = String(form.get('message') ?? '');

    const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
    const country = request.headers.get('CF-IPCountry') ?? 'unknown';
    const page = sameSite(request.headers.get('Referer'), url);

    const client = new Mailkube({ apiKey: env.MAILKUBE_API_KEY, timeoutMs: 8000 });

    try {
      await client.emails.send({
        from: 'Formulaire de contact <[email protected]>',
        to: '[email protected]',
        replyTo: email,
        subject: `Nouveau message de ${name}`,
        html: `<h2>Nouveau message de ${escapeHtml(name)}</h2>`
          + `<p><b>Email :</b> ${escapeHtml(email)}</p>`
          + `<p>${escapeHtml(message).replaceAll('\n', '<br>')}</p>`
          + `<hr><p style="color:#6b7280;font-size:12px">`
          + `Envoyé depuis <a href="${escapeHtml(page)}">${escapeHtml(page)}</a>`
          + ` · IP ${escapeHtml(ip)} · ${escapeHtml(flag(country))}</p>`,
        text: `${name} <${email}>\n\n${message}\n\nEnvoyé depuis ${page} · IP ${ip} · ${flag(country)}`,
        idempotencyKey: await fingerprint(name, email, message),
      });
    } catch (error) {
      console.error(error instanceof ApiError
        ? `${error.statusCode} ${error.errorName} (request ${error.requestId})`
        : error);
      return new Response("Votre message n'a pas pu être envoyé, réessayez.", { status: 502 });
    }

    return Response.redirect(new URL('/thanks.html', url), 303);
  },
};

function flag(country) {
  if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return country;
  return String.fromCodePoint(...[...country].map((c) => 0x1f1a5 + c.charCodeAt(0)));
}

function escapeHtml(value) {
  return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}

function sameSite(referer, url) {
  try {
    return new URL(referer).origin === url.origin ? referer : url.origin;
  } catch {
    return url.origin;
  }
}

async function fingerprint(...parts) {
  const bytes = new TextEncoder().encode(parts.join('\n'));
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}

CF-IPCountry est un code à deux lettres, XX quand Cloudflare ne parvient pas à situer l’adresse et T1 pour Tor. flag transforme un vrai code en emoji et laisse passer ces deux-là.

idempotencyKey est une empreinte du message. Un double clic poste le formulaire deux fois, et l’API répond au second envoi en rejouant la première réponse, pendant 24 heures au plus.

timeoutMs ramène le délai par défaut du SDK de 30 à 8 secondes, puisque le visiteur attend devant le bouton d’envoi.

Déployer et ajouter la clé

npx wrangler deploy
Uploaded contact-form
Deployed contact-form triggers
  https://contact-form.<your-subdomain>.workers.dev
npx wrangler secret put MAILKUBE_API_KEY

Envoyer un message de test

Envoyez le formulaire depuis votre adresse workers.dev, ou postez les mêmes champs depuis un terminal :

curl -i https://contact-form.<your-subdomain>.workers.dev/api/contact \
  -d name='Claire Dubois' \
  -d email=[email protected] \
  -d message='Test du formulaire de contact'
HTTP/2 303
location: https://contact-form.<your-subdomain>.workers.dev/thanks.html

Un 303 signifie que l’API a accepté le message. Sur un 502, npx wrangler tail affiche l’erreur journalisée par le handler.

Suivre le message dans les logs

Ouvrez Logs dans le dashboard et cliquez sur Details pour le message.

Logs dans le dashboard, à l'ouverture du message de Claire Dubois. Les adresses, les IP et les identifiants sont des exemples.

Le haut du panneau affiche le statut et le canal. Tout ce que le SDK envoie est marqué API, et un message soumis en SMTP est marqué SMTP.

Message ID est le header RFC Message-ID, la valeur que le SDK renvoie dans email.messageId. Retrouvez-le dans les headers de l’email reçu pour rattacher cette copie à cette ligne.

La timeline se lit de haut en bas. Auth correspond à la vérification de votre API key, Reception au message accepté pour un destinataire, et Delivery cite mot pour mot la réponse du serveur destinataire. Un message envoyé à trois destinataires reçoit une Reception et une Delivery pour chacun.

Chaque ouverture et chaque clic qui suivent forment un événement à part, avec l’adresse IP, le pays et le navigateur du lecteur. Le panneau montre le message de Claire lu sur un ordinateur portable, relu sur un téléphone, et son lien vers les tarifs suivi depuis les deux.

Opened et Clicked n’apparaissent que si le suivi des ouvertures et des clics est activé sur le domaine d’envoi, et l’adresse IP, le pays et le navigateur seulement si le domaine les enregistre. Le suivi des ouvertures passe par une image invisible : un client mail qui charge les images avant le lecteur, comme le fait Apple Mail Privacy Protection, enregistre une ouverture que personne n’a faite. Lisez les ouvertures comme une approximation, et les clics comme le signal le plus sûr.

Dépannage

403 invalid_api_key dans npx wrangler tail. Le Worker n’a pas de clé, n’a pas la bonne, ou a une clé restreinte à des IP sources. Lancez wrangler secret put depuis le dossier du projet pour que le secret arrive sur ce Worker. Les requêtes sortantes d’un Worker partent des adresses partagées de Cloudflare, qu’une liste d’IP autorisées refuse.

422 from_domain_not_allowed. L’adresse from est sur un autre domaine que celui auquel votre clé est rattachée. Chaque clé envoie pour un seul domaine, contact@ doit donc en faire partie.

Bloquer les bots avec Turnstile

Un formulaire public attire les bots. Cette étape facultative place Cloudflare Turnstile devant l’envoi : un message n’atteint le SDK qu’une fois accepté par Turnstile.

Créez un widget Turnstile pour votre nom d’hôte dans le dashboard Cloudflare, puis placez-le dans le formulaire avec sa clé de site :

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form method="post" action="/api/contact">
  <!-- champs name, email et message comme avant -->
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Envoyer</button>
</form>

Le widget ajoute un champ cf-turnstile-response au formulaire. Stockez le secret sur le Worker :

npx wrangler secret put TURNSTILE_SECRET_KEY

Vérifiez ensuite le jeton dans le handler, juste après la lecture du formulaire :

const check = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: env.TURNSTILE_SECRET_KEY,
    response: String(form.get('cf-turnstile-response') ?? ''),
  }),
});
if (!(await check.json()).success) {
  return new Response('Confirmez que vous êtes humain, puis réessayez.', { status: 403 });
}

Pendant vos tests, la clé de site 1x00000000000000000000AA avec la clé secrète 1x0000000000000000000000000000000AA passe toujours.

Pour aller plus loin

La référence du SDK Node(ouvre un nouvel onglet) liste chaque paramètre d’envoi et chaque runtime sur lequel le SDK est testé. Pour recevoir les événements de livraison, de bounce, d’ouverture et de clic dans un Worker, la section webhooks d’Envoyer un email depuis une fonction serverless les vérifie sur le même runtime.

Trois étapes jusqu'au premier envoi

Créez un compte, vérifiez un domaine et envoyez votre premier message dès aujourd'hui.

Commencer