@byo-oss/mailerv0.2.0library · Mailer

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/mailer

AWS SES through @wraps.dev/email. The default.

lib/mailer.ts
import { wrapsMailer } from "@byo-oss/mailer/wraps";

export const mailer = wrapsMailer({
  from: "Acme Support <help@acme.com>",
});
same mailer.send() · swap the adapter

Pick an adapter: the call your packages make stays the same.

Relies on

peerDependencies · @byo-oss/mailer 0.2.0 · all optional
@wraps.dev/emailoptional
peer · >=0.15.1

wrapsMailer: send through your own AWS SES account. The default for BYO packages.

@aws-sdk/client-sesv2optional
peer · ^3.527.0

sesMailer: send through an SESv2Client your app already has.

@opencoredev/email-sdkoptional
peer · ^3.0.0

emailSdkMailer: Resend, Postmark, SendGrid, SMTP and more, through a client you build.

vitestoptional
peer · >=3

The ./testing contract suite, for adapters you write.

[01] supported

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.

adaptersends throughreports 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-sesv2yes, 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

adapterwhat it still rejects or can't do
cloudflarerejects display-name recipients ("Alice <alice@example.com>")
sesreports no Message-ID, so use wrapsMailer or sesMailer when you need to thread
jetemailneeds a display name in from
lettrrejects List-Unsubscribe, display-name recipients and more than 10 headers
smtpkeep 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
[02] what you get

An interface, not a provider.

01

One interface

mailer.send({ to, subject, html, text, replyTo?, headers? }). A BYO package takes a Mailer and never imports a provider SDK.

02

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.

03

Retry or fail

A throw means try again later. A PermanentMailError means the send can never succeed, so the caller marks it failed.

04

Replies that thread

replyTo is its own field and In-Reply-To and References pass through as headers. Headers the provider owns are rejected.

05

The real Message-ID

wrapsMailer and sesMailer return the Message-ID SES actually sent, in us-east-1, so the next reply can thread.

06

One contract

Every first-party adapter runs the testMailer suite. memoryMailer records messages for your own tests.

[03] quickstart

Four steps. One file.

With Wraps, the default. The docs cover your own SES client and email-sdk.

  1. step 01

    Install

    The mailer, plus the SDK for the adapter you use. Every provider SDK is an optional peer dependency.

    terminal
    npm install @byo-oss/mailer @wraps.dev/email
  2. step 02

    Create a mailer

    From a server file. The sender address, or its domain, must be verified with your provider.

    lib/mailer.ts
    import { wrapsMailer } from "@byo-oss/mailer/wraps";
    
    export const mailer = wrapsMailer({
      from: "Acme Support <help@acme.com>",
    });
  3. step 03

    Hand it to the package that sends mail

    BYO packages that send email take a mailer option.

    lib/auth.ts
    import { support } from "@byo-oss/support";
    import { mailer } from "./mailer";
    
    betterAuth({ plugins: [support({ mailer /* ... */ })] });
  4. step 04

    Or call it yourself

    replyTo goes to the provider's reply-to option, never into headers.

    app/actions.ts
    const { 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",
    });
Full quickstart in the docs →
[04] delivery

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
lib/send.ts
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
  }
}
adapterPermanentMailErrorretried
wrapsMailervalidation error, MessageRejected, InvalidParameterValuethrottling, network, credentials, unverified sender
sesMailerBadRequestException, MessageRejectedunverified address or sandbox, sending paused, throttling, network
emailSdkMailervalidation error, or a 400 or 422 from every adapter tried401, 403, 404, 429, 5xx, no status
Delivery semantics →
[05] switching

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.

[06] what this is not

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.

templates

You pass finished html and text.

cc, bcc, attachments

Not in the message type yet. Added in a minor release when a package needs them.

queue and retries

The package that calls send owns those. The mailer says which errors to retry.

inbound mail

That belongs to the package that receives it.

Write your own adapter →