Email SDK
Adapters

Field support

Which EmailMessage fields each adapter maps, and which it rejects before the request.

Email SDK keeps one message shape, but provider APIs do not expose the same features. Every adapter either maps a field or rejects it with an EmailValidationError before calling the provider. There is no silent drop. This page is the authoritative matrix.

Reading the tables: Yes = mapped, No = rejected before the request, Values = each tag's value is sent but its name is dropped, One tag = the provider API represents a single tag and a second one fails fast. Of the one-tag providers, Postmark keeps the name (the tag arrives as a joined name:value string) while Mailtrap and Lettermint forward only the value.

API adapters

The best fit when your app needs CC, BCC, reply-to, custom headers, tags, metadata, or attachments.

AdapterCCBCCReply-ToHeadersMetadataTagsAttachmentsSend at
ResendYesYesYesYesNoYesYesYes
PostmarkYesYesYesYesYesOne tagYesNo
SendGridYesYesYesYesYesValuesYesYes
CloudflareYesYesYesYesNoNoYesNo
UnosendYesYesYesYesNoYesYesNo
AWS SESYesYesYesYesNoYesYesNo
MailgunYesYesYesYesYesValuesYesYes
MailerSendYesYesYesYesNoValuesYesYes
BrevoYesYesYesYesYesValuesYesYes
Mailchimp TransactionalYesYesNoYesYesValuesYesYes
MailtrapYesYesYesYesYesOne tagYesNo
JetEmailYesYesYesYesNoNoYesNo
LettermintYesYesYesYesYesOne tagYesNo

Narrow adapters

Useful services whose public APIs cover less of the EmailMessage shape. The SDK keeps them safe by rejecting what they cannot represent.

AdapterCCBCCReply-ToHeadersMetadataTagsAttachmentsSend at
SparkPostNoNoYesYesYesYesYesYes
IterableNoNoNoNoYesNoNoNo
LoopsNoNoNoNoYesNoYesNo
PrimitiveNoNoNoNoNoNoYesNo
SequenzyNoNoYesNoYesNoYesNo
PlunkNoNoYesYesYesNoYesNo
ScalewayYesYesYesYesNoNoYesNo
ZeptoMailYesYesYesNoNoNoYesNo
MailPaceYesYesYesNoNoNoNoNo

SMTP transport

Built in, no Nodemailer. SMTP maps address fields and headers directly into the message but has no provider-side concepts like tags or metadata.

AdapterCCBCCReply-ToHeadersMetadataTagsAttachmentsSend at
SMTPYesYesYesYesNoNoNoNo

Recipient variables

recipientVariables personalizes a batch send with %recipient.key% tokens (see the message reference). Unlike the fields above, it is never rejected: it works on every adapter. The difference is how:

Native batch (one API call)Client-side fallback (one send per recipient)
Mailgun, SendGridevery other adapter

Native routes substitute provider-side, so %recipient.key% reaches the provider untouched; fallback routes substitute before the request. The same message code runs on any route, so a fallback to a non-native adapter still personalizes every recipient.

Choosing compatible routes

A fallback route is only safe when the backup adapter supports every field your messages actually use. Pick routes from your message shape, not provider popularity:

Your messages use…Compatible route examples
Addresses, subject, body, headersAlmost anything; Resend + SMTP works
AttachmentsResend, Postmark, SendGrid, Mailgun, MailerSend…
Metadata for analytics or routingPostmark, SendGrid, Mailgun, Brevo, Mailtrap
Tags and metadata togetherSendGrid, Mailgun, Brevo, Mailtrap
Scheduled sends (sendAt)Resend, SendGrid, Mailgun, Brevo, MailerSend…

This pair works because both routes can carry every field used:

const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! }), smtp({ host: process.env.SMTP_HOST! })],
  fallback: ["smtp"],
});

await email.send({
  from: "Acme <hello@acme.com>",
  to: "user@example.com",
  replyTo: "support@example.com",
  subject: "Password reset",
  text: "Use this link to reset your password.",
  headers: { "X-Template": "password-reset" },
});

The same client with tags or metadata on the message would fail fast on the SMTP route instead of dropping those fields. That failure is the feature: you find out about the incompatible route in development, not by discovering missing analytics data in the provider dashboard weeks later.

Before adding a backup route, run through this checklist:

  • Does the backup adapter support every EmailMessage field your messages use?
  • Does it preserve attachments when receipts, exports, or files matter?
  • Does it preserve metadata or tags your app relies on for provider dashboards, analytics, or routing?
  • Does it support replyTo and headers if support workflows depend on them?
  • Has the backup provider account been live-verified (one real smoke send) in the target environment?

Scheduled sends

sendAt (a Date or ISO 8601 string) maps to each provider's native scheduling parameter. The SDK never queues or delays anything itself:

AdapterProvider parameterWire format
Resendscheduled_atISO 8601
SendGridsend_atUnix timestamp (seconds)
Mailguno:deliverytimeRFC 2822
MailerSendsend_atUnix timestamp (seconds)
BrevoscheduledAtISO 8601
Mailchimp Transactionalsend_atYYYY-MM-DD HH:MM:SS (UTC)
SparkPostoptions.start_timeYYYY-MM-DDTHH:MM:SS+00:00

Two things to keep in mind:

  • The SDK validates that sendAt parses to a real date but does not enforce a scheduling window — providers apply their own limits (SendGrid, MailerSend, and Brevo cap at 72 hours out, Mailgun at 3–7 days depending on plan, SparkPost at 3 days) and most treat a past date as "send now".
  • Adapters without a Yes in the Send at column reject sendAt before any request, like every other unmapped field. If you need scheduling with those providers, delay the send call with your own queue or job scheduler.

Attachment rules

Attachment content is treated as raw data by default and Base64-encoded for APIs that need it:

attachments: [{ filename: "receipt.txt", content: "Thanks for your order.", contentType: "text/plain" }];

Already have Base64? Mark it so it is not double-encoded:

attachments: [
  { filename: "receipt.pdf", content: base64Pdf, contentEncoding: "base64", contentType: "application/pdf" },
];

Adapters with attachment support can also read from disk via path:

attachments: [{ filename: "receipt.pdf", path: "./receipt.pdf", contentType: "application/pdf" }];

Adapter-specific notes

Local checks are not deliverability

These checks stop bad requests before they leave your process. Live delivery still needs a ready provider account: verified senders or domains, the right region, API scopes, and any sandbox or allow-list rules. Finish with one real smoke send per route.

On this page