Skip to content

mailer v1: the transport layer ported from upyo 0.7.0, with 71 upstream bugs fixed - #2

Merged
Alex-Dolid merged 158 commits into
mainfrom
feature/v1
Oct 17, 2026
Merged

Alex-Dolid merged 158 commits into
mainfrom
feature/v1

Conversation

@Alex-Dolid

Copy link
Copy Markdown
Contributor

Summary

The transport layer of @alexify/mailer, ported file by file from upyo 0.7.0 (MIT, © 2025 Hong Minhee), then hardened: 71 upstream bugs fixed, one fix: commit each with a test that fails without it. Plain CommonJS with hand-written types, Node.js ≥ 22, zero runtime dependencies. The reliability layer (queue, audit log, delivery events) is not in this PR: it is designed, with no API yet.

What's in it

The port: the root entry and 16 subpaths, one per upyo package.

  • Root: createMessage and the message model, the Transport / RawTransport / VerifiableTransport contract, Receipt with classified errors.
  • ./mime (MIME builder, DKIM RSA-SHA256 and Ed25519) and ./smtp (pool, STARTTLS, AUTH PLAIN/LOGIN/XOAUTH2/OAUTHBEARER, PIPELINING, DSN, SMTPUTF8).
  • HTTP providers on fetch: ./ses (SigV4 on node:crypto, no AWS SDK), ./resend, ./sendgrid, ./mailgun, ./mailtrap, ./maileroo, ./lettermint, ./plunk; and ./jmap (RFC 8620/8621).
  • Decorators: ./pool, ./retry, ./otel (the providers passed in), ./logger (any pino-like logger, in place of LogTape), and ./mock.

Fixed after the port. The full list is in CLAUDE.md ("Code ported from upyo"), the user-facing part in CHANGELOG.

  • Never send twice. A failed receipt can carry deliveryUnknown. A failure after SMTP's final dot, after an HTTP 2xx, or on a timeout or reset that may have reached the provider is marked so, and neither ./retry nor ./pool sends it again. The Message-ID is pinned across attempts.
  • Injection and leaks. Address domains held to host-name characters (no ESMTP parameters through RCPT TO), display names quoted, attachments and DKIM settings checked before they reach a header. Credentials kept out of util.inspect and JSON.stringify, addresses redacted in otel and logger output by default. No redirect followed with credentials, HTTPS required for provider URLs (plain HTTP only to loopback), response bodies capped at 4 MiB, MIME boundaries from crypto.randomUUID().
  • Retries and timeouts. One retry policy for every HTTP provider: Retry-After waited for up to 30 s (beyond that the failure goes back to the caller), the timeout covers the body, timeout: 0 and Infinity mean none, an abort keeps the caller's reason.
  • Correctness. Inline parts in multipart/related; lines with encoded words within 76 characters; DKIM signs field instances bottom-up, over-signs From, Reply-To and Subject, and reads PKCS#1 keys; SES sends custom and threading headers; JMAP follows maxObjectsInSet and maxSizeRequest, keys blobs per attachment and trusts only the Session's origin; SMTP sends SNI, bounds replies and resets a pooled connection when it is next taken.
  • OpenTelemetry. Metrics are no longer sampled, each message is counted once, active sends no longer drift, duration buckets are passed on, retries are recorded, sendMany streams.

Added beyond upstream

  • Injected dependencies on every transport: fetch, generateId (a cuid2, ULID or nanoid in place of crypto.randomUUID()), a pino-like logger, and random for the decorators.
  • onRetry in the options of a send, passed through every decorator.
  • concurrency for sendMany of SendGrid, Mailgun, Plunk and Maileroo; sendMany of SES, Resend and JMAP reads a batch at a time.

Docs. Guides for every subpath adapted from upyo, a Known issues page, and the comparison of 14 Node.js email libraries with @alexify/mailer as its first column: tinted in the brand teal and pinned beside the features on screens from 768px (🔜 marks the roadmap, with footnotes).

Tooling. oxlint rules that enforce the code conventions (loops instead of array methods in src/), a size budget per entry (pnpm size --check), a tarball check (check:pack), an attribution-header test for every shipped file, a module-graph test with no cycles, bundle tests through esbuild, opt-in suites against Mailpit and real providers, and a zero-dependency benchmark harness.

Breaking changes against upyo 0.7.0

  • feat(core)!: a failed Receipt may carry deliveryUnknown: true, never retryable.
  • refactor(otel)!: metric and attribute names follow the OpenTelemetry semantic conventions.
  • fix(otel)!: auto, serviceName, serviceVersion and metrics.samplingRate throw a TypeError (they were never read, or skewed the metrics).
  • fix(smtp)!: cram-md5 is gone, and an unknown AUTH method is refused when the transport is made.
  • fix(pool)!: continueOnSuccess: true throws (honored, it would deliver a message twice).
  • validateSsl: false throws a TypeError on the HTTP providers, since fetch cannot honor it.

