Skip to content

SMTP

SMTP (Simple Mail Transfer Protocol) is the standard protocol for sending emails across networks, as defined in RFC 5321. Most email providers offer SMTP servers alongside their proprietary APIs, making SMTP a universal fallback option when specific transports aren't available for your email provider. The SMTP protocol provides reliable, widely-supported email delivery with features like authentication, encryption, and delivery confirmation.

Upyo provides a comprehensive SMTP transport through the @upyo/smtp package, offering connection pooling, TLS support, multiple authentication methods, and efficient bulk sending capabilities.

CAUTION

The SMTP transport currently does not support edge functions or web browsers. If you need to use Upyo in these environments, consider using other transports like Mailgun or similar services that provide HTTP APIs.

Installation

To use the SMTP transport, you need to install the @upyo/smtp package:

npm add @upyo/smtp
pnpm add @upyo/smtp
yarn add @upyo/smtp
deno add jsr:@upyo/smtp
bun add @upyo/smtp

Basic usage

The SMTP transport requires connection details for your SMTP server, including the hostname, port, and authentication credentials. Most email providers offer SMTP access through their settings or developer documentation.

import { 
SmtpTransport
} from "@upyo/smtp";
import {
createMessage
} from "@upyo/core";
// Create transport with basic configuration const
transport
= new
SmtpTransport
({
host
: "smtp.gmail.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
pass
: "your-app-password",
}, }); const
message
=
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Hello from Upyo SMTP",
content
: {
text
: "This email was sent using the SMTP transport." },
}); 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
(", "));
} // Clean up connections when done await
transport
.
closeAllConnections
();

The transport automatically handles connection management, protocol negotiation, and message formatting. When you're finished sending emails, it's important to close connections to free up resources.

Automatic resource management

Modern JavaScript environments support automatic resource cleanup using the await using statement, which automatically closes SMTP connections when the transport goes out of scope:

import { 
SmtpTransport
} from "@upyo/smtp";
import {
createMessage
} from "@upyo/core";
await using
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 587,
secure
: false,
auth
: {
user
: "username",
pass
: "password",
}, }); const
message
=
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "System Notification",
content
: {
text
: "Your backup completed successfully." },
}); await
transport
.
send
(
message
);
// Connections are automatically closed when transport goes out of scope

This approach eliminates the need to manually call ~SmtpTransport.closeAllConnections() and ensures proper cleanup even if errors occur.

Connection configuration

The SMTP transport offers extensive configuration options to work with different email providers and security requirements. Connection settings control timeouts, pooling, and protocol behavior:

import { 
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "mail.example.com",
port
: 587,
secure
: false,
requireTls
: true,
auth
: {
user
: "[email protected]",
pass
: "secure-password",
method
: "plain",
},
connectionTimeout
: 30000,
socketTimeout
: 60000,
localName
: "mail.mycompany.com",
pool
: true,
poolSize
: 10,
});

The ~SmtpConfig.host and ~SmtpConfig.port specify your SMTP server details, while ~SmtpConfig.secure determines whether to use implicit TLS and ~SmtpConfig.requireTls makes a STARTTLS upgrade mandatory for plaintext connections. Connection and socket timeouts prevent hanging connections, and the ~SmtpConfig.localName identifies your server during the SMTP handshake. Connection pooling improves performance by reusing connections across multiple messages.

~SmtpConfig.poolSize caps how many connections one transport may have open at the same time. The cap counts connections that are being established, connections that are currently sending, and idle connections kept for reuse, so it applies whether or not ~SmtpConfig.pool is enabled. Concurrent ~SmtpTransport.send() and ~SmtpTransport.sendMany() calls that arrive once the cap is reached wait for a connection to be handed back rather than opening another one, which is what lets you match ~SmtpConfig.poolSize to the simultaneous-connection limit your provider enforces. A waiting call still honours its AbortSignal, so cancelling it rejects without sending the message. Setting ~SmtpConfig.poolSize to Infinity opts out of the limit entirely.

