GenerateSaaS

Email

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.

ProviderEnv varsNotes
"smtp"SMTP_HOST, SMTP_PORT (+ SMTP_USER/SMTP_PASSWORD)Targets Mailpit locally; persistent nodemailer connection.
"ses"AMAZON_SES_REGION, AMAZON_SES_KEY, AMAZON_SES_SECRETAWS SES over HTTP API.
"resend"RESEND_API_KEYResend 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 (default 3) throttles the worker - raise it for high-volume providers, lower it for conservative relays.
  • from is optional on sendTemplateEmail (auto-selected); required on sendEmail.

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.

IdentityUsed forSelected when
transactionalPassword resets, verifications, magic links, invitationsTransactional template.
marketingFounder-style notes: welcome, onboarding nudges, re-engagement, announcementsFounder-style template.
supportContact-form destination + reply-to identityNever 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).

TemplatePurposeDefault sender
welcomePost-signup founder hellomarketing
email-verificationVerify email addresstransactional
password-resetReset password linktransactional
magic-linkPasswordless sign-intransactional
organization-invitationInvite to an orgtransactional
change-email-verificationConfirm email changetransactional
waitlist-confirmationWaitlist signup confirmtransactional
contact-formContact-form relaytransactional
getting-started-check-inOnboarding nudgemarketing
feedback-requestAsk for feedbackmarketing
we-miss-youRe-engagementmarketing
announcementProduct announcementmarketing

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

Create the component + a render*Email(props) function in packages/mail/src/templates/, exported from templates/index.ts.
Add { template: "your-template"; props: YourProps } to the TemplateEmailType union in types.ts.
Add its 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 advances inactiveEmailLevel to arm the next tier.
  • lastActiveAt is 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-sync job (cron 0 3 * * *) diffs opted-in users against the provider audience, adding and removing contacts.
  • When disabled, the job is filtered out and getNewsletterProvider() throws.
ProviderEnv 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.

On this page