Not in this PR

  • The reliability layer (an outbox in MongoDB, a BullMQ driver, the audit log, delivery events, suppression, webhooks): design only, see Why @alexify/mailer?.
  • One known issue stays open: on Node 24, a 401 during the upload of a raw JMAP message of unknown size surfaces as a retryable network error (its test is todo on Node 24).
  • No release: the version stays 0.0.1.

Test plan

  • pnpm test: 1940 tests, 0 failures, 1 todo (the JMAP case above)
  • pnpm run test:coverage: 98.73% lines and statements, 94.31% branches, 100% functions
  • pnpm run test:types, pnpm run check:dts
  • pnpm run lint, pnpm run format:check
  • pnpm size --check, pnpm run check:pack (148 files)
  • pnpm docs:comparison leaves no diff; pnpm run docs:build
  • Comparison page checked in the browser: light and dark theme; desktop (column pinned, no seam while scrolling) and 375px (tinted, not pinned, no horizontal page scroll)
  • CI on Node 22 and 24, Linux and Windows
  • pnpm test:mailpit, pnpm test:e2e (opt-in, not run here)

🤖 Generated with Claude Code

Alex-Dolid and others added 30 commits October 14, 2026 20:39
The transport layer is about to be ported from upyo 0.7.0, one subpath per
upstream package. This sets up what every subpath needs before the first one
lands:

- LICENSE carries upyo's MIT notice, and CLAUDE.md the rules of the port
  (attribution header, upstream behavior kept, upstream lint suppressed on
  the line, describe/it kept in ported suites).
- export-parity, bundle, size and check:dts read the exports map, so a
  subpath is checked as soon as it is declared; graph.test.js forbids require
  cycles and cross-subpath requires outside an allow-list.
- src/version.js holds the name and version for the OpenTelemetry scope,
  tested against package.json and bumped by the release process.
- .gitattributes keeps LF on Windows checkouts, where MIME and DKIM tests
  compare bytes; sideEffects is false, and tests may use console.

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

The root entry now exports @upyo/core: createMessage and the Message,
Address and Attachment model, the Transport, RawTransport and
VerifiableTransport contract, Receipt and its error classification,
Message-ID and threading helpers, the iCalendar part and the raw-message
plan. The modules live in src/core/, the types in index.d.ts, next to the
createMailer placeholder.

The code is upstream's, converted to CommonJS: the only changes are the
fallbacks for runtimes without AbortSignal.any or Blob, which Node 22 always
has. Upstream's suites are ported as they are; tests/unit/core.test.js covers
the paths they leave out, and the root size budget goes to 7 KB.

CLAUDE.md describes the ported architecture, revises the decisions on the
transport and the providers, and lists the upstream bugs to fix after the
port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mock exports MockTransport: a transport that keeps what it is sent in
memory, with a configurable response, delay and failure rate, and helpers to
find and wait for messages. It is upstream's code converted to CommonJS, with
its types in src/mock/index.d.ts and its suites ported as they are;
tests/unit/mock.test.js covers the setters, aborts and timeouts they leave
out. The pool suites of a later commit use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mime exports composeMessage, which turns a Message into replayable MIME
bytes with a separate envelope (Bcc stays out of the headers), optionally
DKIM-signed with RSA-SHA256 or Ed25519 in buffered or streaming body mode,
and MimeAttachmentReplayError. The SMTP transport of the next commit builds
on its internal modules.

The one runtime dependency of @upyo/mime, @noble/hashes, becomes
node:crypto's createHash: the incremental SHA-256 of the body hash and of
the replay check. Signing stays on WebCrypto, as upstream.

