Skip to content

mailer v1.1: ten providers, sendmail, verified webhooks and reliability decorators - #3

Merged
Alex-Dolid merged 143 commits into
mainfrom
feature/v1.1
Oct 18, 2026
Merged

Alex-Dolid merged 143 commits into
mainfrom
feature/v1.1

Conversation

@Alex-Dolid

Copy link
Copy Markdown
Contributor

Summary

1.1.0 adds what nodemailer, unemail and sently did better than this package, ported onto its contracts, and the first parts of the reliability layer. Every finding of a code review of the branch is fixed in it.

  • 139 commits, 388 files, +37,115 / −2,129.
  • 19 new subpaths.
  • Version 1.0.0 → 1.1.0; CHANGELOG.md has the full [1.1.0] entry.

What's new

  • Transports:
    • ./sendmail;
    • ten HTTP providers: ./postmark, ./brevo, ./mailjet, ./mailersend, ./sparkpost, ./zeptomail, ./smtp2go, ./azure, ./scaleway, ./cloudflare.
    • All 17 HTTP providers but JMAP now request through one shared client (src/internal/api-client.js), SES's SigV4 included.
  • SMTP: an injected connect and an HTTP CONNECT proxy (createHttpProxyConnector); maxMessages per pooled connection.
  • Messages:
    • list (List-* fields, RFC 8058 one-click);
    • scheduledAt, tracking and metadata;
    • IDN addresses as A-labels.
  • ./attachments: fromPath, fromUrl, fromDataUri, fromStream and inlineDataUrls. Nothing is read implicitly, and fromUrl refuses private addresses, written or resolved.
  • ./render: a renderer interface with React Email passed in, and a linear htmlToText.
  • ./webhooks: verifiers for 15 providers into one DeliveryEvent model.
    • Each request is verified before it is read: a signature, or credentials where the provider signs nothing.
    • Replay protection is two-phase: verify() returns commit()/release(), and reservations are fenced by a token.
    • Every provider's event mapping follows one documented rule, pinned by a table test.
  • Decorators: ./breaker, ./ratelimit, ./idempotency, ./hooks, ./tee.
    • Each takes a pino-like logger.
    • The rate limit and idempotency take a store that processes share.
  • Observability:
    • mailer.delivery_unknown on otel spans and counters;
    • the error category and code on mailer.email.messages;
    • deliveryUnknown in ./logger's email.failed;
    • pool.verifyAll().
  • Platforms: CI runs the unit suite and the new tests/runtime on Bun 1.4.3 and Deno 2.9.6, as well as on Node 22 and 24.

Upgrading from 1.0

See "Upgrading from 1.0" in the changelog. In short:

  • SMTP defaults:
    • EHLO names the machine, not localhost;
    • a pooled SMTP connection carries at most 100 messages.
  • Message output:
    • bodies are encoded by their content: 7bit, quoted-printable or base64;
    • custom header fields are capitalized;
    • DKIM h= also signs List-Unsubscribe and List-Unsubscribe-Post.
  • Failure classification: a provider limit or an unsupported field is a non-retryable unsupported failure, which ./pool fails over with. A refused TLS certificate is a non-retryable configuration failure at the first attempt.
  • Errors of the 1.0 HTTP providers: they keep their names, arguments and fields, and also carry provider, category and retryable.
  • verify(): a 403 passes only where a send-only key gets one: SES, Mailgun, SparkPost, MailerSend, Scaleway. A 403 from SendGrid or Resend now fails.
  • Loggers: a logger that throws is reported once, by its error's name and code, not on every record.
  • Deno: give the HTTP transports a fetch that keeps no idle connection, as the docs show.

Review

The branch went through a review in seven dimensions: maintainability, performance, security, scalability, resilience, governance and observability. An audit of the plan against the code followed.

  • Each fix is its own commit, with a test that fails on the code before it.
  • The bugs of 1.1 found this way are R1–R41 in CLAUDE.md. Among them:
    • a quadratic htmlToText and a ReDoS in inlineDataUrls;
    • webhook events lost when a handler failed;
    • idempotency keys stuck for a day;
    • SNS certificate cache eviction;
    • copies from beforeSend that skipped createMessage's checks;
    • timeouts over 2^31 ms firing at once.

