Send transactional and marketing email and sync newsletter lists with @repo/mail.
Email lives in @repo/mail and is governed by config.email in packages/config/src/index.ts. There is no enabled flag - mail is always wired up; config.email.provider only selects the vendor, and every send is queued through a background worker.
Providers
config.email.provider is baked in at init from the provider you selected; switch it any time. The module loads lazily via getEmailProvider(), so only the selected SDK runs.
| Provider | Env vars | Notes |
|---|---|---|
"smtp" | SMTP_HOST, SMTP_PORT (+ SMTP_USER/SMTP_PASSWORD) | Targets Mailpit locally; persistent nodemailer connection. |
"ses" | AMAZON_SES_REGION, AMAZON_SES_KEY, AMAZON_SES_SECRET | AWS SES over HTTP API. |
"resend" | RESEND_API_KEY | Resend transactional delivery over HTTP. |
SMTP opens a persistent connection - fine for a long-running server but a poor fit for serverless, where invocations are short-lived. On serverless prefer "resend" or "ses" (HTTP APIs). See Deployment.
Sending
Two entry points are exported from @repo/mail. Both return immediately - the provider send() runs in the send-email worker with three retries (see Background jobs).
import { sendEmail, sendTemplateEmail } from "@repo/mail";
// Raw HTML/text - fires an `email/send` event, returns immediately.
await sendEmail({ to, from, subject, html /* , text, replyTo, headers */ });
// Renders a React Email template, picks the sender, adds marketing unsubscribe headers when needed, then queues.
await sendTemplateEmail({
to,
subject,
templateData: { template: "welcome", props: { /* typed per template */ } }
});config.email.limitPerSecond(default3) throttles the worker - raise it for high-volume providers, lower it for conservative relays.fromis optional onsendTemplateEmail(auto-selected); required onsendEmail.
Sender identities
config.email.senders defines three sender identities ({ email, senderName }); the marketing identity additionally requires signatureName. sendTemplateEmail auto-picks based on the template's email class, not its visual layout.
| Identity | Used for | Selected when |
|---|---|---|
transactional | Password resets, verifications, magic links, invitations | Transactional template. |
marketing | Founder-style notes: welcome, onboarding nudges, re-engagement, announcements | Founder-style template. |
support | Contact-form destination + reply-to identity | Never auto-picked; explicit recipient. |
Founder-style templates receive List-Unsubscribe / List-Unsubscribe-Post headers when their props carry an unsubscribeUrl. They serialize to the exact markup mail-client composers produce for hand-typed messages (bare div lines, no layout chrome, hidden preheader, tables, images, or tooling artifacts), so they are indistinguishable from a personal note from the founder and land in the primary inbox tab instead of promotions.
Founder-style emails sign with the marketing sender's signatureName (required) - set it to the founder's first name. It signs every founder-style email and appears in the welcome introduction ("I'm {signatureName}, the founder of ..."), so replace the generated placeholder before going live.
Templates
Templates are React Email components in packages/mail/src/templates/, each a member of the TemplateEmailType discriminated union in packages/mail/src/types.ts. Pass the template discriminator and its typed props. Preview and iterate with pnpm dev:mail (React Email dev server on port 3030).
| Template | Purpose | Default sender |
|---|---|---|
welcome | Post-signup founder hello | marketing |
email-verification | Verify email address | transactional |
password-reset | Reset password link | transactional |
magic-link | Passwordless sign-in | transactional |
organization-invitation | Invite to an org | transactional |
change-email-verification | Confirm email change | transactional |
waitlist-confirmation | Waitlist signup confirm | transactional |
contact-form | Contact-form relay | transactional |
getting-started-check-in | Onboarding nudge | marketing |
feedback-request | Ask for feedback | marketing |
we-miss-you | Re-engagement | marketing |
announcement | Product announcement | marketing |
contact-form is the only admin-facing template. The /contact/submit route sends it to config.email.senders.support from the transactional sender, with replyTo set to the submitter so you can reply directly. It is guarded by a honeypot, optional captcha, and per-IP and per-email rate limits - see Captcha.
Add a template
render*Email(props) function in packages/mail/src/templates/, exported from templates/index.ts.{ template: "your-template"; props: YourProps } to the TemplateEmailType union in types.ts.case to renderTemplate in templates-map.ts; if it is a founder-style email, also add it to the founderTemplates set so the sender and unsubscribe headers resolve correctly.Re-engagement
config.email.inactiveEmailSchedule (default [7, 30, 90]) lists day-thresholds since a user's lastActiveAt. The daily inactive-user-check cron (0 10 * * *) finds marketing-opted-in users idle past the threshold for their current inactiveEmailLevel; the we-miss-you function sends level-aware copy and increments the level.
- Escalating ladder:
[7, 30, 90]emails a user at 7, 30, and 90 days idle. Each send advancesinactiveEmailLevelto arm the next tier. lastActiveAtis stamped by Better Auth on session create and refresh - a user with no new session is never marked active.- Cancelled mid-flight if the user is deleted or disables marketing.
[]unregisters both functions entirely.
inactiveEmailLevel only ever increments - it is never reset when a lapsed user returns. The ladder fires once per tier and does not re-arm on a later relapse.
See Background jobs.
Newsletter
Bulk list sync is gated by config.newsletter - { enabled: false } | { enabled: true; provider: "resend" | "listmonk" }. Generated projects ship { enabled: false }; enable it and pick a provider to start syncing. Subscription is the marketingOptIn auth field (defaultValue: true), not a separate form.
- When enabled, the
newsletter-syncjob (cron0 3 * * *) diffs opted-in users against the provider audience, adding and removing contacts. - When disabled, the job is filtered out and
getNewsletterProvider()throws.
| Provider | Env vars |
|---|---|
"listmonk" | LISTMONK_URL, LISTMONK_API_USER, LISTMONK_API_TOKEN, LISTMONK_LIST_ID |
"resend" | RESEND_API_KEY, RESEND_AUDIENCE_ID |
config.newsletter.provider: "resend" (list audience) is separate from config.email.provider: "resend" (transactional delivery). They can use Resend independently or together.
Frequently asked questions
Do I need a separate subscribe form?
No. Newsletter membership tracks the marketingOptIn user field; the newsletter-sync cron mirrors opted-in users into the provider audience.
Why is mail sent asynchronously instead of inline?
Every send is queued so requests return immediately and the same backend serves any frontend. Failures retry (three attempts) inside the send-email worker.
How do I capture email in local development?
Use "smtp" with pnpm infra (Mailpit): set SMTP_HOST=localhost and SMTP_PORT=1025, then read captured mail at http://localhost:8025.