Each provider is its own entry point, so you only bundle the one you import. Every provider
exposes the same send() and is_error() methods: swap providers without changing your
calling code.
This page is the email roster. The other channels’ providers live with their channel: SMS (The SMS Works, PureSMS, Twilio, Amazon SNS), WhatsApp (Twilio, Meta Cloud API), Push (Web Push, FCM), and the chat platforms (Slack, Discord, Teams, Telegram), each on its own page.
The environment variables are what postboi init writes and the
zero-config mail() reads; the constructor options are the same values passed
explicitly when you construct a provider directly. Non-secret
bits (a Mailgun domain, an SES region) can instead live in postboi.config.ts;
keep the secrets in the environment.
Want another provider? Quit being a baby and open a PR.
Using a provider directly
Useful when you want an explicit instance, or credentials that don’t come from the environment:
Every provider also accepts default, timeout, retries, retry_delay, auto_text,
and hooks on top of its own credentials. See Common constructor options.
Mock provider
postboi/mock records messages in-memory instead of sending them, with the same normalisation as a
real provider. Perfect for tests.
Pass log: true to print each message as it is captured: subject, addresses, attachment
names, and the body in full, so a magic link or confirmation code is readable in the
terminal. It is off by default so test suites stay quiet.
mail() turns it on whenever it resolves the mock itself, which happens two ways:
- No credential in development. See below.
provider: 'mock'inpostboi.config.tsorPOSTBOI_PROVIDER. Choosing the mock is choosing not to send, so printing is the only way to observe it.
Constructing new Mock() yourself stays silent. That is the test path, where the point is
asserting on sent rather than printing to the run.
No credential yet
A fresh clone has no token, and that is the normal state of a checkout. Rather than
failing, mail() logs the message to the console and carries on, so sign-in links and
receipts are readable and app code needs no if (token) branch around every send:
This only happens when the environment identifies itself as development
(NODE_ENV=development). Anywhere else a missing credential throws no_token or no_provider, including when the environment is unset or
unrecognised. The asymmetry is deliberate: a real deploy that lost its secret must fail
loudly, because mail that silently becomes a console line locks people out with no error
anywhere. NODE_ENV=test throws too, so suites keep asserting the real failure.
Custom headers & tags
headers and tags on send() are forwarded to each provider’s
native concept, and quietly ignored where unsupported:
- headers → Resend, Postmark, SendGrid, Mailgun (
h:), Brevo, SparkPost, Mandrill, Plunk, Mailtrap, Scaleway, Cloudflare, SES, Mailjet, Elastic Email, Lettermint, Unosend, SMTP2GO (custom_headers), SocketLabs, Azure, Gmail, MailChannels, Maileroo, AhaSend, Postal, Customer.io, Infobip, JetEmail, Lettr, Netcore (asx-apiheader), Yandex Cloud Postbox. - tags → SendGrid (categories), Mailgun (
o:tag), Brevo, MailerSend, Mandrill, MailPace, SES (EmailTags), Resend, Lettermint, Unosend ({name,value}pairs), AhaSend, Maileroo (a name→value map, each tag as its own name), Netcore, Yandex Cloud Postbox. Postmark, Mailtrap, Postal, Lettr, SocketLabs (as themailingId) and Alibaba Direct Mail (as theTagName) use the first tag only.
A few of the roster shape the message around their own model rather than the wire:
- Loops, Iterable, Klaviyo and HubSpot only send emails designed in their
own editors, so each takes the template to use (
transactional_id/campaign_id/metric/email_id) and hands the send over as data variables (subject,html,text,fromand the recipient’sname) for that template to place. One recipient per send; a batch withdatareaches several. Klaviyo is the furthest from a send: there is no transactional endpoint at all, so the message is filed as an event and a flow triggered by that metric is what actually mails. Nothing goes out until one exists, and Klaviyo answers202either way. - HubSpot reports a refusal (
INVALID_TO_ADDRESS,PREVIOUSLY_BOUNCED, a missing template property) assendResulton an HTTP 200. Anything butSENTorQUEUEDis thrown with that value as the error code rather than returned as a success. - Primitive addresses one person per request with no cc or bcc, so several recipients is an error rather than a guess.
- AhaSend has no cc/bcc (every address gets its own copy), so
ccandbccjoin the recipient list. Customer.io has no cc either;ccaddresses join its comma-separatedto, and every send is filed against the person the first recipient identifies. - OneSignal sends through its
/notificationsendpoint on the email channel, and answers200with noidwhen it reached nobody, and that case is thrown too.include_unsubscribeddefaults to true, because a password reset is owed to the person whatever they think of the newsletter; turn it off for anything they could reasonably have opted out of. It has no cc (those addresses join the recipient list) andbccmaps toemail_bcc, which caps at five. - Alibaba Direct Mail signs its own requests (HMAC-SHA1 over sorted form parameters)
rather than carrying a token, sends from the
AccountNamethefromaddress names (which must be verified in the console, in that region), and has neither cc, bcc nor attachments:ccandbccaddresses join the recipient list, each getting their own copy, up to 100 per send. - Yandex Cloud Postbox implements Amazon’s SES v2 API, so it is
postboi/sespointed atpostbox.cloud.yandex.netand signed forru-central1: same payload, same attachments, same headers and tags. Its credential is a service account’s static access key, and that account needs thepostbox.senderrole. - Netcore carries one content block, so the HTML body is the message: a
textalternative alongside it is dropped, and a text-only send goes out as that body. - Azure sends from the bare
senderAddress(the display name is set on the domain in Azure) and its tracking is a resource-level switch; the one per-send control is turning it off withtracking: { opens: false, clicks: false }. - Gmail sends as a Google Workspace mailbox through a service account with
domain-wide delegation (the
fromaddress is the mailbox impersonated unlessusersays otherwise), or with anaccess_tokenyou minted yourself. - SendPulse and Gmail exchange their credentials for a short-lived token first;
the exchange is cached across instances, so
mail()doesn’t pay it per send.
Sequenzy’s send API has no slot for either, nor for a plain-text part: the HTML body is
the message, and a text-only send goes out as that body. Its company_id is only for a seq_user_… account key that can reach several workspaces; a seq_live_… workspace key
needs nothing more.
The same forwarded-where-supported convention applies to scheduled_at, tracking and unsubscribe_url, and most providers’ delivery
events can be received and normalized too, see Webhooks.