Skip to content
LogoLogo

@portable/smtp

Sends email over SMTP (RFC 5321) or LMTP (RFC 2033) from any runtime with a TCP socket API: Cloudflare Workers, Deno, Bun and Node 24+. You pass the function that opens the socket, so the package itself only needs Web Streams. The message can be a ReadableStream, such as the output of @portable/mail signed with @portable/dkim, and is streamed to the server without buffering.

Replaces the SMTP transport and smtp-connection of nodemailer.

npm install @portable/smtp
import * as Smtp from '@portable/smtp';
import { connect } from 'cloudflare:sockets';
 
const result = await Smtp.send(
  {
    from: 'me@example.com',
    to: ['you@example.com'],
    message: 'Subject: Hi\r\n\r\nHello',
  },
  {
    connect,
    host: 'smtp.example.com',
    auth: { user: 'me@example.com', pass: env.SMTP_PASSWORD },
    signal: AbortSignal.timeout(30_000),
  },
);
result.accepted; // ['you@example.com']

Cloudflare Workers can't connect to port 25, so use a submission port (587 or 465) on a relay.

To send several messages over one connection, open a client:

await using client = await Smtp.connect({
  connect,
  host: 'smtp.example.com',
  auth,
});
for (const mail of outbox) {
  await client.send(mail);
}

await using sends QUIT and closes the connection when the block ends, even if a send throws. Without it, call client.close() in a finally block.

Sockets

connect is called as connect({ hostname, port }, { secureTransport, allowHalfOpen }) and returns a socket with the shape of the Sockets API: readable, writable, opened, closed, close() and startTls(). connect from cloudflare:sockets fits as it is. For Deno and Node, wrap the native socket.

Deno:

import type * as Smtp from '@portable/smtp';
 
function wrap(
  opening: Promise<Deno.Conn>,
  hostname: string,
): Smtp.Socket {
  let conn: Deno.Conn | undefined;
  const opened = opening.then((value) => {
    conn = value;
  });
  const closed = Promise.withResolvers<void>();
  return {
    opened,
    closed: closed.promise,
    readable: new ReadableStream<Uint8Array>(
      {
        async pull(controller) {
          await opened;
          const buffer = new Uint8Array(16 * 1024);
          const length = await conn!.read(buffer).catch(() => null);
          if (length === null) {
            controller.close();
            closed.resolve();
          } else {
            controller.enqueue(buffer.subarray(0, length));
          }
        },
      },
      { highWaterMark: 0 },
    ),
    writable: new WritableStream<Uint8Array>({
      async write(chunk) {
        await opened;
        for (let offset = 0; offset < chunk.length;) {
          offset += await conn!.write(chunk.subarray(offset));
        }
      },
    }),
    close() {
      conn?.close();
      closed.resolve();
    },
    startTls() {
      return wrap(
        opened.then(() =>
          Deno.startTls(conn as Deno.TcpConn, { hostname }),
        ),
        hostname,
      );
    },
  };
}
 
export const connect: Smtp.Connect = (
  { hostname, port },
  { secureTransport },
) =>
  wrap(
    secureTransport === 'on'
      ? Deno.connectTls({ hostname, port })
      : Deno.connect({ hostname, port }),
    hostname,
  );

Node:

import net from 'node:net';
import tls from 'node:tls';
import type * as Smtp from '@portable/smtp';
 
function wrap(
  socket: net.Socket,
  ready: string,
  hostname: string,
): Smtp.Socket {
  socket.setNoDelay(true);
  const opened = new Promise<void>((resolve, reject) => {
    socket.once(ready, resolve);
    socket.once('error', reject);
  });
  const closed = new Promise<void>((resolve) =>
    socket.once('close', () => resolve()),
  );
  let attached = true;
  return {
    opened,
    closed,
    readable: new ReadableStream<Uint8Array>({
      start(controller) {
        socket.on('data', (chunk: Buffer) => {
          controller.enqueue(new Uint8Array(chunk));
          if ((controller.desiredSize ?? 0) <= 0) {
            socket.pause();
          }
        });
        socket.once('close', () => attached && controller.close());
      },
      pull: () => void socket.resume(),
      cancel: () => void socket.destroy(),
    }),
    writable: new WritableStream<Uint8Array>({
      write: (chunk) =>
        new Promise((resolve, reject) => {
          if (socket.write(chunk)) {
            return resolve();
          }
          socket.once('drain', resolve);
          socket.once('close', () =>
            reject(new Error('Socket closed')),
          );
        }),
    }),
    close() {
      socket.destroy();
      return closed;
    },
    startTls() {
      attached = false;
      socket.removeAllListeners('data');
      return wrap(
        tls.connect({ socket, servername: hostname }),
        'secureConnect',
        hostname,
      );
    },
  };
}
 
