@upyo/smtp
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.