Email

Email

Warpkit sends email with React Email components. Two providers are configured out of the box: Resend (default) and Nodemailer (SMTP).

Configuration

Set EMAIL_PROVIDER to select the provider (defaults to resend):

EMAIL_PROVIDER=resend   # default
EMAIL_PROVIDER=smtp

Resend

RESEND_API_KEY=re_...

SMTP / Nodemailer

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=...
SMTP_PASS=...

Sender name and address come from config.ts:

resend: {
  fromEmail: 'noreply@myapp.com',
  fromName: 'MyApp',
},

Sending email

import { sendEmail } from '@/server/email';
import { WelcomeEmail } from '@/server/email-templates';

await sendEmail({
  to: user.email,
  subject: `Welcome to ${config.appName}!`,
  react: WelcomeEmail({ name: user.name, appName: config.appName }),
});

You can also pass raw HTML instead of a React component:

await sendEmail({
  to: user.email,
  subject: 'Hello',
  html: '<p>Hello world</p>',
});

Built-in templates

All templates live in src/server/email-templates.tsx.

TemplateSent when
OtpEmailOTP login code
MagicLinkEmailMagic link login
WelcomeEmailUser signs up for the first time
LeadConfirmationEmailEmail submitted to waitlist
ReceiptEmailOne-time or credits purchase completes (queued via handleReceiptEmail job)
OnboardingDay3Email3 days after signup (queued job)
OnboardingDay7Email7 days after signup (queued job)
TrialExpiringEmailTrial subscription is about to end (queued job)
ReengagementEmailInactive user re-engagement nudge (queued job)
WinbackEmail7 days after a subscription is canceled (queued job)

Two kinds of email, with different rules:

KindEmailsConsentUnsubscribe link
AccountOTP, magic link, welcome, receipt, trial ending, securityNot needed: it's about the user's own accountNone
MarketingOnboarding day 3 and day 7, re-engagement, win-backOpt-inFooter link + List-Unsubscribe header

Opt-in worldwide. config.marketingEmails.defaultEnabled is false: a user who never answered gets no marketing email. The EU, UK and Canada require opt-in; the US (CAN-SPAM) allows opt-out, but one rule everywhere beats guessing where a user lives from their IP. Flip it to true only if you sell solely where opt-out is legal.

Where consent comes from: an unticked checkbox on the first onboarding step ("Send me tips and product updates"), and the toggle in Settings → Notifications. Opting in also stores the time (marketing_emails_consented_at in the user's settings), the proof of consent GDPR asks for. Both live in src/features/notification-preferences/server/marketing-consent.server.ts.

Checked when the email is sent, not when it's scheduled. Drip jobs are queued days ahead, so each marketing handler is wrapped in withMarketingConsent (src/features/jobs/handlers/), which skips users who opted out or deleted their account since. It checks the user still exists before opening their per-user DB, so it never recreates a deleted account's data directory. Account emails re-check their own condition instead: the trial reminder only sends while the subscription is still trialing, and win-back skips anyone who resubscribed.

Unsubscribe is /api/unsubscribe?token=..., a signed, non-expiring per-user token (src/lib/unsubscribe-token.server.ts, HMAC on BETTER_AUTH_SECRET). GET shows a confirm button and changes nothing, because email security scanners open every link in a message before the person does. POST unsubscribes: both the confirm button and RFC 8058 one-click, which Gmail and Yahoo require from bulk senders and send unattended to the List-Unsubscribe URL. Pass unsubscribeUrl to sendEmail() for marketing sends only; it sets both headers.

Adding a marketing email: a job type in src/features/jobs/types.ts, a handler that builds its link with the user's token (see sendWinbackEmail in src/server/email.ts), and a worker registered as withMarketingConsent(handler) in workers.ts.

Not legal advice: this is the standard reading of GDPR, ePrivacy, CASL and CAN-SPAM. Have counsel check your privacy policy before marketing to EU businesses at volume.

Creating a template

// src/server/email-templates.tsx
export function InvoiceEmail({ amount, invoiceUrl }: { amount: number; invoiceUrl: string }) {
  return (
    <Layout appName={config.appName} preview={`Invoice for $${amount}`}>
      <E.Heading>Your invoice is ready</E.Heading>
      <E.Text>Amount due: <strong>${amount}</strong></E.Text>
      <E.Button href={invoiceUrl}>View invoice</E.Button>
    </Layout>
  );
}

Use @react-email/components (E.*) for all elements. The Layout wrapper handles HTML structure, body styles, and the footer.

Email preview

Visit /dev/emails in development to render all templates in the browser. No emails sent, no API credits used. The route is disabled in production.

Adding a different provider

Warpkit uses the resend npm package and nodemailer directly. To add Mailgun, Postmark, SendGrid, AWS SES, or others, follow the React Email integrations guide.

The pattern is the same for all providers:

  1. Install the provider's SDK
  2. Add a new branch in src/server/email.ts for EMAIL_PROVIDER=<name>
  3. Call render(params.react) from @react-email/render to get the HTML string
  4. Pass it to the provider's send function

Example adding Postmark:

import { ServerClient } from 'postmark';

if (provider === 'postmark') {
  const client = new ServerClient(process.env.POSTMARK_API_TOKEN ?? '');
  const html = await render(params.react);
  const result = await client.sendEmail({
    From: `${config.resend.fromName} <${config.resend.fromEmail}>`,
    To: Array.isArray(params.to) ? params.to.join(',') : params.to,
    Subject: params.subject,
    HtmlBody: html,
  });
  return ok({ id: result.MessageID });
}