Testing

  • pnpm run prepublishOnly passes:
    • lint and format;
    • unit tests: 2425 pass, 0 fail, 1 known todo (JMAP 401 on Node 24, upstream bug 14);
    • coverage: 99.19 lines, 95.29 branches, 100 functions;
    • test:runtime (50), tsd, check:dts, size budgets, check:pack.
  • pnpm docs:comparison shows no drift, and pnpm docs:build passes.
  • Not run locally: the Bun and Deno suites (CI only) and pnpm test:e2e against real providers. tests/e2e/providers.e2e.test.js covers the ten new providers and is skipped without credentials.

Known gaps, by decision

  • fromUrl resolves names before it connects, but DNS rebinding is not closed. Closing it needs a connect-time check on node:https, which could not be verified on Bun and Deno here. The docs say how to pass such a fetch.
  • No fixtures of real webhook bodies (no provider accounts). The mappings come from the providers' documentation.
  • The Scaleway certificate host is not narrowed, as the URL form is undocumented; the pinned trust chain, CA-only and valid, is the check.

Notes for reviewers

  • Size budgets rose for most entries, by the policy in CLAUDE.md: each raise is in the commit that needed the room.
  • The upyo-ported HTTP clients changed internally only; receipts and public error classes are unchanged.

🤖 Generated with Claude Code