NOTE

Because a ~SmtpTransport.sendMany() call holds its connection until the iteration ends, running more concurrent ~SmtpTransport.sendMany() calls than ~SmtpConfig.poolSize makes the extra ones wait for an earlier iteration to finish. Raise ~SmtpConfig.poolSize, or use separate transports, when you need more bulk streams at once.

Command pipelining

This feature is introduced in Upyo 0.6.0.

When a server advertises the PIPELINING extension defined by RFC 2920, Upyo sends MAIL FROM and all RCPT TO commands together instead of waiting for a reply after each command. This cuts the number of network round trips for messages with multiple recipients. Upyo then reads every reply in command order, including multiline replies, before continuing with DATA.

Pipelining is negotiated automatically and does not require a configuration option. Servers that do not advertise it keep the standard sequential command flow. A rejected recipient is still reported through ~SmtpReceipt.rejectedRecipients when at least one other recipient accepts the message.

Message size declaration

This feature is introduced in Upyo 0.6.0.

Upyo automatically uses the SIZE extension defined by RFC 1870 when the server advertises it. The transport adds the encoded message size in octets to MAIL FROM, allowing the server to reject the message before its content is uploaded.

If the server advertises a fixed maximum, Upyo returns a failed receipt without sending MAIL FROM when the message exceeds that limit. A bare SIZE capability, or SIZE 0, means that no fixed maximum was advertised, so Upyo still declares the message size without rejecting it locally. Servers that do not advertise SIZE retain the existing SMTP flow.

The declared size covers the headers, encoded body, and line endings sent after the server accepts DATA. It does not include the DATA terminator or dots added for SMTP transparency.

Enhanced status codes

This feature is introduced in Upyo 0.6.0.

SMTP servers can prefix reply text with an enhanced status code such as 5.1.1, as defined by RFC 2034 and RFC 3463. When a failure contains a valid code, Upyo preserves the final reply line's text in providerDetails.response and exposes the parsed value through providerDetails.enhancedStatusCode. The parsed ~SmtpEnhancedStatusCode contains the complete code and numeric class, subject, and detail fields.

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
)) {
const
status
=
error
.
providerDetails
.
enhancedStatusCode
;
if (
status
!= null) {
console
.
log
(
status
.
code
,
status
.
class
,
status
.
subject
,
status
.
detail
);
} }

If delivery succeeds for at least one recipient, each rejected entry in ~SmtpReceipt.rejectedRecipients exposes its enhanced code through ~SmtpRejectedRecipient.enhancedStatusCode.

Upyo uses the enhanced subject to refine categories where its meaning is unambiguous. Address and message-content statuses use validation, while network/routing statuses use network. Other subjects retain the category derived from the traditional reply class. A 4.x.x code remains retryable and a 5.x.x code remains non-retryable.

The enhanced code must appear at the start of the reply text, use fields of one to three digits without leading zeroes, and have the same class as the three-digit SMTP reply. If any condition is not met, Upyo preserves the reply line's text but ignores the enhanced code. Servers that return only traditional replies continue to work unchanged. Because the code space is extensible through the IANA registry, Upyo does not reject an otherwise valid code merely because its subject or detail is unknown.

Envelope overrides

This feature is introduced in Upyo 0.6.0.

Use the envelope send option when delivery errors or recipient routing need addresses that differ from the visible message headers. The following message still displays [email protected] as its From address and [email protected] as its To address:

import { 
createMessage
} from "@upyo/core";
import {
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 465,
secure
: true,
}); const
message
=
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Invoice",
content
: {
text
: "Your invoice is attached." },
}); await
transport
.
send
(
message
, {
envelope
: {
from
: "[email protected]",
to
: ["[email protected]"],
}, });

~SmtpEnvelopeOptions.from controls MAIL FROM, while ~SmtpEnvelopeOptions.to supplies the addresses for RCPT TO. Omit either field to derive that side from the message as before. Set from to null for the null reverse-path used by delivery notifications:

