Skip to content

Getting Started ​

Status

Version 1.0 is the transport layer, ported from upyo 0.7.0; 1.1 adds sendmail, ten more HTTP providers, verified webhooks into delivery events, and decorators for circuit breaking, rate limits, idempotency, hooks and mirroring; 1.2 adds the queue on BullMQ or a MongoDB outbox, templates by name and the audit.

Installation ​

bash
pnpm add @alexify/mailer
# or
npm install @alexify/mailer

Node.js 22 or newer. The package has no runtime dependencies: every transport is one package, reached through a subpath (@alexify/mailer/smtp), and what a decorator needs from your application (an OpenTelemetry provider, a logger) is passed in.

Bun and Deno ​

The package runs on Bun and Deno through their Node compatibility. CI runs its tests on Bun 1.4.3 and Deno 2.9.6 (Linux), sending over real sockets, TLS and fetch.

bash
bun add @alexify/mailer
deno add npm:@alexify/mailer

On Deno, import it by its npm name, a subpath included (import { SmtpTransport } from 'npm:@alexify/mailer/smtp'), and grant what the transports you use need:

  • --allow-net to send: SMTP, the HTTP providers, JMAP, fromUrl and the certificates of SNS for ./webhooks;
  • --allow-read for fromPath;
  • --allow-run and --allow-env for ./sendmail, as Deno's child_process passes the environment on;
  • --allow-sys=hostname for the default EHLO name of ./smtp; without it, the name is [127.0.0.1] unless you set localName.

Deno's fetch sends a request again when a reused connection closes before its answer, so a message the provider took may go twice, where Node and Bun report it as of unknown delivery. Give the HTTP transports and JMAP a fetch that keeps no idle connection:

js
import { PostmarkTransport } from 'npm:@alexify/mailer/postmark';

const client = Deno.createHttpClient({ poolMaxIdlePerHost: 0 });
const fetchOnce = (input, init) => fetch(input, { ...init, client });

const transport = new PostmarkTransport({
  serverToken: Deno.env.get('POSTMARK_TOKEN'),
  fetch: fetchOnce,
});

Each request then opens a connection of its own, which costs a handshake; see known issues.

Choosing a transport ​

Each transport implements the same Transport contract of the root entry, so they can be swapped without changing the code that builds messages:

  • SMTP (@alexify/mailer/smtp): universal email protocol, works with any SMTP server; DKIM signing, pooling, STARTTLS, DSN, an HTTP CONNECT proxy.
  • Sendmail (@alexify/mailer/sendmail): the sendmail command of the system (Postfix, Exim, msmtp).
  • JMAP (@alexify/mailer/jmap): RFC 8620/8621 mail servers such as Stalwart or Fastmail.
  • HTTP providers, one subpath each: Amazon SES (ses, SES v2 with SigV4), Resend (resend), SendGrid (sendgrid), Mailgun (mailgun), Postmark (postmark), Brevo (brevo), Mailjet (mailjet), MailerSend (mailersend), SparkPost (sparkpost), Mailtrap (mailtrap), Maileroo (maileroo), Lettermint (lettermint), ZeptoMail (zeptomail), SMTP2GO (smtp2go), Azure Communication Services (azure), Scaleway (scaleway), Cloudflare (cloudflare) and Plunk (plunk). Each page lists what the provider takes of a message: tags, metadata, tracking, a schedule, raw MIME.
  • Mock (@alexify/mailer/mock): captures emails without sending, for tests.

And the decorators, which wrap any transport:

  • Pool (@alexify/mailer/pool): several transports, picked by a strategy, with failover.
  • Retry (@alexify/mailer/retry): retries with backoff, jitter and Retry-After.
  • Circuit breaker (@alexify/mailer/breaker): stops sending to a failing provider, then lets one probe through.
  • Rate limit (@alexify/mailer/ratelimit): a token bucket per key, waiting or refusing.
  • Idempotency (@alexify/mailer/idempotency): a key reserved before each send, so a repeat is answered, not sent.
  • Hooks (@alexify/mailer/hooks): before and after each send, on each retry and error.
  • Tee (@alexify/mailer/tee): mirrors each message to other transports, without waiting for them.
  • OpenTelemetry (@alexify/mailer/otel): spans and metrics, with your providers.
  • Logger (@alexify/mailer/logger): the delivery lifecycle, logged through your pino-like logger.

