Skip to content

Latest commit

 

History

History
769 lines (587 loc) · 35.1 KB

File metadata and controls

769 lines (587 loc) · 35.1 KB

Drivers

Resend — unemail/drivers/resend

resend({ apiKey: process.env.RESEND_API_KEY!, endpoint?, fetch?, timeoutMs? })

Native batch (/emails/batch), scheduling, cancel(), retrieve(), and provider-side idempotency via the Idempotency-Key header when the message carries an idempotencyKey. A batch presents one key derived from its messages' keys — stable for the same batch, so a retry is recognised as a repeat rather than duplicating every message in it.

Batches are chunked at Resend's cap of 100 messages per request. Resend's batch endpoint does not accept attachments, so a batch carrying one is sent message by message instead; the caller sees no difference.

Resend has no metadata field, so message.metadata is sent as X-Metadata-* headers — which is what comes back on its webhook events.

The key is checked for its re_ prefix at construction.

Postmark — unemail/drivers/postmark

postmark({ token, messageStream?, endpoint?, fetch?, timeoutMs? })

Pass the per-server token, not the account token.

Postmark reports per-message failures inside a 200 batch response; those become individual failed results rather than a failed batch. Batches are chunked at its cap of 500.

Its Tag is a single string with no value, so the first tag's name goes there and every tag — the first included — is also carried as Metadata.

Templated and plain messages use different endpoints and cannot be mixed in one batch — a mixed batch fails with INVALID_OPTIONS before any request is made.

Route with message.stream, which overrides the driver's messageStream.

Amazon SES — unemail/drivers/ses

ses({ region, accessKeyId?, secretAccessKey?, sessionToken?,
      configurationSetName?, fromArn?, endpoint?, fetch? })