Upstream reaches most of the MIME builder through the SMTP suites; the new
tests/unit/mime.test.js covers it directly and reads every message back with
postal-mime (a devDependency, as upstream's compose suite uses it). It found
an upstream line-length bug, added to the list in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./smtp exports SmtpTransport (send, sendRaw, sendMany, verify, async
dispose) on node:net and node:tls: a connection pool, implicit TLS and
STARTTLS (required when requireTls is set), AUTH PLAIN, LOGIN, XOAUTH2 and
OAUTHBEARER with token refresh, PIPELINING, SIZE, DSN, SMTPUTF8 and
8BITMIME, dot-stuffing with backpressure, and DKIM through ./mime. Its errors
and receipt helpers come with it; its d.ts re-exports the DKIM types.

Upstream's suites run against the local mock SMTP server; the Mailpit and
OAuth e2e suites move to tests/mailpit and tests/e2e behind the new
test:mailpit and test:e2e scripts, with Mailpit's compose file and the test
certificate (now in tests/fixtures/smtp).

Changes to the tests, not to the transport:
- the mock server no longer turns a client's connection reset into an
  unhandled 'error' event: macOS and Windows send an RST where Linux, which
  upstream's CI runs on, sends a FIN, and three upstream tests fail there;
- the transport suite dials a released local port instead of 1025 (Mailpit's
  port) and 9999;
- a receiver check allows the globalThis of sloppy-mode CommonJS.

tests/unit/smtp.test.js covers what upstream reached only through Mailpit:
broken connections, AUTH LOGIN rejections, a rejected DATA and the DATA
writer's guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./ses exports SesTransport: SES v2 SendEmail over fetch with a SigV4
signature on WebCrypto, static or session credentials, configuration sets
and default tags, retries with exponential backoff for 5xx and network
errors, a request timeout, and sendMany in concurrent batches. Its types
are SesConfig and SesAuthentication.

Upstream's unit suites are ported; the e2e suite (real credentials) goes to
tests/e2e. Its test of a runtime without AbortSignal.any is not ported:
Node 22 always has it, and the core keeps no fallback. tests/unit/ses.test.js
covers what upstream reached only end to end: session credentials, HTTP and
network failures, timeouts, an abort between retries and attachment
defaults. The dead convertMessagesToBulk of upstream is excluded from the
coverage, as listed in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./resend exports ResendTransport: the Resend REST API over fetch, with
attachments, tags, threading and custom headers, an idempotency key per
message, retries and a request timeout, and sendMany through the batch API
(in chunks of 100) when no message has attachments, tags or a calendar
part. ResendApiError stays a type only, as upstream exports it.

Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its
two tests of a runtime without AbortSignal.any are left out (Node 22 always
has it). tests/unit/resend.test.js covers what upstream reached only end to
end: a 200 that is not JSON, the timeout, chunked and failed batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./sendgrid exports SendGridTransport and SendGridApiError: the v3 Mail Send
API over fetch, with attachments, categories, priority headers, click and
open tracking settings, retries and a request timeout; a 202 is a success,
identified by X-Message-Id or a generated id.

Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its
test of a runtime without AbortSignal.any is left out. Upstream checked even
a successful send only end to end; tests/unit/sendgrid.test.js covers it,
sendMany, error bodies, retries, the timeout and an abort between retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mailgun exports MailgunTransport and MailgunApiError: the Messages API
over fetch with multipart form data, US and EU regions, attachments and
inline parts, tags, tracking options, retries and a request timeout.

Upstream's unit suites are ported and the e2e suite goes to tests/e2e.
tests/unit/mailgun.test.js covers an unknown region, custom headers, a 2xx
without JSON and sendMany over an async iterable. The inline bug of
upstream (sent under the filename, not the content id) is kept and listed
in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mailtrap exports MailtrapTransport, createMailtrapConfig and the
MailtrapApiError, MailtrapTimeoutError and MailtrapResponseError classes:
the Email Sending and Sandbox APIs over fetch, the batch API for sendMany,
categories, custom variables, retries on 408, 429 and 5xx with jitter and
Retry-After, a request timeout, and no retry once a 2xx body could not be
read. The User-Agent becomes @alexify/mailer/mailtrap.

Upstream's unit suites are ported and the e2e suite goes to tests/e2e.
tests/unit/mailtrap.test.js covers what they leave out: inline attachments,
id-less and unsuccessful responses, the timeout, Retry-After dates, an
abort while waiting to retry, and batches that fail whole or in part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./maileroo exports MailerooTransport, createMailerooConfig, MailerooApiError
and MailerooTimeoutError: the Maileroo v2 sending API over fetch, with a
reference id per message reused across retries, attachments, tags and
tracking, retries on 408, 429 and 5xx with jitter and Retry-After, and a
request timeout.

Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its
test of a runtime without AbortSignal.any is left out.
tests/unit/maileroo.test.js covers the error bodies and the unsuccessful 200
they leave out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./lettermint exports LettermintTransport, createLettermintConfig,
LettermintApiError and LettermintTimeoutError: the Lettermint send and
batch APIs over fetch, with routes, tags, metadata and tracking settings,
an idempotency key per message, retries on 408, 429 and 5xx with jitter and
Retry-After, and a request timeout.

Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its
test of a runtime without AbortSignal.any is left out.
tests/unit/lettermint.test.js covers a 200 without JSON, the error bodies,
the timeout, an abort while waiting to retry, and a batch with nothing left
to send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./plunk exports PlunkTransport: the Plunk send API over fetch (hosted or
self-hosted through baseUrl), with attachments, retries with exponential
backoff for 5xx and network errors, and a request timeout. Its types are
PlunkConfig, ResolvedPlunkConfig, PlunkResponse and PlunkError.

The base64 fallback for runtimes without btoa is dropped (Node always has
it), like the core's fallbacks. Upstream's unit suites are ported, the e2e
suite goes to tests/e2e, and its test of a runtime without AbortSignal.any
is left out. tests/unit/plunk.test.js covers malformed response bodies, an
unreadable error body, the timeout, a retried 5xx and a dropped attachment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./jmap exports JmapTransport (send, sendRaw, sendMany, verify) for RFC 8620
and 8621 servers over fetch: session discovery with a cache, bearer or basic
authentication, drafts mailbox and identity lookup, blob uploads for
attachments and streamed raw messages, Email/set plus EmailSubmission/set,
and batched sendMany. createJmapConfig, JmapApiError, JMAP_ERROR_TYPES,
isCapabilityError and uploadBlob come with it. Its AbortSignal.any fallback
is dropped, like the core's.

Upstream's unit and local-server suites are ported; its e2e suite against
Stalwart goes to tests/e2e, with the compose file and the setup script in
tests/e2e/jmap. On Node 24, fetch answers a 401 to a streamed body with a
network error instead of the response; the seven tests of that are todo on
Node 24 (they pass on 22), and the bug is listed in CLAUDE.md.
tests/unit/jmap.test.js covers what upstream reached only end to end:
account, mailbox and identity discovery failures, rejected creations and
submissions, a foreign abort and verification of a session without the
submission capability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./retry exports RetryTransport and createRetryTransport: a transport that
wraps another one and retries a failed send with exponential backoff, full
or no jitter and Retry-After, under an attempt limit; a custom shouldRetry
and wait can be passed, a caller's abort is never retried, and sendMany
keeps the input order under a concurrency limit and a launch interval.

Upstream's suite is ported; its iterator accesses split over lines are
rewritten through a variable (no-unexpected-multiline), the behavior is the
same. tests/unit/retry.test.js covers the default wait, the validation of
the options and receipts without a retryable flag.

Every d.ts that uses AsyncDisposable now references esnext.disposable on its
own, and the subpath d.ts files import types only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./pool exports PoolTransport and the RoundRobinStrategy, WeightedStrategy,
PriorityStrategy and SelectorStrategy classes: a transport over several
others that picks one per message by the strategy (or a strategy object of
the application), fails over to another on a failed send up to maxRetries,
and applies an optional per-attempt timeout.

Upstream's suites are ported; they use ./mock. tests/unit/pool.test.js
covers weights that leave nothing to pick, aborts before a send, a timeout
without a caller signal and a dispose that throws. The unused
continueOnSuccess of upstream is kept and listed in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./otel exports OpenTelemetryTransport, createOpenTelemetryTransport,
createEmailAttributeExtractor, createErrorClassifier and
defaultErrorClassifier: a transport that wraps another one with a client
span per send or batch, events and statuses, and counters and histograms of
attempts, messages, durations, sizes and active sends, under sampling rates.

The OpenTelemetry API is passed in, never required: tracerProvider and
meterProvider come from the application (required while tracing or metrics
are on), the fallback to the global providers of @opentelemetry/api is
gone, SpanKind and SpanStatusCode become their numeric values, and the
tracer and meter are named from src/version.js. The d.ts declares the
providers as structural types; a type test checks that the real ones fit.
The upyo.* span attributes become mailer.*, and the metric prefix mailer.

Upstream's suites are ported; the integration suite runs on the real
OpenTelemetry SDK 1.x (devDependencies). tests/unit/otel.test.js covers
sampled-out sends, the transport name and network attributes guessed from
a config, custom batch attributes and failed batches; the dead
recordRetry and extractNetworkAttributes of upstream are excluded from the
coverage.

Also: pool's d.ts now references esnext.disposable itself, and a test keeps
every d.ts that uses AsyncDisposable doing so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./logger exports LoggerTransport: the delivery lifecycle of each message
(email.sending, email.sent, email.failed, with counts, duration, receipt and
errors) logged through a logger the application passes in, around a
wrapped transport or alone (log-only, with synthetic receipts). It is
@upyo/logtape's transport with the logger injected instead of LogTape's
getLogger(category):

- any pino-like logger fits, called as logger[level](properties, message);
  pino's level names (warn, not warning), checked when the transport is made;
- a thrown error goes under err, the key pino serializes;
- the inline mode builds its text itself, as pino interpolates no template;
- the category option is gone (pass logger.child() instead), and the
  log-only receipts are logger-<uuid>.

The pending queue and the sendMany logic are upstream's. Upstream's suite is
ported onto a recording pino-like logger in place of LogTape's recorder;
tests/unit/logger.test.js checks the validation and a real pino logger
(a devDependency), and the type tests check that pino fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The messages (compose, attachments, calendar, MIME) and transports pages of
upyo are adapted for the port: examples in CommonJS on
@alexify/mailer/<subpath>, one install block for the one package, no
Deno, Bun, JSR or edge-runtime claims, and upyo's version notes dropped.
The OpenTelemetry page passes the providers in, and the logger page is new
(a pino-like logger in place of LogTape). Getting Started lists the
transports and decorators, the API page every entry point, and one sidebar
covers the guide, the messages and the transports.

README, llms.txt and CHANGELOG describe the ported transport layer and
credit upyo; CLAUDE.md gets the opt-in test scripts, the platform-dependent
tests, the coverage and lint policies of the port, the docs layout and the
Phase 1 progress, and the npm-publish skill the per-subpath size budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createMailer only threw "createMailer is not implemented yet": it held the
name of the root entry while nothing else was exported. The root entry now
exports the ported message model and transport contract, so the placeholder,
its Mailer type and tests go. It was never released, so nothing depends on
it; the reliability layer gets its own API when it is designed.

The bundle test of the root entry now builds a message through the bundle,
and the README, the guide, the API page, llms.txt, CHANGELOG and CLAUDE.md
no longer mention the placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page still described a library being designed, with tiles of what
it would do and a warning that the package did none of it yet. The tiles
now show what is here: the SMTP client, MIME and DKIM, the eight HTTP
providers and JMAP, the decorators and the zero-dependency design, with one
tile for the reliability layer as the next step. The hero tagline follows,
a Transports action replaces the Why link (the last tile leads there), the
warning becomes a status note crediting upyo, and a short example shows a
message sent through SMTP with retries.

The why page no longer says the transport starts with SES alone, signed on
node:crypto, or that the library starts with few providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isEmailAddress toggled quotes over the whole address and validated the
domain with URL.canParse alone, which reads "@", ":", "/", "?" and "#" as
other parts of a URL. A quote in the domain hid a second "@" from the scan,
so `user@example.com> NOTIFY=NEVER ORCPT=rfc822;a"@x.com` passed createMessage
and the SMTP envelope and was written as
`RCPT TO:<user@example.com> NOTIFY=NEVER ...>`, injecting ESMTP parameters.

Only a quoted local part may now hold an "@", the domain is held to letters,
digits, "-", "." and non-ASCII characters with no empty label, and a domain
literal must be an IPv4 address or an IPv6 one (tagged "IPv6:" or not, no
zone index). As defense in depth, ./smtp re-checks each path right before it
writes MAIL FROM and RCPT TO (checkSmtpPath).

An upstream bug, recorded as the first of the "fixed after the port" list in
CLAUDE.md. The ported test that delivered a sender with a port now expects
the envelope to refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MIME builder and formatAddress wrote a printable display name as it
was, ./ses wrote it in quotes without escaping, and ./mailgun and ./resend
escaped `"` but not `\`. A name such as `Support <attacker@evil.com>, Real`
therefore put a second address into Reply-To, and `x\", eve@evil.example, "`
broke out of the provider's quoted string.

src/core/address.js now formats every mailbox: a name of atoms goes as it
is, anything else (or text that reads as an encoded word) as a quoted
string with `\` and `"` escaped (formatMailbox, formatQuotedMailbox for the
providers whose output was always quoted, formatPhrase for the MIME builder,
which keeps encoded words for non-ASCII names). createMessage refuses a line
break in a name, the formatters a control character, and parseAddress reads
a quoted name holding "<" or "," back, so it inverts formatAddress.

