wrapsMailer: send through your own AWS SES account. The default for BYO packages.
Mailer
One email sender for every BYO package.
A package takes a Mailer, so you choose how mail goes out and the package never imports a provider SDK. Send through your own AWS SES account with Wraps, through an SES client you already have, or through any of 18 providers email-sdk can carry replies through. See every supported provider.
npm i @byo-oss/mailerAWS SES through @wraps.dev/email. The default.
import { wrapsMailer } from "@byo-oss/mailer/wraps";
export const mailer = wrapsMailer({
from: "Acme Support <help@acme.com>",
});An SESv2Client your app already has.
import { SESv2Client } from "@aws-sdk/client-sesv2";
import { sesMailer } from "@byo-oss/mailer/ses";
export const mailer = sesMailer({
from: "Acme Support <help@acme.com>",
client: new SESv2Client({ region: "us-east-1" }),
});Resend, Postmark, SendGrid, SMTP and more.
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { emailSdkMailer } from "@byo-oss/mailer/email-sdk";
export const mailer = emailSdkMailer({
from: "Acme Support <help@acme.com>",
client: createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
telemetry: false,
}),
});Records messages instead of sending. For tests.
import { memoryMailer } from "@byo-oss/mailer";
const mailer = memoryMailer();
await doSomethingThatSendsMail(mailer);
expect(mailer.sent[0]?.to).toBe("alice@example.com");Pick an adapter: the call your packages make stays the same.
Relies on
peerDependencies · @byo-oss/mailer 0.2.0 · all optionalsesMailer: send through an SESv2Client your app already has.
emailSdkMailer: Resend, Postmark, SendGrid, SMTP and more, through a client you build.
The ./testing contract suite, for adapters you write.
Wraps, SES and 18 more providers.
Four adapters. Two send through AWS SES directly. emailSdkMailer carries 18 providers through email-sdk, the ones that can carry a reply. Swap one for another and nothing that takes the mailer changes.
| adapter | sends through | reports the Message-ID |
|---|---|---|
| wrapsMailer @byo-oss/mailer/wraps | AWS SES, through @wraps.dev/email · The default. | yes, in us-east-1 |
| sesMailer @byo-oss/mailer/ses | An SESv2Client your app already has, through @aws-sdk/client-sesv2 | yes, in us-east-1 |
| emailSdkMailer @byo-oss/mailer/email-sdk | 18 providers, through @opencoredev/email-sdk · Listed below. | no, an id only |
| memoryMailer @byo-oss/mailer | Nothing: it records messages · For your tests. | optional |
Through email-sdk: 18 providers
BYO packages send In-Reply-To and References headers and put a routing token in replyTo, so emailSdkMailer accepts only the email-sdk adapters that carry both. These are the adapter names you pass to createEmailClient. A * marks a limit, listed below.
- resend
- postmark
- sendgrid
- cloudflare*
- unosend
- ses*
- mailgun
- mailersend
- brevo
- sparkpost
- jetemail*
- lettermint
- lettr*
- plunk
- mailtrap
- scaleway
- smtp*
- helo
Also accepted: memory, email-sdk's test adapter. emailSdkMailer throws a TypeError when it is created if the client holds any adapter not on this list.
Limits
| adapter | what it still rejects or can't do |
|---|---|
| cloudflare | rejects display-name recipients ("Alice <alice@example.com>") |
| ses | reports no Message-ID, so use wrapsMailer or sesMailer when you need to thread |
| jetemail | needs a display name in from |
| lettr | rejects List-Unsubscribe, display-name recipients and more than 10 headers |
| smtp | keep the default adapter name: a renamed smtp({ name }) is rejected |
Not supported
These email-sdk adapters are rejected, so a client that holds one fails when the mailer is created, not on a send. For these, call the provider's SDK directly or write your own adapter.
- mailchimpiterableloopssequenzyprimitivezeptomailmailpace can't carry both a reply-to address and reply headers
- graph Microsoft Graph only accepts x- headers
- sendheron takes one recipient
An interface, not a provider.
One interface
mailer.send({ to, subject, html, text, replyTo?, headers? }). A BYO package takes a Mailer and never imports a provider SDK.
Your provider
Wraps, your own SES client, or a client built with email-sdk. Each adapter is its own subpath with an optional peer dependency.
Retry or fail
A throw means try again later. A PermanentMailError means the send can never succeed, so the caller marks it failed.
Replies that thread
replyTo is its own field and In-Reply-To and References pass through as headers. Headers the provider owns are rejected.
The real Message-ID
wrapsMailer and sesMailer return the Message-ID SES actually sent, in us-east-1, so the next reply can thread.
One contract
Every first-party adapter runs the testMailer suite. memoryMailer records messages for your own tests.
Four steps. One file.
With Wraps, the default. The docs cover your own SES client and email-sdk.
- step 01
Install
The mailer, plus the SDK for the adapter you use. Every provider SDK is an optional peer dependency.
terminalnpm install @byo-oss/mailer @wraps.dev/email - step 02
Create a mailer
From a server file. The sender address, or its domain, must be verified with your provider.
lib/mailer.tsimport { wrapsMailer } from "@byo-oss/mailer/wraps"; export const mailer = wrapsMailer({ from: "Acme Support <help@acme.com>", }); - step 03
Hand it to the package that sends mail
BYO packages that send email take a mailer option.
lib/auth.tsimport { support } from "@byo-oss/support"; import { mailer } from "./mailer"; betterAuth({ plugins: [support({ mailer /* ... */ })] }); - step 04
Or call it yourself
replyTo goes to the provider's reply-to option, never into headers.
app/actions.tsconst { id, messageId } = await mailer.send({ to: "alice@example.com", subject: "Your ticket was updated", html: "<p>We replied to your ticket.</p>", text: "We replied to your ticket.", replyTo: "support+t0k3n@reply.acme.com", });
Throw to retry. PermanentMailError to fail.
Every adapter maps its provider's errors to the same two outcomes, so a package can retry without knowing who sends the mail. Delivery is at-least-once, and the mailer never changes the message it's given.
- isPermanentMailError works across two installed copies
- replyTo is never copied into headers
- From, To, Subject, Message-ID, ... in headers fail the send
import { isPermanentMailError } from "@byo-oss/mailer";
try {
await mailer.send(message);
} catch (error) {
if (isPermanentMailError(error)) {
// rejected for good: mark it failed
} else {
// anything else: try again later
}
}| adapter | PermanentMailError | retried |
|---|---|---|
| wrapsMailer | validation error, MessageRejected, InvalidParameterValue | throttling, network, credentials, unverified sender |
| sesMailer | BadRequestException, MessageRejected | unverified address or sandbox, sending paused, throttling, network |
| emailSdkMailer | validation error, or a 400 or 422 from every adapter tried | 401, 403, 404, 429, 5xx, no status |
Switching providers is one file.
A package takes a Mailer and never imports a provider SDK, so changing providers means changing the file that builds it. Nothing that takes the mailer changes. The sender address, or its domain, has to be verified with the new provider before you cut over.
Small on purpose.
Changing providers means passing a different adapter: the packages that take a Mailer don't change. When you need more than this interface carries, call your provider's SDK directly or write an adapter that passes the contract suite.
You pass finished html and text.
Not in the message type yet. Added in a minor release when a package needs them.
The package that calls send owns those. The mailer says which errors to retry.
That belongs to the package that receives it.