No @aws-sdk/*: SigV4 is signed with Web Crypto and the message is posted as raw MIME, so attachments and inline images work and the driver runs in a Worker. Credentials fall back to AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN.

SES reads recipients off the envelope, which is where bcc lives — blind recipients reach the provider without appearing in the document.

__type is read for classification, so an expired token is AUTH and throttling is a retryable RATE_LIMIT rather than a generic 400.

SES v2 has no raw-MIME bulk endpoint, so there is no sendBatch; the core sends sequentially.

SMTP — unemail/drivers/smtp

smtp({
  host, port?, secure?, requireTLS?,
  user?, password?, authMethod?, getAccessToken?,
  rejectUnauthorized?, tls?, localName?,
  pool?, maxConnections?, maxMessagesPerConnection?, idleTimeoutMs?,
  connectionTimeoutMs?, commandTimeoutMs?, disposeGraceMs?,
  dkim?,
})

Its own protocol implementation — no nodemailer, no transitive dependencies. port defaults to 465 when secure, 587 otherwise. rejectUnauthorized defaults to true.

AUTO picks the strongest method the server advertises (XOAUTH2, CRAM-MD5, PLAIN, LOGIN).

With pool: true connections are reused; one that fails mid-transaction is discarded rather than returned in an unknown protocol state.

EHLO announces the machine's hostname when it is fully qualified, and localhost.localdomain otherwise. Override it with localName.

DKIM signs the assembled document, and accepts an RSA key in either PKCS8 (BEGIN PRIVATE KEY) or PKCS1 (BEGIN RSA PRIVATE KEY, what openssl genrsa writes); Ed25519 must be PKCS8. Pass a function to select a key per message for multi-tenant sending:

smtp({ host, dkim: (msg) => keyFor(msg.from.email.split("@")[1]!) })

message.raw bypasses the MIME builder entirely; the envelope is still taken from the message's addresses.

Needs node:net and node:tls — this is the one driver that does not run in a Worker.

Every API driver takes timeoutMs (default 30_000) and forwards the instance's AbortSignal, so a cancelled request is cancelled in flight. Lower it behind a user-facing handler: the retry middleware needs control back before the caller's own request times out.

Mailtrap — unemail/drivers/mailtrap

mailtrap({ apiKey, endpoint?, fetch?, timeoutMs?, defaultCategory?, userAgent?,
           sandbox?, inboxId?, sandboxEndpoint?, bulk?, bulkEndpoint? })

Three hosts, one driver. The Email API delivers transactional mail; bulk routes to bulk.api.mailtrap.io, which Mailtrap keeps separate so a newsletter cannot damage the reputation carrying your password resets; and the Email Sandbox captures into an inbox and needs inboxId in the request path. So message.sandbox — or the driver-level sandbox — chooses the whole endpoint rather than setting a field, and a sandbox send with no inboxId is refused before a request is made. This is not the same thing as SendGrid's or Mailgun's test flags, which stay on the production API.

A batch cannot mix sandbox and live messages; that is refused rather than half-sent. Batches chunk at Mailtrap's cap of 500 per request, and a recipient list past its 1000-address limit is refused before the call.

Mailtrap groups by category and rejects a message without one, so a tag named category supplies it and defaultCategory (default general) covers the rest. Other tags travel as custom_variables alongside message.metadata.

It also reports failures inside a 200 response as success: false with an errors array; those become failed results, not silent successes.

Cloudflare Email Routing — unemail/drivers/cloudflare-email

cloudflareEmail({ binding, EmailMessage? })

The send_email binding from your wrangler.toml. It takes a raw RFC 5322 document, which the shared MIME builder produces — so attachments and inline images work as they do over SMTP, with no network call of our own.

The legacy EmailMessage(from, to, raw) constructor takes a single envelope recipient, so a message with more than one to, or any cc/bcc, is refused — sending only to the first would look like a delivery that worked. For several recipients, or for anything new, use the Email Service driver below; Cloudflare keeps this API for compatibility only.

EmailMessage lives in the cloudflare:email virtual module, which this package cannot import and still run on Node, so pass it in:

import { EmailMessage } from "cloudflare:email"
cloudflareEmail({ binding: env.SEND_EMAIL, EmailMessage })

Cloudflare Email Service — unemail/drivers/cloudflare-email-service

cloudflareEmailService({ binding })

The newer Email Sending API on the same send_email binding, taking structured fields rather than raw MIME — so this driver needs no ambient global, no virtual module and no MIME builder. Both Cloudflare drivers ship; pick the one matching the API your binding speaks.

Needs a sender domain onboarded with wrangler email sending enable <domain>.

Limits enforced before the call: 50 recipients across to/cc/bcc, and 32 attachments. The binding also caps a message at 5 MiB, which only it can measure.

The binding throws errors carrying an E_* code, and each is mapped onto the shared taxonomy — E_RATE_LIMIT_EXCEEDED becomes a retryable RATE_LIMIT, E_SENDER_NOT_VERIFIED a permanent AUTH. Without that, retry would keep re-sending messages that only a configuration fix cures.

Cloudflare Email Service over HTTP — unemail/drivers/cloudflare-email-rest

cloudflareEmailRest({ accountId, apiToken, endpoint?, fetch?, timeoutMs? })

The same service as the binding above, reached over the REST API with an account token — so it runs on Node, Deno, Bun or anywhere else with fetch, not only inside a Worker. Inside a Worker, prefer the binding: one less hop and no token to manage.

Two differences worth knowing. Cloudflare uses numeric error codes here rather than the binding's E_* strings, and they are mapped to the same taxonomy. And the response reports the outcome per recipient rather than returning a message id, so a send that was permanently bounced for some addresses and delivered to others is reported as a failure naming them — calling that a success would hide addresses that will never receive it.

SendGrid — unemail/drivers/sendgrid

sendgrid({ apiKey, endpoint?, fetch?, timeoutMs?, onBehalfOf?, ipPoolName?,
           asm?, sandbox?, batchIdForScheduled? })

Batching is SendGrid's personalizations: messages sharing an envelope go in one request. A run is split when it reaches 1000 personalizations, when to+cc+bcc across it would pass 1000 recipients, or when an address would repeat within one request — SendGrid rejects that outright, and two mails to the same person is an easy way to hit it.

The id lives only in the X-Message-Id response header, on a 202 with an empty body.

Scheduling is made cancellable: send_at alone cannot be cancelled, so a scheduled send reserves a batch_id and reports that as the result id, which cancel() then acts on. Opt out with batchIdForScheduled: false.

A template id starting with d- takes the dynamic path (dynamic_template_data); anything else is a legacy template and its variables are stringified into substitutions. There are no template aliases at SendGrid, so template.alias is refused rather than ignored.

Enforced before the wire: ≤10 categories of ≤255 characters (duplicates deduped), reserved headers refused by name, scheduledAt within 72 hours. The EU host is https://api.eu.sendgrid.com, via endpoint.

Mailgun — unemail/drivers/mailgun

mailgun({ apiKey, domain, region?, endpoint?, fetch?, timeoutMs?, sandbox?, ipPool? })

region: "eu" switches to api.eu.mailgun.net.

The Messages API is multipart, not JSON — it takes files — so this driver hands the shared HTTP layer a FormData verbatim.

Messages that differ only in recipient are merged into one request with recipient-variables; without it Mailgun sends one message addressed to everybody and each recipient sees the whole To list. A message with cc, bcc, several recipients or an attachment is sent alone, because batch sending fans out on to only and merging those would change who receives what. Chunked at 1000 recipients.

Tags: ≤3, ≤128 characters. The response id has its angle brackets stripped, because the events API will not accept them back.

cancel() and retrieve() are not declared. Mailgun's only cancel drops the whole domain queue, and retrieval needs a storage key from an event rather than the send id.

Brevo — unemail/drivers/brevo

brevo({ apiKey, endpoint?, fetch?, timeoutMs?, batchId? })

Batches go out as messageVersions, split twice: at 1000 versions and at 2000 recipients across them, so 25 messages of 99 recipients become 20 + 5. A version can only override to/cc/bcc/replyTo/subject/body/params — sender, attachments, headers, tags, template and schedule belong to the request — so a batch disagreeing on any of those falls back to one request per message rather than quietly applying the first message's attachment to everyone.

Brevo's idempotency header is idempotencyKey, and it accepts only a UUID. A UUID-shaped key passes through; anything else is hashed into a v4-shaped one, stable for the same key. Without that the feature is unusable from here, since this library's own convention is welcome:1.

Sandbox is the X-Sib-Sandbox: drop header rather than a separate endpoint. Metadata and tag values ride in X-Mailin-custom, the header Brevo echoes to webhooks — tags itself is a bare string array, so values would otherwise vanish.

cancel() and retrieve() are supported.

MailerSend — unemail/drivers/mailersend

mailersend({ apiKey, endpoint?, fetch?, timeoutMs?, precedenceBulk? })

The id arrives only in the x-message-id header, on a 202 with no body.

Enforced before the request: to ≤50, cc ≤10, bcc ≤10, ≤5 tags, scheduledAt within 72 hours, bulk chunked at 500. In-Reply-To, References and List-Unsubscribe are lifted out of headers into MailerSend's dedicated fields.

retrieve() falls back from /v1/messages/{id} to /v1/bulk-email/{id} on a 404, so the id sendBatch hands back is actually resolvable.

The bulk endpoint caps at 5 rather than 500 on trial plans, and the driver cannot tell which plan a key belongs to.

Loops — unemail/drivers/loops

loops({ apiKey, transactionalId?, addToAudience?, endpoint?, fetch?, timeoutMs? })

Loops has no free-form body: a send is a stored transactionalId plus dataVariables. So features declares html: false, text: false, and a message carrying text or html is refused with UNSUPPORTED pointing at template.variables — the 0.5 driver sent it as an empty template, and the recipient got nothing while the call reported success.

Also refused, before the request: no transactional id, more than one recipient, any cc/bcc/replyTo, an idempotency key over 100 characters.

dataVariables merges metadata, then tag name/value pairs, then template.variables. Loops' own template can reference those in its From, Reply-To, Cc, Bcc and Subject, which is the only per-send way to set them.

ZeptoMail — unemail/drivers/zeptomail

zeptomail({ token, endpoint?, bounceAddress?, clientReference?,
            trackClicks?, trackOpens?, fetch?, timeoutMs? })

The Zoho-enczapikey prefix is added for you, and tolerated if you already pasted it in.

Its batch endpoint is one message fanned to many recipients with per- recipient merge_info, not N distinct messages — so messages are grouped by everything the batch cannot vary, and a message with cc or bcc stays off the batch path entirely, because the fan-out would copy those recipients once per to entry.

Chunking counts addresses, not messages: the documented cap of 500 is addresses, so 300 two-recipient messages split 500 + 100. A message-count splitter would sail past it.

Error classification reads error.details[].code, which says far more than the status. Note that LE_101/LE_102 — credits exhausted — are mapped non-retryable rather than RATE_LIMIT: waiting does not bring credits back.

Enforced before the request: ≤500 addresses per field, ≤60 attachments, subject ≤500 characters.

No regional hosts are hard-coded. Zoho's documentation names exactly one API host; the .eu/.in pattern appears only in unofficial SDKs, and guessing one would mean POSTing a live key at a host that may not exist. Use endpoint if yours differs.

MailChannels — unemail/drivers/mailchannels

mailchannels({ apiKey, endpoint?, async?, dkim?, campaignId?, envelopeFrom?,
               transactional?, trackingDomain?, unsubscribeDomain?,
               fetch?, timeoutMs? })

apiKey is required. The free unauthenticated Cloudflare Workers integration was terminated on 30 June 2024; MailChannels is a paid product and an unauthenticated request is rejected. A bad or wrongly-scoped key answers 403, not 401.

Its one-request batch is the standout: messages are grouped by shared body, each becomes a personalization, chunked at 1000. Outcomes are read by each result's own index rather than by position, and a personalization with no outcome fails loudly instead of borrowing its neighbour's id.

DKIM is per personalization and may be a function of the message, so multi-tenant sending works inside one batch request:

mailchannels({ apiKey, dkim: (msg) => keyFor(msg.from.email.split("@")[1]!) })

message.sandbox maps to the dry-run mode, which returns the rendered document instead of sending it. Templates are inline Mustache only — there is no stored-template system, so template.id/alias is refused with a reason and template.variables drives the Mustache path.

Mailcrab — unemail/drivers/mailcrab

mailcrab({ host?, port?, httpPort?, httpEndpoint?, prefix?, secure?, ... })

A local catcher for development: correct SMTP defaults (localhost:1025, plain, no auth) plus an inbox you can read back.

docker run --rm -p 1080:1080 -p 1025:1025 marlonb/mailcrab

getInstance() returns the inbox over Mailcrab's HTTP API on 1080 — list, get, find, last, byMessageId, delete, clear, version — every method returning a Result and none throwing. retrieve(id) takes either Mailcrab's UUID or the Message-ID that send() returned.

Mailcrab parses only From and To, so find() also matches the envelope recipients — the only place a cc address appears. MAILCRAB_PREFIX nests every route, which is what prefix moves.

ENABLE_TLS_AUTH gives implicit TLS with a self-signed certificate, so secure maps to implicit TLS with verification off. That is correct for a local catcher and wrong anywhere else.

HTTP — unemail/drivers/http

http({ endpoint, method?, auth?, headers?, body?, extractId?, classify?,
       features?, name?, fetch?, timeoutMs? })

For an API this library has no driver for: an internal gateway, a self-hosted relay, a webhook-shaped endpoint. Ten lines and an unsupported provider gets retry, rate limiting and the circuit breaker working properly:

http({
  endpoint: "https://mail.internal/send",
  auth: { type: "bearer", token: process.env.GATEWAY_TOKEN! },
  body: (msg) => ({ to: msg.to.map((a) => a.email), subject: msg.subject, html: msg.html }),
  extractId: (body) => (body as { id: string }).id,
  classify: (status) => (status === 409 ? { code: "RATE_LIMIT", retryable: true } : null),
})

classify is the one worth using: without it every non-2xx is judged by status alone, so a gateway that signals throttling with a 409 looks permanent and retry gives up.

No sendBatch. An unknown endpoint gives no way to know which of N messages an answer refers to, and a wrong mapping is worse than N requests — the core then sends sequentially and positionality is exact by construction.

features is unset by default, because the driver cannot know what an arbitrary gateway supports. Declare it and the core's early refusal starts working for you.

Tee — unemail/drivers/tee

tee([primary, ...mirrors], { onSecondaryError?, name? })

Sends the same message through several drivers at once — shadowing a new provider against the incumbent, or mirroring production mail into a local catcher.

tee([resend({ apiKey }), mailcrab()])

The first driver is the primary and its result is the send's result. A mirror failing never fails the send — that is the whole difference from fallback. But it is not swallowed either: failures are appended to ctx.meta.tee, which the core copies onto both EmailResult.meta and EmailError.meta, and handed to onSecondaryError. A tee whose mirror failures are invisible is useless for the thing it exists to do.

Legs run concurrently and all are awaited. Concurrently because a shadow only measures anything under the same conditions as the primary; awaited because a dropped promise never finishes on a runtime that freezes the isolate at the response, and because the meta snapshot is taken the moment the pipeline returns. The cost is that a send takes the slowest leg's time — bound it with that leg's own timeoutMs.

Mailjet — unemail/drivers/mailjet

mailjet({ apiKeyPublic, apiKeyPrivate, endpoint?, fetch?, timeoutMs? })

Its Messages array is the batch — batching is the shape of the API rather than a separate endpoint — and the response carries a status per message and per recipient, which is exactly what the positional sendBatch contract wants. Nothing has to be inferred.

SandboxMode maps to message.sandbox, and a per-message failure arriving inside a 200 becomes that message's failure rather than the batch's.

Scaleway Transactional Email — unemail/drivers/scaleway

scaleway({ secretKey, projectId, region?, endpoint?, fetch?, timeoutMs? })

European residency without a marketing suite attached. region is in the path — fr-par by default — and project_id is required by the API, so it is a driver option. The API is v1alpha1, which is worth knowing: an alpha API can move.

One limit is enforced and the rest deliberately are not. The 2 MB cap on the whole email is the only TEM quota whose maximum equals its default and cannot be raised, so it is checked before the request. The 10-attachment and 10-recipient caps are upgradable per account, and refusing locally would block a customer whose quota was raised. The MIME whitelist is Scaleway's to change, and a false rejection is unrecoverable.

Two provider-shape error rules: quotas_exceeded arrives as a 403, which would read as AUTH, and is reclassified RATE_LIMIT non-retryable — a monthly quota does not clear by waiting. And invalid_arguments has its details[] folded into the message, which otherwise says only "invalid argument(s)".

Scaleway bills one email object per recipient, so a three-recipient send answers with three: EmailResult.id is the first and provider carries them all.

scheduling is not claimed. Scaleway's send_before is a delivery deadline, not a send time — mapping scheduledAt onto it would send immediately and mean something else entirely.

Mailbreeze — unemail/drivers/mailbreeze

mailbreeze({ apiKey, endpoint?, fetch?, timeoutMs? })

The key must be sk_live_… or sk_test_…, checked at construction, because the sandbox is a property of the key rather than the request. So message.sandbox on a live key is refused with a reason rather than ignored — believing a live send was a test is the failure worth preventing — and the response's sandbox flag is normalized to a boolean on EmailResult.provider, so a live send reads false rather than undefined.

attachments is not claimed. Mailbreeze takes attachmentIds from a three-step presigned-upload flow, not inline content; there is no wire format for an attachment this library could produce, so the core refuses one with UNSUPPORTED instead of dropping it.

Its documentation contradicts itself on the base path and the auth header — /v1/emails with x-api-key in the quickstart, /api/v1/emails with a bearer token in the reference samples. The driver follows the former, which two pages agree on; endpoint covers the other if it turns out to be live.

Azure Communication Services — unemail/drivers/azure-communication

azureCommunication({ connectionString })
azureCommunication({ endpoint, accessKey })

Takes the connection string the portal gives you, or the two halves separately.

Authentication is an HMAC-SHA256 signature over {VERB}\n{path+query}\n{x-ms-date};{host};{x-ms-content-sha256}, signed with Web Crypto — no @azure/* package, so it runs in a Worker. The access key is base64 and must be decoded before it is used as the HMAC key; using it raw signs every request wrong and Azure answers with a bare 401.

This is the only driver where retrieve() costs nothing. A send is a long-running operation: it answers 202 with an operation id, and retrieve(id) polls it. Everywhere else, per-message status is a paid add-on. retrieve receives the instance's AbortSignal, because a poll a caller has given up on should stop.

Operation-Id is a caller-supplied idempotency handle, so message.idempotencyKey maps onto it — hashed into a UUID when it is not already one, since Azure insists.

Two things to know. Succeeded means accepted for delivery, not delivered, so it maps to sent rather than delivered; real delivery arrives over Event Grid, which this library has no channel for. And senderAddress is a bare string in the current schema, so from.name has nowhere to go — the sender display name is configured on the sender in the portal.

The 10 MB request cap is checked on the serialized body before signing, so an oversized request costs no HMAC and no round trip.

SMTP2GO — unemail/drivers/smtp2go

smtp2go({ apiKey, region?, endpoint?, fetch?, timeoutMs?, fastAccept? })

region picks {region}-api.smtp2go.com (us, eu, au); leaving it unset uses the global host, which routes by the DNS resolver's location rather than by where the account's data lives.

It answers 200 even when the send failed, with the reason in failures and a count in failed. A driver reading the status alone reports every send as a success. Handled the way Postmark's per-message errors are. fastAccept trades that report for speed: succeeded, failed and failures are absent entirely, so nothing can be checked.

The first driver to accept an attachment by URL. features.remoteAttachments is declared, and { filename, mimetype, url } goes on the wire instead of base64 — so a large file never passes through this process.

Inline images are keyed by the cid, not the filename: SMTP2GO addresses them as cid:<filename>, so the content id the caller wrote into the HTML has to be the filename the provider sees, or the reference resolves to nothing.

features.batch is not declared. /email/send takes one message per request — its own counters class "an email with multiple recipients" as one email — so claiming a batch would promise a request the provider cannot do. The core's sequential path already gives positional results.

A message with more than 100 recipients in a field is refused, not split: splitting one message across requests changes what each recipient sees in the header, and leaves one message with several provider ids and no honest way to pick one.

cancel() and retrieve() work on scheduled sends, reported by schedule_id — the only handle cancel accepts. A scheduled send you cannot cancel is a footgun.

Rejected keys arrive as a 400, not a 401, so the classification reads data.error_code rather than the status.

Mailpit — unemail/drivers/mailpit

mailpit({ host?, port?, httpPort?, httpEndpoint?, ... })

The catcher Laravel Sail and DDEV ship, and the maintained successor to MailHog — which has been abandoned since 2020 and carries a known stored XSS in its web UI, so this is the one to point people at.

docker run --rm -p 8025:8025 -p 1025:1025 axllent/mailpit

Same shape as mailcrab: the smtp driver with the right defaults (localhost:1025, plain, no auth), plus an inbox over the HTTP API on 8025, returned by getInstance(). Every method returns a Result; none throws.

Beyond what Mailcrab offers: full-text search with Mailpit's own to: from: subject: tag: is: has: before: after: prefixes; tags, which the driver makes assertable end to end by writing X-Tags onto the message before the SMTP hop, because that is the header Mailpit reads them from; the raw message and attachment bytes, reachable now that the shared HTTP layer carries text and binary; and htmlCheck(id), 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.

Two behaviours taken from Mailpit's source rather than its docs. It stores the Message-ID with the angle brackets stripped and matches it with SQL LIKE, so byMessageId() strips them too and then compares exactly, because search returns a superset. And DELETE /api/v1/messages treats an absent or empty id list as "delete everything" — so delete([]) sends no request at all.

AhaSend — unemail/drivers/ahasend

ahasend({ apiKey, accountId, endpoint?, fetch?, timeoutMs? })

Every route is account-scoped — POST /v2/accounts/{account_id}/messages — so accountId is required. Its own Authentication page's examples call /v2/messages with no account segment; the OpenAPI has no such route, and the driver follows the OpenAPI.

Idempotency is genuinely first-class here, which is rare enough to be the reason to pick it. Idempotency-Key replays the stored status and body for 24 hours with an Idempotent-Replayed header, which the driver surfaces on result.meta.idempotentReplayed. An in-flight key answers 409 with Retry-After — retryable — and the same key with a different payload answers 422, which is not. A 5xx releases the key.

Two send endpoints, and which one you get depends on the message. /messages has no cc or bcc at all and fans a recipient list out into one message each; /messages/conversation is the only one that puts several addresses into a real To/Cc/Bcc header. The driver routes to conversation when the message needs it — any cc, any bcc, or more than one recipient — and to /messages otherwise, where the two behave identically.

features.batch is not claimed: the recipients array is not a batch, it is a fan-out, and there is no batch route. A send answers 202 with one verdict per recipient, so a partial failure is possible even for a single message; one accepted recipient counts as success and every verdict is left on result.provider.

content_id keeps its angle brackets, or the file arrives as a download instead of rendering inline.

Mock — unemail/drivers/mock

mock({ fail?, failWhen?, latencyMs?, inbox? })

getInstance() returns the inbox: messages, find(address), last(), clear(). Messages are stored normalized, so assertions see what a real driver would have seen.

Fallback — unemail/drivers/fallback

fallback(drivers, { shouldFailover?, onFailover?, name? })

Per message, not per batch: only the messages a leg failed reach the next one. INVALID_OPTIONS and UNSUPPORTED do not fail over — the next provider would reject them too. Legs initialize lazily as they are reached.

Round-robin — unemail/drivers/round-robin

roundRobin(drivers, { weights?, name? })

Spreads sends to stay under each provider's limit, or to warm a second sending domain. A batch is partitioned and each partition goes to its driver in one request, so native batching survives the split. It does not fail over — put it behind fallback if you need that.

Capability matrix

Read it at runtime rather than hard-coding it:

if (email.driver.features?.scheduling) await email.send({ ...msg, scheduledAt })

The core reads it too. A message asking for something the driver has said it cannot do — a template on a driver without templates, a scheduledAt on one without scheduling, an attachments[].url on one that cannot fetch it — comes back UNSUPPORTED rather than being sent without the part that mattered. Only that message fails; the rest of a batch goes out. A driver that declares no features at all is not second-guessed.

batch scheduling templates tracking tagging sandbox idempotency url attach cancel retrieve Worker
resend
postmark
ses
smtp
sendgrid
mailgun
mailjet
brevo
mailersend
smtp2go
mailtrap
zeptomail
mailchannels
scaleway
azure-communication
ahasend
mailbreeze
loops
cloudflare-email
cloudflare-email-service
cloudflare-email-rest
mailcrab
mailpit
http you declare it
mock

Most drivers support attachments, html, text, reply-to and custom headers. The exceptions are where the provider genuinely has no such field: loops has no free-form body, mailchannels has no stored templates, and mailbreeze takes attachments only through a presigned upload this library cannot express.

"Worker" means it needs nothing beyond fetch and Web Crypto. smtp, mailcrab and mailpit need node:net and node:tls.