export const connect: Smtp.Connect = (
  { hostname, port },
  { secureTransport },
) =>
  secureTransport === 'on'
    ? wrap(
        tls.connect({ host: hostname, port, servername: hostname }),
        'secureConnect',
        hostname,
      )
    : wrap(net.connect({ host: hostname, port }), 'connect', hostname);

API

Smtp.connect(options)

Connects, reads the greeting, sends EHLO (or HELO if EHLO is refused), starts TLS, logs in, and resolves with a Client.

OptionDefaultDescription
connectRequiredOpens the socket. See Sockets.
hostRequiredThe server's host name.
port465 when secure is 'on', otherwise 587
secure'on' for port 465, otherwise 'starttls''on' starts TLS right away. 'starttls' requires the server to offer STARTTLS and fails if it doesn't. 'off' never encrypts.
authNone{ user, pass } for AUTH PLAIN, or LOGIN when the server offers only LOGIN. Set method: 'LOGIN' to force it. { user, accessToken } for XOAUTH2.
allowInsecureAuthfalseSend credentials over an unencrypted connection. Without it, AUTH requires TLS.
name'[127.0.0.1]'The host name sent with EHLO. A name with CR or LF makes connect reject with a RangeError.
lmtpfalseSpeak LMTP: LHLO, and one reply per recipient after the message.
signalNoneLimits connecting, TLS and login. Aborting rejects with signal.reason and closes the socket.

With auth set, the client always authenticates. If the server offers AUTH but neither PLAIN nor LOGIN, it fails without sending the password.

client.send(mail, { signal })

Sends one message and resolves with { accepted, rejected, response }. It resolves when at least one recipient accepted the message, and rejected lists the others with their replies. Calls made at the same time run one after another.

FieldDescription
fromThe envelope sender. '' sends MAIL FROM:<>, as bounces do.
toOne recipient or an array.
messageThe raw message as a string, Uint8Array or ReadableStream<Uint8Array>. Bare CR and bare LF become CRLF, and lines starting with a dot get a second dot.
sizeThe message size for the SIZE extension. Computed for strings and bytes. A message over the server's limit fails before MAIL FROM.
dsn{ ret, envid, notify }: RFC 3461 delivery status notification parameters, sent when the server offers DSN.
requireTlsAsk the server to relay only over TLS (RFC 8689). Fails before MAIL FROM if the connection isn't encrypted or the server doesn't offer REQUIRETLS.

The client uses PIPELINING when offered, sending MAIL FROM, every RCPT TO and DATA in one write. It declares BODY=8BITMIME whenever the server offers it, and SMTPUTF8 when an address is not ASCII. An address with a control character or an angle bracket is rejected before anything is sent.

When a stream message isn't sent in full, because the envelope was refused, the connection failed or the signal aborted, the stream is cancelled.

Aborting signal rejects with signal.reason. A send that has started also closes the connection, because the server may be partway through the message. A send still waiting for earlier calls rejects at once, and the connection stays open.

client.close({ signal })

Waits for queued sends, sends QUIT and closes the socket. It never rejects. Where the runtime has Symbol.asyncDispose, the client also has [Symbol.asyncDispose](), which calls close(), so await using closes it at the end of the block.

Smtp.send(mail, options)

Connects with options, sends one message and closes the connection. Here signal limits the whole call.

Smtp.CommandError

Every failure rejects with a Smtp.CommandError, except a TypeError or RangeError for invalid options and signal.reason when the signal aborts. Credentials never appear in its fields.