import { 
createMessage
} from "@upyo/core";
import {
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 465,
secure
: true,
}); const
notification
=
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Delivery status notification",
content
: {
text
: "The message could not be delivered." },
}); await
transport
.
send
(
notification
, {
envelope
: {
from
: null },
});

The option also accepts a resolver for bulk VERP delivery. The resolver receives each message and its zero-based position in the send operation:

import { 
createMessage
} from "@upyo/core";
import {
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 465,
secure
: true,
}); const
messages
= [
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Newsletter",
content
: {
text
: "Hello, first subscriber." },
}),
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Newsletter",
content
: {
text
: "Hello, second subscriber." },
}), ]; 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 ~SmtpTransport.sendMany(). Invalid addresses and empty recipient lists produce a non-retryable failed receipt with the code smtp.envelope-invalid. Upyo rejects them before sending MAIL FROM, and a bad item in sendMany() does not prevent later messages from using the same connection.

The effective envelope drives DSN recipient validation and SMTPUTF8 negotiation. Mailbox addresses written to visible From, To, Cc, and Reply-To headers can still require SMTPUTF8 even when the envelope overrides them. The override does not alter message headers or DKIM signatures.

Internationalized addresses

This feature is introduced in Upyo 0.6.0.

Upyo automatically uses the SMTPUTF8 extension defined by RFC 6531 when the effective SMTP envelope or a visible From, To, Cc, or Reply-To mailbox contains a non-ASCII character. The default envelope includes the message's Bcc addresses. The transport requires the server to advertise both SMTPUTF8 and 8BITMIME, then adds BODY=8BITMIME SMTPUTF8 to MAIL FROM. This supports UTF-8 local parts and Unicode domain labels without another configuration option.

If either required extension is missing, Upyo returns a non-retryable failed receipt with the code smtp.smtputf8-unsupported before sending MAIL FROM. The connection remains available for a later ASCII-only message.

Unicode display names and subjects do not by themselves require SMTPUTF8. Upyo continues to encode those values as RFC 2047 encoded words, so an address such as José <[email protected]> follows the ordinary ASCII SMTP flow. An ASCII A-label domain such as xn--r8jz45g.xn--zckzah likewise does not require SMTPUTF8, while its Unicode U-label form does.

Delivery status notifications

This feature is introduced in Upyo 0.6.0.

Use the dsn send option to request delivery status notifications through the SMTP DSN extension defined by RFC 3461. These settings become parameters on MAIL FROM and RCPT TO; they are not message headers.

import { 
createMessage
} from "@upyo/core";
import {
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 465,
secure
: true,
}); const
message
=
createMessage
({
from
: "[email protected]",
to
: ["[email protected]", "[email protected]"],
subject
: "Delivery report",
content
: {
text
: "Track this delivery." },
}); const
receipt
= await
transport
.
send
(
message
, {
dsn
: {
envelopeId
: "campaign+42",
return
: "headers",
recipients
: {
"[email protected]": {
notify
: ["success", "failure", "delay"],
originalRecipient
: "[email protected]",
}, "[email protected]": {
notify
: ["never"],
}, }, }, });

~SmtpDsnOptions.envelopeId sets ENVID, a non-empty identifier copied into a later notification. ~SmtpDsnOptions.return sets RET=FULL or RET=HDRS and controls how much of a failed message may be returned. Each key in ~SmtpDsnOptions.recipients must exactly match an address in the effective SMTP envelope. When ~SmtpTransportOptions.envelope replaces the recipients, the DSN keys must match the replacement addresses rather than the message's To, Cc, or Bcc fields.

The ~SmtpDsnRecipientOptions.notify array accepts "success", "failure", and "delay". Use ["never"] by itself to suppress notifications for one recipient. If notify is omitted, the server keeps its default failure and optional delay behavior. ~SmtpDsnRecipientOptions.originalRecipient sets an ORCPT value with the rfc822 address type. On initial submission, RFC 3461 requires this value to equal the corresponding envelope recipient.