An upstream bug, recorded in CLAUDE.md. The fixes of this phase add code to
the root entry, so its CI size budget goes from 7 to 8 KB min+gzip, which
still catches a bundled third-party module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
composeMessage wrote an attachment's contentType and contentId into the
part headers as given; only createMessage refused a line break in them. A
message built by hand, as one read back from the planned queue would be,
could therefore inject part headers such as `X-Injected: 1`. The MIME
builder already re-checks addresses and custom headers, so it now runs the
same checkAttachment as createMessage. Parameters in a content type, such as
`text/plain; charset=utf-8`, are still written as they are.

An upstream bug, recorded in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SMTP client appended every chunk of a reply to a string and split the
whole of it again, so a server that never ended a line made each chunk
rescan everything received until the socket timeout, and a reply could hold
any number of lines. createReplyReader keeps only the incomplete last line
between chunks and refuses a line over 8192 characters or a reply over 1000
lines with a RangeError, destroying the socket; the reply to DATA was
already bounded at 64 KiB.

Three parsing bugs go with it. A final reply line that is the code alone
("250"), which RFC 5321 section 4.2 allows, was taken for a continuation
and hung until the timeout, in commands, the greeting and after DATA; the
greeting resolved on a line still waiting for its CRLF; and toString() per
chunk replaced a UTF-8 character split across two chunks. The reader
decodes with a StringDecoder and only complete lines count.