FieldDescription
code'ECONNECTION', 'EPROTOCOL', 'ETLS', 'EAUTH', 'EENVELOPE' or 'EMESSAGE'.
commandThe command the server refused, such as 'RCPT TO', or 'CONNECT'.
responseCodeThe reply code, such as 550.
enhancedCodeThe RFC 3463 code, such as '5.1.1'.
responseThe server's whole reply.
rejectedWhen every recipient was refused, each one with its reply.
codeMeaning
ECONNECTIONThe socket failed to open or closed early, or the server replied 421 to any command. Retry on a new connection.
EPROTOCOLThe greeting wasn't 220, EHLO and HELO were refused, or a reply was malformed or over 1 MiB.
ETLSSTARTTLS was not offered or was refused, or the TLS handshake failed.
EAUTHLogin failed, no supported method was offered, or AUTH over plaintext without allowInsecureAuth.
EENVELOPEAn address is invalid, the server refused the sender, every recipient or DATA, or the server can't honor SMTPUTF8 or REQUIRETLS.
EMESSAGEThe message is over the SIZE limit, the message stream errored, or the server refused the message after DATA.

Types

TypeDescription
Smtp.ClientAn open session from connect: send, close and [Symbol.asyncDispose], secure (whether the connection is encrypted) and extensions (the extension lines of the EHLO reply).
Smtp.MailA message and its envelope, as send takes them.
Smtp.ResultThe outcome of a send that reached at least one recipient.
Smtp.RejectionA recipient the server refused, with its reply.
Smtp.AuthCredentials for AUTH PLAIN or LOGIN, or an OAuth 2.0 access token for XOAUTH2.
Smtp.DsnRFC 3461 delivery status notification parameters.
Smtp.ConnectThe function that opens a socket, such as connect from cloudflare:sockets.
Smtp.SocketThe socket Connect returns.
Smtp.SocketOptionsThe options Connect receives.

Why use this over nodemailer

  • It runs in Cloudflare Workers and Deno as well as Node, and has no dependencies outside @portable/*.
  • STARTTLS is required unless you pass secure: 'off'. nodemailer continues in plaintext when STARTTLS fails unless requireTLS is set.
  • Credentials are never sent over plaintext unless allowInsecureAuth is set, and never skipped: nodemailer sends the message unauthenticated when the server doesn't advertise AUTH, unless forceAuth is set.
  • Bare CR and bare LF are converted to CRLF before dot-stuffing, so a message can't smuggle an end of DATA. An oracle test checks that the output is byte-identical to nodemailer's.
  • Every 421 reply is ECONNECTION, so one check covers "retry later on a new connection".
  • 8-bit messages are declared with BODY=8BITMIME without an extra option.

The benchmarks send to a server on localhost that answers at once.

@portable/smtpnodemailer
Send 10 small messages over one connection1,3601,200
Send a 1 MB message63.351.9

Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.

@portable/smtpnodemailer
Minified and gzipped6.1 kB15 kB
Runtime dependencies40
Node built-ins usednoneBuffer, crypto, dns, events, fs, http, https, net, os, stream, tls, url, util, zlib
TypeScript typesincludednone
Version compared–10.2.1

Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.

  • Smtp.send and client.send send an empty message. nodemailer refuses it.
  • Smtp.connect throws a RangeError for a name that contains CR or LF. nodemailer removes the CR and LF instead.
  • nodemailer's opportunisticTLS, logger and debug options have no equivalent.
  • nodemailer refreshes an OAuth2 access token itself. auth: { user, accessToken } sends the token it is given, so the caller refreshes it.
  • CRAM-MD5 and custom auth.type handlers aren't supported, because WebCrypto has no MD5.
  • mail.dsn has no equivalent of nodemailer's orcpt.
  • nodemailer's pool: true has no equivalent. Keep a Client open and send several messages over it instead.
  • nodemailer's proxy and getSocket options have no equivalent. Open the tunnel in the connect function instead.
  • nodemailer's info.rejected is a list of address strings. result.rejected is a list of objects with recipient, responseCode, enhancedCode and response.
  • The result has no messageId or envelope, which nodemailer returns in info.