Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
143 commits
Select commit Hold shift + click to select a range
dbc44fa
chore: prepare the ports of nodemailer, unemail and sently
Alex-Dolid Oct 17, 2026
83e5752
feat(core): write IDN domains as A-labels
Alex-Dolid Oct 17, 2026
a3ef853
feat(mime): write custom field names capitalized
Alex-Dolid Oct 17, 2026
dcd7061
feat(core): List-* header fields and one-click unsubscription
Alex-Dolid Oct 17, 2026
8508607
feat(mime): choose 7bit, quoted-printable or base64 by content
Alex-Dolid Oct 17, 2026
0568108
feat(core): scheduledAt, tracking and metadata on the message
Alex-Dolid Oct 17, 2026
4ba78ca
feat(attachments): attachments from a path, a URL, a data URI or a st…
Alex-Dolid Oct 17, 2026
800e8b3
test(mailpit): the message features of 1.1 against Mailpit
Alex-Dolid Oct 17, 2026
dbd944d
feat(smtp): an injected connect and an HTTP CONNECT proxy
Alex-Dolid Oct 17, 2026
43946bd
feat(smtp): an FQDN for EHLO by default
Alex-Dolid Oct 17, 2026
8f6f7d1
feat(smtp): maxMessages per pooled connection
Alex-Dolid Oct 17, 2026
7a38dc9
feat(sendmail): a transport over the sendmail command
Alex-Dolid Oct 17, 2026
2a8d19f
fix(mime): refuse a DKIM domain or selector with a tab or line break
Alex-Dolid Oct 17, 2026
82bcc8d
feat: forward sendRaw and verify through every decorator; raw on SES …
Alex-Dolid Oct 17, 2026
77cbc2b
feat: verify() on the HTTP providers that can check a key
Alex-Dolid Oct 17, 2026
1ac2057
test(mailpit): an HTTP CONNECT proxy and sendmail against Mailpit
Alex-Dolid Oct 17, 2026
b0c63c9
feat(postmark): a transport over the Postmark API
Alex-Dolid Oct 17, 2026
343ef24
feat(brevo): a transport over the Brevo API
Alex-Dolid Oct 17, 2026
c8b0913
feat(mailjet): a transport over the Mailjet Send API v3.1
Alex-Dolid Oct 17, 2026
52226e7
feat(mailersend): a transport over the MailerSend API
Alex-Dolid Oct 17, 2026
e22fde6
feat(sparkpost): a transport over the SparkPost Transmissions API
Alex-Dolid Oct 17, 2026
c2b2840
feat(zeptomail): a transport over the ZeptoMail API
Alex-Dolid Oct 17, 2026
05d47f2
feat(smtp2go): a transport over the SMTP2GO API
Alex-Dolid Oct 17, 2026
fe23fb1
feat(azure): a transport over Azure Communication Services Email
Alex-Dolid Oct 17, 2026
fa49506
feat(scaleway): a transport over Scaleway Transactional Email
Alex-Dolid Oct 17, 2026
e3528cd
feat(cloudflare): a transport over the Cloudflare Email Service REST API
Alex-Dolid Oct 17, 2026
4292dce
fix(providers): a refusal before any request is a validation failure
Alex-Dolid Oct 17, 2026
fbfb106
feat(resend): scheduledAt and metadata on Resend
Alex-Dolid Oct 17, 2026
df928d2
feat(sendgrid): scheduledAt, tracking and metadata on SendGrid
Alex-Dolid Oct 17, 2026
80e9329
feat(mailgun): scheduledAt, tracking and metadata on Mailgun
Alex-Dolid Oct 17, 2026
49b9d6c
feat(mailtrap): the metadata of the message on Mailtrap
Alex-Dolid Oct 17, 2026
c55a851
feat(ses): the metadata of the message as SES tags
Alex-Dolid Oct 17, 2026
f5d5752
feat(maileroo): scheduledAt, tracking and metadata on Maileroo
Alex-Dolid Oct 17, 2026
18732c3
feat(lettermint): the metadata and tracking of the message on Lettermint
Alex-Dolid Oct 17, 2026
4b6f19c
feat(breaker): a circuit breaker decorator
Alex-Dolid Oct 17, 2026
79d82d6
feat(ratelimit): a token-bucket rate limit decorator
Alex-Dolid Oct 17, 2026
452d774
feat(idempotency): an idempotency decorator with reserved keys
Alex-Dolid Oct 17, 2026
1491af5
feat(hooks): a decorator that calls hooks around each send
Alex-Dolid Oct 17, 2026
0fc2dbe
feat(tee): a decorator that mirrors each message to other transports
Alex-Dolid Oct 17, 2026
152852e
test: the decorators of 1.1 composed with a pool
Alex-Dolid Oct 17, 2026
0dc23b7
feat(render): render templates into the content of a message
Alex-Dolid Oct 17, 2026
03c8c0e
feat(core): the model of delivery events
Alex-Dolid Oct 17, 2026
620ec98
feat(webhooks): verified events of Amazon SES
Alex-Dolid Oct 17, 2026
7130037
feat(webhooks): verified events of Resend, SendGrid, Mailgun and Post…
Alex-Dolid Oct 17, 2026
1e43ede
feat(webhooks): verified events of MailerSend, Mailtrap, Brevo, Mailj…
Alex-Dolid Oct 17, 2026
dda1c7d
feat(webhooks): verified events of Lettermint, Maileroo, SMTP2GO, Azu…
Alex-Dolid Oct 17, 2026
74161eb
ci: run the package on Bun and Deno
Alex-Dolid Oct 17, 2026
b27abae
fix(smtp): a refused TLS certificate is a configuration failure
Alex-Dolid Oct 17, 2026
66b3a78
docs: 1.1 in the README, the guides, the comparison and the changelog
Alex-Dolid Oct 17, 2026
c37df8f
fix: a refused TLS certificate fails every HTTP transport as configur…
Alex-Dolid Oct 17, 2026
89c5279
chore(release): 1.1.0
Alex-Dolid Oct 17, 2026
4130c3b
fix(webhooks): keep an id as taken once its events are handled
Alex-Dolid Oct 17, 2026
5eb15ec
fix(attachments): bound inlineDataUrls, refuse more ranges and a down…
Alex-Dolid Oct 17, 2026
61848be
fix(render): htmlToText linear in its input
Alex-Dolid Oct 17, 2026
960e543
fix(webhooks): bound SNS certificate reads, check the Azure validatio…
Alex-Dolid Oct 17, 2026
d7272e6
fix(attachments): resolve names in the default check, read the file c…
Alex-Dolid Oct 17, 2026
f4cc191
fix(smtp): proxy credentials not in clear, a deadline for the whole C…
Alex-Dolid Oct 17, 2026
05f62b3
fix(ratelimit): hold maxKeys buckets, stop the waits when disposed of
Alex-Dolid Oct 17, 2026
fe66528
fix(tee): bound each copy, wait for them when disposed of; limit warn…
Alex-Dolid Oct 17, 2026
5477765
fix(idempotency): a lease for a reservation, fenced by a token
Alex-Dolid Oct 17, 2026
00c350d
fix: an unsupported category, which ./pool fails over with
Alex-Dolid Oct 17, 2026
94724b0
feat(webhooks): the Message-ID of an event, and its recipient by domain
Alex-Dolid Oct 17, 2026
70f0145
chore(size): the webhooks budget for the delivery and correlation fields
Alex-Dolid Oct 17, 2026
502c62d
fix(mailtrap): read Retry-After as every other provider does
Alex-Dolid Oct 17, 2026
c9b15b8
fix: settle the messages a batch is left with when its caller stops
Alex-Dolid Oct 17, 2026
947e8a6
test: a generator in the gate test that yields, as lint asks
Alex-Dolid Oct 17, 2026
a348b1c
test: the iterator of the breaker test through a variable, as lint asks
Alex-Dolid Oct 17, 2026
44e9fca
fix(breaker): a probe has a deadline; results count in their period
Alex-Dolid Oct 17, 2026
4a7aeb5
fix(sendmail): a timeout, a bound on the processes, no success unread
Alex-Dolid Oct 17, 2026
729bf6c
feat(otel, logger): tell a failure of unknown delivery apart
Alex-Dolid Oct 17, 2026
7964976
fix(postmark, mailjet): split a batch by its bytes as well
Alex-Dolid Oct 17, 2026
b95da8b
test: the delivery and redaction contracts over every provider of 1.1
Alex-Dolid Oct 17, 2026
1b01155
refactor: check every clock option, keep the loops of pool and retry …
Alex-Dolid Oct 17, 2026
bc6f9bc
feat(pool): verifyAll, how each transport did
Alex-Dolid Oct 17, 2026
705411a
feat(webhooks): onVerify, each request a verifier takes or refuses
Alex-Dolid Oct 17, 2026
9af06b7
feat: a logger for the breaker, the rate limit, idempotency and the tee
Alex-Dolid Oct 17, 2026
5778caf
feat(ratelimit): a store of buckets that processes share
Alex-Dolid Oct 18, 2026
afb6d81
perf: answers in place no longer split the batches of the wrapped sen…
Alex-Dolid Oct 18, 2026
7bc002c
chore(bench): the hot paths of 1.1, and a baseline taken after them
Alex-Dolid Oct 18, 2026
683bc88
refactor: one place for what the decorators forward to their transport
Alex-Dolid Oct 18, 2026
3d9f346
refactor: the send, sendMany and verify the providers of 1.1 share
Alex-Dolid Oct 18, 2026
45ebc5a
refactor: keep verifyByGet and sendManyConcurrently with what they use
Alex-Dolid Oct 18, 2026
8d337ba
fix(hooks): a signal for the hooks, and what beforeSend may return
Alex-Dolid Oct 18, 2026
d58c203
fix: measure the intervals of breaker, rate limit and stores on a mon…
Alex-Dolid Oct 18, 2026
ff65ca7
fix(webhooks): compare credentials by digest, keep ids hashed, refuse…
Alex-Dolid Oct 18, 2026
53a4f12
fix(core): bound the keys and the values of metadata
Alex-Dolid Oct 18, 2026
65772d4
docs: what the EHLO name tells recipients, and when tee starts its mi…
Alex-Dolid Oct 18, 2026
068467d
perf(pool): make the entries of sendRaw once
Alex-Dolid Oct 18, 2026
391ffef
perf(webhooks): send the ids of a batch to the replay store together
Alex-Dolid Oct 18, 2026
711755b
docs(contributing): list the gates prepublishOnly runs, kept by a test
Alex-Dolid Oct 18, 2026
ff568e1
docs(claude): the policy of the size budgets
Alex-Dolid Oct 18, 2026
c2f7211
test(e2e): the providers of 1.1 against their real APIs
Alex-Dolid Oct 18, 2026
5cac9b3
docs: the changelog of 1.1 with what its review added, verifyAll in t…
Alex-Dolid Oct 18, 2026
a11c2cd
docs(comparison): the shared store of the rate limit, webhooks taken …
Alex-Dolid Oct 18, 2026
0576e08
fix(webhooks): one rule for the mapping of events, pinned for every p…
Alex-Dolid Oct 18, 2026
3c896d7
test(sendmail): a send that waits for a place leaves the queue when a…
Alex-Dolid Oct 18, 2026
c2fa302
refactor(resend): the client on createApiClient, ResendApiError over …
Alex-Dolid Oct 18, 2026
f959b10
refactor(sendgrid): the client on createApiClient, SendGridApiError o…
Alex-Dolid Oct 18, 2026
838bd10
refactor(mailgun): the client on createApiClient, MailgunApiError ove…
Alex-Dolid Oct 18, 2026
c84d282
refactor(maileroo): the client on createApiClient, its errors over th…
Alex-Dolid Oct 18, 2026
ecfa38d
refactor(lettermint): the client on createApiClient, its errors over …
Alex-Dolid Oct 18, 2026
d914114
refactor(plunk): the client on createApiClient, PlunkApiError over th…
Alex-Dolid Oct 18, 2026
2edf46d
refactor(mailtrap): the client on createApiClient, its errors over th…
Alex-Dolid Oct 18, 2026
c079382
docs: the HTTP clients of the upyo providers on the shared one
Alex-Dolid Oct 18, 2026
58c1574
fix(gate): a message the wrapped sendMany never took is settled as un…
Alex-Dolid Oct 18, 2026
64bc5cd
fix(breaker): a clock that throws gives the last time it gave
Alex-Dolid Oct 18, 2026
a8da325
fix(ratelimit): full buckets dropped first, a bounded queue, the abor…
Alex-Dolid Oct 18, 2026
edac8b8
fix(sendmail): a process that ignores the SIGINT of a broken message …
Alex-Dolid Oct 18, 2026
28710ec
fix(tee): the mirrors start once the primary has answered
Alex-Dolid Oct 18, 2026
fa38834
fix(hooks): a message a beforeSend returns is checked as createMessag…
Alex-Dolid Oct 18, 2026
cdadd4a
fix(webhooks): SNS certificates cached by their one spelling, once ve…
Alex-Dolid Oct 18, 2026
880a9aa
fix(webhooks): Scaleway anchors are CAs valid when trusted; the rule …
Alex-Dolid Oct 18, 2026
e04fd1d
fix(webhooks): no store where no id is kept, no password in an Azure URL
Alex-Dolid Oct 18, 2026
f0be2ed
fix(webhooks): the replay store fences a reservation by the token of …
Alex-Dolid Oct 18, 2026
c1e5aea
fix(webhooks): find a message of unknown delivery by the correlationI…
Alex-Dolid Oct 18, 2026
85749fd
test(webhooks): the guide's handler on node:http, and the window of t…
Alex-Dolid Oct 18, 2026
4909985
fix: a timeout past what a timer keeps is a long one, not one of 1 ms
Alex-Dolid Oct 18, 2026
8867886
fix: a logger that throws is told once, and keys reach the logs redacted
Alex-Dolid Oct 18, 2026
7e60117
fix: every failed receipt of the library names its category
Alex-Dolid Oct 18, 2026
d5340d8
test: no interval of a default clock moves with the time of day
Alex-Dolid Oct 18, 2026
069289a
fix(api): the retry loop takes what a provider classifies as retryable
Alex-Dolid Oct 18, 2026
e71d2d0
fix(verify): a 403 verifies only for a provider that refuses a sendin…
Alex-Dolid Oct 18, 2026
8f1d01d
refactor(ses): the client on createApiClient, SigV4 in its authorize
Alex-Dolid Oct 18, 2026
bf963c5
refactor: one isCallerAbort, isAbortError and abortReason, in interna…
Alex-Dolid Oct 18, 2026
fc4a5ba
refactor: the decorators start sendRaw, verify and dispose in one way
Alex-Dolid Oct 18, 2026
6708edb
refactor: one way to read the input of a sendMany, and the text of a …
Alex-Dolid Oct 18, 2026
f3ffb67
refactor: one writer of an address and of metadata header fields for …
Alex-Dolid Oct 18, 2026
bf80e26
refactor: the transports of the providers of 1.1 on one base
Alex-Dolid Oct 18, 2026
a7b4212
test: the contracts of delivery over every HTTP provider of the expor…
Alex-Dolid Oct 18, 2026
6df3a6b
fix(postmark, mailjet): a request goes as soon as the next message wo…
Alex-Dolid Oct 18, 2026
209a090
feat(otel): count failed messages by the category and the code of the…
Alex-Dolid Oct 18, 2026
7cd92ae
fix(breaker, tee): a code of their own for a refused send and a faile…
Alex-Dolid Oct 18, 2026
b0b3faa
fix(jmap): verify tells onRetry of the reads it retries
Alex-Dolid Oct 18, 2026
ce0c1de
fix(attachments): inlineDataUrls bounds its HTML before it reads it
Alex-Dolid Oct 18, 2026
4791022
chore(bench): the cases the plan of the review asked for, and a basel…
Alex-Dolid Oct 18, 2026
b6dad98
docs: what several processes share, the bugs the audit of the plan found
Alex-Dolid Oct 18, 2026
a983baa
docs(changelog): what the audit of the plan changed in 1.1
Alex-Dolid Oct 18, 2026
ad710c1
test(runtime): the default check of fromUrl resolves a name on every …
Alex-Dolid Oct 18, 2026
a2d2229
refactor: drop convertBatch and the reject of verify, which nothing r…
Alex-Dolid Oct 18, 2026
ce804cd
fix(tee): the deadline of an awaited mirror keeps the process alive
Alex-Dolid Oct 18, 2026
28ddf0b
test(webhooks): the hanging certificate fetch holds a handle, as a re…
Alex-Dolid Oct 18, 2026
404a702
test(runtime): count the attempts at an untrusted certificate through…
Alex-Dolid Oct 18, 2026
5dd05b7
test(render): time htmlToText at 256 KB and 1 MB, four times apart
Alex-Dolid Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 14 additions & 2 deletions .claude/skills/npm-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,24 @@ pnpm install --frozen-lockfile # install deps exactly as locked
pnpm run lint # oxlint
pnpm run format:check # oxfmt
pnpm run test:coverage # tests under the c8 coverage gate
pnpm run test:runtime # tests/runtime: sockets, TLS, fetch and spawn, no mock
pnpm run test:types # tsd
pnpm run check:dts # tsc over the d.ts of every entry of the exports map
pnpm size --check # the budgets of scripts/size-budgets.json
pnpm run check:pack # the tarball: every file of the exports map, nothing else
```

These are the same gates `prepublishOnly` runs. CI adds two more; run them too:
These are the same gates `prepublishOnly` runs. CI adds three more; run them too:

```bash
pnpm docs:comparison && git diff --exit-code docs/guide/comparison.md # the table matches docs/comparison.json
pnpm run docs:build # the docs site; a dead link fails it
pnpm run test:bun && pnpm run test:deno # the unit and runtime suites on Bun and Deno
```

The last needs the versions of Bun and Deno that `.github/workflows/ci.yml` pins; without them
installed, say so and rely on CI.

Then in parallel: `pnpm outdated` and `pnpm audit` (report to user; devDependencies only, the package has no runtime dependencies).

If any gate fails, stop and fix issues before continuing.
Expand Down Expand Up @@ -183,11 +188,18 @@ Report the file count and the bundle size from `pnpm run size` to the user.
Run the gates one more time after all edits:

```bash
pnpm run lint && pnpm run format:check && pnpm run test:coverage && pnpm run test:types && pnpm run check:dts && pnpm size --check && pnpm run check:pack && pnpm run docs:build
pnpm run lint && pnpm run format:check && pnpm run test:coverage && pnpm run test:runtime && pnpm run test:types && pnpm run check:dts && pnpm size --check && pnpm run check:pack && pnpm run docs:build
```

Must exit 0. If it fails, fix and re-run.

Then ask the user whether to run the opt-in suites against the real services, which only they
can: `pnpm test:e2e` with the sandbox credentials of the providers (each suite is skipped until
its variables are set; `tests/e2e/providers.e2e.test.js` names those of the providers of 1.1),
and `pnpm test:mailpit` against the local Mailpit. A release that changed a provider's mapping or
its error handling should have run that provider's suite. Report which suites ran and which were
skipped.

## Step 8: Summary & Next Steps

Present a summary to the user:
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
# Sockets, TLS, fetch and child processes of the runtime, with no mock;
# the same files run on Bun and Deno below.
- run: pnpm run test:runtime

# Bun and Deno run the unit and runtime suites through their own node:test.
# The versions are pinned, as the docs name the ones tested; engines stays
# Node >= 22, and Node installs the devDependencies the tests import.
runtimes:
name: test (${{ matrix.runtime }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
runtime: [bun, deno]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.3
- if: matrix.runtime == 'deno'
uses: denoland/setup-deno@v2
with:
deno-version: 2.9.6
- run: pnpm run test:${{ matrix.runtime }}

coverage:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -123,7 +155,7 @@ jobs:
ci-success:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [lint, types, test, coverage, docs]
needs: [lint, types, test, runtimes, coverage, docs]
if: always()
steps:
- name: Fail when any needed job failed, was cancelled or was skipped
Expand Down
192 changes: 191 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,195 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2026-10-18

The features of three libraries of the [comparison](https://mailerat.vercel.app/guide/comparison)
where they led this package, ported onto its contracts:
[nodemailer](https://github.com/nodemailer/nodemailer) 10.0.16 (MIT-0, © 2011-2023 Andris
Reinman), [unemail](https://github.com/productdevbook/unemail) 0.7.0 (MIT, © 2025 Wind) and
[sently](https://github.com/omqkhafi/sently) 1.2.2 (MIT, © 2026 sendx contributors). What is
ported is the feature, the mapping of a provider's fields, an algorithm or a test case; the HTTP
layer, the retries and the receipts are this package's. The known bugs of the sources are fixed in
the port, each with a test that fails on the source's behavior, and listed in `CLAUDE.md`. The
release adds sendmail, ten HTTP providers, attachments from paths and URLs, templates, verified
webhooks into one model of delivery events, five decorators, and Bun and Deno in CI. Everything is
additive but the defaults listed below.

### Upgrading from 1.0

- **EHLO names the machine:** the default is its host name when that is a fully qualified domain
name, its address otherwise (`[127.0.0.1]` at worst), no longer `localhost`, which servers such
as Postfix with `reject_non_fqdn_helo_hostname` refuse. Set `localName` to choose it.
- **A pooled SMTP connection carries at most 100 messages** (`maxMessages`), then is closed with
`QUIT` and replaced; `Infinity` keeps the old behavior.
- **Bodies are encoded by their content:** 7bit for short ASCII lines, quoted-printable for mostly
Latin text, base64 otherwise (Cyrillic, CJK), always with CRLF line breaks. Every body was
quoted-printable.
- **Custom header fields are written capitalized** (`X-Custom-Header`, `List-Unsubscribe-Post`)
rather than in lower case. Field names are case-insensitive, so readers and DKIM see no
difference.
- **The default DKIM `h=` signs `List-Unsubscribe` and `List-Unsubscribe-Post`** too, as
RFC 8058 asks.
- **Failures are classified more precisely:** a message a transport cannot send as it is (a
provider's limit, a field it cannot express, `scheduledAt` where nothing schedules, a message
over the SMTP server's `SIZE`) is a non-retryable failure of the new category `unsupported`,
where it was `unknown`, `validation` or `rejected`, and `./pool` fails over with it to a
transport that can send it; and a TLS certificate refused in the handshake is a non-retryable
`configuration` failure at the first attempt, where SMTP reported it as `unknown` and the HTTP
transports retried it and reported a retryable `network` failure (`deliveryUnknown` on Deno).
- **The HTTP providers of 1.0 share one client:** SES, Resend, SendGrid, Mailgun, Maileroo,
Lettermint, Plunk and Mailtrap request through the client of the providers of 1.1. Their errors
(`SendGridApiError`, `MailtrapTimeoutError`…) keep their names, arguments and fields, and carry
`provider`, `category` and `retryable` besides; an error answer whose body cannot be read keeps
its status; and a message Mailgun cannot express is an `unsupported` failure, as elsewhere.
- **`verify()` takes a 403 only where a key that may only send gets one:** SES, Mailgun and, of
1.1, SparkPost, MailerSend and Scaleway. A 403 of SendGrid or Resend fails `verify()`, where it
passed it.
- **A logger that throws is told once:** the first failure of the logger of a transport or
decorator is a process warning (`MAILER_LOGGER_FAILED`) by the name and code of its error, not
every record with its text, which could name a recipient.
- **On Deno, give the HTTP transports a `fetch` that keeps no idle connection**
(`Deno.createHttpClient({ poolMaxIdlePerHost: 0 })`): Deno's own sends a request again when a
reused connection closes before its answer. See
[known issues](https://mailerat.vercel.app/guide/known-issues#deno).

### Added

- **Internationalized domains as A-labels:** `user@bücher.de` goes out as
`user@xn--bcher-kva.de` in header fields, SMTP envelopes, JMAP and every provider, so it needs
no SMTPUTF8; a local part beyond ASCII still does. The `Message` keeps the address as given.
- **`list` on the message:** 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. Formatted as nodemailer does; a URL with
whitespace, controls or angle brackets is refused, and one-click needs an `https` URL.
- **`scheduledAt`, `tracking` and `metadata` on the message,** from unemail's model: when the
provider should deliver it, open and click tracking for this message, and values the provider
returns in its events. A transport that cannot schedule refuses the message with an
`unsupported` receipt rather than sending it at once, and `./pool` fails over with it. Resend,
SendGrid, Mailgun, Maileroo, Brevo, MailerSend, SparkPost and SMTP2GO schedule; ten providers
take `tracking`, seventeen `metadata` (each page says how).
- **`./attachments`:** `fromPath`, `fromUrl`, `fromDataUri`, `fromStream` and `inlineDataUrls`,
for what nodemailer read through `path`, `href` and data URIs. Nothing is read implicitly:
`fromUrl` goes through an injected `fetch`, checks each redirect with `allowUrl` (by default the
host as written: no `localhost`, and no IP literal of a loopback, private, link-local,
multicast, reserved or documentation range, NAT64 and 6to4 included, as written or as a name
resolves), refuses a redirect from https to http, and bounds time and bytes; `fromPath` can be
held to a `root` by real path and to `maxBytes`, and reads the file it checked;
`inlineDataUrls` refuses HTML longer than `maxLength` before it reads it.
- **`./render`:** `renderContent(renderer, input)` for the content of `createMessage`,
`createReactEmailRenderer({ render })` with the `render` of `@react-email/render` passed in, and
`htmlToText`, linear in the length of its input.
- **`./smtp` connects through what you give it:** `connect` opens each connection (a proxy, a
tunnel, a socket of another module) with implicit TLS or STARTTLS over it;
`createHttpProxyConnector` makes one for an HTTP or HTTPS proxy, whose credentials go in clear
to an `http://` proxy only on this machine or with `allowPlaintextCredentials`, under one
deadline for the CONNECT; `localAddress` picks the address to connect from.
- **`./sendmail`:** the sendmail command of the system (Postfix, Exim, msmtp), one process per
message, composed by `./mime` with DKIM; `sendRaw` and `verify`. Only an exit with 0 after the
whole message succeeds, a signal after it is `deliveryUnknown`, and the sysexits codes are
classified; a process has a `timeout` (60 seconds, then SIGTERM and SIGKILL) and at most
`maxConcurrent` (10) run at once.
- **Raw messages and `verify()` through every decorator:** `./pool`, `./retry`, `./otel`,
`./logger` and `./mock` pass `sendRaw` and `verify` on. `./ses` and `./mailgun` send raw MIME,
and `./ses`, `./sendgrid`, `./resend` and `./mailgun` verify their key, one that may only send
included.
- **Ten HTTP providers:** `./postmark` (with `/email/batch`), `./brevo` (an idempotency key on
every request), `./mailjet` (the Send API v3.1 batch), `./mailersend`, `./sparkpost` (US or EU,
raw MIME), `./zeptomail`, `./smtp2go` (US, EU or AU), `./azure` (Azure Communication Services,
signed with the access key, repeatable requests), `./scaleway` and `./cloudflare` (the REST API
of Cloudflare Email Service, raw MIME). Each goes through the request layer of the others: no
redirect followed, a bounded body, `deliveryUnknown` when the provider may have taken the
message. The batches of Postmark and Mailjet are cut by their bytes as well as their count.
- **`./breaker`:** a circuit breaker that opens after `failureThreshold` failures of the
provider, answers with a retryable `service-unavailable` receipt while open (which `./pool`
fails over with), and lets exactly one probe through after `resetTimeoutMs`, for at most
`probeTimeoutMs`.
- **`./ratelimit`:** a token bucket per `key`, waiting first in, first out, or refusing with a
retryable `rate-limit` receipt; an aborted send leaves the queue, and a cost over `burst` is
refused. At most `maxKeys` buckets are kept in the process, the full ones dropped first, or a
`store` of buckets that processes share holds them (`take(key, cost, limits)`); at most
`maxQueue` messages wait in a bucket.
- **`./idempotency`:** a key reserved in a store before the send, so a repeat is answered with
the first receipt, a send in flight as of unknown delivery, and the same key for another
message refused. A reservation lasts `leaseMs` (10 minutes), so a key a crash left reserved is
free again, and its token fences it: only its owner completes or releases it; the receipt is
kept `ttlMs` (a day). `createMemoryIdempotencyStore` is the in-process store; a shared one
implements `reserve`, `complete` and `release` with the token.
- **`./hooks`:** `beforeSend` may replace or refuse a message, `afterSend`, `onRetry` and
`onError` hear each send, with a context made for each that carries its signal.
- **A `logger` for `./breaker`, `./ratelimit`, `./idempotency` and `./tee`,** pino-like, with a
code for each event (`breaker.open`, `breaker.refused`, `ratelimit.waiting`,
`ratelimit.waited`, `idempotency.in_flight`, `tee.mirror_failed`…).
- **`./tee`:** mirrors each message to other transports once the primary has answered it, with
one Message-ID, each copy with a timeout of its own and not awaited by default (with
`awaitMirrors`, awaited until that timeout at most, which keeps the process alive for it), the
primary's receipt the one returned; disposal waits for the copies in flight.
- **Delivery events and `./webhooks`:** the `DeliveryEvent` model at the root, one event per
message and recipient, and a verifier per provider for SES, Resend, SendGrid, Mailgun,
Postmark, MailerSend, Mailtrap, Brevo, Mailjet, SparkPost, Lettermint, Maileroo, SMTP2GO, Azure
and Scaleway. Nothing is read before it is verified: a signature where the provider signs (SNS
with a topic allowlist, Svix, ECDSA, HMAC), its Basic credentials or token where it does not,
within a window of time and once by its id. `verify()` reserves the id for `leaseMs` and gives
the events with `commit()` and `release()`: an id is kept as taken once the application has
handled its events, so a delivery whose handler failed is taken again, and another delivery of
it meanwhile is refused as `pending`. `replayStore` is required where a verifier keeps ids
(`createMemoryReplayStore()` in one process, a shared store of `reserve`, `commit` and
`release` under the token of each delivery, or `null`), and a refusal carries the `status` to
answer the provider with.
`onVerify` hears each request taken or refused, and an event carries the `correlationId` of the
message's metadata, given back by every provider but Azure, to find the events of a message of
unknown delivery, and the `rfcMessageId` the provider reports.
- **`./pool` `verifyAll()`:** how each transport did, `verified`, `failed` or `unverifiable`,
where `verify()` passes when one does.
- **Failures stand out in the metrics:** `mailer.email.messages` of `./otel` takes the category
and the code of a failure (`error.type`, `mailer.error.code`).
- **A failure of unknown delivery stands out:** `./otel` sets `mailer.delivery_unknown` on its
span and its message counter, and `./logger` writes `deliveryUnknown` in `email.failed`, for
the messages to reconcile.
- **Bun and Deno:** CI runs the unit suite and the new `tests/runtime` (the package by its name,
with no mock: sockets, TLS, DKIM, `fetch`, a child process) on Bun 1.4.3 and Deno 2.9.6, as
well as on Node 22 and 24. `package.json` declares `"type": "commonjs"`, which Deno needs and
Node assumes.
- **Documentation** for every new subpath, a guide to webhooks and events, Bun and Deno in
getting started, and the comparison updated.

