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

@upyo/smtp

v0.6.0

Published

SMTP transport for Upyo email library

Readme

@upyo/smtp

JSR npm

SMTP transport implementation for the Upyo email library.

Features

  • Full SMTP protocol implementation
  • TLS/SSL support
  • Connection pooling
  • Multiple authentication methods
  • OAuth 2.0 authentication (SASL XOAUTH2 and OAUTHBEARER)
  • HTML and plain text email support
  • File attachments (regular and inline)
  • Multiple recipients (To, CC, BCC)
  • SMTP PIPELINING for faster multi-recipient delivery
  • SMTP SIZE declaration and advertised-limit checks
  • Structured SMTP enhanced status codes
  • SMTPUTF8 internationalized address delivery
  • SMTP delivery status notification requests
  • Custom headers
  • Priority levels
  • Comprehensive testing utilities
  • TypeScript support
  • Cross-runtime compatibility (Node.js, Bun, Deno)
  • STARTTLS support for secure connection upgrade
  • DKIM signing support (RSA-SHA256 and Ed25519-SHA256)

Installation

npm  add       @upyo/core @upyo/smtp
pnpm add       @upyo/core @upyo/smtp
yarn add       @upyo/core @upyo/smtp
deno add --jsr @upyo/core @upyo/smtp
bun  add       @upyo/core @upyo/smtp

Usage

Basic Email Sending

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";

const transport = new SmtpTransport({
  host: "smtp.example.com",
  port: 587,
  secure: false,
  requireTls: true,
  auth: {
    user: "username",
    pass: "password",
  },
});

const message = createMessage({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Hello from Upyo!",
  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(", "));
}

Sending Multiple Emails

const messages = [message1, message2, message3];

for await (const receipt of transport.sendMany(messages)) {
  if (receipt.successful) {
    console.log(`Email sent with ID: ${receipt.messageId}`);
  } else {
    console.error(`Email failed: ${receipt.errorMessages.join(", ")}`);
  }
}

Configuration options

SmtpConfig

| Option | Type | Default | Description | | ------------------- | ---------------- | -------------- | -------------------------------- | | host | string | | SMTP server hostname | | port | number | 587 | SMTP server port | | secure | boolean | Port-dependent | Use implicit TLS from the start | | requireTls | boolean | false | Require a STARTTLS upgrade | | auth | SmtpAuth | | Authentication configuration | | tls | SmtpTlsOptions | | TLS configuration | | connectionTimeout | number | 60000 | Connection timeout (ms) | | socketTimeout | number | 60000 | Socket timeout (ms) | | localName | string | "localhost" | Local hostname for HELO/EHLO | | pool | boolean | true | Enable connection pooling | | poolSize | number | 5 | Maximum pool connections | | dkim | DkimConfig | | DKIM signing configuration |

The requireTls option is available since Upyo 0.6.0.

Set requireTls: true with secure: false to issue STARTTLS even when the server does not advertise it and fail delivery unless the upgrade succeeds. When secure is omitted, it defaults to true on port 465 and false on all other ports. Regardless of this option, SMTP authentication to non-loopback hosts requires either an implicit TLS connection or a successful STARTTLS upgrade. Cleartext authentication to loopback hosts remains available for local development.

SmtpAuth

SmtpAuth is a discriminated union of three strategies.

Username/password (SmtpUserPassAuth), discriminated by pass:

| Option | Type | Default | Description | | -------- | ---------------------------------- | --------- | ----------- | | user | string | | Username | | pass | string | | Password | | method | "plain" \| "login" \| "cram-md5" | "plain" | Auth method |

OAuth 2.0 access token (SmtpOAuth2TokenAuth), discriminated by accessToken:

| Option | Type | Default | Description | | ------------- | ------------------------------- | ----------- | ------------------------------------------- | | user | string | | User (email address) | | accessToken | string \| OAuth2TokenProvider | | Access token, or a callback returning one | | method | "xoauth2" \| "oauthbearer" | "xoauth2" | SASL mechanism (auto-detected when omitted) |

OAuth 2.0 refresh-token flow (SmtpOAuth2RefreshAuth), discriminated by refreshToken:

| Option | Type | Default | Description | | --------------- | ---------------------------- | ----------- | ------------------------------------------- | | user | string | | User (email address) | | clientId | string | | OAuth 2.0 client identifier | | clientSecret | string | | OAuth 2.0 client secret (optional) | | refreshToken | string | | OAuth 2.0 refresh token | | tokenEndpoint | string | | Token endpoint URL | | scope | string | | Space-delimited scopes (optional) | | method | "xoauth2" \| "oauthbearer" | "xoauth2" | SASL mechanism (auto-detected when omitted) |

The access token may be a static string or a callback (OAuth2TokenProvider) that returns a fresh token on demand—use the callback to integrate an OAuth client such as google-auth-library or msal-node. With the refresh-token flow the transport runs the refresh_token grant itself, caching the access token across pooled connections. See the OAuth 2.0 authentication guide for details.

Message size limits

This feature is introduced in Upyo 0.6.0.

When the server advertises the SIZE extension, the transport declares the encoded message size on MAIL FROM. If the server also advertises a fixed maximum, an oversized message produces a failed receipt before Upyo sends the envelope or uploads the message. Bare SIZE and SIZE 0 advertisements do not set a fixed maximum. This behavior is automatic and needs no configuration.

See RFC 1870 for the SMTP Message Size Declaration extension.

Enhanced status codes

This feature is introduced in Upyo 0.6.0.

SMTP failures include a parsed enhanced status code when the server prefixes its reply text with a valid RFC 2034 code such as 5.1.1. The final reply line's text remains in providerDetails.response, while providerDetails.enhancedStatusCode provides the complete code and its numeric class, subject, and detail fields. Address and message-content statuses are categorized as validation failures, and network/routing statuses as network failures.

Use isSmtpResponseProviderDetails() to narrow the provider-specific details:

import { isSmtpResponseProviderDetails } from "@upyo/smtp";

const error = receipt.successful ? undefined : receipt.errors?.[0];
if (isSmtpResponseProviderDetails(error?.providerDetails)) {
  console.log(error.providerDetails.enhancedStatusCode?.subject);
}

Partially rejected recipients expose the same structure through receipt.rejectedRecipients[].enhancedStatusCode. Replies without an enhanced code, with malformed fields, or with a class that conflicts with the three-digit SMTP reply continue to use the traditional reply classification.

See RFC 2034 for the SMTP extension and RFC 3463 for the status-code structure.

Envelope overrides

This feature is introduced in Upyo 0.6.0.

Use the envelope send option when the SMTP reverse-path or recipients must differ from the visible message headers:

const receipt = await transport.send(message, {
  envelope: {
    from: "[email protected]",
    to: ["[email protected]"],
  },
});

Omitting from or to keeps the value derived from the message. Set from to null to send MAIL FROM:<>. The override changes only MAIL FROM and RCPT TO; the From, To, Cc, Bcc, and Reply-To message fields remain unchanged.

For bulk VERP delivery, pass a resolver that returns an override for each message:

for await (const receipt of transport.sendMany(messages, {
  envelope: (_message, index) => ({
    from: `bounces+${index}@bounce.example.com`,
  }),
})) {
  console.log(receipt);
}

A plain override applies to every message passed to sendMany(). Invalid addresses and empty recipient lists return a non-retryable failed receipt with the code smtp.envelope-invalid before MAIL FROM is sent.

Internationalized addresses

This feature is introduced in Upyo 0.6.0.

The transport automatically negotiates RFC 6531 SMTPUTF8 when the effective SMTP envelope or a visible message-header mailbox contains a non-ASCII character. A supporting server must advertise both SMTPUTF8 and 8BITMIME; Upyo then sends BODY=8BITMIME SMTPUTF8 on MAIL FROM.

If either capability is missing, delivery returns a non-retryable failed receipt with the code smtp.smtputf8-unsupported before the mail transaction starts. Unicode display names and subjects continue to use RFC 2047 encoding and do not require SMTPUTF8 when the mailbox addresses remain ASCII-only.

Delivery status notifications

This feature is introduced in Upyo 0.6.0.

Pass SMTP-specific DSN settings to send() to request delivery status notifications under RFC 3461:

const receipt = await transport.send(message, {
  dsn: {
    envelopeId: "campaign+42",
    return: "headers",
    recipients: {
      "[email protected]": {
        notify: ["success", "failure", "delay"],
        originalRecipient: "[email protected]",
      },
    },
  },
});

envelopeId and return become ENVID and RET parameters on MAIL FROM. Each recipient's notify and originalRecipient values become NOTIFY and ORCPT parameters on its RCPT TO command. Upyo validates them against the effective envelope, including any override, and xtext-escapes the values without adding them to the message headers. envelopeId must not be empty, and originalRecipient must exactly match its envelope recipient.

If the server does not advertise DSN, the transport returns a non-retryable failed receipt with the code smtp.dsn-unsupported before sending MAIL FROM. Invalid settings use smtp.dsn-invalid.

DKIM signing

DKIM (DomainKeys Identified Mail) signing is supported for improved email deliverability and authentication:

import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
import { readFileSync } from "node:fs";

const transport = new SmtpTransport({
  host: "smtp.example.com",
  port: 587,
  secure: false,
  auth: { user: "[email protected]", pass: "password" },
  dkim: {
    signatures: [{
      signingDomain: "example.com",
      selector: "mail",
      privateKey: readFileSync("./dkim-private.pem", "utf8"),
    }],
  },
});

DkimConfig

| Option | Type | Default | Description | | ------------------ | ---------------------------- | --------- | ------------------------------- | | signatures | DkimSignature[] | | Array of DKIM signature configs | | onSigningFailure | "throw" \| "send-unsigned" | "throw" | Action when signing fails |

DkimSignature

| Option | Type | Default | Description | | ------------------ | ---------------------------------- | ------------------- | --------------------------------------- | | signingDomain | string | | Domain for DKIM key (d= tag) | | selector | string | | DKIM selector (s= tag) | | privateKey | string \| CryptoKey | | Private key (PEM string or CryptoKey) | | algorithm | "rsa-sha256" \| "ed25519-sha256" | "rsa-sha256" | Signing algorithm | | canonicalization | string | "relaxed/relaxed" | Header/body canonicalization | | headerFields | string[] | From, To, … | Headers to sign |

Testing

Mock SMTP Server

For unit testing, use the included mock SMTP server:

import { MockSmtpServer, SmtpTransport } from "@upyo/smtp";

const server = new MockSmtpServer();
const port = await server.start();

const transport = new SmtpTransport({
  host: "localhost",
  port,
  secure: false,
  auth: { user: "test", pass: "test" },
});

// Send test email
await transport.send(message);

// Check received messages
const received = server.getReceivedMessages();
console.log(received[0].data); // Raw email content

await server.stop();

OAuth 2.0 end-to-end tests

The OAuth 2.0 end-to-end tests run against a real provider (e.g. Gmail or Outlook) and are skipped unless the following environment variables are set:

| Variable | Description | | ----------------------- | -------------------------------------- | | SMTP_OAUTH2_HOST | SMTP host (e.g. smtp.gmail.com) | | SMTP_OAUTH2_PORT | SMTP port (default 587) | | SMTP_OAUTH2_SECURE | true for implicit TLS | | SMTP_OAUTH2_USER | User (email address) | | SMTP_OAUTH2_MECHANISM | xoauth2 or oauthbearer (optional) | | SMTP_OAUTH2_FROM | Envelope sender (defaults to the user) | | SMTP_OAUTH2_TO | Recipient (defaults to the user) |

Provide a token source as either a static access token:

| Variable | Description | | -------------------------- | ------------------ | | SMTP_OAUTH2_ACCESS_TOKEN | OAuth access token |

or the refresh-token flow:

| Variable | Description | | ---------------------------- | --------------------------------- | | SMTP_OAUTH2_CLIENT_ID | OAuth client identifier | | SMTP_OAUTH2_CLIENT_SECRET | OAuth client secret (optional) | | SMTP_OAUTH2_REFRESH_TOKEN | OAuth refresh token | | SMTP_OAUTH2_TOKEN_ENDPOINT | Token endpoint URL | | SMTP_OAUTH2_SCOPE | Space-delimited scopes (optional) |

A Gmail access token can be minted with google-auth-library, and an Outlook one with msal-node.

Sending raw MIME

This feature is introduced in Upyo 0.6.0.

SmtpTransport implements RawTransport for already serialized messages, including signed or encrypted MIME. Provide delivery addresses separately:

import { SmtpTransport } from "@upyo/smtp";

const transport = new SmtpTransport({ host: "localhost", port: 1025 });
try {
  await transport.sendRaw({
    envelope: { from: "[email protected]", to: ["[email protected]"] },
    content: new TextEncoder().encode("Subject: Hello\r\n\r\nHello!\r\n"),
    encoding: "7bit",
  });
} finally {
  await transport.closeAllConnections();
}

Raw sources accept bytes, promised bytes, Blob, or replayable attachment-style factories. With encoding specified, the source is read once; otherwise it is analyzed and then read again. Every reader must produce identical bytes. Automatic analysis requires SMTPUTF8 for any non-ASCII byte. Specify 8bit when all MIME headers are ASCII and only the body needs 8BITMIME. Upyo checks only top-level headers; the caller must ensure nested MIME headers are ASCII. Use utf8 or omit encoding if unsure. Both utf8 and internationalized envelope addresses require SMTPUTF8 and 8BITMIME. Unsupported capabilities produce a failed receipt before MAIL FROM.

The content must already have CRLF line endings including the final CRLF, nonempty headers, no NUL, and no line longer than 998 bytes excluding CRLF. SMTP delivery adds only dot-stuffing and protocol framing. It does not compose headers, remove Bcc, add Date or Message-ID, or run configured DKIM signing. The caller is responsible for the MIME structure and any existing signatures.

sendRaw() accepts dsn and signal options. Its envelope cannot be overridden through options; use envelope.from: null for a null reverse-path. Receipts include partial recipient rejections just like send(). The returned message ID identifies the SMTP transaction, not necessarily the MIME Message-ID.

Known sizes exclude dot-stuffing and protocol framing. A factory with an explicit encoding starts reading after the server accepts DATA and does not need a preliminary size pass. Inactivity limits and cancellation cover reading and writing; a failure during DATA closes that connection without completing the message. Raw delivery is not retried automatically.