Automatización de emails con Claude Code: de leads a monetización
Implementa automatización de emails con Claude Code: lead magnets, consentimiento, reintentos, analítica y ventas.
La automatización de emails no es solo enviar un mensaje después de que alguien complete un formulario. Un flujo que genera ingresos entrega el lead magnet, inicia una secuencia de onboarding, hace seguimiento a una consulta, guarda el consentimiento, procesa bajas, deja de escribir a direcciones con rebotes, reintenta fallos temporales y mide qué CTA termina en compra, formación o consultoría.
Claude Code encaja bien porque el email toca muchas piezas a la vez: esquemas, plantillas, adaptadores de proveedor, colas, webhooks, eventos de analítica y documentación. En una revisión del funnel de PDF gratuito de este sitio, el error inicial fue pedir solo una función de envío con Resend. Funcionaba, pero el consentimiento, la URL de baja, los rebotes y la analítica de CTA quedaron como parches. Es mejor pedir primero una tabla de diseño y una lista de archivos, limitar el alcance y después dejar que Claude Code implemente.
En esta guía construiremos una base Node.js/TypeScript independiente del proveedor. Podrás alternar una API estilo Resend o estilo SendGrid, incluir límites de envío, cola y reintentos, plantillas, eventos de entrega, analítica y un camino comercial hacia productos, formación o consultoría. Para el contexto de monetización, revisa también auditoría del funnel de contenidos, implementación de analítica y gestión de cookies y consentimiento.
Diseña antes de escribir código
Un lead magnet es un recurso gratuito, como un PDF, checklist o plantilla, que se entrega a cambio de un email. Onboarding es la secuencia que ayuda a una persona a empezar después de registrarse, comprar o apuntarse a una formación. Seguimiento de consultoría es el email operativo que resume una conversación, comparte próximos pasos o propone una fecha.
No metas todo en una etiqueta genérica de newsletter. Cada tipo tiene consentimiento, tono, métricas y riesgos distintos.
| Objetivo | Destinatario | Email ejemplo | Ruta comercial | Riesgo a controlar |
|---|---|---|---|---|
| Captura de leads | Lector que pidió un PDF | Enlace de descarga y guía relacionada | PDF gratuito a productos | Guardar consentimiento y enlace de baja |
| Onboarding | Cliente o alumno | Guía inicial, checklist, bloqueos frecuentes | Plantillas, curso, soporte | No convertir recibos en ventas agresivas |
| Seguimiento | Lead cualificado | Notas, propuesta, próxima reserva | Formación y consultoría | Personalizar según la conversación |
| Reactivación | Lector con consentimiento pero inactivo | Caso práctico o actualización importante | Producto o consulta | Vigilar frecuencia, bajas y rebotes |
También conviene explicar los términos. SPF es un registro DNS que indica qué servidores pueden enviar en nombre de tu dominio. DKIM añade una firma para demostrar que el mensaje fue autorizado y no se modificó. DMARC indica qué política aplicar si SPF o DKIM no se alinean. Un rebote es una entrega fallida. Un límite de tasa aparece cuando el proveedor reduce o rechaza solicitudes porque envías demasiado rápido, llegas a una cuota o tu reputación necesita cuidado.
Usa documentación oficial como referencia. Para Gmail, consulta Google email sender guidelines. Para proveedores, empieza por Resend domain management o Twilio SendGrid domain authentication. DMARC fue actualizado en 2026 por RFC 9989, que reemplaza al antiguo RFC 7489. Para email comercial en EE. UU., revisa la guía CAN-SPAM de la FTC. Esto es guía técnica, no asesoría legal.
flowchart LR
Visitor["Lector"]
Form["Formulario de lead"]
Consent["Registro de consentimiento"]
Queue["Cola de email"]
Provider["Resend / SendGrid"]
Inbox["Bandeja de entrada"]
Webhook["Eventos de entrega"]
Analytics["Analítica"]
Offer["Producto / formación / consultoría"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
Prompt para Claude Code
Un prompt vago produce una función de envío. Un prompt útil define objetivos, límites y verificación.
Implementa automatización de emails en este repositorio.
Los objetivos son entregar un lead magnet, una secuencia de onboarding de 3 emails y seguimiento de consultoría.
Restricciones:
- Usar Node.js 20+ y TypeScript.
- Crear un adapter que cambie entre API estilo Resend y API estilo SendGrid.
- Mantener API keys solo en variables de entorno del servidor.
- Crear schemas para lead, email job, unsubscribe y provider event.
- Reintentar 429 y 5xx con exponential backoff.
- No enviar a direcciones con unsubscribe, complaint o suppression.
- Añadir hard bounces repetidos a suppression list.
- Incluir versión texto, HTML, URL de baja y remitente claro.
- Documentar enlaces oficiales de proveedor y autenticación.
- Añadir scripts ejecutables y pruebas enfocadas.
Primero muestra tabla de diseño y lista de archivos. Espera aprobación antes de editar.
Implementación mínima copiable
Este starter usa un JSON local como cola para que puedas ejecutarlo en un proyecto de prueba. En producción, cámbialo por Postgres, Redis, SQS, Cloud Tasks u otra cola durable con bloqueo y auditoría.
{
"type": "module",
"scripts": {
"lead:send": "tsx scripts/send-lead-magnet.ts",
"email:worker": "tsx scripts/email-worker.ts"
},
"dependencies": {
"zod": "latest"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
// src/email/schema.ts
import { z } from "zod";
export const leadSchema = z.object({
email: z.string().email(),
name: z.string().trim().min(1).max(80),
locale: z.enum(["ja", "en", "zh", "ko", "es", "fr", "de", "pt", "hi", "id"]).default("es"),
source: z.enum(["article", "product", "workshop", "consultation"]),
consentAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export const sendMessageSchema = z.object({
to: z.string().email(),
from: z.string().email(),
fromName: z.string().min(1),
replyTo: z.string().email().optional(),
subject: z.string().min(1).max(120),
text: z.string().min(1),
html: z.string().min(1),
unsubscribeUrl: z.string().url(),
category: z.enum(["lead_magnet", "onboarding", "consultation_followup"]),
metadata: z.record(z.string()).default({}),
});
export const emailJobSchema = z.object({
message: sendMessageSchema,
maxAttempts: z.number().int().min(1).max(8).default(4),
});
export type SendMessage = z.infer<typeof sendMessageSchema>;
export type EmailJobInput = z.infer<typeof emailJobSchema>;
// src/email/provider.ts
import { randomUUID } from "node:crypto";
import type { SendMessage } from "./schema";
type SendResult = { providerMessageId: string; acceptedAt: string };
export interface EmailProvider { send(message: SendMessage): Promise<SendResult>; }
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing env: ${name}`);
return value;
}
async function parseProviderError(response: Response): Promise<Error> {
const body = await response.text().catch(() => "");
const retryable = response.status === 429 || response.status >= 500;
const error = new Error(`Email provider error ${response.status}: ${body || response.statusText}`);
(error as Error & { retryable?: boolean }).retryable = retryable;
return error;
}
export class ResendProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: `${message.fromName} <${message.from}>`,
to: [message.to],
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
html: message.html,
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
const data = (await response.json().catch(() => ({}))) as { id?: string };
return { providerMessageId: data.id ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export class SendGridProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("SENDGRID_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: message.to }], custom_args: message.metadata }],
from: { email: message.from, name: message.fromName },
reply_to: message.replyTo ? { email: message.replyTo } : undefined,
subject: message.subject,
content: [
{ type: "text/plain", value: message.text },
{ type: "text/html", value: message.html },
],
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
return { providerMessageId: response.headers.get("x-message-id") ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export function createEmailProvider(): EmailProvider {
return process.env.EMAIL_PROVIDER === "sendgrid" ? new SendGridProvider() : new ResendProvider();
}
// src/email/queue.ts
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { emailJobSchema, type EmailJobInput } from "./schema";
type StoredJob = EmailJobInput & {
id: string;
status: "scheduled" | "processing" | "sent" | "failed";
attempts: number;
nextAttemptAt: string;
lastError?: string;
};
const queueFile = process.env.EMAIL_QUEUE_FILE ?? ".email-queue.json";
async function loadQueue(): Promise<StoredJob[]> {
if (!existsSync(queueFile)) return [];
return JSON.parse(await readFile(queueFile, "utf8")) as StoredJob[];
}
async function saveQueue(jobs: StoredJob[]) {
await writeFile(queueFile, JSON.stringify(jobs, null, 2) + "\n");
}
export async function enqueueEmail(input: EmailJobInput) {
const parsed = emailJobSchema.parse(input);
const jobs = await loadQueue();
const job: StoredJob = { ...parsed, id: randomUUID(), status: "scheduled", attempts: 0, nextAttemptAt: new Date().toISOString() };
jobs.push(job);
await saveQueue(jobs);
return job.id;
}
export async function claimDueJobs(limit = 5): Promise<StoredJob[]> {
const now = Date.now();
const jobs = await loadQueue();
const due = jobs.filter((job) => job.status === "scheduled" && Date.parse(job.nextAttemptAt) <= now).slice(0, limit);
for (const job of due) job.status = "processing";
await saveQueue(jobs);
return due;
}
export async function completeJob(id: string) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (job) job.status = "sent";
await saveQueue(jobs);
}
export async function failJob(id: string, error: unknown) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (!job) return;
job.attempts += 1;
job.lastError = error instanceof Error ? error.message : String(error);
if (job.attempts >= job.maxAttempts) {
job.status = "failed";
} else {
const delayMs = Math.min(15 * 60_000, 2 ** job.attempts * 1000);
job.status = "scheduled";
job.nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
}
await saveQueue(jobs);
}
// scripts/email-worker.ts
import { claimDueJobs, completeJob, failJob } from "../src/email/queue";
import { createEmailProvider } from "../src/email/provider";
const provider = createEmailProvider();
const jobs = await claimDueJobs(Number(process.env.EMAIL_WORKER_BATCH ?? 3));
for (const job of jobs) {
try {
const result = await provider.send(job.message);
await completeJob(job.id);
console.log(`sent ${job.id} as ${result.providerMessageId}`);
} catch (error) {
await failJob(job.id, error);
console.error(`failed ${job.id}`, error);
}
}
Prueba primero con una dirección que controles. No envíes a lectores reales hasta autenticar el dominio y comprobar que la baja funciona.
npm install
EMAIL_TO=you@example.com APP_URL=https://example.com npm run lead:send
EMAIL_PROVIDER=resend RESEND_API_KEY=re_xxx npm run email:worker
Rebotes, bajas y analítica
La API del proveedor solo confirma que aceptó la petición. No demuestra lectura, clic ni interés. Normaliza los webhooks a un modelo propio y actualiza una suppression list cuando haya rebote duro, queja o baja.
// src/email/events.ts
import { z } from "zod";
const providerEventSchema = z.object({
provider: z.enum(["resend", "sendgrid", "unknown"]),
type: z.enum(["delivered", "bounce", "complaint", "unsubscribe", "open", "click", "deferred"]),
email: z.string().email().optional(),
providerMessageId: z.string().optional(),
reason: z.string().optional(),
occurredAt: z.string().datetime(),
});
export function normalizeProviderEvent(payload: unknown) {
const raw = payload as Record<string, unknown>;
const type = String(raw.type ?? raw.event ?? "delivered");
const mappedType =
type.includes("bounce") ? "bounce" :
type.includes("complaint") || type.includes("spam") ? "complaint" :
type.includes("unsubscribe") ? "unsubscribe" :
type.includes("click") ? "click" :
type.includes("open") ? "open" :
type.includes("defer") ? "deferred" :
"delivered";
return providerEventSchema.parse({
provider: raw.sg_event_id ? "sendgrid" : raw.created_at ? "resend" : "unknown",
type: mappedType,
email: String(raw.email ?? raw.recipient ?? "") || undefined,
providerMessageId: String(raw.email_id ?? raw.sg_message_id ?? ""),
reason: typeof raw.reason === "string" ? raw.reason : undefined,
occurredAt: new Date(String(raw.created_at ?? Date.now())).toISOString(),
});
}
No te quedes solo con aperturas. Bloqueo de imágenes y protección de privacidad distorsionan ese dato. Mide descargas, clics de CTA, formularios de consultoría iniciados, respuestas, bajas, rebotes y compras. Usa nombres como lead_magnet_requested, email_cta_click y consultation_request_started.
Casos de uso reales
Primer caso: PDF gratuito debajo de un artículo técnico. Envía la descarga al instante, luego una explicación del fallo más común, después una plantilla de producto y finalmente una invitación a formación o consultoría. Un email, una acción principal, enlace de baja siempre visible.
Segundo caso: onboarding tras compra. Una persona que compró una guía o entró en un taller necesita empezar, resolver bloqueos y descubrir material avanzado. La mejor venta adicional suele ser ayudarle a tener éxito, no llenar el recibo de promociones.
Tercer caso: seguimiento de consultoría. Incluye notas de la reunión, decisiones, próximos pasos, enlaces relevantes, plazo de respuesta y CTA de reserva o propuesta. Si el mensaje ignora la conversación real, parecerá spam.
Cuarto caso: reactivación de leads inactivos. Envía solo actualizaciones importantes, historias de fallos útiles o recursos nuevos. Si no vuelven clics ni respuestas, reduce frecuencia o detén la secuencia.
Fallos frecuentes
El primer fallo es exponer la API key en el navegador. El envío debe vivir en el servidor.
El segundo es enviar desde un dominio no autenticado. Configura SPF, DKIM y DMARC para tu dominio.
El tercero es ignorar bajas y rebotes. Unsubscribe, complaint y hard bounce deben salir de las campañas normales.
El cuarto es reintentar inmediatamente tras un rate limit. Maneja 429 y 5xx temporales con backoff y ritmo estable. Los límites cambian por cuenta, plan, reputación y destinatario.
El quinto es mezclar emails transaccionales y promocionales. Reset de contraseña, recibos y alertas deben ser claros. Los CTA comerciales pertenecen a mensajes con consentimiento y contexto.
CTA de monetización
El sistema está completo cuando el lector puede elegir el siguiente paso sin presión. En ClaudeCodeLab, principiantes pueden empezar con el PDF gratuito, quienes implementan pueden revisar productos y plantillas, y equipos que necesitan aplicarlo a un repositorio real pueden usar formación y consultoría.
Al probar este flujo, la mejora más grande vino de diseñar consentimiento, bajas, rebotes y analítica de CTA antes del código específico del proveedor. Empieza con un solo email de lead magnet, verifica entrega, baja, rebote y clic, y luego amplía a onboarding y seguimiento.
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.