Sending
Nodemailer transport
If your application already sends mail with Nodemailer, the whole migration is the line where the transport is constructed. Every sendMail() call you have stays exactly as it is.
The whole change
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: 'smtp.sendgrid.net',
port: 587,
auth: { user: 'apikey', pass: process.env.SENDGRID_KEY },
})
// …and several hundred sendMail() calls, elsewhere.@posthaste/nodemailer is a Nodemailer transport plugin that wraps the TypeScript SDK. It has one dependency — the SDK — and it does not import Nodemailer itself, so it adds nothing to your lockfile that Nodemailer was not already in.
npm i @posthaste/nodemailerRequires Node 22 or newer, and Nodemailer 6 or newer. ESM only. The from address must be on one of your verified domains, exactly as it must for a direct API send.
await transporter.sendMail({
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Invoice 2026-114',
text: 'Your invoice is attached.',
html: '<p>Your invoice is attached.</p>',
attachments: [
{ filename: 'invoice.pdf', path: './invoices/2026-114.pdf' },
],
})When to use this instead of the SMTP relay
Posthaste already runs an SMTP relay, and Nodemailer can point at it today with no new package at all. That is a perfectly good answer, and for a long-running server on a network that allows outbound SMTP it is often the simpler one — so here is the honest comparison rather than a pitch.
| Reach for | When |
|---|---|
| SMTP relay | You are on a normal server or container, port 587 is open outbound, and you send ordinary mail. Nothing to install; works from any language, not just Node. |
| This transport | Outbound SMTP is blocked or throttled where you run — most serverless and edge platforms either block port 25/587 outright or make a long-lived connection expensive. An HTTPS request has none of those problems. |
| This transport | You want the fields SMTP has nowhere to put: tags and metadata, message streams, stored templates, an explicit idempotency key, or scheduled sending. |
| This transport | You want to branch on why a message was refused. SMTP gives you a three-digit code and a sentence; this gives you err.type, err.retryAfterSeconds, and the evidence behind the refusal. |
One caveat that runs the other way: the relay accepts a complete RFC 5322 message, so anything Nodemailer can compose it can carry — calendar invitations, AMP parts, several alternatives. This transport sends fields, and the table below lists what that costs.
What maps, what changes, what is refused
Nodemailer’s mail.data is a rich, loosely-typed shape; the send API is narrower. Every option lands in one of three buckets, and the governing rule is that nothing is dropped in silence — a team that migrates and does not notice their Bcc stopped arriving is the outcome this design exists to prevent. So an option that cannot be honoured is a synchronous error naming the field, never a quiet omission.
Mapped, unchanged
| Nodemailer | Becomes | Notes |
|---|---|---|
from | from | Display name kept. { name, address }, "N <a@b>" and a bare address all work; a name containing a comma is quoted, so one sender cannot become two. |
to, cc, bcc | to, cc, bcc | Every form: string, comma string, { name, address }, and arrays of any of those. Each recipient is a separate message and counts as one send. See the display-name caveat below. |
replyTo | replyTo | Display names kept, and several addresses kept. |
subject, text, html | same | text and html accept a string, a Buffer, a stream, { path } or { href }. |
attachments | attachments | content as a Buffer, string, stream or encoded string; path; href; and data: URIs. Resolved by Nodemailer’s own resolver, so disableFileAccess and disableUrlAccess are honoured. |
headers | headers | Object, array of { key, value }, and { prepared, value }. |
list.unsubscribe | listUnsubscribe | Formatted exactly as Nodemailer would: a bare address becomes <mailto:…>, a scheme-less host gets http://. |
priority | headers | The same three headers Nodemailer writes — X-Priority, X-MSMail-Priority, Importance. normal adds nothing, also as Nodemailer does. |
xMailer, inReplyTo, references | headers | X-Mailer, In-Reply-To, References. |
Mapped, with something changed
Each of these produces a mapping warning: it appears on info.posthaste.mappingWarnings and is passed to the transport’s onWarning hook, so you find out on the first send rather than in a support ticket.
| Nodemailer | What happens | Why |
|---|---|---|
Display names on to / cc / bcc | The name is dropped; the address is unchanged. | The API addresses recipients by bare address. This is the one place the mapping loses something a recipient would have seen — and it is only how their own address is labelled in their own client, which most clients override from the address book anyway. Refusing instead would break the migration for nearly every application that has ever set a recipient name. |
alternatives holding a text/plain or text/html part | Folded into the matching field, if that field is empty. | A longhand spelling of text / html. Any other alternative type — or a duplicate of a field already set — is refused; see below. |
encoding, textEncoding, attachments[].contentTransferEncoding | Ignored. | These select a MIME transfer encoding, and Posthaste composes the MIME server-side. The bytes the recipient’s client decodes are identical either way, which is why this is a warning and not a refusal. |
An attachment with no contentType and an unusual extension | Sent as application/octet-stream. | The API requires a content type. Common extensions are recognised; the package does not ship a full MIME database, so set contentType explicitly if the type matters. |
contentDisposition: 'inline' with no cid | Sent as a regular attachment. | An inline part with no Content-ID has nothing to be inline to. The SMTP relay makes exactly the same downgrade, so both entrances treat the same message alike. |
| Any field this transport does not recognise | Not sent, and reported as a warning. | A Nodemailer option newer than the transport must not vanish without trace. It is a warning rather than an error because applications routinely hang their own annotations off mail.data. |
Refused
These throw before anything is sent. The error names the field and says what to do instead. All of them are things that could not be honoured and would change what the recipient receives if they were ignored.
| Nodemailer | Why it is refused |
|---|---|
raw | A pre-composed RFC 5322 message. This transport sends fields. Posthaste does accept raw messages — through the SMTP relay, using Nodemailer’s ordinary SMTP transport. |
envelope | It overrides who is actually delivered to. Posthaste builds the envelope itself — the return path is a VERP token, which is how a bounce is attributed to one message. Ignoring this would deliver to a different set of people than you asked for. |
messageId, date, sender | Written and DKIM-signed by the platform. A caller-supplied copy either duplicates a signed header or drifts from it and fails DMARC. Use info.messageId for the id, and posthaste.scheduledAt to send later. |
dkim | Messages are signed with the sending domain’s own key, held encrypted by the platform — that is what domain verification sets up. Ignoring a supplied key would silently change DMARC alignment, so it is refused rather than dropped. |
icalEvent, amp, watchHtml, other alternatives | Extra MIME alternative parts. The API carries one text part and one html part. Attach an .ics file rather than losing a calendar invitation. |
attachDataUrls | It rewrites data: URIs in the html into cid: attachments. This transport does not do that rewrite, and leaving the URIs in place renders differently — and much larger — in most clients. |
dsn | SMTP delivery status notifications. Posthaste owns bounce handling end to end; subscribe to the bounced webhook instead. |
normalizeHeaderKey | A function that rewrites header names during composition, which happens server-side here. |
A reserved name in headers | From, To, Cc, Bcc, Sender, Subject, Date, Message-ID, MIME-Version, Content-*, Return-Path, DKIM-Signature, Received, Authentication-Results, List-Unsubscribe, Feedback-ID and ARC-*. The API refuses these too; the error names the first-class field to use instead. |
The same header name twice in headers | The API carries one value per name, and there is no correct way to collapse two into one — so it is refused rather than silently reduced to the first. |
list keys other than unsubscribe | List-Help, List-Id and friends have no API field, and a subscriber’s filters key off them. |
| Group address syntax, and per-attachment `headers` or `raw` | { name, group: [...] } presents recipients as a named set that the API cannot express; per-part headers have nowhere to go. |
Attachment limits — the count, the total size and the blocked executable types — are deliberately not duplicated in the client. They are enforced by the API, which refuses with the limit and the measured value attached, and the transport surfaces that refusal as err.responseCode === 552 with the numbers on err.body.
Seeing the warnings
The mapping warnings are on every info, but the useful place to read them is a hook, because that is what fires on the first send rather than whenever somebody next inspects a result. The transport never writes to your process’s output on its own.
const transporter = nodemailer.createTransport(
posthasteTransport({
apiKey: process.env.POSTHASTE_API_KEY,
onWarning: (w) => logger.warn({ field: w.field, code: w.code }, w.message),
}),
)What sendMail returns
The fields Nodemailer callers already destructure are all present and mean what they mean for the SMTP transport, plus one addition for everything Nodemailer has no field for.
const info = await transporter.sendMail({ … })
info.messageId // 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ' — the Posthaste id
info.accepted // ['[email protected]']
info.rejected // [] — suppressed recipients land here
info.response // '202 2.0.0 Accepted: msg_AZLm… queued for 1 recipient'
info.envelope // { from: '[email protected]', to: [...] }
info.posthaste // everything Nodemailer has no field for:
// .id .status .duplicate .scheduledAt .groupId
// .emails per-recipient outcome on a fan-out
// .suppressed who was skipped, and why
// .warnings pre-send lint findings
// .mappingWarnings what this transport had to changeinfo.messageId is the Posthaste message id, not an RFC 5322 Message-ID: the latter is composed server-side at delivery, and it is the Posthaste id that every webhook, log line and waybill is keyed by.
A suppressed recipient appears in info.rejected, where an existing if (info.rejected.length) branch already looks for it. When the only recipient is suppressed there is nothing to accept, and that is an error rather than an empty accepted — the same asymmetry SMTP has.
The fields Nodemailer has no word for
Anything the send API offers that Nodemailer does not goes under a single posthaste key. It is additive: an application that has never heard of it sends exactly what it always sent.
await transporter.sendMail({
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Invoice 2026-114',
text: 'Your invoice is attached.',
// Everything the API offers that Nodemailer has no word for.
posthaste: {
stream: 'transactional',
tags: ['invoice'],
metadata: { invoiceId: '2026-114' },
idempotencyKey: 'invoice-2026-114',
scheduledAt: '2026-09-01T09:00:00Z',
},
})Stored templates work the same way, and are the one case where a message legitimately has no text and no html.
await transporter.sendMail({
from: 'Acme <[email protected]>',
to: '[email protected]',
// No text and no html: the stored template supplies both.
posthaste: {
template: 'invoice-issued',
variables: { name: 'Ada', amount: '£42.00' },
},
})The same keys can be set once on the transport as posthasteTransport({ apiKey, posthaste: { stream: … } }), where a per-message value overrides them.
Errors
Every failure arrives as a PosthasteTransportError, which is deliberately two things at once. It carries code, responseCode and response, so the catch blocks you wrote against the SMTP transport keep working unchanged. And it carries the whole typed Posthaste rejection — the type, the evidence, the retry-after — so new code can branch on something better than a number.
import { isPosthasteTransportError } from '@posthaste/nodemailer'
try {
await transporter.sendMail(message)
} catch (err) {
// Existing code keeps working — these are the fields it already reads.
err.code // 'EENVELOPE'
err.responseCode // 550
err.response // '550 suppressed: [email protected] is suppressed'
if (!isPosthasteTransportError(err)) throw err
// New code gets something better than a number.
if (err.type === 'suppressed') {
const { address, reason } = err.body.error // 'complaint' — never retry
await stopMailing(address, reason)
}
if (err.type === 'content_blocked') {
// Every finding, warnings included, so one fix pass covers everything.
console.error(err.body.error.check, err.body.error.findings)
}
if (err.transient) {
await retryAfter(err.retryAfterSeconds ?? 60)
}
}The mapping from a Posthaste error type to an SMTP-shaped code is chosen so that the 4xx/5xx split is correct: every retry loop ever written against SMTP treats 4xx as “come back later” and 5xx as “never send this again”.
| Posthaste type | code | responseCode | Retry? |
|---|---|---|---|
suppressed, invalid_address, domain_not_verified, domain_not_found, bulk_send_refused | EENVELOPE | 550 | Never. |
content_blocked, attachment_type_blocked, attachment_invalid, schedule_too_far | EMESSAGE | 554 | Not until the message changes. |
attachments_too_many, attachments_too_large, fanout_too_large | EMESSAGE | 552 | Not until the message changes. |
unauthorized, forbidden, account_suspended | EAUTH | 535 | Never — no request from this key will succeed until it is fixed. |
rate_limited, platform_paused | ETHROTTLE | 451 | Yes, after err.retryAfterSeconds. |
daily_limit_reached, monthly_limit_reached | ETHROTTLE | 452 | Yes, but err.retryAfterSeconds will be hours — see sending limits. |
internal | EPOSTHASTE | 451 | Yes. |
connection_error, timeout | ECONNECTION, ETIMEDOUT | absent | Yes. |
| A field this transport refuses | EENVELOPE or EMESSAGE | absent | No — fix the message. |
No responseCode means nothing answered. Either the request never reached the API, or this transport refused the message before sending it. That absence is how you tell “we did not send” from “they said no”, and err.transient already accounts for it.
verify()
transporter.verify() makes a real authenticated request rather than a reachability ping, so a true from it is a claim worth having: the base URL answers, the key exists and is not revoked, and it holds the emails:send scope.
// Throws unless the key exists, is not revoked, AND holds emails:send.
await transporter.verify()The scope check is the part that earns the call. A key without emails:send authenticates perfectly and then fails every send with a 403; a verify() that returned true for it would be answering “does this key exist” while you were asking “can I send mail”.
TypeScript
Your existing sendMail calls typecheck unchanged — they are ordinary Mail.Options, and that is the drop-in claim. Only the two additive surfaces need help, for a reason worth stating plainly: @types/nodemailer does not let any transport type its own additions.
Mail.Optionscannot be augmented. It lives inside adeclare namespace Mailin a module that endsexport = Mail, so adeclare module 'nodemailer/lib/mailer'block adds to the module’s export scope rather than to that namespace. All three spellings compile happily and augment nothing.createTransport<T>(transport: Transport<T>)returnsTransporter<SMTPTransport.SentMessageInfo>— the inferredTis discarded. Every custom transport in the ecosystem gets the SMTP transport’s info type back whatever it really returns.
So the package exports two helpers. Both are optional, both are erased at runtime, and JavaScript users never need either.
import { posthasteMessage, posthasteInfo } from '@posthaste/nodemailer'
const info = posthasteInfo(
await transporter.sendMail(
posthasteMessage({ from, to, subject, text }, { tags: ['receipt'] }),
),
)
info.posthaste.duplicate // typedposthasteMessage returns a NEW object rather than writing onto the one you passed. A message template reused across a loop must not carry the previous iteration’s idempotency key — that is the one mistake in this area that duplicates or suppresses real mail.
NextSend an email →Every field on POST /v1/emails, including replyTo and List-Unsubscribe.