Private packages

@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

NameKindPurpose
RESEND_API_KEYsecretResend API key. Optional locally; without it emails are logged, not sent.
MAIL_FROMvar in wrangler.jsoncSender 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 invalid

Tokens 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.

On this page