Upstream bugs, recorded in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transports keep their configuration, and with it API keys, passwords,
OAuth tokens and DKIM private keys, on plain properties: TypeScript's
`private` is not private at run time. util.inspect(), console.log() and
JSON.stringify() of a transport, or of a ./retry or ./pool around one, so
printed them to any log or error reporter that serialized it.

src/internal/redact.js copies an object with every credential field and
credential header (Authorization, X-API-Key, ...) replaced by [REDACTED],
naming emitters such as sockets rather than walking them and cutting
cycles with a WeakSet. The ten transports that hold credentials return that
copy from toJSON() and inspect it from [util.inspect.custom]; the
configuration itself keeps its values. src/internal/ holds private modules
any subpath may require; it is not in the exports map, so the module graph
test needs no new edge.

The helper adds about 0.5 KB min+gzip to each of those subpaths, so the
budgets of smtp, jmap, mailgun, maileroo, lettermint and plunk go up by
1 KB. Recorded in CLAUDE.md as an upstream bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./otel left error.message out of span attributes unless recordSensitiveData
was on, but the span status, the exception event and the email.send_failed
event carried the raw error text anyway, and ./logger wrote failures with
their raw error text and the provider's raw response. SMTP names the
recipient in its rejections ("RCPT TO failed for bob@example.com: ..."), so
recipient addresses reached telemetry and logs by default, contrary to
SECURITY.md. ./plunk also put the start of the recipient's local part into
its synthetic message IDs.