Upyo validates notification combinations, recipient keys, parameter lengths, and the printable US-ASCII range before sending the SMTP envelope. It also applies RFC 3461 xtext escaping to spaces, plus signs, and equals signs in ENVID and ORCPT. Upyo emits the RFC 3461 rfc822 form and does not implement the UTF-8 address type or encodings defined by RFC 6533.

If any DSN parameter is requested but the server does not advertise DSN, the send returns a non-retryable failed receipt with the code smtp.dsn-unsupported; Upyo does not send MAIL FROM. Invalid settings use the code smtp.dsn-invalid. An empty dsn object has no effect, preserving the ordinary SMTP flow.

The SMTP server sends a requested notification later as a separate message in the RFC 3464 format. A successful ~SmtpTransport.send() receipt confirms only that the server accepted the original message; it is not the later DSN.

When passed to ~SmtpTransport.sendMany(), one dsn option applies to every message. Every configured recipient key must therefore be present in each effective envelope. Call ~SmtpTransport.send() separately when messages need different DSN settings.

Authentication methods

The SMTP transport supports multiple authentication mechanisms commonly used by email providers. The most widely supported method is PLAIN authentication, which works with virtually all SMTP servers:

import { 
SmtpTransport
} from "@upyo/smtp";
// PLAIN authentication (most common) const
gmailTransport
= new
SmtpTransport
({
host
: "smtp.gmail.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
pass
: "your-app-password",
method
: "plain",
}, }); // LOGIN authentication for older servers const
outlookTransport
= new
SmtpTransport
({
host
: "smtp-mail.outlook.com",
port
: 587,
secure
: false,
auth
: {
user
: "[email protected]",
pass
: "your-password",
method
: "login",
}, });

When using services like Gmail, you'll need to generate an app-specific password rather than using your regular account password. The transport automatically detects server capabilities and chooses the appropriate authentication method if you don't specify one.

IMPORTANT

SMTP authentication requires a secure connection (secure: true, or a successful STARTTLS upgrade on port 587). To protect passwords and access tokens, authentication over a cleartext connection to a non-loopback host is refused. Loopback hosts remain available for local development.

OAuth 2.0 authentication

Many providers—including Gmail and Outlook—now require OAuth 2.0 instead of passwords. The transport supports the SASL XOAUTH2 mechanism (the de-facto standard used by Google and Microsoft) and OAUTHBEARER (RFC 7628). Instead of pass, provide an accessToken:

import { 
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.gmail.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
accessToken
: "ya29.a0Af…your-access-token",
}, });

When method is omitted, the transport selects a mechanism advertised by the server, preferring XOAUTH2. Set method: "oauthbearer" to force OAUTHBEARER.

Refreshing tokens automatically

Access tokens are short-lived, so a static string is rarely enough. To refresh tokens transparently, pass a callback as accessToken. It is invoked each time a new connection authenticates, which lets you delegate to an OAuth client such as google-auth-library (Gmail) or msal-node (Outlook):

import { 
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.gmail.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
// Called for every new connection; obtain a fresh token here.
accessToken
: () =>
getFreshAccessToken
(),
}, });

Alternatively, let the transport run the refresh_token grant itself. Provide your client credentials, a refresh token, and the token endpoint; the transport exchanges them for an access token and caches it until shortly before it expires, sharing the cached token across all pooled connections:

import { 
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "smtp.gmail.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
clientId
: "…apps.googleusercontent.com",
clientSecret
: "GOCSPX-…",
refreshToken
: "1//…",
tokenEndpoint
: "https://oauth2.googleapis.com/token",
}, });

Because a connection authenticates once when it is established, the callback or refresh runs per new connection rather than per message.

TLS and security configuration

Security is crucial for email transmission, and the SMTP transport provides comprehensive TLS configuration options. You can control encryption, certificate validation, and TLS protocol versions:

