Implémenter SendGrid avec Claude Code sans négliger la sécurité
Implémentez SendGrid avec Claude Code: expéditeur vérifié, Mail Send API, retries, logs et délivrabilité.
SendGrid est un service cloud d’envoi d’e-mails applicatifs par API. Il peut servir aux confirmations de formulaire de contact, aux e-mails d’onboarding, aux rapports quotidiens, aux notifications transactionnelles et aux relances commerciales lorsque le cadre de consentement et de désinscription est clair.
Le piège est que le code paraît simple. Si vous demandez seulement à Claude Code “ajoute l’envoi SendGrid”, vous obtiendrez probablement un appel API fonctionnel, mais pas forcément la vérification de l’expéditeur, la protection de la clé API, la prévention des doublons lors des retries, la gestion des bounces, les plaintes spam, les logs fournisseur ni l’opt-out. Un e-mail envoyé ne se récupère pas. Il faut donc définir la frontière opérationnelle avant la fonction fetch.
Ce guide s’appuie sur la documentation officielle Twilio SendGrid de la v3 Mail Send API, la page SendGrid des erreurs de validation et le site SendGrid. Vous y trouverez un script Node.js prêt à copier, sûr par défaut: dry-run sans --send, validation du payload, mode sandbox, retries limités aux erreurs temporaires et log local pour illustrer l’idempotence.
Pour les fondations voisines, consultez aussi l’automatisation e-mail avec Claude Code, le développement d’API, la gestion des variables d’environnement et les bonnes pratiques de sécurité.
Les bases SendGrid avant le code
L’API Mail Send consiste à envoyer du JSON vers POST https://api.sendgrid.com/v3/mail/send avec l’en-tête Authorization: Bearer SENDGRID_API_KEY. Cette partie est simple. Ce qui rend l’implémentation fiable, c’est la préparation autour.
| Élément | Sens pratique | À vérifier |
|---|---|---|
| Expéditeur vérifié | SendGrid confirme que l’adresse from peut envoyer | Single Sender pour un test, Domain Authentication pour la production |
| Authentification de domaine | Le DNS prouve que votre domaine peut envoyer via SendGrid | SPF/DKIM validés avant le trafic réel |
| Clé API | Secret utilisé par le serveur pour appeler SendGrid | Uniquement côté serveur, jamais dans le navigateur ni dans Git |
personalizations | Données par destinataire: to, sujet, custom args, données de template | Un destinataire par personalization pour ne pas exposer une liste |
| Suppression | Adresses à exclure après bounce, plainte ou désinscription | Vérifier votre propre liste avant l’appel SendGrid |
| Log fournisseur | Statut HTTP, corps de réponse et x-message-id | Garder assez d’informations pour support et anti-doublon |
SPF est un enregistrement DNS qui indique quels serveurs peuvent envoyer pour votre domaine. DKIM signe le message pour prouver qu’il est autorisé et non modifié. DMARC définit la politique côté réception lorsque SPF ou DKIM échoue. Pour un débutant, l’idée essentielle est simple: l’authentification de l’expéditeur est la pièce d’identité de la délivrabilité.
N’utilisez pas une adresse Gmail au hasard dans from. Pour une preuve de concept, vérifiez un Single Sender. En production, authentifiez votre domaine et envoyez depuis une adresse produit, support ou équipe. Beaucoup d’erreurs de validation viennent d’un from invalide, de personalizations mal formées, d’un contenu absent ou d’une mauvaise configuration de template.
Quatre cas d’usage à distinguer
Ne mettez pas tous les e-mails derrière une seule fonction sendMail générique. Chaque flux a son consentement, sa fréquence, son ton, ses risques et ses logs.
| Cas d’usage | Exemple | Garde-fou nécessaire |
|---|---|---|
| Formulaire de contact | Confirmation au visiteur et notification interne | Échapper les champs utilisateur, séparer mail admin et mail visiteur |
| Onboarding transactionnel | Inscription confirmée, guide de première connexion, achat | Rester attendu et utile, sans transformer l’e-mail en promotion forcée |
| Rapport quotidien | Chiffre d’affaires, erreurs, réservations, progression | Utiliser une idempotency key pour éviter les doublons lors d’un retry |
| Vente ou outreach | Suivi après rendez-vous, proposition, ressource promise | Ajouter opt-out, identité de l’expéditeur et vérification suppression |
L’outreach doit être traité avec prudence. Pouvoir envoyer techniquement ne signifie pas que l’envoi est approprié. Les règles changent selon le pays, la relation, le contexte B2B ou B2C et le contenu du message. Cet article n’est pas un avis juridique. Au minimum, donnez la raison du contact, identifiez l’expéditeur et fournissez une désinscription fonctionnelle.
flowchart LR
App["App / changement Claude Code"]
Validate["Validation du payload"]
Log["Log et idempotency key"]
SendGrid["SendGrid Mail Send API"]
Inbox["Boîte de réception"]
Events["Bounce / Spam / Unsubscribe"]
Suppression["Liste de suppression"]
App --> Validate --> Log --> SendGrid --> Inbox
SendGrid --> Events --> Suppression
Suppression --> Validate
Script Node.js prêt à copier
Le script suivant fonctionne avec Node.js 20 ou plus, sans dépendance. Par défaut, il fait un dry-run: il affiche le payload, écrit le log et ne contacte pas SendGrid. Utilisez --send pour un vrai appel API, et --send --sandbox pour faire valider la requête par SendGrid sans livrer l’e-mail.
// sendgrid-safe-send.mjs
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
const ENDPOINT = process.env.SENDGRID_API_BASE ?? "https://api.sendgrid.com/v3/mail/send";
const LOG_PATH = process.env.SENDGRID_SEND_LOG ?? ".sendgrid-send-log.json";
const DRY_RUN = !process.argv.includes("--send");
const SANDBOX = process.argv.includes("--sandbox");
const MAX_ATTEMPTS = Number.parseInt(process.env.SENDGRID_MAX_ATTEMPTS ?? "3", 10);
const recipient = {
email: process.env.MAIL_TO ?? "recipient@example.com",
name: process.env.MAIL_TO_NAME ?? "Test Recipient",
};
const message = {
from: {
email: process.env.MAIL_FROM ?? "verified-sender@example.com",
name: process.env.MAIL_FROM_NAME ?? "ClaudeCodeLab Demo",
},
reply_to: {
email: process.env.MAIL_REPLY_TO ?? process.env.MAIL_FROM ?? "verified-sender@example.com",
},
personalizations: [
{
to: [recipient],
custom_args: {
use_case: process.env.MAIL_USE_CASE ?? "dry_run_demo",
},
},
],
subject: process.env.MAIL_SUBJECT ?? `SendGrid dry-run test for ${recipient.name}`,
content: [
{
type: "text/plain",
value: `Hello ${recipient.name},\n\nThis is a safe SendGrid test from Claude Code.\n`,
},
{
type: "text/html",
value: `<p>Hello ${escapeHtml(recipient.name)},</p><p>This is a safe SendGrid test from Claude Code.</p>`,
},
],
categories: ["claude-code-demo"],
mail_settings: {
sandbox_mode: { enable: SANDBOX },
},
};
validatePayload(message);
const idempotencyKey = makeIdempotencyKey(message);
for (const personalization of message.personalizations) {
personalization.custom_args = {
...(personalization.custom_args ?? {}),
idempotency_key: idempotencyKey,
};
}
await sendWithRetry(message, idempotencyKey);
function validatePayload(payload) {
if (!Number.isInteger(MAX_ATTEMPTS) || MAX_ATTEMPTS < 1 || MAX_ATTEMPTS > 5) {
throw new Error("SENDGRID_MAX_ATTEMPTS must be an integer from 1 to 5.");
}
assertEmail(payload.from?.email, "from.email");
if (!DRY_RUN && payload.from.email.endsWith("@example.com")) {
throw new Error("Set MAIL_FROM to a verified SendGrid sender before using --send.");
}
if (!Array.isArray(payload.personalizations) || payload.personalizations.length === 0) {
throw new Error("personalizations must contain at least one recipient.");
}
for (const [index, personalization] of payload.personalizations.entries()) {
if (!Array.isArray(personalization.to) || personalization.to.length !== 1) {
throw new Error(`personalizations[${index}].to must contain exactly one recipient.`);
}
assertEmail(personalization.to[0]?.email, `personalizations[${index}].to[0].email`);
}
if (!payload.subject && !payload.template_id) {
throw new Error("Provide a subject or a SendGrid template_id.");
}
const hasContent = Array.isArray(payload.content)
&& payload.content.some((item) => typeof item.value === "string" && item.value.trim());
if (!hasContent && !payload.template_id) {
throw new Error("Provide text/html content or a SendGrid template_id.");
}
}
function assertEmail(value, field) {
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`${field} must be a valid email address.`);
}
}
function makeIdempotencyKey(payload) {
const stableEnvelope = {
from: payload.from.email.toLowerCase(),
to: payload.personalizations.map((item) => item.to[0].email.toLowerCase()),
subject: payload.subject,
content: payload.content?.map((item) => item.value),
useCase: payload.personalizations.map((item) => item.custom_args?.use_case ?? ""),
};
return createHash("sha256").update(JSON.stringify(stableEnvelope)).digest("hex").slice(0, 32);
}
async function sendWithRetry(payload, idempotencyKey) {
const log = await readJsonLog();
const previous = log[idempotencyKey];
if (previous?.status === "accepted") {
console.log(`Already accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
if (previous?.status === "pending") {
throw new Error(`A send is already pending. idempotencyKey=${idempotencyKey}`);
}
if (DRY_RUN) {
log[idempotencyKey] = {
status: "dry-run",
updatedAt: new Date().toISOString(),
to: payload.personalizations.map((item) => item.to[0].email),
};
await writeJsonLog(log);
console.log("Dry run only. Add --send to call SendGrid.");
console.log(JSON.stringify({ idempotencyKey, payload }, null, 2));
return;
}
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
throw new Error("SENDGRID_API_KEY is required when using --send.");
}
log[idempotencyKey] = { status: "pending", updatedAt: new Date().toISOString() };
await writeJsonLog(log);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const responseBody = await response.text();
const providerMessageId = response.headers.get("x-message-id");
if (response.status === 202) {
log[idempotencyKey] = {
status: "accepted",
statusCode: response.status,
providerMessageId,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
console.log(`Accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
const retryable = response.status === 429 || response.status >= 500;
log[idempotencyKey] = {
status: retryable && attempt < MAX_ATTEMPTS ? "retrying" : "failed",
statusCode: response.status,
responseBody: responseBody.slice(0, 2000),
attempt,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
if (!retryable || attempt === MAX_ATTEMPTS) {
throw new Error(`SendGrid request failed with HTTP ${response.status}: ${responseBody}`);
}
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000));
}
}
async function readJsonLog() {
if (!existsSync(LOG_PATH)) return {};
return JSON.parse(await readFile(LOG_PATH, "utf8"));
}
async function writeJsonLog(log) {
await writeFile(LOG_PATH, `${JSON.stringify(log, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
Commencez par le dry-run. Sous Windows PowerShell:
node .\sendgrid-safe-send.mjs
$env:SENDGRID_API_KEY="SG.xxxxx"
$env:MAIL_FROM="verified@example.com"
$env:MAIL_TO="you@example.net"
node .\sendgrid-safe-send.mjs --send --sandbox
node .\sendgrid-safe-send.mjs --send
Sous macOS ou Linux:
SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="verified@example.com" MAIL_TO="you@example.net" node sendgrid-safe-send.mjs --send --sandbox
Le log JSON local est volontairement simple. En production, utilisez Postgres, Redis, SQS, Cloud Tasks ou une autre file durable. Ajoutez une contrainte unique sur idempotency_key et séparez l’état fournisseur de l’état métier.
Prompt utile pour Claude Code
Un prompt vague produit souvent seulement une fonction d’envoi. Donnez à Claude Code le but métier, les limites de sécurité et les conditions de vérification.
Ajoute l'envoi d'e-mails SendGrid dans ce dépôt.
Les flux sont: confirmation de formulaire de contact, onboarding après inscription, rapports quotidiens et suivi commercial.
Contraintes:
- Utiliser SendGrid Mail Send API v3.
- Lire la clé API uniquement depuis la variable serveur SENDGRID_API_KEY.
- Tous les scripts sont en dry-run par défaut et envoient seulement avec --send.
- Utiliser exactement un destinataire par personalization pour ne pas exposer les listes.
- Réessayer seulement 429 et 5xx avec exponential backoff.
- Vérifier unsubscribe, bounce et spam complaint avant l'envoi.
- Stocker provider response, HTTP status, x-message-id et idempotency key.
- Ajouter un chemin opt-out pour les e-mails d'outreach.
- Lier la documentation officielle SendGrid dans le README.
Retourne d'abord la table de conception et la liste des fichiers. Attends mon accord avant modification.
Ce cadrage oblige Claude Code à traiter consentement, suppression, logs et retries. Il limite aussi les conflits lorsque plusieurs personnes modifient le dépôt.
Échecs fréquents à anticiper
| Échec | Conséquence | Prévention |
|---|---|---|
| Fuite de clé API | Quelqu’un peut envoyer depuis votre compte et nuire à la réputation | Ignorer .env, scanner les secrets, faire une rotation immédiate |
| Expéditeur non vérifié | Erreurs 400, blocages ou mauvaise délivrabilité | Vérifier Single Sender ou authentifier le domaine |
| Retry qui duplique | Le même reçu, rapport ou message commercial arrive plusieurs fois | Utiliser send log et idempotency key avant l’appel fournisseur |
| Outreach sans opt-out | Plaintes et risque juridique augmentent | Inclure désinscription, identité et raison de l’envoi |
| Volume trop rapide | Rate limits et dégradation de réputation | Commencer petit et surveiller bounces et plaintes |
| Pas de réponse fournisseur stockée | Le support ne peut pas expliquer l’incident | Sauver status, body, x-message-id et hash destinataire |
| Liste de destinataires exposée | Les adresses d’autres utilisateurs deviennent visibles | Un destinataire par personalization |
Un 202 Accepted de SendGrid ne prouve pas que l’e-mail est arrivé en boîte de réception. Il indique que SendGrid a accepté la demande. Les événements de bounce, block, spam report et unsubscribe restent nécessaires pour piloter la délivrabilité.
Délivrabilité et CTA
La délivrabilité ne dépend pas seulement du DNS. Elle dépend aussi des attentes du destinataire, de la fréquence, du contenu, de l’historique de bounce, du taux de plainte et de la facilité de désinscription. Suivez au minimum les volumes envoyés, accepted, bounces, blocked, spam reports et unsubscribes.
Dans un funnel ClaudeCodeLab, le CTA doit correspondre au contexte. Une confirmation de contact peut renvoyer vers un article utile. Un onboarding peut proposer une checklist ou un template. Un rapport quotidien doit rester opérationnel. Un suivi commercial ne devrait inviter à une consultation que si la relation le justifie. Pour une mise en place dans un vrai dépôt, la page formation et conseil Claude Code peut couvrir variables d’environnement, SendGrid, revue de sécurité, suppression et logs.
Résultat de vérification pratique
Quand Masa a testé cet exemple, le choix le plus utile a été le dry-run par défaut. Sans option, le script affiche le payload et écrit le log local. Avec --send et un MAIL_FROM en @example.com, il s’arrête avant l’appel API. Avec --send --sandbox, SendGrid peut valider la forme de la requête sans livrer l’e-mail. En projet réel, ce log local doit devenir une file en base avec contrainte unique d’idempotence, alimentée par les événements bounce, spam complaint et unsubscribe avant chaque envoi.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.