src/internal/redact-addresses.js replaces every address in a text with
[REDACTED], scanning from each "@" to the separators around it (with a
bounded look for a quoted local part or a domain literal), so it runs in
linear time on any input. ./otel applies it to the status and events unless
recordSensitiveData is on; ./logger applies it to errorMessages, errors and
receipt and leaves providerDetails out unless recordMessage is on. A receipt
returned to the application keeps its full text. SECURITY.md and the otel
and logger guides now say what is redacted and when.

Upstream bugs, recorded in CLAUDE.md. The otel size budget goes from 4 to
5 KB min+gzip for the helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed receipt can now carry `deliveryUnknown: true` (createFailedReceipt
option `deliveryUnknown`): the request or the message data went out but no
answer came back, so the message may have been accepted and sending it
again may deliver it twice. Such a receipt, and the errors made for it, are
never retryable. It is the `delivery_unknown` state of the roadmap's event
model, to be reconciled from delivery events rather than retried.

./retry does not retry it unless shouldRetry says so. ./pool stops failing
over at it and at an error of category validation or rejected, which every
provider would share; upstream failed over on any failed receipt, sending a
refused message to each transport in turn. The pool's own timeout now
produces an unknown delivery: upstream failed over after it, so the slow
transport and the next one could both deliver. Errors of the transport
itself (auth, rate limit, outage) still fail over.

Both decorators pin one Message-ID per message before the first attempt
(pinMessageId, with a WeakMap of copies, so the caller's message is left as
it is): each attempt used to compose a new one, so a duplicate could not be
told apart. The sender-rooted generator moves from the MIME builder to
src/core/message-id.js for that.