import { 
SmtpTransport
} from "@upyo/smtp";
const
transport
= new
SmtpTransport
({
host
: "secure-smtp.example.com",
port
: 465,
secure
: true,
auth
: {
user
: "[email protected]",
pass
: "password",
},
tls
: {
rejectUnauthorized
: true,
minVersion
: "TLSv1.2",
maxVersion
: "TLSv1.3",
ca
: ["-----BEGIN CERTIFICATE-----\n..."],
}, });

Setting secure: true establishes a TLS connection from the start, while rejectUnauthorized: true ensures certificate validation. You can specify custom certificate authorities, client certificates, and acceptable TLS versions based on your security requirements.

STARTTLS support

The SMTP transport automatically supports STARTTLS, which allows upgrading a plain connection to an encrypted TLS connection. When secure is set to false and the server advertises STARTTLS capability, the transport will automatically upgrade the connection before authentication. Set requireTls to true when the connection must be encrypted even if the server does not advertise STARTTLS:

import { 
SmtpTransport
} from "@upyo/smtp";
// STARTTLS will be used automatically with port 587 const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 587, // Standard submission port with STARTTLS
secure
: false, // Start with plain connection
requireTls
: true, // Fail unless the STARTTLS upgrade succeeds
auth
: {
user
: "[email protected]",
pass
: "password",
}, });

This configuration is commonly used with port 587 (mail submission port) and is required by many modern email providers including Protonmail, Office 365, and others that enforce encryption via STARTTLS. When requireTls is true, the transport issues STARTTLS even if the server does not advertise the capability and fails delivery if the upgrade is rejected or cannot be completed. This also protects message content on connections that do not use SMTP authentication. The transport follows RFC 3207 for STARTTLS negotiation and automatically re-negotiates capabilities after the connection is upgraded.

TIP

Use secure: false with requireTls: true on port 587 for mandatory STARTTLS, or secure: true on port 465 for direct TLS connections. Even when requireTls is false, the transport refuses to authenticate to a non-loopback server over cleartext.

DKIM signing

This feature is introduced in Upyo 0.4.0.

DKIM (DomainKeys Identified Mail) is an email authentication method that allows the sender to attach a digital signature to outgoing emails. This helps recipients verify that the email was actually sent from the claimed domain and hasn't been modified in transit, improving deliverability and reducing the chance of emails being marked as spam.

The SMTP transport supports DKIM signing through the ~SmtpConfig.dkim configuration option. DKIM signatures are generated using the standard Web Crypto API, ensuring cross-runtime compatibility (Node.js, Deno, Bun).

NOTE

The DKIM implementation follows RFC 6376 and RFC 8463, supporting both rsa-sha256 (most widely used) and ed25519-sha256 (shorter keys) algorithms.

Basic DKIM configuration

To enable DKIM signing, provide a dkim configuration with your private key and domain information:

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"),
}], }, });

The signingDomain should match your email's From address domain, and the selector is used to look up the public key in DNS (e.g., mail._domainkey.example.com).

DkimSignature options

Each signature in the signatures array can have the following options:

OptionTypeDefaultDescription
signingDomainstring(required)Domain for DKIM key (d= tag)
selectorstring(required)DKIM selector (s= tag)
privateKeystring | CryptoKey(required)Private key (PEM string or CryptoKey)
algorithm"rsa-sha256" | "ed25519-sha256""rsa-sha256"Signing algorithm (a= tag)
canonicalizationstring"relaxed/relaxed"Header/body canonicalization (c= tag)
headerFieldsstring[]["from", "to", "subject", "date"]Headers to sign (h= tag)

Using Ed25519 keys

Ed25519 offers shorter keys than RSA while providing equivalent security. This is particularly useful when DNS TXT record size is a concern:

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
: "ed25519",
privateKey
:
readFileSync
("./dkim-ed25519.pem", "utf8"),
algorithm
: "ed25519-sha256",
}], }, });

Using CryptoKey

If you already have a CryptoKey object (from Web Crypto API), you can pass it directly instead of a PEM string:

