easyemailjs
v1.0.0
Published
easymail: correos para Node.js sobre nodemailer con cola, reintentos, envío directo y auditoría completa en tu base de datos (easy-db).
Downloads
442
Maintainers
Readme
easymail
Correos para Node.js sobre nodemailer, con cola, reintentos, envío directo y auditoría completa guardada en tu base de datos mediante easy-db.
Cada correo, ya sea encolado o enviado al instante, queda registrado con todo su contenido: remitente, destinatarios, asunto, cuerpo, adjuntos, plantilla y datos. También guarda su estado, sus intentos, el último error, la respuesta del servidor y un historial de eventos.
npm install easyemailjs easy-db
npx easymail initEl paquete se publica como
easyemailjsporque npm no aceptaeasymailnieasyemailjs: se parecen demasiado aeasy-mailyeasymail-js, que ya existen. Si quieres importarlo comoeasymail, usanpm install easymail@npm:easyemailjs.
Inicio rápido
1. Tablas. Agrega las tablas de easymail a tu database/schema.ts de easy-db:
import { schema } from "easy-db";
import { mailTables } from "easyemailjs";
export default schema({
...mailTables(), // mail_messages + mail_events
users: t => { /* … */ },
});npx easy-db migration:diff
npx easy-db migrateSi prefieres no usar migration:diff, ejecuta npx easymail init --migration. Crea database/migrations/<fecha>-easymail.ts con export default mailMigration().
2. Configuración (.env):
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
MAIL_URL=smtp://usuario:[email protected]:587
MAIL_FROM="Mi App <[email protected]>"3. Enviar:
import { createMailer } from "easyemailjs";
export const mail = createMailer(); // lee MAIL_URL y MAIL_FROM
await mail.send({ to: "[email protected]", subject: "Bienvenida", html: "<p>Hola Ana</p>" }); // cola
await mail.sendNow({ to: "[email protected]", subject: "Tu código", text: "123456" }); // directo
mail.worker({ concurrency: 2 }).start(); // procesa la colaCola vs. envío directo
| | mail.send() | mail.sendNow() |
|---|---|---|
| Cuándo se envía | Lo envía un worker | En el momento, dentro de la llamada |
| Responde | Al instante (status: "pending") | Cuando el servidor SMTP acepta (status: "sent") |
| Registro en BD | Sí | Sí, siempre (mode: "direct") |
| Si falla temporalmente (4xx, red) | Reintenta con espera creciente | Queda retrying y lo termina el worker |
| Si falla definitivamente (5xx) | failed | failed y lanza MailDeliveryError |
try {
await mail.sendNow({ to, subject: "Código", text: codigo });
} catch (e) {
if (e instanceof MailDeliveryError) console.log(e.code, e.record); // el registro ya está en la BD
}
await mail.sendNow(msg, { queueOnFailure: false }); // no dejarlo en cola: failed directo
await mail.sendNow(msg, { throwOnFailure: false }); // no lanzar; revisar record.statusEnvío dentro de una transacción (outbox)
send() se une a la transacción de easy-db que esté en curso. Si la transacción se revierte, el correo tampoco existe:
import { transaction } from "easy-db";
await transaction(async () => {
const user = await User.create(datos);
await mail.send({ to: user.email, template: "bienvenida", data: { nombre: user.name } });
}); // si algo falla, no queda ni el usuario ni el correoSi dos transacciones usan la misma
idempotencyKeyal mismo tiempo, una de ellas falla con el error de clave única de la base de datos. Fuera de una transacción, easymail devuelve el correo existente.
sendNow()envía en el acto, aunque estés dentro de una transacción. Si después la transacción falla, el correo ya salió. Para correos atados a una transacción, usasend().
Estados y auditoría
pending ──► processing ──► sent
▲ │
│ ├──► retrying ──► processing … (hasta maxAttempts)
│ └──► failed
└── retry() ◄── failed / cancelled cancel(): pending | retrying | failed ──► cancelledCada cambio deja un evento en mail_events. Los tipos son queued, scheduled, processing, sent, attempt_failed, retry_scheduled, failed, cancelled, requeued, lock_expired, content_purged y stale_result (resultado de un intento cuyo bloqueo ya había vencido; se registra pero no cambia el estado). Cada evento guarda el número de intento, el estado, un mensaje, datos (código de error, código SMTP, si se puede reintentar…) y el actor.
await mail.events(id);
// [{ type: "queued" }, { type: "processing", attempt: 1 }, { type: "attempt_failed", data: { code: "EMESSAGE", responseCode: 421 } },
// { type: "retry_scheduled", message: "attempt 2 of 5 at 2026-10-18T…" }, { type: "processing", attempt: 2 }, { type: "sent" }]Qué guarda la tabla mail_messages
| Grupo | Columnas |
|---|---|
| Identidad | id, uuid, messageId (Message-ID, fijo entre reintentos), idempotencyKey |
| Contenido | sender, to, cc, bcc, replyTo, subject, text, html, headers, attachments, template, templateData |
| Cola | status, mode (queue/direct), transport, priority, attempts, maxAttempts, availableAt, lockedAt, lockedBy |
| Resultado | sentAt, failedAt, cancelledAt, response, accepted, rejected, lastError, lastErrorCode |
| Tu aplicación | tags, metadata, actor, createdAt, updatedAt |
El mensaje
await mail.send({
from: "Ventas <[email protected]>", // por defecto, el de la configuración
to: ["Ana <[email protected]>", { name: "Luis", address: "[email protected]" }],
cc: "[email protected], [email protected]",
bcc: "[email protected]",
replyTo: "[email protected]",
subject: "Factura F-001",
html: "<p>Adjuntamos tu factura</p>", // si no das `text`, se genera desde el HTML
attachments: [
{ path: "./facturas/F-001.pdf" }, // archivo local (lo lee el worker)
{ filename: "detalle.csv", content: csv }, // contenido: se guarda en la BD
{ filename: "logo.png", content: buffer, cid: "logo" }, // imagen en línea: <img src="cid:logo">
{ href: "https://midominio.com/terminos.pdf" },
],
headers: { "X-Factura": "F-001" },
priority: 10, // mayor = antes
sendAt: "2026-10-20T08:00:00-06:00",
maxAttempts: 8,
transport: "facturacion", // transporte con nombre
idempotencyKey: `factura-F-001`, // repetir la llamada no duplica el correo
tags: ["factura"],
metadata: { clienteId: 42 },
actor: "user:7", // quién lo originó (queda en la auditoría)
});Todo se valida antes de guardar. Si algo está mal, se lanza un solo MailValidationError con la lista completa en e.issues. Las validaciones incluyen:
- direcciones válidas, sin duplicados y sin saltos de línea (evita la inyección de encabezados);
- asunto obligatorio de hasta 998 caracteres;
htmlotext;- encabezados válidos que no pisen los que maneja easymail;
- adjuntos que existan y respeten los límites de tamaño y cantidad;
- fechas, prioridad y transporte válidos.
Plantillas
import { createMailer, defineTemplate, html } from "easyemailjs";
const bienvenida = defineTemplate<{ nombre: string; url: string }>(({ nombre, url }) => ({
subject: `Bienvenido, ${nombre}`,
html: html`<h1>Hola ${nombre}</h1><p><a href="${url}">Confirma tu cuenta</a></p>`, // html`` escapa los valores
}));
const mail = createMailer({ templates: { bienvenida } });
mail.template("codigo", ({ codigo }) => ({ subject: "Tu código", text: `Código: ${codigo}` }));
await mail.send({ to: "[email protected]", template: "bienvenida", data: { nombre: "Ana", url } });
await mail.render("bienvenida", { nombre: "Ana", url }); // vista previa, sin enviarPara anidar fragmentos de html, márcalos con raw(): html`<ul>${items.map(i => raw(html`<li>${i}</li>`))}</ul>`. Si no, se escapan dos veces.
La plantilla se procesa al encolar. El resultado se guarda en la BD, y en templateData quedan los datos para la auditoría. El worker no necesita las plantillas.
Transportes (SMTP a tu medida)
createMailer({ transport: "smtp://usuario:[email protected]:587" }); // STARTTLS
createMailer({ transport: "smtps://usuario:[email protected]:465" }); // TLS directo
createMailer({
transport: {
host: "smtp.proveedor.com", port: 587, secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true, maxConnections: 5, // conexiones reutilizables (recomendado con el worker)
tls: { rejectUnauthorized: true },
dkim: { domainName: "midominio.com", keySelector: "mail", privateKey },
},
});
createMailer({ transport: "log" }); // desarrollo: imprime en consola
createMailer({ transport: "memory" }); // pruebas
createMailer({ transport: nodemailer.createTransport(...) }); // cualquier transporter de nodemailer
createMailer({ transport: { send: async (msg) => ({ messageId: await miApi(msg) }) } }); // propio (API HTTP…)Si no hay transport, se lee el entorno en este orden: MAIL_URL o SMTP_URL, y luego SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER y SMTP_PASS.
Varios transportes:
createMailer({
transport: process.env.MAIL_URL, // "default"
transports: { masivo: { host: "smtp.ses…", pool: true } },
});
await mail.send({ ...msg, transport: "masivo" });Proveedores que funcionan con esa URL: Resend, Brevo, Amazon SES, Postmark, Mailgun, SMTP2GO, MailerSend, Gmail (con contraseña de aplicación)…
Reintentos
createMailer({
retry: {
attempts: 5, // intentos totales
delay: "30s", // primera espera
maxDelay: "1h",
backoff: "exponential", // 30s, 1m, 2m, 4m… | "linear" | "fixed" | (intento, error) => ms
jitter: true, // ±20 %
},
isRetryable: (error) => undefined, // true/false para decidir tú; undefined = regla por defecto
});La regla por defecto:
- Se reintentan: códigos SMTP 4xx, errores de red (conexión, timeout, DNS, TLS) y errores de autenticación. La autenticación se reintenta para que, al corregir las credenciales, la cola se recupere sola.
- No se reintentan: 5xx (dirección inexistente, rechazado), sobres inválidos y adjuntos que ya no existen.
- Destinatarios rechazados: si los rechaza todos, el correo queda
failed. Si rechaza solo algunos, quedasenty los rechazados se guardan enrejected.
Worker
const worker = mail.worker({
concurrency: 4, // envíos en paralelo
pollInterval: "5s",
lockTimeout: "10m", // si un worker muere a mitad, otro retoma el correo
rateLimit: { max: 10, per: "1s" }, // límite de tu proveedor
handleSignals: true, // se detiene limpio con Ctrl+C / SIGTERM
}).start();
await worker.stop(); // espera a que terminen los envíos en curso
await worker.runOnce(); // un ciclo (cron, serverless)
await worker.drain(); // hasta vaciar lo disponible- Varios workers a la vez: puede haber workers en varios procesos o servidores trabajando sobre la misma tabla. Cada correo se reclama con un
UPDATEcondicionado al estado, así que dos workers nunca envían el mismo correo. Las pruebas lo verifican con 3 workers concurrentes en SQLite, PostgreSQL y MySQL. - Envío inmediato: en el mismo proceso, el worker despierta en cuanto entra un correo nuevo, sin esperar a
pollInterval. - Entrega "al menos una vez": si el proceso muere justo después de que el servidor aceptó el correo, pero antes de guardar
sent, el correo se reintenta al vencerlockTimeout. Usa unlockTimeoutmayor que elsocketTimeoutde tu SMTP. ElMessage-IDno cambia entre reintentos, lo que ayuda a los servidores a detectar duplicados.
Eventos: mail.on("queued" | "sent" | "retrying" | "failed" | "cancelled", …) y worker.on("cycle" | "recovered" | "error", …).
Administración
await mail.find(id | uuid); // null si no existe
await mail.findOrFail(id | uuid);
await mail.list({ status: ["failed"], search: "factura", actor: "user:7", page: 1, perPage: 20 });
await mail.stats(); // { pending, processing, retrying, sent, failed, cancelled, total }
await mail.retry(id, { actor: "admin" }); // failed / cancelled → pending (reinicia intentos)
await mail.retryFailed();
await mail.cancel(id, { reason: "duplicado" });
await mail.purge({ olderThan: "90d" }); // borra sent/failed/cancelled antiguos (y sus eventos)
await mail.purge({ olderThan: "30d", contentOnly: true }); // conserva la auditoría, vacía html/texto/adjuntos
await mail.verify(); // prueba conexión y credenciales SMTPCon keepContent: false, el contenido se borra al enviarse. Se conserva solo la auditoría: destinatarios, asunto, estado y eventos.
Hooks
createMailer({
hooks: {
beforeSend(message, record) { message.headers = { ...message.headers, "X-Tenant": "acme" }; },
afterSend(record, result) { metricas.increment("mail.sent"); },
onFailure(record, error) { alertar(`Correo ${record.id} falló: ${record.lastError}`); },
},
});CLI
npx easymail init [--migration] # easymail.config.ts (+ migración de easy-db)
npx easymail verify [--transport=nombre] # prueba la conexión SMTP
npx easymail test [email protected] # envía un correo de prueba (queda registrado)
npx easymail work [--concurrency=4] # worker hasta Ctrl+C
npx easymail work --once # procesa lo pendiente y sale (cron)
npx easymail stats
npx easymail list --status=failed
npx easymail show 42 # correo + historial de auditoría
npx easymail retry 42 | --failed
npx easymail cancel 42 --reason="duplicado"
npx easymail purge --older-than=90d [--content-only]La CLI lee easymail.config.ts. Ese archivo puede exportar defineMailConfig({...}) o un mailer ya creado con createMailer(), para compartir plantillas con tu aplicación. Para integrarla en otra CLI: import { runCli } from "easyemailjs/cli".
Configuración completa
import { defineMailConfig } from "easyemailjs";
export default defineMailConfig({
from: "Mi App <[email protected]>", // o MAIL_FROM
replyTo: "[email protected]",
transport: process.env.MAIL_URL,
transports: {},
tables: { mails: "mail_messages", events: "mail_events" }, // igual que en mailTables({...})
retry: { attempts: 5, delay: "30s", maxDelay: "1h", backoff: "exponential", jitter: true },
isRetryable: undefined,
direct: { queueOnFailure: true, throwOnFailure: true },
limits: { maxRecipients: 100, maxAttachmentBytes: 10 * 1024 * 1024, maxAttachments: 20 },
keepContent: true,
autoText: true,
templates: {},
messageIdDomain: undefined, // por defecto, el dominio del remitente
hooks: {},
logger: undefined, // { info, warn, error } | false
database: undefined, // { config: { database: process.env.DATABASE_URL } } si easy-db no tiene su propio config
worker: { concurrency: 1, pollInterval: "5s", lockTimeout: "10m" },
});Bases de datos
Las pruebas corren contra SQLite, PostgreSQL y MySQL/MariaDB.
Búsqueda (
list({ search })): usaLIKE, así que distingue mayúsculas en PostgreSQL y no escapa%ni_.
MySQL guarda las fechas sin milisegundos: por eso un correo programado puede salir hasta 1 segundo antes de su hora.
MySQL/MariaDB: easy-db crea
textcomoTEXT, que admite hasta 64 KB. easymail rechaza con un mensaje claro los cuerpos más grandes. En PostgreSQL y SQLite no hay ese límite.
Desarrollo
npm test # unitarias + integración (SQLite) + SMTP real local
EASYMAIL_PG_URL=postgres://… npm test # también PostgreSQL
EASYMAIL_MYSQL_URL=mysql://… npm test # también MySQL / MariaDB
npm run test:coverage
npm run typecheck # incluye pruebas de tipos
npm run e2e # npm pack → proyecto nuevo → flujo completo con SMTP y CLIeasy-db se enlaza desde ../easy-db ("easy-db": "file:../easy-db" en devDependencies). En GitLab CI se clona y se construye antes de instalar (ver .gitlab-ci.yml).
Arquitectura
src/
├── mailer.ts Mailer: send, sendNow, plantillas, administración, entrega y manejo de errores
├── worker.ts MailWorker: ciclo, concurrencia, límite de envíos, recuperación de bloqueos
├── message.ts validación y normalización del mensaje
├── classify.ts errores temporales vs. permanentes
├── config.ts defineMailConfig, valores por defecto, backoff
├── templates.ts plantillas
├── store/
│ ├── tables.ts mailTables(), mailMigration() — tablas de easy-db
│ └── store.ts acceso a datos con modelos de easy-db; reclamo atómico de correos
├── transport/ SMTP (nodemailer), log, memory, transportes propios
├── cli/ npx easymail …
└── util/ duraciones, html → texto, escapeLicencia
MIT
