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_SECRET (+ AMAZON_SES_CONFIGURATION_SET, AMAZON_SES_SNS_TOPIC_ARN for events)AWS SES v2 over HTTP API.
"resend"RESEND_API_KEY (+ RESEND_WEBHOOK_SECRET for events)Resend 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 */ } }
});
  • Every send is logged and checked against the suppression list before the provider call - bounced addresses get nothing, complaining ones get no marketing. See Email deliverability.
  • 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.

Writing marketing emails

Short, plain emails get opened and answered; long ones read like a campaign. The shipped lifecycle emails follow these rules - keep them when you edit those or write an announcement.

RuleWhy
Subject of one or two words (Welcome, Check-in, Feedback?)Short subjects open noticeably more often
Body under 100 wordsReplies drop sharply past 100 words
Open with the point, not "I wanted to reach out"The first line decides whether the rest is read
One ask per emailA single clear question gets answered
At most two follow-upsThe re-engagement sequence stops after its third email for this reason

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