BREAKING CHANGE: a timeout of ./pool no longer fails over and its receipt
is no longer retryable; a pool no longer fails over at a rejected message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A connection that closed, timed out or failed after the terminating dot of
DATA went out raised the same error as one before it ("SMTP connection
closed during DATA.", "SMTP DATA timeout."), which classifies as a
retryable network or timeout failure. The server may well have accepted the
message by then, so a retry, by ./retry or by the application, could deliver
it twice.

writeMessageData now wraps any failure after the dot is written in an
SmtpDeliveryUnknownError (with the failure as its cause), and
createSmtpFailure turns it into a receipt with deliveryUnknown, the code
smtp.delivery-unknown and the category of the cause. A failure before the
dot is unchanged and stays retryable. The flat test of a reply over 64 KiB
after the dot now expects it.

An upstream bug, recorded in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
send() and sendMany() went through a client built with replayable requests,
so a network failure or a timeout on the request that creates and submits
the email (Email/set with EmailSubmission/set) retried it, up to `retries`
times: a lost answer could submit the message twice. Only sendRaw() avoided
that.

That request now goes through a submission client with no retries, still
with a string body (a streamed one would turn a 401 into a network error on
Node 24, known bug 14). A failure without an answer from the server marks
the receipt deliveryUnknown, unless the connection was never made: JmapApiError
now keeps the failure as its cause, and src/internal/unsent.js finds a
refused connection, an unknown host or a rejected certificate in that chain,
after which the request certainly never left and stays retryable. The
session, mailbox and identity requests keep their retries.

An upstream bug, recorded in CLAUDE.md; the two ported tests that pinned
the retry now expect a single attempt of unknown delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alex-Dolid and others added 25 commits October 16, 2026 20:44
Nothing called it, upstream or here; it was kept under c8 ignore with the
known upstream bug 10. CLAUDE.md now says that code dead upstream is
removed rather than ignored.

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

send (92 lines) keeps the loop over transports; one attempt, recording a
failed receipt or a throw into one record, is attempt() and recordThrown,
and the receipt errors are appended in loops instead of spread pushes and
maps. The configuration resolves its entries and counts the enabled ones
and the valid weights in one pass instead of a filter, a map and a some.
Behavior is unchanged.

The budget of ./pool goes from 2.5 to 3 KB: it was at 2547 of 2560
bytes, the property names of the record add 76, and round-robin across
batches and the health of Phase 2 come next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getMessagesTo looked through a spread of the three recipient lists for
each message; it checks each list in place. findMessageBy, findMessagesBy
and getMessagesBySubject are loops. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prepareMimeStream (94 lines) hashes the body once in hashBody, signs the
headers in signHeaders, which keeps the signatures made before one that
fails under send-unsigned, and checks a streamed replay in replayBody.
Behavior is unchanged.

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

isProgrammerError, hasRetryableError and getRetryAfterMilliseconds of
./retry are loops; the batch loop of ./otel keeps the messages of a
failed receipt through keepErrorMessages, four blocks deep at most.
Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The JSON parse failures were caught as jsonError and parseError, against
the catch (error) of the code style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
func-style (expressions), no-nested-ternary and max-depth 4 apply
everywhere; in src/, no-restricted-properties refuses the array iteration
methods, the unicorn rules no-array-for-each, no-array-reduce,
no-useless-spread and prefer-set-has apply, and max-lines-per-function
warns over 80 lines; pnpm lint fails on an unused disable directive.
no-console is off only for scripts, bench and the opt-in suites, as
CLAUDE.md says, no longer for every test.

The tests follow: function declarations become expressions, nested
ternaries become ifs or tables, a JMAP fetch stub reads its URL through
tests/helpers/jmap/request-url.js, two unit tests stop logging, and a
property access split over lines goes through a variable. Four helpers of
src/ that captured nothing move to module scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each read its whole input into an array before the first message went
out, so a long or endless iterable was held in memory, or never sent.
chunks() of internal/batch.js reads an iterable, synchronous or not, an
array at a time: SES reads batchSize messages, Resend 100 (the batch API
is now chosen per chunk, not for the whole input), and JMAP batches of
maxObjectsInSet after one discovery, made only once there is a first
message; a failed discovery still fails every message, read as it comes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The providers that send one message per request sent a batch strictly one
after the other. With `concurrency` (1 by default, the behavior so far)
they send that many at once through sendConcurrently of
internal/batch.js, which reads a message only when a send can start,
gives the receipts in the order of the messages, observes a later
failure until its turn, and closes the input when the caller stops. The
option must be a positive integer (checkConcurrency).

The budgets of ./sendgrid, ./mailgun and ./plunk go from 5 to 5.5 KB:
the shared module adds about 0.2 KB, which took SendGrid to 5.1 and the
other two to 4.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the sends in flight counted against maxConcurrent, so while a slow
first message was out, each later one that finished freed its slot for
the next, and the whole input could be sent with its receipts held for
the first; every turn also raced all the sends in flight, adding a
reaction to each. A freed slot is still refilled, as the ported tests
ask, but no more than maxConcurrent finished receipts wait for an
earlier one, and a send that settles wakes the loop (SendManyRun, a
class with short methods in place of a 126-line generator).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendMany reset the strategy, so round-robin sent every batch of one
message through the first transport, and two batches at once reset each
other's rotation. The strategy now goes on from where the sends before
left it; reset() stays on the strategies for the caller's own code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue of messages waiting for a receipt grows only with the read-ahead
of the wrapped transport, which streaming sendMany, concurrency and the
bound of ./retry now keep to a batch at most; no limit of its own is
needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/unit/attribution.test.js reads src/ and the root index.d.ts: each
file starts with the upyo header (the upstream file, or the package
directory for a d.ts) and carries its notice, or starts with "Not ported
from upyo:". src/index.js, src/version.js, src/core/text.js and the root
index.d.ts get headers of that form, and LICENSE must keep the notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The requests JMAP retries are those of discovery, Mailbox/get and
Identity/get; the submission, the session and the uploads are not
retried. executeRequest takes the onRetry of the send, and send(),
sendMany() and sendRaw() pass it through getDraftsMailboxId,
getIdentityId and getIdentityMap, so ./otel, ./logger and the caller hear
those retries as they hear the HTTP providers'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inline parts (known upstream bug 1) went into the multipart/mixed of the
files, so some clients showed an image the HTML refers to by cid: as an
attachment. They go with the body in a multipart/related whose type
names the root part (RFC 2387); with files too, the related part comes
first in a multipart/mixed, as ./jmap already built it. A message without
attachments keeps its structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first encoded word of a header (known upstream bug 13) shared its
line with the field name, up to 84 characters, and other lines folded at
78, past the 76 that RFC 2047 §2 allows a line with an encoded word. A
field with encoded words folds at 76; its first word is split at a whole
UTF-8 character so that the first line keeps text and fits, the parts
decoding as one text (§6.2); a field name too long for any text after it
leaves the first line to itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mailgun makes the file name of an inline part its Content-ID, and the
converter sent the file name (known upstream bug 2), so the cid: of the
HTML matched nothing. The part goes under its contentId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maxSizeRequest of the Session (RFC 8620 §2) was not followed, so a
batch over it, or one message with a long body, was refused whole. A
batch goes in as many requests as the limit takes, grouped in order by
sizes measured once per message (groupBySize), each request failing on
its own; a message whose request alone is over the limit fails with
jmap.request_too_large and is not sent, in a batch or alone.

The budget of ./jmap goes from 11.5 to 12.5 KB: it was at its limit, and
onRetry and this fix add 0.4 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node 24's fetch answers an early 401 to a streamed request body with
"fetch failed" (known upstream bug 14), so an authentication rejection
of the import, the submission or a raw upload became a retryable network
error. Requests go as a buffer: upstream streamed them against a replay
seen in Deno, and Node's fetch does not replay a POST whose connection
drops (checked against Node 24.14.1, undici 7.24.4). A raw message of a
known size up to 4 MiB is read into one buffer after validation; one of
unknown size still has to stream, and its test stays todo on Node 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Custom header fields and the threading ones were dropped (known upstream
bug 10), so a reply lost its place in the thread. SES v2 takes them in
the Headers of Simple content since March 2024: up to 15 fields of up to
996 characters, printable ASCII, without the fields SES sets itself.
They go there with those fields left out and a Latin-1 value in RFC 2047
encoded words; more fields, or a longer one, fail the send before any
request.

The budget of ./ses goes from 6 to 6.5 KB, as it reached 6.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cram-md5 was a method of the SmtpAuth type of upstream (known bug 5) and
failed at the first send, as any method the connection did not know.
It is removed from the type, as the user decided: CRAM-MD5 is obsolete,
its server keeps a password equivalent, and AUTH goes over TLS here
anyway. SmtpTransport now refuses an unknown method, or one of the other
kind of credentials, with a TypeError when it is made (checkAuthMethod).
The docs no longer say a password's method is detected: it is PLAIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ses, resend, sendgrid, mailgun, mailtrap and plunk accepted validateSsl
and never read it (known upstream bug 3), so a caller who set it to
false believed certificates went unchecked. Node's fetch checks every
certificate and has no switch for it without undici, so false now throws
a TypeError that points to a fetch of the caller's own with an undici
dispatcher, as the user decided; true or absent change nothing. The
check sits in internal/validate-ssl.js, so the other providers do not
bundle it.

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

continueOnSuccess was accepted and never read (known upstream bug 7);
honored, it would send a message that went out through one transport
again through the others, against the guard of the pool against double
delivery. As the user decided, it is gone from the PoolConfig and
ResolvedPoolConfig types, and continueOnSuccess: true throws a TypeError
when the pool is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comparison now opens with a column for @alexify/mailer itself, filled
from the code of this branch: the transport as built, the reliability layer
as 🔜 with a footnote to the plan, and deliveryUnknown noted on retry and
failover. custom.css tints the column in the brand teal and, from 768px,
pins it beside the features.

The matrix moves to separate borders, as collapsed ones belong to the table
and let scrolled text show through the seam of a pinned cell, and the
titles of the group rows stick while the table scrolls sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first release: the transport layer ported from upyo 0.7.0, with the
upstream bugs fixed since. The version goes to 1.0.0 in package.json and
src/version.js, CHANGELOG turns [Unreleased] into [1.0.0] with a section
on upgrading from upyo 0.7, and the work-in-progress notes, the package
description, llms.txt, SECURITY.md and the comparison now say what 1.0
holds and that the reliability layer is still being designed.

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

vercel Bot commented Oct 17, 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 17, 2026 1:40pm UTC

SmtpConnection#quit() sends QUIT and closes without waiting for the 221,
so the mock SMTP server's reply to QUIT can meet a closed socket: EPIPE
on Linux, ECONNABORTED on Windows. Only ECONNRESET, what macOS reports,
was taken as the client leaving; the others became an uncaught 'error'
and failed "a pooled connection whose reset fails is replaced" on every
CI job while it passed on macOS. A new test emits each code on a server
socket, so the rule is checked on every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alex-Dolid
Alex-Dolid merged commit 42549a6 into main Oct 17, 2026
11 checks passed
@Alex-Dolid
Alex-Dolid deleted the feature/v1 branch October 17, 2026 13:41

This branch was successfully deployed

1 active deployment
Preview — ae633f00 Deployed Oct 17, 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