npm.io
0.6.0 • Published 1 month ago

@upyo/smtp

Licence
MIT
Version
0.6.0
Deps
1
Size
257 kB
Vulns
0
Weekly
0
Stars
588

@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: "sender@example.com",
  to: "recipient@example.net",
  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: "bounces+customer-42@bounce.example.com",
    to: ["delivery@example.net"],
  },
});

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: {
      "recipient@example.net": {
        notify: ["success", "failure", "delay"],
        originalRecipient: "recipient@example.net",
      },
    },
  },
});

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: "user@example.com", 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: "sender@example.com", to: ["recipient@example.net"] },
    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.

Keywords