npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 init

El paquete se publica como easyemailjs porque npm no acepta easymail ni easyemailjs: se parecen demasiado a easy-mail y easymail-js, que ya existen. Si quieres importarlo como easymail, usa npm 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 migrate

Si 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 cola

Cola 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.status

Enví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 correo

Si dos transacciones usan la misma idempotencyKey al 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, usa send().


Estados y auditoría

pending ──► processing ──► sent
   ▲            │
   │            ├──► retrying ──► processing … (hasta maxAttempts)
   │            └──► failed
   └── retry() ◄── failed / cancelled          cancel(): pending | retrying | failed ──► cancelled

Cada 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;
  • html o text;
  • 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 enviar

Para 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, queda sent y los rechazados se guardan en rejected.

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 UPDATE condicionado 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 vencer lockTimeout. Usa un lockTimeout mayor que el socketTimeout de tu SMTP. El Message-ID no 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 SMTP

Con 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 })): usa LIKE, 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 text como TEXT, 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 CLI

easy-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, escape

Licencia

MIT