Cómo implementar correos SendGrid con Claude Code de forma segura
Implementa SendGrid con Claude Code: remitente verificado, Mail Send API, reintentos, logs y entregabilidad.
SendGrid es un servicio de entrega de correo en la nube para enviar emails de aplicación mediante API. Sirve para confirmaciones de formularios de contacto, onboarding después de un registro, reportes diarios, avisos transaccionales y seguimiento comercial cuando existe una base legítima y una salida clara para darse de baja.
El riesgo es que el código parece demasiado sencillo. Si le pides a Claude Code “envía email con SendGrid”, probablemente obtendrás una llamada API que funciona, pero no necesariamente un remitente verificado, manejo correcto del API key, protección contra duplicados en reintentos, gestión de bounces, quejas de spam, logs del proveedor ni opt-out. El correo sale de tu sistema y no se puede retirar después. Por eso conviene diseñar la frontera operativa antes de escribir el fetch.
Esta guía usa como base la documentación oficial de Twilio SendGrid para la v3 Mail Send API, la referencia de errores de validación y el sitio de SendGrid. Incluye un script Node.js copiable que es seguro por defecto: ejecuta dry-run si no pasas --send, valida el payload, permite validación en sandbox, reintenta solo fallos temporales y guarda un log local como demostración de idempotencia.
Para completar la base, enlaza este tema con automatización de email con Claude Code, desarrollo de API, gestión de variables de entorno y buenas prácticas de seguridad.
Conceptos de SendGrid antes del código
La API Mail Send es directa: POST https://api.sendgrid.com/v3/mail/send con JSON y el encabezado Authorization: Bearer SENDGRID_API_KEY. Lo que decide la calidad no es solo esa llamada, sino todo lo que la rodea.
| Elemento | Significado práctico | Qué verificar |
|---|---|---|
| Remitente verificado | SendGrid confirma que puedes enviar desde ese from | Single Sender para pruebas pequeñas, Domain Authentication para producción |
| Autenticación de dominio | DNS demuestra que tu dominio puede enviar mediante SendGrid | SPF/DKIM verificados antes de enviar tráfico real |
| API key | Secreto que permite al servidor llamar a SendGrid | Solo en variables de entorno del servidor, nunca en frontend ni Git |
personalizations | Datos por destinatario: to, asunto, custom args o datos de plantilla | Un destinatario por personalization para no exponer listas |
| Suppression | Direcciones bloqueadas por bounce, queja o baja | Consultar tu propia lista antes de enviar |
| Log del proveedor | Estado HTTP, cuerpo de respuesta y x-message-id | Guardar lo necesario para soporte, auditoría y evitar duplicados |
SPF es un registro DNS que indica qué servidores pueden enviar por tu dominio. DKIM firma el mensaje para que el receptor pueda verificar que fue autorizado y no se modificó. DMARC indica qué política aplicar cuando SPF o DKIM no alinean. No hace falta memorizar todos los detalles al principio, pero sí entender que la autenticación del remitente es la base de la entregabilidad.
No empieces con una dirección Gmail cualquiera en from. Para una prueba local, verifica un Single Sender. Para producción, autentica tu dominio y usa una dirección real de producto, soporte o equipo. Muchos errores de validación vienen de from inválido, personalizations mal formado, falta de contenido o uso incorrecto de plantillas.
Cuatro casos de uso reales
No trates todos los correos como una sola función sendMail. Cada flujo tiene consentimiento, frecuencia, tono, riesgo y logging diferentes.
| Caso de uso | Ejemplo | Control necesario |
|---|---|---|
| Formulario de contacto | Confirmación al visitante y aviso al equipo | Escapar entradas del usuario, separar correo interno y correo al visitante |
| Onboarding transaccional | Registro completado, guía de primer acceso, instrucciones de compra | Mantenerlo esperado y útil, sin convertirlo en publicidad agresiva |
| Reporte diario | Ventas, errores, reservas, progreso de curso | Usar idempotency key para que un reintento no parezca reporte duplicado |
| Ventas u outreach | Seguimiento después de una reunión, propuesta, recurso prometido | Incluir opt-out, respetar suppressions y revisar requisitos legales locales |
En ventas y outreach hay que ser especialmente cuidadoso. Poder enviar técnicamente no significa que debas hacerlo. Las reglas dependen del país, la relación previa, si es B2B o B2C y el tipo de mensaje. Este artículo no sustituye asesoría legal. Como mínimo, explica por qué escribes, identifica al remitente y ofrece una forma funcional de darse de baja.
flowchart LR
App["App / cambio de Claude Code"]
Validate["Validación del payload"]
Log["Log e idempotency key"]
SendGrid["SendGrid Mail Send API"]
Inbox["Bandeja de entrada"]
Events["Bounce / Spam / Unsubscribe"]
Suppression["Lista de suppression"]
App --> Validate --> Log --> SendGrid --> Inbox
SendGrid --> Events --> Suppression
Suppression --> Validate
Script Node.js copiable
El siguiente script funciona con Node.js 20 o superior y no necesita dependencias. Por defecto hace dry-run: imprime el payload y guarda el log, pero no llama a SendGrid. Usa --send para una llamada real y --send --sandbox para que SendGrid valide la solicitud sin entregar el correo.
// 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("'", "'");
}
Primero ejecuta dry-run. En 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
En macOS o Linux:
SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="verified@example.com" MAIL_TO="you@example.net" node sendgrid-safe-send.mjs --send --sandbox
El log JSON local es solo para aprendizaje. En producción, mueve la misma idea a Postgres, Redis, SQS, Cloud Tasks u otra cola duradera. Añade una restricción única para idempotency_key y guarda el estado del proveedor separado del estado de negocio.
Prompt para Claude Code
Un prompt útil no pide solo código. Pide diseño, límites y verificación.
Agrega envío de email con SendGrid a este repositorio.
Los flujos son confirmación de formulario de contacto, onboarding de registro, reportes diarios y seguimiento comercial.
Restricciones:
- Usar SendGrid Mail Send API v3.
- Leer el API key solo desde la variable de entorno del servidor SENDGRID_API_KEY.
- Todos los scripts deben ser dry-run por defecto y enviar solo con --send.
- Usar exactamente un destinatario por personalization para no exponer listas.
- Reintentar solo 429 y 5xx con exponential backoff.
- Revisar unsubscribe, bounce y spam complaint antes de enviar.
- Guardar provider response, HTTP status, x-message-id e idempotency key.
- Incluir ruta de opt-out en emails de outreach.
- Dejar enlaces a la documentación oficial de SendGrid en el README.
Primero devuelve la tabla de diseño y la lista de archivos. Espera aprobación antes de editar.
Así obligas a Claude Code a pensar en consentimiento, suppression, logs y reintentos. También reduces conflictos en un repositorio con varias personas, porque primero aparece el alcance de archivos.
Fallos comunes
| Fallo | Consecuencia | Prevención |
|---|---|---|
| Filtrar el API key | Terceros pueden enviar desde tu cuenta y dañar reputación | Ignorar .env, escanear secretos y rotar la clave |
| Remitente no verificado | Errores 400, bloqueos o mala ubicación en inbox | Verificar Single Sender o autenticar dominio |
| Reintento duplicado | Llega dos veces el reporte, recibo o correo comercial | Usar send log e idempotency key antes de llamar al proveedor |
| Outreach sin opt-out | Aumentan quejas y riesgo legal | Incluir baja, identidad de empresa y motivo del mensaje |
| Enviar demasiado rápido | Rate limits y problemas de reputación | Empezar con poco volumen y observar bounces y quejas |
| No guardar respuesta del proveedor | Soporte no puede explicar qué pasó | Guardar status, body, x-message-id y hash del destinatario |
| Exponer lista de destinatarios | Un usuario ve emails de otros usuarios | Un destinatario por personalization |
Un 202 Accepted de SendGrid no significa que el mensaje llegó a la bandeja de entrada. Significa que SendGrid aceptó la solicitud para procesarla. Para operar bien necesitas eventos posteriores: bounces, blocks, spam reports y unsubscribes.
Entregabilidad y CTA
La entregabilidad no depende solo del DNS. También importan la expectativa del destinatario, el volumen, la claridad del contenido, la tasa de bounce, las quejas y la facilidad para darse de baja. Como mínimo mide enviados, accepted, bounces, blocked, spam reports y unsubscribes.
En un embudo de ClaudeCodeLab, el CTA debe respetar el momento del lector. Una confirmación de contacto puede enlazar a un artículo útil; un onboarding puede ofrecer una checklist o plantilla; un reporte diario debe seguir siendo operativo; un seguimiento comercial solo debe invitar a consultoría si la relación lo justifica. Para implementarlo en un repositorio real, la página de formación y consultoría Claude Code puede cubrir variables de entorno, SendGrid, revisión de seguridad, suppression y diseño de logs.
Resultado de la verificación práctica
Cuando Masa probó este ejemplo, la decisión más útil fue hacer que dry-run fuera el valor por defecto. Sin flags, el script solo imprimió el payload y escribió el log local. Con --send y un MAIL_FROM de @example.com, se detuvo antes de llamar a la API. Con --send --sandbox, SendGrid pudo validar la forma de la solicitud sin entregar correo. En proyectos reales, ese log local debe convertirse en una cola con base de datos, restricción única de idempotencia y checks de bounce, spam complaint y unsubscribe antes de cada envío.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.