@repo/mail
Transactional email with React Email templates delivered through Resend.
@repo/mail owns every transactional email the platform sends. It ships React
Email templates for the welcome, email verification, password reset,
organization invitation, invitation reminder, and notification digest emails,
and wraps them behind one typed factory, createMailer. When no Resend API key
is configured (typical in local dev), emails are logged to the console with
their action link instead of sent, so link-based flows like password reset
still work end to end.
Creating a mailer
The package has a single entry point. createMailer(options) returns a
Mailer, and the Mailer type is useful for passing the mailer into other
packages (for example @repo/auth accepts one):
import { createMailer } from '@repo/mail';
import { env } from 'cloudflare:workers';
const mailer = createMailer({
apiKey: env.RESEND_API_KEY,
from: env.MAIL_FROM,
});apiKey is optional. If it is omitted, every send becomes a [mail] console
log that includes the email's primary action URL.
apps/web already creates a mailer this way and hands it to the auth layer,
which sends the welcome, verification, reset, and invitation emails at the
right lifecycle moments. Job handlers also receive a ready-made mailer in
their JobContext (see @repo/jobs).
Sender methods
Each method takes a typed options object and renders the matching template.
Every sender also accepts an optional locale; pass the recipient's stored
user.locale where known and the subject and body render in their language
(see @repo/i18n):
await mailer.sendWelcomeEmail({ to, name, appLink, locale: user.locale });
await mailer.sendVerificationEmail({ to, name, verifyLink });
await mailer.sendResetPassword({ to, name, resetLink });
await mailer.sendOrganizationInvitation({ to, inviterName, organizationName, inviteLink });
await mailer.sendInvitationReminder({ to, inviterName, organizationName, inviteLink });
await mailer.sendNotificationDigest({ to, organizationName, items, appLink });sendNotificationDigest is called by @repo/notifications when a user's
email outbox flushes, so a burst of activity lands as one digest email. Its
items are NotificationDigestItem values (also exported from the package);
it is a no-op when items is empty, and uses the single item's title as the
subject when there is exactly one.
A failed Resend delivery throws, so callers decide whether a send failure should fail the surrounding request.
Configuration
| Name | Kind | Purpose |
|---|---|---|
RESEND_API_KEY | secret | Resend API key. Optional locally; without it emails are logged, not sent. |
MAIL_FROM | var in wrangler.jsonc | Sender address, e.g. Onyx <hello@yourdomain.com>. |
In production MAIL_FROM must use a Resend-verified domain. The default
onboarding@resend.dev only delivers to the Resend account owner.
Bulk email and unsubscribe
Senders split into transactional email (verification, reset, magic links, email change, account deletion), which never carries an unsubscribe, and bulk email (the notification digest and invitation reminders), whose senders require an unsubscribeUrl. The URL becomes a footer link plus the RFC 8058 List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers, which Gmail and Yahoo require of bulk senders.
@repo/mail/unsubscribe mints and verifies the tokens:
import { unsubscribeUrl, verifyUnsubscribeToken } from '@repo/mail/unsubscribe';
const url = await unsubscribeUrl(baseUrl, secret, { kind: 'notification-emails', id: userId });
const subject = await verifyUnsubscribeToken(secret, token); // null when invalidTokens are HMAC-signed with BETTER_AUTH_SECRET and never expire (unsubscribe links get clicked months later). The web app serves the endpoint at /mail/unsubscribe: a confirm page on GET (so prefetchers cannot unsubscribe anyone) and the RFC 8058 one-click application on POST.