What happens after a send comes back through the provider's webhooks: @alexify/mailer/webhooks verifies them and reads their events into one model of delivery events.

If none of these fit your needs, you can also create a custom transport.

Sending your first email ​

Here's a basic example using Gmail through SMTP:

js
const { createMessage } = require('@alexify/mailer');
const { SmtpTransport } = require('@alexify/mailer/smtp');

const transport = new SmtpTransport({
  host: 'smtp.gmail.com',
  port: 465,
  secure: true, // Use TLS
  auth: {
    user: 'your@gmail.com',
    pass: 'your-app-password',
  },
});

const message = createMessage({
  from: 'sender@example.com',
  to: 'recipient@example.net',
  subject: 'Hello from mailer!',
  content: { text: 'This is a test email.' },
});

const receipt = await transport.send(message);
if (receipt.successful) {
  console.log('Message sent with ID:', receipt.messageId);
} else {
  console.error('Send failed:', receipt.errorMessages.join(', '));
}

The same names import from ESM:

js
import { createMessage } from '@alexify/mailer';
import { SmtpTransport } from '@alexify/mailer/smtp';

Your own fetch and identifiers ​

Every transport that makes HTTP requests (the HTTP providers and JMAP) takes a fetch in its configuration, used in place of the global one at each request: one bound to an undici Agent for keep-alive and connection limits, a ProxyAgent for a corporate proxy, mTLS, or an instrumented one. The package still depends on nothing; undici is yours:

js
const { Agent, fetch: undiciFetch } = require('undici');
const { ResendTransport } = require('@alexify/mailer/resend');

const dispatcher = new Agent({ connections: 16, keepAliveTimeout: 30_000 });
const transport = new ResendTransport({
  apiKey: process.env.RESEND_API_KEY,
  fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }),
});

The JMAP transport streams a raw message whose size it does not know, so its fetch must take a body stream with duplex: 'half', as undici's does.

Every transport also takes a pino-like logger for what happens inside it and its receipts do not tell: the retries of an HTTP request at debug (with the attempt, the wait and the status, never an address), and for SMTP its connections and protocol (see diagnostics). Without one a transport writes nothing, except a console.warn for mail sent without TLS or without the DKIM signature it was asked for.

Every transport also takes generateId, which makes the identifiers otherwise drawn from crypto.randomUUID(): a generated Message-ID, an idempotency key, and the message ID of a receipt the provider gave none for. See identifiers of your own.

From here, see composing messages for HTML content, multiple recipients, threading and List-Unsubscribe, attachments, rendering templates and calendar invitations.

See Why @alexify/mailer? for what the library is built to do, and the API for every entry point.

Several processes ​

Some parts keep state, and by default they keep it in the process. Several processes, or a function that makes its transports for each request, each have their own unless the part is given a store they share:

PartWhat it keepsIn one processShared by several
./ratelimitthe token bucketsat most maxKeys bucketsa store of take(), such as a script on Redis, so that N processes send no more than the one limit
./idempotencythe keys reserved and the receiptscreateMemoryIdempotencyStore()a store of reserve, complete and release, such as createMongoIdempotencyStore()
./queuethe jobscreateMemoryQueue()a driver they share: BullMQ, or the MongoDB outbox
./webhooksthe ids of the requests takencreateMemoryReplayStore()a store of reserve, commit and release; required, null to keep none
./breakerthe state of the circuitone per transportnone: each process opens its own circuit on the failures it sees
./poolthe turn of round-robinone per transportnone
./smtppooled connections, OAuth 2.0 tokensone pool per transportnone

A store of a rate limit or of idempotency that each process makes for itself gives no more than the memory one: the limit is spent N times over, and a key is sent once per process.

Released under the MIT License.