import { 
SmtpTransport
} from "@upyo/smtp";
// Import a private key using Web Crypto API const
privateKey
= await
crypto
.
subtle
.
importKey
(
"pkcs8", new
Uint8Array
([/* ... key bytes ... */]),
{
name
: "Ed25519" },
false, ["sign"], ); const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 587,
secure
: false,
dkim
: {
signatures
: [{
signingDomain
: "example.com",
selector
: "mykey",
privateKey
:
privateKey
, // CryptoKey object
algorithm
: "ed25519-sha256",
}], }, });

Body processing

Since Upyo 0.6.0.

dkim.bodyMode selects how attachment bytes are read for signing:

ModeSource reads per sendAdditional attachment memory
No DKIM signaturesOneFixed buffers plus the largest source chunk
"buffered" (default)OneComplete MIME body
"streaming"TwoFixed buffers plus the largest source chunk
import { 
SmtpTransport
} from "@upyo/smtp";
import {
readFileSync
} from "node:fs";
const
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 465,
secure
: true,
dkim
: {
bodyMode
: "streaming",
signatures
: [{
signingDomain
: "example.com",
selector
: "mail",
privateKey
:
readFileSync
("./dkim-private.pem", "utf8"),
}], }, });

Streaming hashes the body before MAIL FROM, then reopens the attachment sources for DATA. All signatures share these two reads. The extra I/O and hashing delay delivery and hold an authenticated connection during the first pass; the receiving server's idle limit still applies. The memory bound excludes caller-owned data, text/HTML, headers, and runtime/socket buffers. An empty signatures array behaves like unsigned sending.

Attachment factories must reopen identical bytes on every invocation. A changed second pass fails before the DATA terminator with the non-retryable smtp.attachment-replay-mismatch receipt code. Source errors, cancellation, size-limit failures, and replay mismatches never trigger send-unsigned fallback. If a later signature fails cryptographically, earlier successful signatures remain on the message.

Unsigned factory sources have no known size, so SMTP omits the optional MAIL FROM SIZE parameter and enforces the server's advertised limit while writing DATA. Known byte-array and Blob sizes are checked before MAIL FROM. DKIM's first pass establishes the final size before submission. A failure during DATA closes the connection without accepting a truncated message.

socketTimeout measures inactivity while reading or writing attachments, not total transfer duration. Nonempty source progress and completed writes reset the timer; an endless stream of empty chunks does not. Cancellation is passed to the source, and failed DATA connections are not returned to the pool.

See attachment factories for file-backed sources and the custom-transport migration guide.

Multiple DKIM signatures

You can add multiple DKIM signatures to a single email, which is useful when sending on behalf of multiple domains or when rotating keys:

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
: "mail2024",
privateKey
:
readFileSync
("./dkim-2024.pem", "utf8"),
}, {
signingDomain
: "example.com",
selector
: "mail2025",
privateKey
:
readFileSync
("./dkim-2025.pem", "utf8"),
}, ], }, });

Error handling

By default, if DKIM signing fails (e.g., due to an invalid private key), the transport throws an error. You can change this behavior using the onSigningFailure option:

import { 
SmtpTransport
} from "@upyo/smtp";
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
: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
}],
onSigningFailure
: "send-unsigned", // or "throw" (default)
}, });

With onSigningFailure: "send-unsigned", the email will be sent without a DKIM signature if signing fails, rather than failing the entire send operation.

Bulk email sending

For sending multiple emails efficiently, the SMTP transport provides a ~SmtpTransport.sendMany() method that reuses connections and handles errors gracefully. This approach is much more efficient than calling ~SmtpTransport.send() multiple times:

import { 
SmtpTransport
} from "@upyo/smtp";
import {
createMessage
} from "@upyo/core";
await using
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 587,
secure
: false,
auth
: {
user
: "[email protected]",
pass
: "password",
},
poolSize
: 5,
}); const
messages
= [
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Weekly Newsletter #1",
content
: {
text
: "Welcome to our newsletter!" },
}),
createMessage
({
from
: "[email protected]",
to
: "[email protected]",
subject
: "Weekly Newsletter #2",
content
: {
text
: "Thank you for subscribing!" },
}), ]; for await (const
receipt
of
transport
.
sendMany
(
messages
)) {
if (
receipt
.
successful
) {
console
.
log
(`Message ${
receipt
.
messageId
} sent successfully`);
} else {
console
.
error
(`Failed to send message: ${
receipt
.
errorMessages
.
join
(", ")}`);
} }

The ~SmtpTransport.sendMany() method processes messages sequentially, providing individual receipts for each message. Connection pooling ensures efficient resource usage, and failed messages don't prevent subsequent messages from being sent. The whole iteration runs on a single connection drawn from the same ~SmtpConfig.poolSize budget as ~SmtpTransport.send().

Development and testing

For local development and testing, you can use development SMTP servers or configure the transport for testing environments. The package supports various testing scenarios including mock servers:

import { 
SmtpTransport
} from "@upyo/smtp";
// Local development with Mailpit (popular SMTP testing tool) const
devTransport
= new
SmtpTransport
({
host
: "localhost",
port
: 1025,
secure
: false,
// No authentication needed for local testing }); // Testing configuration with relaxed security const
testTransport
= new
SmtpTransport
({
host
: "test-smtp.example.com",
port
: 587,
secure
: false,
auth
: {
user
: "[email protected]",
pass
: "test-password",
},
tls
: {
rejectUnauthorized
: false, // For self-signed certificates in test environments
},
connectionTimeout
: 5000, // Shorter timeouts for faster test feedback
});

TIP

Mailpit is an excellent development SMTP server that provides a modern web interface for testing email functionality. It acts as an SMTP server that accepts all emails but doesn't deliver them, instead storing them locally for inspection. Mailpit offers features like HTML and plain text email viewing, attachment downloads, search functionality, and even webhook testing for email events.

You can install Mailpit as a standalone binary, run it via Docker, or use package managers like Homebrew. The default configuration listens on port 1025 for SMTP and provides a web UI on port 8025, making it perfect for local development workflows where you need to verify email content and formatting without sending real emails.

Sending raw MIME

Use MIME composition to create raw bytes from an Upyo message without opening a transport connection.

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.

Verifying the configuration

Call ~SmtpTransport.verify() to check your SMTP settings without sending an email. It opens a fresh connection, checks the server greeting and EHLO/HELO negotiation, applies the same TLS policy as sending, and performs any configured authentication, including OAuth 2.0. Relay configurations without authentication are supported.

import { 
SmtpAuthError
,
SmtpResponseError
,
SmtpTransport
} from "@upyo/smtp";
await using
transport
= new
SmtpTransport
({
host
: "smtp.example.com",
port
: 587,
requireTls
: true,
}); try { await
transport
.
verify
({
signal
:
AbortSignal
.
timeout
(10_000) });
} catch (
error
) {
if (
error
instanceof
SmtpAuthError
) {
console
.
error
("Check the SMTP credentials.");
} else if (
error
instanceof
SmtpResponseError
) {
console
.
error
(
error
.
command
,
error
.
code
,
error
.
response
);
} else { throw
error
;
} }

Unlike sending, verification rejects on failure and does not return a receipt. ~SmtpAuthResponseError extends ~SmtpAuthError with the SMTP reply's code, command, and response. Network, TLS, and timeout failures may use native error types. Cancellation preserves the caller's abort reason.

Verification shares the transport's poolSize limit and shutdown barrier. It waits when all slots are busy; when necessary, it replaces one idle connection to make room for a fresh handshake. Its connection is closed afterward, including on failure or cancellation, and is never pooled. The existing connection and socket timeouts apply; use an abort signal to bound the whole call, including waiting for capacity.

Success confirms setup at verification time. It does not guarantee that a particular sender, recipient, or message will be accepted, or that delivery will succeed. No envelope or message data is sent. Verification is an optional transport capability; wrappers do not automatically expose it.