Email for Node.js, with a reliability layer. Its own SMTP client, MIME builder, DKIM signing and eighteen HTTP providers, and on top of them what the other libraries leave to the application: a persistent queue, on BullMQ or a transactional outbox in MongoDB, whose workers render templates of the engine you use and know a permanent failure from a temporary one, an audit of every state of a message, one model of delivery events and idempotency; next suppression. Zero runtime dependencies.
Status: 1.2 is the transport layer and the reliability layer on it. 1.0 ported the transport from upyo 0.7.0; 1.1 adds what nodemailer, unemail and sently did better: sendmail, ten more HTTP providers, attachments from paths and URLs, one-click List-Unsubscribe, verified webhooks into one model of delivery events, and decorators for circuit breaking, rate limits, idempotency, hooks and mirroring. 1.2 adds the queue (
./queue, with a BullMQ driver in./bullmqand a transactional outbox in./mongodb), templates by name over nine engines (./templates) and the audit of the states of a message (./audit). Suppression is next.
- Its own transport. An SMTP client (pool, STARTTLS, PIPELINING, DSN, SMTPUTF8, OAuth 2.0, an
HTTP CONNECT proxy or any socket you connect), sendmail, a MIME builder (IDN domains as
punycode, 7bit, quoted-printable or base64 by content) and DKIM (RSA-SHA256, Ed25519), JMAP, and
eighteen HTTP providers on
fetch: Amazon SES v2, Resend, SendGrid, Mailgun, Postmark, Brevo, Mailjet, MailerSend, SparkPost, Mailtrap, Maileroo, Lettermint, ZeptoMail, SMTP2GO, Azure Communication Services, Scaleway, Cloudflare and Plunk. Secure by default: TLS certificates are verified, AUTH never goes over plain text, CRLF in a header is rejected, and no file or URL is read for an attachment unless you ask. - What happens after
send(). The webhooks of fifteen providers, each verified before it is read and taken as handled only once your handler commits it, give one model of delivery events (delivered, bounced, complained, β¦). A queue on BullMQ or a store of yours sends each message once per key, retries what may pass and fails what will not, and an audit sink hears every state fromqueuedtobounced; in MongoDB, the job is written in the transaction of its cause. Next: suppression. None of the fourteen libraries compared has them. - Sent once, or known to be unknown. Errors are classified as permanent or temporary, and a
failure after which the provider may have taken the message is marked
deliveryUnknown:./retryand./poolnever send it again../idempotencyreserves a key before the send,./breakerlets one probe through a failing provider, and./ratelimitkeeps to its quota. - Correct before broad. The tests read the composed messages back with an independent parser
and check DKIM with an independent verifier, and the suites run on Node 22 and 24, Bun and Deno;
golden
.emlmessages and fuzzing of headers,multipart/related, dot-stuffing and internationalized addresses are planned. - Zero dependencies. Built on
node:*modules andfetch. What a part needs from your application is passed in: the OpenTelemetry providers, a pino-like logger, BullMQ's classes, your template engine (EJS, Handlebars, Pug, MJML, Liquid, Nunjucks, Eta, Mustache, React Email) and the collections of MongoDB. CommonJS with ESM named imports and hand-written TypeScript typings, no build step.
pnpm add @alexify/mailer
# or
npm install @alexify/mailerRequires Node.js 22 or newer. Bun and Deno run it too, tested in CI; on Deno, see
Bun and Deno for the
permissions and the fetch to pass.
The root entry builds messages; each transport is a subpath of the same package:
const { createMessage } = require('@alexify/mailer');
const { SmtpTransport } = require('@alexify/mailer/smtp');
const transport = new SmtpTransport({
host: 'smtp.example.com',
port: 587,
auth: { user: 'user@example.com', pass: 'secret' },
});
const receipt = await transport.send(
createMessage({
from: 'Alex <alex@example.com>',
to: 'bob@example.net',
subject: 'Hello',
content: { text: 'Hi there.', html: '<p>Hi there.</p>' },
}),
);
if (!receipt.successful) console.error(receipt.errorMessages);| Subpath | What it is |
|---|---|
@alexify/mailer |
messages, attachments, calendar parts, the Transport contract, receipts, delivery events |
/mime |
MIME composition and DKIM signing |
/attachments, /render |
attachments from a path, a URL, a data URI or a stream; the content of a renderer, React Email |
/templates |
templates by name over EJS, Handlebars, Pug, MJML, Liquid, Nunjucks, Eta, Mustache or React Email: locales, layouts, subjects, CSS inlined |
/queue, /bullmq, /mongodb, /audit |
the mail queue and its worker, the BullMQ driver, the MongoDB outbox (and its audit and idempotency stores), the states of a message and their sinks |
/smtp, /sendmail, /jmap |
SMTP, sendmail and JMAP transports |
/ses, /resend, /sendgrid, /mailgun, /postmark, /brevo, /mailjet, /mailersend, /sparkpost, /mailtrap, /maileroo, /lettermint, /zeptomail, /smtp2go, /azure, /scaleway, /cloudflare, /plunk |
HTTP providers |
/webhooks |
verified webhooks of fifteen providers, read into delivery events |
/pool, /retry, /breaker, /ratelimit, /idempotency, /hooks, /tee, /otel, /logger |
decorators: failover, retries, circuit breaking, rate limits, idempotency, hooks, mirroring, OpenTelemetry, logging |
/mock |
an in-memory transport for tests |
Every transport takes generateId for the identifiers it makes (a ULID or a cuid2 instead of a
UUID) and a pino-like logger for what happens inside it, and every HTTP transport a fetch,
such as one bound to an undici Agent or a proxy; see
your own fetch and identifiers.
Through a queue, the worker renders the template and sends the message, and the queue retries what may pass:
const { Queue, Worker } = require('bullmq');
const ejs = require('ejs');
const { createMailQueue } = require('@alexify/mailer/queue');
const { createBullMQDriver } = require('@alexify/mailer/bullmq');
const { createEjsEngine, createFileLoader, createTemplateRenderer } = require('@alexify/mailer/templates');
const queue = createMailQueue({
driver: createBullMQDriver({ bullmq: { Queue, Worker }, connection: { host: 'localhost' } }),
// Whoever can write to Redis cannot make the worker send a message of theirs.
signingKeys: [{ id: '2026-10', secret: process.env.MAIL_JOB_KEY }],
});
await queue.enqueue({
kind: 'template',
template: 'reset-password', // emails/reset-password.ejs, its subject in its front matter
context: { url },
from: 'no-reply@example.com',
to: user.email,
idempotencyKey: `reset-password:${resetRequest.id}`, // an id, never an address or a token
});
queue.createWorker({
transport,
renderer: createTemplateRenderer({
engine: createEjsEngine({ ejs }),
loader: createFileLoader({ dir: 'emails' }),
}),
});A job holds its message only until it is done with (retainData), and may be sealed by a codec
of yours; a job that kills its worker is given up rather than sent round for ever; tenants can
take turns (fairness: 'tenant' on the outbox), and instrumentHandler of /otel gives the
worker its spans and metrics. See the queue guide.
The same names import from ESM (import { SmtpTransport } from '@alexify/mailer/smtp'). The
API reference lists every member, and the
guide every transport.
The comparison puts Nodemailer, emailjs, Upyo, unemail, sently, worker-mailer, edge-mailer, MIMEText and the SDKs of Resend, SendGrid, Mailgun, Postmark, Mailtrap and Amazon SES side by side, feature by feature, from their source code.
The transport layer is a port of upyo 0.7.0 by Hong Minhee (MIT), converted to CommonJS with hand-written types and no dependencies. Features of 1.1 are ported from nodemailer 10.0.16 by Andris Reinman (MIT-0), unemail 0.7.0 (MIT) and sently 1.2.2 (MIT), with their known bugs fixed in the port. Their notices are in LICENSE.
See CHANGELOG.md.
MIT Β© Alexis Technologies