Send transactional and marketing email through SMTP, SES, or Resend with @repo/mail, and sync newsletter lists with config.newsletter.
Email lives in @repo/mail, governed by config.email in packages/config/src/index.ts. There is no enabled flag - mail is always wired; config.email.provider selects the vendor and every send is queued through a background worker.
Providers
config.email.provider is baked in at init and switchable any time. getEmailProvider() loads lazily, so only the selected SDK runs.
| Provider | Env vars | Notes |
|---|---|---|
"smtp" | SMTP_HOST, SMTP_PORT (+ SMTP_USER/SMTP_PASSWORD) | Persistent nodemailer connection; targets Mailpit locally. |
"ses" | AMAZON_SES_REGION, AMAZON_SES_KEY, AMAZON_SES_SECRET | AWS SES over HTTP API. |
"resend" | RESEND_API_KEY | Resend transactional delivery over HTTP. |
EMAIL_PROVIDERoverridesconfig.email.providerper environment when set, so a staging deploy can use a different vendor than the committed choice.- Local capture: run
pnpm infrafor Mailpit, setSMTP_HOST=localhostandSMTP_PORT=1025, then read mail athttp://localhost:8025.
SMTP opens a persistent connection - a poor fit for serverless, where invocations are short-lived. Prefer "resend" or "ses" (HTTP APIs) there. 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.fromis optional onsendTemplateEmail(auto-selected), required onsendEmail.
Sender identities
config.email.senders defines three identities ({ email, senderName }); marketing additionally requires signatureName. sendTemplateEmail picks one from the template's email class, not its visual layout.
| Identity | Used for | Selected when |
|---|---|---|
transactional | Password resets, verifications, magic links, invitations | Transactional template. |
marketing | Welcome, onboarding nudges, re-engagement, announcements | Founder-style template. |
support | Contact-form destination and reply-to identity | Never auto-picked; explicit recipient. |
- Founder-style templates serialize to the markup a mail-client composer produces for a hand-typed message - no layout chrome, preheader, tables, or tooling artifacts - so they land in the primary inbox tab instead of promotions. They carry
List-Unsubscribe/List-Unsubscribe-Postheaders when their props include anunsubscribeUrl. marketing.signatureNamesigns every founder-style email and fills the welcome introduction. 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. Preview them with pnpm dev:mail (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. /contact/submit sends it to config.email.senders.support from the transactional sender, with replyTo set to the submitter. 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; for a founder-style email, also add it to the founderTemplates set so the sender and unsubscribe headers resolve.KNOWN_TEMPLATES in packages/utils/src/email-analytics.ts so its opens and clicks reach Email analytics.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.
| Behavior | Detail |
|---|---|
| Escalating ladder | [7, 30, 90] emails a user at 7, 30, and 90 days idle; each send arms the next tier. |
lastActiveAt | Stamped by Better Auth on session create and refresh - no new session means no activity. |
| 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, so the ladder fires once per tier and does not re-arm. See Background jobs.
Newsletter
Bulk list sync is gated by config.newsletter - { enabled: false } | { enabled: true; provider: "resend" | "listmonk" }. Generated projects ship { enabled: false }; subscription is the marketingOptIn auth field (defaultValue: true), not a separate form.
| State | Effect |
|---|---|
| Enabled | The newsletter-sync job (cron 0 3 * * *) diffs opted-in users against the provider audience, adding and removing contacts. |
| Disabled | The job is unregistered 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). Use Resend for either, or both.
Email analytics
Background jobs
Notifications
Configuration
Projects
The project entity that scopes a user's work inside their account or inside their active organization - ownership, the API, config.projects.enabled, and the useActiveProject hook.
Email analytics
Track opens and clicks on marketing emails with signed, provider-agnostic hooks and read per-template rates on the admin Emails page.