Skip to content

feat: seven more drivers, and the four core gaps they would have worked around - #143

Merged
productdevbook merged 5 commits into
mainfrom
feat/core-gaps
Sep 2, 2026
Merged

productdevbook merged 5 commits into
mainfrom
feat/core-gaps

Conversation

@productdevbook

Copy link
Copy Markdown
Owner

Closes #135, closes #136, closes #137, closes #138, closes #139, closes #140, closes #141, closes #142.

Seven drivers — 28 in total now — and the four core gaps that building them would each have worked around. The gaps landed first, so none of the seven had to route around the library.

The core, before the drivers

A templated send no longer needs a subject. A template carries its own, and an always-present subject overrides it — which was wrong for Brevo and Postmark, and Loops discarded it outright. NormalizedMessage.subject is optional now, so every driver had to say what it does without one; TypeScript found all twenty-eight sites.

An attachment can be a URL the provider fetches, so a large file never passes through this process. Exactly one of content and url. A driver must claim features.remoteAttachments or the core refuses the message — sending it with the attachment quietly missing is the failure worth preventing.

httpText and httpBytes sit alongside httpJson. The JSON variant parses and discards anything that is not JSON, which made a raw RFC 5322 message and an attachment's bytes unreachable — Mailcrab had to leave /api/message/{id}/raw unexposed for exactly that reason. Both catchers can read them now.

The fourth gap — a nullable EmailResult.id — is deliberately not done. Every id a driver composes today is stable and resolvable by something; a nullable id would push that uncertainty onto every caller to fix a problem three drivers have.

Two more came out of the agents' own reports: toUuid had been copied verbatim into Brevo and Azure, and cancel/retrieve could not be cancelled — which matters because Azure's retrieve is a poll.

Where reading the documentation changed the answer

Two of my own issues were wrong, and the drivers are right:

And what the providers themselves get wrong or bury:

  • Azure's access key is base64 and must be decoded before use as the HMAC key. Raw, it signs every request wrong for a bare 401.
  • AhaSend's own Authentication examples call a route that does not exist. Every one says /v2/messages; the OpenAPI has only /v2/accounts/{account_id}/messages.
  • Scaleway's send_before is a delivery deadline, not a send time. Mapping scheduledAt onto it would send immediately and mean something else, so scheduling is not claimed.
  • Mailbreeze takes attachmentIds from a presigned upload, not inline content — so attachments: false is the honest declaration. Its docs also contradict themselves on the base path and auth header.
  • SMTP2GO keys inline images by the cid, not the filename — it addresses them as cid:<filename>, so the two must be the same value or the reference resolves to nothing.
  • Mailpit stores Message-ID with the brackets stripped and matches with SQL LIKE, and DELETE /api/v1/messages treats an empty id list as delete everything — so delete([]) sends no request at all. Both read out of its Go source, not its docs.

Worth knowing about two of them

Azure is the only driver where retrieve() costs nothing. A send is a long-running operation you poll; everywhere else per-message status is a paid add-on. HMAC-SHA256 over Web Crypto, no @azure/*, so it runs in a Worker. The signature is verified in tests the way DKIM is — re-derived from the spec and checked with crypto.subtle.verify, with discrimination proved against a tampered body, host, verb, date and key.

Mailpit brings htmlCheck(), which scores a message's HTML against real mail clients. A test that asserts a template renders in Outlook is something no other driver here can offer.

Verification

793 tests, 40 files. Coverage rose to 91.46 / 84.16 / 90.66 / 93.43, and the floors go to 91/84/90/93.

They sat under the floor for about an hour, while three drivers' sources were in the tree and their tests were not. I left the floor alone and waited rather than lowering it to fit — lowering a threshold to reach green is not reaching green.

Lint, format, typecheck with isolatedDeclarations, version consistency, export-map parity, JSR dry run, build with every .d.mts present, and bundle budgets all clean.

Noticed, not fixed

  • mailpit.retrieve() ignores the abort signal. The inbox methods have no signal parameter and adding one changes every public signature there; bounded by timeoutMs instead.
  • AhaSend treats one accepted recipient as success, with every per-recipient verdict on result.provider. Result is binary and failing the whole send would make a retry re-send to recipients already queued — but it is a judgement call worth a second opinion.
  • SMTP2GO's schedule format is inferred: the request example is "2025-09-10 13:15:00 +1200" and the search response echoes ISO 8601. The driver sends ISO; if a live send rejects it, it is one line.
  • Neither catcher has been run against a live server. Mailpit is the cheap one to fix — a gated integration test against docker run axllent/mailpit would confirm the JSON field casing taken from its swagger.
  • rejectUnauthorized: false in mailcrab and mailpit for their self-signed local listeners. Correct there, and a standing wart.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb

productdevbook and others added 5 commits September 2, 2026 02:49
Listed in #142 as what building the next batch of drivers would push on.
Doing them per driver would have meant four workarounds, so they land first.

**A templated send no longer needs a subject.** `EmailMessage.subject` is
required unless `template` is set, because the template carries its own and
an always-present subject overrides it — which is almost never what the
caller meant. That was wrong for Brevo and Postmark, and Loops discarded the
subject outright. `NormalizedMessage.subject` is optional now, so every
driver had to say what it does without one; TypeScript found all of them.

**An attachment can be a URL the provider fetches itself**, so a large file
never passes through this process. Exactly one of `content` and `url`,
checked at normalization. A driver has to claim `features.remoteAttachments`
or the core refuses the message — sending it with the attachment quietly
missing is the failure worth preventing. `attachmentToBase64` throws with a
readable reason rather than encoding nothing.

**`httpText` and `httpBytes` sit alongside `httpJson`.** The JSON variant
parses and discards anything that is not JSON, which made a raw RFC 5322
message and an attachment's bytes unreachable — the mailcrab driver had to
leave `/api/message/{id}/raw` unexposed for exactly that reason. All three
share one request path, so classification, timeout, abort, `bodyInit` and
`onResponse` behave identically; only the decoding differs. The text variant
defaults to GET, since that is what reading a resource looks like.

The fourth gap — `EmailResult.id` having no "the provider issued none"
state — is left alone deliberately. Every id a driver composes today is
stable and resolvable by something; a nullable id would push that
uncertainty onto every caller to fix a problem three drivers have.

599 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
Left out when the driver was written because `httpJson` parses and discards
anything that is not JSON, so `/api/message/{id}/raw` and
`/attachment/{index}` were unreachable — that limitation is the reason the
text and byte variants were added to the shared layer.

`raw()` returns the RFC 5322 document exactly as it arrived, which is what
to assert against when the wire format is the point: the parsed view drops
what it could not read. `attachment(id, index)" returns the bytes the
listing only describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
Six of the seven drivers proposed for after 0.6.0, each written against the
provider's own documentation. AhaSend follows.

Reading the docs corrected two of my own issues:

- **#136 was wrong about Mailjet.** A batch where any message fails answers
  **400, not 200** — with the accepted messages' ids still in the body. A
  driver reading the status alone reports every message in the request as
  failed, including ones Mailjet has already sent. And `SandboxMode` is a
  payload *root* property, not a per-message one, so a dry-run message
  cannot share a request with a live one.
- **#137's "chunk at 100 recipients" was wrong for SMTP2GO.** Its send
  endpoint takes one message per request — its own counters class a
  multi-recipient email as one email — so `features.batch` is not declared
  rather than promising a request the provider cannot do. A message over the
  per-field cap is refused rather than split: splitting changes what each
  recipient sees in the header and leaves one message with several ids.

What the providers themselves got wrong or hid:

- Azure's access key is **base64** and must be decoded before it is used as
  the HMAC key. Using it raw signs every request wrong, for a bare 401.
- Scaleway's `send_before` is a delivery *deadline*, not a send time, so
  `scheduling` is not claimed — mapping `scheduledAt` onto it would send
  immediately and mean something else.
- Mailbreeze takes `attachmentIds` from a presigned-upload flow, not inline
  content, so `attachments: false` is the honest declaration. Its docs also
  contradict themselves on the base path and auth header; the driver follows
  the two pages that agree and `endpoint` covers the other.
- SMTP2GO keys its inline images by the **cid, not the filename** — it
  addresses them as `cid:<filename>`, so the two have to be the same value
  or the reference resolves to nothing.

Azure is the only driver here where `retrieve()` costs nothing: a send is a
long-running operation you poll, where everywhere else per-message status is
a paid add-on. It is also the reason `retrieve` now receives an
`AbortSignal` — a poll a caller has given up on should stop.

SMTP2GO is the first driver to accept `Attachment.url`, which is what that
core addition was for.

`toUuid` was duplicated verbatim in brevo and azure; it is shared now.

762 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
The last two of the seven drivers proposed after 0.6.0. Twenty-eight now.

**AhaSend** is here for one property: idempotency is first-class rather than
a header somebody remembered. `Idempotency-Key` replays the stored status
*and* body for 24 hours and says so in a header, which the driver surfaces
on `result.meta`. An in-flight key answers 409 — retryable — and the same
key with a different payload answers 422, which is not.

Two things its own documentation gets wrong. Every Authentication example
calls `/v2/messages`; the OpenAPI has no such route, only
`/v2/accounts/{account_id}/messages`, so `accountId` is required. And its
`recipients` array is not a batch — the spec says it "sends a separate
message for each recipient" — so `features.batch` is not claimed. The real
multi-recipient endpoint is `/messages/conversation`, which the driver
routes to when a message has cc, bcc, or more than one recipient, because
`/messages` has no cc or bcc fields at all.

**Mailpit** is the catcher Laravel Sail and DDEV ship, and MailHog's
maintained successor — MailHog has been abandoned since 2020 with a known
stored XSS, so this is the one to point people at. It carries what the
Mailcrab driver could not: raw messages and attachment bytes, now that the
shared layer reads text and binary, plus full-text search, tags, and
`htmlCheck()` — which scores a message against real mail clients. A test
that asserts a template renders in Outlook is something no other driver here
can offer.

Two behaviours read out of Mailpit's source rather than its docs, because
that is the difference between a driver that works and one that looks right:
it stores `Message-ID` with the brackets stripped and matches with SQL
`LIKE`, so lookup strips them and then compares exactly; and
`DELETE /api/v1/messages` treats an empty id list as "delete everything", so
`delete([])` sends no request at all.

Coverage rose to 91.46/84.16/90.66/93.43 with these two, so the floors go to
91/84/90/93. They sat under the floor for an hour while the sources were in
and the tests were not — raised now that the gain is real rather than
lowered when it was not.

793 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
MIGRATION.md gains a 0.6 → 0.7 section: the optional subject on a templated
send (the one a caller has to act on), attachment-by-URL, the abort signal
on cancel and retrieve, and the text and byte variants in the driver layer.

docs/drivers.md covers all twenty-eight, and the capability matrix is
rebuilt from what the drivers declare — so it says what the core will refuse
rather than what a provider's marketing page claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
@productdevbook
productdevbook merged commit 5b8d040 into main Sep 2, 2026
3 checks passed
@productdevbook
productdevbook deleted the feat/core-gaps branch September 2, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment