GenerateSaaS

Email

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.

ProviderEnv varsNotes
"smtp"SMTP_HOST, SMTP_PORT (+ SMTP_USER/SMTP_PASSWORD)Persistent nodemailer connection; targets Mailpit locally.
"ses"AMAZON_SES_REGION, AMAZON_SES_KEY, AMAZON_SES_SECRETAWS SES over HTTP API.
"resend"RESEND_API_KEYResend transactional delivery over HTTP.
  • EMAIL_PROVIDER overrides config.email.provider per environment when set, so a staging deploy can use a different vendor than the committed choice.
  • Local capture: run pnpm infra for Mailpit, set SMTP_HOST=localhost and SMTP_PORT=1025, then read mail at http://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 (default 3) throttles the worker - raise it for high-volume providers.
  • from is optional on sendTemplateEmail (auto-selected), required on sendEmail.

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.

IdentityUsed forSelected when
transactionalPassword resets, verifications, magic links, invitationsTransactional template.
marketingWelcome, onboarding nudges, re-engagement, announcementsFounder-style template.
supportContact-form destination and reply-to identityNever 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-Post headers when their props include an unsubscribeUrl.
  • marketing.signatureName signs 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).

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

Create the component and 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; for a founder-style email, also add it to the founderTemplates set so the sender and unsubscribe headers resolve.
For a founder-style email, add it to 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.

BehaviorDetail
Escalating ladder[7, 30, 90] emails a user at 7, 30, and 90 days idle; each send arms the next tier.
lastActiveAtStamped by Better Auth on session create and refresh - no new session means no activity.
CancelledMid-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.

StateEffect
EnabledThe newsletter-sync job (cron 0 3 * * *) diffs opted-in users against the provider audience, adding and removing contacts.
DisabledThe job is unregistered 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). Use Resend for either, or both.

On this page