Skip to main content
Guides File 032

Providers

Every supported email provider, its import path, and constructor options. The other channels' providers live on their own pages.

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.

Provider Import Environment variables Constructor options
AhaSend postboi/ahasend AHASEND_API_KEY, AHASEND_ACCOUNT_ID api_key, account_id
Alibaba Direct Mail postboi/alibaba ALIBABA_CLOUD_ACCESS_KEY_ID, ALIBABA_CLOUD_ACCESS_KEY_SECRET, ALIBABA_CLOUD_REGION_ID access_key_id, access_key_secret, region?, address_type?
Amazon SES postboi/ses AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION access_key_id, secret_access_key, region, session_token?
Azure Communication Services postboi/azure COMMUNICATION_SERVICES_CONNECTION_STRING connection_string (or endpoint, access_key)
Brevo postboi/brevo BREVO_API_KEY api_key
Cloudflare postboi/cloudflare CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID api_key, account_id
Customer.io postboi/customerio CUSTOMERIO_APP_API_KEY, CUSTOMERIO_REGION api_key, region?
Elastic Email postboi/elasticemail ELASTICEMAIL_API_KEY api_key
Gmail (Google Workspace) postboi/gmail GMAIL_CLIENT_EMAIL, GMAIL_PRIVATE_KEY, GMAIL_USER client_email, private_key, user? (or access_token)
HubSpot postboi/hubspot HUBSPOT_ACCESS_TOKEN, HUBSPOT_EMAIL_ID api_key, email_id
Infobip postboi/infobip INFOBIP_API_KEY, INFOBIP_BASE_URL api_key, base_url
Iterable postboi/iterable ITERABLE_API_KEY, ITERABLE_CAMPAIGN_ID, ITERABLE_REGION api_key, campaign_id, region?
JetEmail postboi/jetemail JETEMAIL_API_KEY api_key
Klaviyo postboi/klaviyo KLAVIYO_API_KEY, KLAVIYO_METRIC api_key, metric?, revision?
Lettr postboi/lettr LETTR_API_KEY api_key
Lettermint postboi/lettermint LETTERMINT_SENDING_TOKEN, LETTERMINT_ROUTE api_key, route?
Loops postboi/loops LOOPS_API_KEY, LOOPS_TRANSACTIONAL_ID api_key, transactional_id
MailChannels postboi/mailchannels MAILCHANNELS_API_KEY api_key, dkim?
Maileroo postboi/maileroo MAILEROO_API_KEY api_key
MailerSend postboi/mailersend MAILERSEND_API_KEY api_key
Mailgun postboi/mailgun MAILGUN_API_KEY, MAILGUN_DOMAIN api_key, domain, region?
Mailjet postboi/mailjet MJ_APIKEY_PUBLIC, MJ_APIKEY_PRIVATE api_key, api_secret
MailPace postboi/mailpace MAILPACE_SERVER_TOKEN api_key
Mailtrap postboi/mailtrap MAILTRAP_TOKEN api_key, sandbox?, inbox_id?
Mandrill postboi/mandrill MANDRILL_API_KEY api_key
Microsoft 365 postboi/microsoft365 MS365_TENANT_ID, MS365_CLIENT_ID, MS365_CLIENT_SECRET tenant_id, client_id, client_secret
Netcore (Pepipost) postboi/netcore NETCORE_API_KEY, NETCORE_REGION api_key, region?
OneSignal postboi/onesignal ONESIGNAL_API_KEY, ONESIGNAL_APP_ID api_key, app_id, include_unsubscribed?
Plunk postboi/plunk PLUNK_API_KEY api_key
Postal postboi/postal POSTAL_HOST, POSTAL_API_KEY host, api_key
Postmark postboi/postmark POSTMARK_SERVER_TOKEN api_key, message_stream?
Primitive postboi/primitive PRIMITIVE_API_KEY api_key
Resend postboi/resend RESEND_API_KEY api_key
Scaleway postboi/scaleway SCALEWAY_SECRET_KEY, SCALEWAY_PROJECT_ID, SCALEWAY_REGION secret_key, project_id, region
SendPulse postboi/sendpulse SENDPULSE_CLIENT_ID, SENDPULSE_CLIENT_SECRET client_id, client_secret
SendGrid postboi/sendgrid SENDGRID_API_KEY api_key, region?
Sequenzy postboi/sequenzy SEQUENZY_API_KEY, SEQUENZY_COMPANY_ID api_key, company_id?
SMTP2GO postboi/smtp2go SMTP2GO_API_KEY, SMTP2GO_REGION api_key, region?
SMTP (any) postboi/smtp SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE host, port?, user?, pass?, secure?
SocketLabs postboi/socketlabs SOCKETLABS_SERVER_ID, SOCKETLABS_API_KEY server_id, api_key
SparkPost postboi/sparkpost SPARKPOST_API_KEY api_key, region?
Unosend postboi/unosend UNOSEND_API_KEY api_key
Yandex Cloud Postbox postboi/yandex YANDEX_ACCESS_KEY_ID, YANDEX_SECRET_ACCESS_KEY, YANDEX_REGION access_key_id, secret_access_key, region?
ZeptoMail postboi/zepto ZEPTO_TOKEN api_key
Mock (testing) postboi/mock none none

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:

import Resend from 'postboi/resend'

const mail = new Resend({
	api_key: process.env.RESEND_API_KEY!,
	default: { from: 'no-reply@example.com' }
})

await mail.send({
	to: 'someone@example.com',
	subject: 'hello',
	body: '<p>hello world</p>'
})

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.

import Mock from 'postboi/mock'

const mail = new Mock({ default: { from: 'no-reply@example.com' } })
await mail.send({ to: 'contact@example.com', subject: 'Hi', body: '<p>Hello</p>' })

expect(mail.sent).toHaveLength(1)

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' in postboi.config.ts or POSTBOI_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:

postboi: no POSTBOI_TOKEN configured, so mail is logged to the console instead of sending. Run `bunx postboi init` to send for real.
postboi (mock): Your sign-in link
  to:   partner@example.com
  from: Acme <no-reply@example.com>

Click to sign in: https://example.com/verify?token=abc123

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 (as x-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 the mailingId) and Alibaba Direct Mail (as the TagName) 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, from and the recipient’s name) for that template to place. One recipient per send; a batch with data reaches 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 answers 202 either way.
  • HubSpot reports a refusal (INVALID_TO_ADDRESS, PREVIOUSLY_BOUNCED, a missing template property) as sendResult on an HTTP 200. Anything but SENT or QUEUED is 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 cc and bcc join the recipient list. Customer.io has no cc either; cc addresses join its comma-separated to, and every send is filed against the person the first recipient identifies.
  • OneSignal sends through its /notifications endpoint on the email channel, and answers 200 with no id when it reached nobody, and that case is thrown too. include_unsubscribed defaults 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) and bcc maps to email_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 AccountName the from address names (which must be verified in the console, in that region), and has neither cc, bcc nor attachments: cc and bcc addresses 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/ses pointed at postbox.cloud.yandex.net and signed for ru-central1: same payload, same attachments, same headers and tags. Its credential is a service account’s static access key, and that account needs the postbox.sender role.
  • Netcore carries one content block, so the HTML body is the message: a text alternative 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 with tracking: { opens: false, clicks: false }.
  • Gmail sends as a Google Workspace mailbox through a service account with domain-wide delegation (the from address is the mailbox impersonated unless user says otherwise), or with an access_token you 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.