Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Mail

Send emails with one call. Six providers, React templates, and a local inbox you can read in your browser.

@zap-ts/mail gives the app one way to send an email. You write the body of the email in React. You call sendEmail. The package sends it.

Six providers can do the sending: SMTP, console, Resend, Postmark, SendGrid and Brevo. A provider is the service that delivers the email. You pick one with a setting. Your code never names it.

A fresh clone sends every email to your own machine. Nothing goes to a real person.

Send an email

The package exports two things you need.

  • EmailLive builds the email client from your settings.
  • sendEmail renders the React body and sends it.
import { EmailLive, sendEmail } from "@zap-ts/mail";
import { Effect } from "effect";

const program = sendEmail({
  to: "user@example.com",
  subject: "Welcome!",
  react: <p>Thanks for joining us.</p>,
});

await Effect.runPromise(program.pipe(Effect.provide(EmailLive)));

You do not pass the sender address. It comes from MAIL_FROM.

If a send fails, the package tries again, up to three times. It waits a little longer before each try. If all tries fail, it writes a warning and stops. The rest of your code keeps working. So a broken email never breaks the page the user is on.

If you need to know that an email went out, read the log. The call itself does not tell you.

Every provider is reached over HTTP. So the same code runs on Node and on Cloudflare Workers.

Templates

A template is a function that returns one ready-made email. The templates live in @zap-ts/mail/templates. Each one returns { subject, react }. That is what sendEmail needs, apart from the address.

import { verifyEmail } from "@zap-ts/mail/templates";

const { subject, react } = verifyEmail("https://app.example.com/api/auth/verify-email?token=1");

Six templates are about sign-in and accounts: verifyEmail, resetPasswordEmail, deleteAccountEmail, magicLinkEmail, signInCodeEmail and invitationEmail.

One template is about payments: lifecycleEmail. You pass it an event. The event is trial_will_end, payment_failed or canceled. The template picks the right body for that event.

You do not have to call these yourself. @zap-ts/authentication already sends the sign-in emails. @zap-ts/billing already sends the payment ones.

The templates that ship are plain. They have no design and no logo. Replace them with your own. Keep the names of the exported functions, because the rest of the code imports those names.

See a template in your browser

apps/emails is a small app that shows the templates. Each file in apps/emails/emails loads one template and draws it. You edit the real template and see the change at once.

Start it with:

pnpm run dev:emails

It runs on port 3004.

Pick a provider

The MAIL_PROVIDER setting picks the provider when the app starts. Two of the six need no account:

  • smtp sends to Mailpit. Mailpit is a fake mail server for your machine. It starts with docker compose up. It takes mail on port 1025. You read the mail at http://localhost:8025.
  • console prints the email instead of sending it. It prints the addresses, the subject and the body. Tests use this.

The other four need a key. A key is a secret string from the provider. If the key is missing, the app fails when it starts. It does not fail later, in the middle of a send.

MAIL_PROVIDER What it reads
smtp SMTP_HOST, SMTP_PORT
console nothing
resend RESEND_API_KEY
postmark POSTMARK_SERVER_TOKEN
sendgrid SENDGRID_API_KEY
brevo BREVO_API_KEY

To change provider, change one setting and add one key. You do not change any import.

Environment

These values ship in .env.schema:

MAIL_PROVIDER=smtp
MAIL_FROM=zap.ts <noreply@zapstudio.dev>
SMTP_HOST=127.0.0.1
SMTP_PORT=1025

RESEND_API_KEY, POSTMARK_SERVER_TOKEN, SENDGRID_API_KEY and BREVO_API_KEY are there too, but they are empty. Each one has a link to the documentation of that provider. Fill in the one you picked.

See Environment to learn how varlock turns these lines into the typed ENV that the code reads.

Last updated on September 22, 2026

Was this page helpful?