### Security

- **An attachment reads no file or URL it was not given by the application** (nodemailer read
any `path` or `href`, following redirects to any address, cloud metadata included), and a data
URI that does not parse is refused rather than read as a path.
- **A List-Unsubscribe URL or a metadata entry cannot add a header field:** a line break,
whitespace or angle bracket in a URL is refused, and a metadata key is held to
`[A-Za-z0-9_.-]`, its value to a string without control characters, with at most 64 keys and
1024 characters to a value.
- **A DKIM signing domain or selector with a tab, CR or LF is refused** before it reaches the
host parser, which removed them and let the name, line break and all, into the
`DKIM-Signature` field (the fix of 1.0 missed it).
- **The webhooks of a provider are verified before they are read,** where sently parsed SES,
SendGrid, Postmark and Brevo events unverified and checked Resend and Mailgun with no window
of time or replay check. Basic credentials and tokens are compared by an HMAC of each, so the
time of a refusal tells nothing of their length; a replay store is given the provider and a
SHA-256 of each id, never an id that names a recipient; and a verifier whose provider signs no
time refuses `toleranceMs` and `now` rather than ignore them.

### Fixed

- **A TLS certificate refused in the handshake was misclassified everywhere:** `unknown` in an
SMTP receipt, a retried and retryable `network` failure on the HTTP transports (their receipts
read the words `fetch failed`), and `deliveryUnknown` on Deno, whose `fetch` gives it no code;
`classifyReceiptError` also missed `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, what Node and Bun report
for an untrusted CA. It is now a non-retryable `configuration` failure at the first attempt, on
every transport and runtime.
- **A message refused by a provider's limits before any request was `unknown`;** it is a
non-retryable `unsupported` failure, which `./pool` fails over with, as a message no transport
could send (`validation`, `rejected`) stops it.
- **A pooled SMTP connection was reused for any number of messages,** while servers limit the
messages of one connection (`maxMessages`, above).
- **A timeout over 2^31 - 1 ms fired at once:** `timeout: 3e9` timed out every request of an
HTTP provider, and an SMTP socket at once; such a timeout is now the longest a timer keeps, in
effect none.
- **`verify()` of `./jmap` told `onRetry` of none of the reads it retried;** it tells it of each.

## [1.0.0] - 2026-10-17

The first release: the transport layer, ported from [upyo](https://github.com/dahlia/upyo) 0.7.0
Expand Down Expand Up @@ -427,5 +616,6 @@ for code written against upyo 0.7:
`./resend`, `./sendgrid`, `./mailgun`, `./plunk` and `./jmap` used to retry no 4xx at all, and
waited without jitter, the first three without a cap.

[unreleased]: https://github.com/Alexis-Technologies/mailer/compare/v1.0.0...HEAD
[unreleased]: https://github.com/Alexis-Technologies/mailer/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/Alexis-Technologies/mailer/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/Alexis-Technologies/mailer/releases/tag/v1.0.0
Loading
Loading