Alex-Dolid and others added 30 commits October 17, 2026 19:55
1.1 takes the features of three libraries of the comparison where they lead
this package. LICENSE carries the notices of nodemailer 10.0.16 (MIT-0),
unemail 0.7.0 and sently 1.2.2 (MIT). The attribution test reads a table of
sources, each with its version, path pattern and notice, and a second
source of a file needs its own notice; the marker of code that is not a
port becomes "// Not ported:". CLAUDE.md gains the rules of these ports:
the feature is ported onto the contracts of the package, and a known
upstream bug is fixed in the feat commit, with a test and an entry in the
list. The decision on providers allows the ten most used of unemail and
sently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Unicode domain under an ASCII local part needed SMTPUTF8, so mail to
user@bücher.de failed on any server without it, while nodemailer sends
it as user@xn--bcher-kva.de. toAsciiAddress, after nodemailer's
normalizeDomain, maps the domain with domainToASCII (IDNA with the
mapping of UTS #46) and leaves a domain at which the host parser would
cut, an address literal and a local part beyond ASCII as given. It is
applied where a message is written: header fields, SMTP envelopes
(overrides and raw messages too), JMAP and the provider converters. The
Message keeps the address as the application gave it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headers keeps names in lower case, so every custom field went out as
x-custom-header or list-unsubscribe-post. The MIME writer capitalizes
each word of the name, as nodemailer's _normalizeHeaderKey does, with
MIME, DKIM, ARC and BIMI at the start, -ID, -SPF, -FBL and -MD5 at the
end and X-SMTPAPI upper-cased, and Content-features as RFC 2912 spells
it. Names are case-insensitive, so readers and DKIM verification see no
difference; ported tests that pinned lower case say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createMessage takes `list`: the List-Id of RFC 2919, the URL fields of
RFC 2369 (help, unsubscribe, subscribe, post, owner, archive) and, with
oneClick, the List-Unsubscribe-Post of RFC 8058 that Gmail and Yahoo ask
of bulk senders. The fields go into Message.headers, so every transport
that sends custom header fields sends them. The formatting is
nodemailer's (`<url> (comment)`, a bare address as mailto) and the
one-click field unemail's.

Fixed in the port: unemail built List-Unsubscribe after its line-break
check, so a CRLF in the URL added header fields (U1), and turned
one-click on for any URL, http included (U2); nodemailer wrote no
List-Unsubscribe-Post, and neither signed it (N1). A URL with
whitespace, controls or angle brackets, another scheme or none is
refused rather than stripped or guessed, oneClick is opt-in and needs
an https URL, and the default DKIM h= covers List-Unsubscribe and
List-Unsubscribe-Post. The root budget goes to 9.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every text and HTML body went quoted-printable, so Cyrillic or CJK text
took about three times its UTF-8 bytes, plain ASCII was escaped for
nothing, and a bare LF went as =0A on one long line. As nodemailer does,
a body is first given CRLF line breaks, then written 7bit when it is
ASCII with lines of at most 76 columns, quoted-printable when it has
more Latin letters than other characters, and base64 otherwise. A line
that ends in whitespace goes quoted-printable too, so an MTA that trims
it cannot break a DKIM signature over the simple body. The budgets of
./mime and ./smtp go to 7.5 and 20.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From unemail's message model: scheduledAt asks the provider to deliver
later, tracking turns open and click tracking on or off for one message,
and metadata holds values the provider returns in its events. createMessage
checks them: a valid Date, booleans of `opens` and `clicks`, and metadata
keys of 1 to 128 of [A-Za-z0-9_.-] with values free of control characters,
since a key may become a header or form field name (unemail wrote both
into X-Metadata-* fields unchecked, U3).

A transport that cannot schedule a message refuses it before anything is
sent, with a non-retryable validation receipt, rather than delivering it
at once: SMTP, JMAP and, until their scheduling APIs are mapped, every
HTTP provider. Resend sends a chunk holding one individually, and JMAP
sends the runs between refused messages, so each receipt keeps its place.
The budgets of the root, Mailtrap, Maileroo and Lettermint go up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ream

A new subpath makes an attachment of what nodemailer read through `path`,
`href` and data URIs and sently through `resolveAttachments`: fromPath
reads a regular file again each time the message is read, fromUrl
fetches an http or https URL once through the injected fetch, fromDataUri
decodes RFC 2397, fromStream reads a one-shot stream into memory, and
inlineDataUrls turns the data-URI images of HTML into inline attachments
(nodemailer's attachDataUrls). File names and types come from the path,
the server or the URI, with a table of about sixty extensions.

Fixed in the port: nodemailer read any path or URL an attachment named,
its client following five redirects to any address with no bound on the
body (N2), and turned a data URL over 50 MB into an empty attachment or
read one that did not parse as a path (N3); sently's basePath compared
paths as written, so a symbolic link led out of it (S1). Here nothing is
read but through these helpers; fromUrl checks each redirect with
allowUrl, by default refusing localhost and loopback, private and
link-local addresses, sends headers to the first origin only and bounds
time and bytes; fromPath compares realpaths. The budgets of Resend and
SendGrid go to 6 KB with readBytes in the shared body reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An IDN domain arrives as its A-label, a one-click List-Unsubscribe
passes Mailpit's own check of the field and of List-Unsubscribe-Post
under a DKIM signature an independent verifier accepts, and bodies in
base64 and 7bit and an attachment of a data URI arrive as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`connect` opens each connection in place of a TCP connection to the
host, as nodemailer's getSocket does: a proxy, a tunnel or a socket of
another module, given the host, port, localAddress and a signal that the
connection timeout and the caller's cancellation abort. Implicit TLS and
STARTTLS go over the socket it resolves to, checked against the host.
createHttpProxyConnector, ported from nodemailer's http-proxy-client,
makes one for an HTTP or HTTPS proxy; `localAddress` picks the address
to connect from.

Fixed in the port (N4): an IPv6 destination is bracketed in the CONNECT
line and Host field, an HTTPS proxy takes `ca` and is sent its name for
SNI, the credentials of the proxy URL are decoded as UTF-8, and what
the server sent with the reply waits in the paused socket. A connection
through `connect` counts as local for plain-text AUTH by its host only,
as the peer of a tunnel is the proxy. The ./smtp budget goes to 22.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream greeted every server with EHLO localhost, which a server that
checks the name, such as Postfix with reject_non_fqdn_helo_hostname,
refuses. As nodemailer's _getHostname does, the default is the host name
of this machine when it is a fully qualified domain name, its address as
an address literal, and [127.0.0.1] otherwise. An explicit localName,
which goes into EHLO as it is, is refused when it holds whitespace or a
control character that would end the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream reused a connection for any number of messages, while servers
limit the messages of one connection and answer the next with an error.
As nodemailer's pool does, a connection that has started maxMessages
transactions (100 by default, Infinity for none) is closed with QUIT
instead of going back to the pool, and sendMany takes a new connection
for the rest of its batch, keeping the order of the receipts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from nodemailer's sendmail transport, pipes each
message to the sendmail command of the system (Postfix, Exim, msmtp, …),
one process per message: `<path> -i -f <sender> -- <recipients>`, or
`args` in place of -f. The message is composed by ./mime, so it gets the
DKIM signature, encodings and IDN addresses of SMTP; sendRaw pipes
serialized MIME, and verify finds the command at its path or on PATH.
Line endings are LF by default, and `spawn` is passed in.

Fixed in the port (N5): a process killed by a signal was a success and
the code of `close` went on as an error; only an exit with 0 succeeds,
a signal after the whole message is deliveryUnknown, and the sysexits
codes are classified (75 and 69 retryable, 127 configuration). stderr,
never read there, is read bounded and redacted into the failure; `--`
ends the options before the recipients; blind recipients go on the
command line only, never in a Bcc field for the MTA to strip; an abort
kills the process, and an attachment that cannot be read kills it with
SIGINT so no part is delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkDkimConfig read a name through domainToASCII, a WHATWG host parser
that removes tabs, CR and LF before it reads the name, so
"exa\r\nmple.com" passed as the valid "example.com" while the name, line
break and all, went into the DKIM-Signature field as given, breaking the
header block. A name with a control character, whitespace, "/", "\",
"?", "#" or "%" is now refused before it reaches the parser, for the
signing domain and the selector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and Mailgun

Upstream's decorators forwarded neither sendRaw nor verify, so a
transport that sends raw MIME lost it once wrapped in ./retry, ./pool,
./otel or ./logger, while nodemailer takes raw messages on every
transport. Each decorator now has both: ./retry retries a raw message
as a message and verifies once, ./pool sends one through its transports
that have sendRaw with its strategy and failover (no selector is asked)
and verifies when one of its transports verifies, ./otel traces it in a
span of its own, ./logger logs it by its envelope, and ./mock keeps raw
messages apart. A wrapped transport without sendRaw gives a failure of
the category configuration.

SES sends raw MIME as the Raw content of SendEmail and Mailgun through
messages.mime, each to the envelope of the message, checked line by line
as SMTP checks it. The budgets of SES and Mailgun go up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES, SendGrid, Resend and Mailgun get verify(), as sently's transports
have, each one authenticated read of the API through the shared retry
layer, retried as a read and never taken for a delivery: SES GetAccount,
SendGrid's scopes with mail.send required, Resend's domains and
Mailgun's sending domain. A key known to the provider but not allowed
to read (403, or Resend's restricted_api_key) is verified too, so a key
that may only send is no false negative; a 401 or another status throws
an error with the statusCode of the answer. Mailtrap, Maileroo,
Lettermint and Plunk offer no request that checks a sending key without
sending, and say so in their pages. The SES budget goes to 7.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message goes to Mailpit through a local HTTP proxy with
createHttpProxyConnector, and through ./sendmail into Mailpit's own
sendmail command in its container when MAILPIT_SENDMAIL names it, with
its blind recipient on the envelope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the Postmark drivers of unemail and sently: a
message goes to /email, a batch of up to 500 to /email/batch with the
receipt of each message's own answer, and verify reads the server of the
token. The first tag is Postmark's Tag (a second is refused), metadata
its Metadata, tracking TrackOpens and TrackLinks; List-* fields,
priority and threading go in Headers; an ErrorCode becomes the category
of the receipt (406 rejected, 10 auth, sender signatures configuration).

The providers of 1.1 share src/internal/api.js: their API error, an
authenticated JSON request on requestWithRetry, the receipt of a failure,
attachments in base64 and header fields with names capitalized (moved
to internal/headers.js from ./mime). Fixed in the port: unemail's fetch
followed redirects with the credentials, read bodies whole and retried
after the provider may have taken the message (U4); sently sent an
inline part without `cid:` and parsed JSON before the status (S2). The
shared modules grew, and the budgets of ./smtp, Resend, SendGrid and
Mailgun with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the Brevo drivers of unemail and sently:
/v3/smtp/email with the api-key, scheduledAt as Brevo's scheduledAt,
tags as its tags, metadata in X-Mailin-custom, List-* fields, priority
and threading in its headers, a sandbox switch, and verify reading the
account. Every request carries an idempotency key among the headers of
the message, as Brevo's documentation places it, as a UUID (toUuid,
from unemail), generated when the message has none, so a request whose
delivery is unknown is retried safely; Brevo's duplicate_parameter is
reported as deliveryUnknown. A second reply-to address and an inline
attachment, which Brevo cannot take, are refused rather than dropped.

Fixed in the port (U5): unemail grouped a batch into messageVersions by
the file names and lengths of the attachments, so one recipient could
get another's file, and its batch keys could collide. sendMany sends
single requests, `concurrency` at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from unemail's Mailjet driver: Messages is the
batch, sent in requests of at most 50 recipients as Mailjet counts them,
and a 400 carries the answer of every message, so each gets its own
receipt, those that went succeeding. The first tag is the
CustomCampaign (a second is refused), metadata the EventPayload,
tracking TrackOpens and TrackClicks only with an HTML part, an inline
part an InlinedAttachment; the fields Mailjet sets itself (List-Id,
X-Mailer, …), a second reply-to address, a subject over 255 characters
and a scheduled message are refused before the request. The key pair
goes as UTF-8 Basic authentication; verify reads the profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the MailerSend drivers of unemail and sently:
/v1/email with a Bearer token, the id of a message the X-Message-Id of
the 202 that answers it. scheduledAt is send_at (at most 72 hours
ahead), tags (up to 5) its tags, tracking its settings, metadata
X-Metadata-<key> headers; In-Reply-To, References and List-Unsubscribe
go in the fields MailerSend has for them, which writes the one-click
Post field itself. Its limits (50 to, 10 cc, 10 bcc, one reply-to) are
refused before the request, and a 422 names the fields it refuses.

Fixed in the port (U6): unemail's batch went through the bulk endpoint,
one id for every message and failures unknown to the receipts; sendMany
sends single requests, `concurrency` at a time. sently dropped custom
headers; they are sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from sently's SparkPost transport onto the shared client of the
HTTP providers. The US or EU host comes from `region`; cc and bcc
recipients get the shared To field in `header_to` and the Cc field in
the headers of the content (S3); tags go on each recipient, metadata on
the transmission, tracking and `scheduledAt` in its options; inline
images in `inline_images`; and `sendRaw` sends a UTF-8 MIME message as
`email_rfc822`. Rejected recipients are counted on the receipt, which
fails as `rejected` when none was accepted. The budget is 8 KB: the
raw-message reader of `sendRaw` adds half a kilobyte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's ZeptoMail driver onto the shared client of the
HTTP providers. The Send Mail token gets its `Zoho-enczapikey` scheme
unless it has it; a message's one tag is its `client_reference`, as
ZeptoMail has no tags; metadata goes in `X-Metadata-<key>` headers and
tracking in `track_opens` and `track_clicks`; inline images in
`inline_images`. The codes in the details of an error choose the
category (auth, rate limit, spent credits, malformed request). Its
limits and a scheduled message are refused before a request. A batch
goes as concurrent single requests rather than the batch endpoint,
which sends one message to many recipients with one answer. There is
no `verify()`: a Send Mail token can only send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's SMTP2GO driver onto the shared client of the HTTP
providers. The region chooses the US, EU or AU host, the global one
without it; the reply-to addresses and metadata go in header fields, as
SMTP2GO has no field for them, and tags and tracking, which it has none
for either, are not sent, as over SMTP. `scheduledAt` is `schedule`,
written in the form SMTP2GO documents and held to its three days by an
injected clock. A 200 whose `failed` count or `failures` say the send
failed is a `rejected` receipt; the id is the `email_id`, which the
events name. `verify` reads the summary of the account, a key without
that permission verified too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's Azure Communication Services driver onto the
shared client of the HTTP providers. Each attempt is signed with the
access key (HMAC-SHA256 on node:crypto, checked in the tests against
Web Crypto) at the time it is sent. The credentials come only from the
options, the endpoint is HTTPS or loopback HTTP, and every send carries
its key as the Operation-Id and the Repeatability-Request-Id, with the
time of the first attempt as Repeatability-First-Sent, so Azure answers
a retry with the outcome of the first request; a repeat answered 409
or 412 is `deliveryUnknown` (U7). Metadata goes in `X-Metadata-<key>`
headers; tracking it cannot express, a schedule and a request over
10 MB are refused before a request. `verify` reads an operation that
does not exist. The budget is 8 KB: the signature and the connection
string add almost a kilobyte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's Scaleway driver onto the shared client of the
HTTP providers. The region is checked and encoded as a segment of the
path; the reply-to addresses and metadata go in `additional_headers`,
as Scaleway has no field for them; an inline attachment, which it has
no Content-ID for, is refused rather than sent as a file the HTML cannot
refer to (U8), as are a schedule and a request over 2 MB. An error's
type names the fields of `invalid_arguments`, and a spent quota is a
`configuration` failure. `verify` lists the domains of the project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's REST driver of Cloudflare Email Service onto the
shared client of the HTTP providers, in the shapes of the REST API
rather than those of the Workers binding: `{ address, name }`,
`reply_to` and `content_id` (U9). The receipt's id is the `message_id`
of the answer, not one made of the recipients and the clock; recipients
that bounced or are suppressed are listed on a successful receipt,
which fails as `rejected` only when none was accepted, and a 200 with
`success: false` is a validation failure. `sendRaw` goes through
`send_raw`; `verify` asks Cloudflare whether the token is active, as a
token of the account and then of a user. The decoding of a raw message
as UTF-8, which SparkPost had of its own, moves to `internal/raw.js`.
The budget is 8 KB: the raw-message reader adds half a kilobyte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A converter refuses a message over a limit of its provider, or with a
field the provider cannot express, with a RangeError before any
request. Its receipt was classified by its words, as `unknown`; it is a
non-retryable `validation` failure, as for a message a transport cannot
schedule. `refusalFailure` (`internal/refusal.js`) makes it, for the
shared `providerFailure` and for the ported providers' own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is Resend's `scheduled_at`, read from the message rather
than from an option of the conversion that nothing passed. `metadata`
goes in the tags, which Resend returns in its events, after `tag1`…
of the message's tags; a key or value a tag cannot hold, or a key equal
to one of those names, is refused before the request. A message with
either goes alone, as the batch API takes neither. Tracking is set per
domain, so a message's `tracking` is not sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is `send_at`, at most 72 hours ahead by an injected clock
(`now`), a later one refused before the request; a message's `tracking`
sets its open and click tracking over the configuration's; `metadata`
is `custom_args`, which SendGrid returns in its events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is `o:deliverytime`, an RFC 2822 date; a message's
`tracking` sets `o:tracking-opens` and `o:tracking-clicks` over the
configuration's; `metadata` goes in `v:<key>` variables, which Mailgun
returns in its events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alex-Dolid and others added 21 commits October 18, 2026 12:46
Receipts of the library's own conditions had none and were classified by
the words of their text: an answer without a message id (the shared sentWith,
Mailjet, Postmark, SMTP2GO, SparkPost), a repeat Brevo or Azure took
before, a pool with no error to tell, and a thrown error in the shared
providerFailure, sendmail and SES. The first are unknown, as a delivery of
unknown delivery is; a thrown error is classified by classifyReceiptError,
the codes along its causes first, then its words. A test fails when a call
of createFailedReceipt in src/ gives no category, no status to tell it by,
no options that carry one and no errors of other transports. ./sparkpost
goes to 8.5 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The monotonic default of the rate limit, of the memory store of
./idempotency and of the replay store of ./webhooks, which only the
breaker's had a test of: a jump of Date.now refills no bucket and ends no
lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
classify() of a provider reached the receipt alone, so a retryable 420 of
SparkPost's spent limit was not retried within the send, while its receipt
said it could be. The shared client passes isRetryable to requestWithRetry:
the retryable of the provider's classification when it gives one, the
status otherwise. ./mailgun and ./scaleway go to 8 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g key with it

verify() took a 403 for a known key that may only send, for every provider,
so a key Postmark, Brevo, Mailjet, SendGrid, SMTP2GO or Azure refused with
it passed. acceptForbidden is set where a key that may only send is refused
the read with a 403: SES (AccessDenied for GetAccount), Mailgun (a domain
sending key), SparkPost, MailerSend and Scaleway. The flow no longer goes
through an error marked verified: requestWithRetry takes an accepts(status)
whose answers go to read without being taken for a delivery, and a status
the check does not take fails with it, retried as it says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES was the HTTP provider left with a client of its own. Its SigV4
signature is the authorize of the shared client, made anew for each attempt
so that X-Amz-Date is the attempt's, the 200 SES answers is checked in the
read, and SesApiError extends ProviderApiError, its errors the
providerDetails. The receipts are those of before; the test of the clients
now leaves JMAP alone out. ./ses goes to 9 KB for the shared client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l/abort.js

The check of a caller's abort was written in the shared client, the
decorators (isAbortOf), the pool, SES and sendmail, the test of an
AbortError in http.js, the pool and SES, and the reason of an aborted signal
in sleep.js and retry. Each is one function of internal/abort.js now, which
sleep.js passes on. A module more is a wrapper more in a bundle: ./ses goes
to 9.5 KB, ./resend to 8, ./lettermint to 7.5, ./retry to 3.5,
./idempotency to 4.5 and ./hooks to 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The start of sendRaw() of a decorator (the caller's abort heard, then the
refusal of a raw message its transport sends none of) was written seven
times; it is refuseUnlessRaw of internal/decorator.js. Retry, OpenTelemetry
and the logger verify and dispose through verifyWrapped and disposeOf, as
the decorators of 1.1 do, in place of copies of their own; verifyWrapped
takes no transport, as the logger may have none. ./otel goes to 5.5 KB and
./logger to 3 for the module they share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…throw

The input of a sendMany was read through three helpers, gate.js's
iteratorOf, retry's toAsyncIterator and batch.js's readItems; they are
iteratorOf and readItems of internal/iterate.js, the first the input's own
iterator, whose return() reaches it while a next() is pending, as a
generator would not let it. The pool disposes of its transports through
disposeOf, and the pool, retry and OpenTelemetry tell what was thrown with
messageOf. ./jmap goes to 13.5 KB, ./brevo to 8 and ./mailjet to 9 for the
module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the providers

The providers of 1.1 each wrote an address their way ({ email, name },
{ address, displayName }, { Email, Name }), seven copies of one function,
and six wrote the metadata as X-Metadata-<key> header fields with copies of
one loop. mailboxOf, mailboxesOf and visitMetadataHeaders of
internal/api.js do both; each provider passes its field names, and its
requests are those of before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the ten transports of 1.1 made its config and its client, redacted
its secrets in toJSON() and its inspection, and sent a batch through
sendManyConcurrently, in the same lines. ProviderTransport of
internal/provider-transport.js does it, the id first so that toJSON() keeps
its order; a transport is its send(), its verify(), and the sendMany() of
Postmark, Mailjet and Azure, which batch their own way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts map

The matrix of delivery.test.js listed the providers by hand; a test now
fails when an HTTP provider of the exports map is not in it. The refusal of
a plain HTTP URL and the cause of a failure of fetch, which only the
providers ported from upyo were held to, cover all eighteen, and a contract
of the abort is added: a signal aborted before the send rejects with its
reason and makes no request, and one aborted during it rejects with its
reason too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uld not fit

The batches were read 500 (Postmark) or 50 (Mailjet) messages at a time and
converted whole, their files in base64, before the first request, so a
batch of large files was held in memory however many requests it then made.
Each message is converted as it is read, and a request goes as soon as the
next one would not fit by count or bytes: no more than a request and a
message are held at once. The pages say a batch request has no idempotency
key, and to send messages with large files one by one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir error

A failed message was counted as a failure alone, so a rate limit, a refused
key and a refusal of the provider could not be told apart from the metrics.
mailer.email.messages takes error.type and mailer.error.code, the category
and code of the first error of the receipt (rate-limit, http.429), as the
plan of the review asked of ./otel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d copy

breaker.open named both the change of state, at warn, and every send the
circuit refused, at debug, so counting the records by code counted both; a
refusal is breaker.refused. The tee wrote MAILER_TEE_MIRROR_FAILED, the code
of its process warning, in its records; they are tee.mirror_failed, as the
other decorators name theirs. The records of the breaker half-opening and
closing and of a key in flight are tested, and tsd holds the logger option
of the four decorators and the maxQueue of the rate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify() retried its Mailbox/get and Identity/get through the client with
no onRetry, so those retries were silent, though the type says the JMAP
transport calls it for the reads it retries. The onRetry of its options
reaches them; the read is a private method of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan of the review asked for the length of the input of inlineDataUrls
to be checked before any scan: maxLength (64 Mi characters) refuses longer
HTML with a RangeError. The page says what fromPath checks at each reading
(maxBytes, the file by its device and inode, a file renamed over it
refused), and the advice on a fetch that checks the address it connects to
names one that can be written without undici.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine of them

- Gate, every tenth answered in place: one counter was raised by the
  admissions and the receipts, so nothing was answered in place; they are
  counted apart, and ten of the hundred are.
- Svix and SendGrid timed their signing with their verification; the
  requests are signed once.
- New: an SNS RSA message verified with its certificate kept, fingerprintOf
  of 1 MiB of HTML, inlineDataUrls of 1 MB with twenty images, and 10,000
  sends waiting for one bucket of the rate limit.

bench/baseline.json is taken again with the twenty cases, on Node 24.14.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Getting started has a table of the parts that keep state, what each keeps
in one process and what several share through a store (SC1 of the plan).
CLAUDE.md lists R21 to R41, found by the audit of the review's plan
against the code, records the decision to keep one ./webhooks entry, names
the modules internal/ gained (abort.js, iterate.js, warn.js,
provider-transport.js), and says SES is on the shared client too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upgrading from 1.0: verify() takes a 403 only where a sending key gets one,
and a logger that throws is told once. Added: the bounded queue of the rate
limit, the new log codes, the tee mirroring after the primary, the token of
the replay store, the category and code of a failure in the metrics, and the
maxLength of inlineDataUrls. Fixed: a timeout past what a timer keeps, and
the retries of JMAP's verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…runtime

The plan of the review asked for the lookup of the default allowUrl to be
checked in tests/runtime, which runs on Node, Bun and Deno: a name that
resolves to a private or the metadata address is refused before any
request, and one that resolves to a public address is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eaches

Postmark and Mailjet convert each message as they read it, so nothing calls
convertBatch; and verify's requests take every answer through accepts(), so
its reject is never called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
mailer Ready Ready Preview Oct 18, 2026 2:37pm UTC

Alex-Dolid and others added 3 commits October 18, 2026 16:53
The deadline of every mirror was unref'd, as a detached copy should not
hold the process. An awaited one is what its caller waits for, so a
script whose mirror hung on a promise ended with the send unsettled, and
on Node 22 the suite of ./tee was cancelled from its awaitMirrors test on.
Only a detached copy's deadline is unref'd now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest does

The stub held nothing that kept the process alive, and the certificate
timeout of SNS is an AbortSignal.timeout, unref'd, so Node 22 ended the
test before the timeout fired. A real request holds its socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a relay

The HTTPS server of Bun emits no `connection`, so the count stayed 0
there though the receipt was right. A TCP relay in front of it counts
each connection on Node, Bun and Deno alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Between 1 and 2 MB, linear is 2 and quadratic 4, and a runner of CI
(Windows on Node 22 and 24, Deno) took 3.1 to 3.3 for `<`, a garbage
collection more in the larger run, though the code is linear (2.0 on a
machine at rest). Four times apart, linear is 4 and quadratic 16, and 8
is as far from each; the smaller inputs also hold less for the collector
and halve the time of the test. The quadratic code still fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alex-Dolid
Alex-Dolid merged commit b96ff6e into main Oct 18, 2026
13 checks passed
@Alex-Dolid
Alex-Dolid deleted the feature/v1.1 branch October 18, 2026 14:39

This branch was successfully deployed

1 active deployment
Preview — 5dd05b7e Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant