Repository navigation
feat: seven more drivers, and the four core gaps they would have worked around - #143
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.subjectis 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
contentandurl. A driver must claimfeatures.remoteAttachmentsor the core refuses the message — sending it with the attachment quietly missing is the failure worth preventing.httpTextandhttpBytessit alongsidehttpJson. 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}/rawunexposed 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:
toUuidhad been copied verbatim into Brevo and Azure, andcancel/retrievecould not be cancelled — which matters because Azure'sretrieveis a poll.Where reading the documentation changed the answer
Two of my own issues were wrong, and the drivers are right:
SandboxModeis a payload root property, so a dry-run message cannot share a request with a live one.features.batchis not declared rather than promising a request the provider cannot do. And a message over the per-field cap is refused, not split: splitting changes what each recipient sees in the header and leaves one message with several provider ids.And what the providers themselves get wrong or bury:
/v2/messages; the OpenAPI has only/v2/accounts/{account_id}/messages.send_beforeis a delivery deadline, not a send time. MappingscheduledAtonto it would send immediately and mean something else, soschedulingis not claimed.attachmentIdsfrom a presigned upload, not inline content — soattachments: falseis the honest declaration. Its docs also contradict themselves on the base path and auth header.cid:<filename>, so the two must be the same value or the reference resolves to nothing.Message-IDwith the brackets stripped and matches with SQLLIKE, andDELETE /api/v1/messagestreats an empty id list as delete everything — sodelete([])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 withcrypto.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.mtspresent, 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 bytimeoutMsinstead.result.provider.Resultis 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.scheduleformat 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.docker run axllent/mailpitwould confirm the JSON field casing taken from its swagger.rejectUnauthorized: falseinmailcrabandmailpitfor their self-signed local listeners. Correct there, and a standing wart.🤖 Generated with Claude Code
https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb