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.
| Template | Sent when |
|---|---|
OtpEmail | OTP login code |
MagicLinkEmail | Magic link login |
WelcomeEmail | User signs up for the first time |
LeadConfirmationEmail | Email submitted to waitlist |
ReceiptEmail | One-time or credits purchase completes (queued via handleReceiptEmail job) |
OnboardingDay3Email | 3 days after signup (queued job) |
OnboardingDay7Email | 7 days after signup (queued job) |
TrialExpiringEmail | Trial subscription is about to end (queued job) |
ReengagementEmail | Inactive user re-engagement nudge (queued job) |
WinbackEmail | 7 days after a subscription is canceled (queued job) |
Marketing email and consent
Two kinds of email, with different rules:
| Kind | Emails | Consent | Unsubscribe link |
|---|---|---|---|
| Account | OTP, magic link, welcome, receipt, trial ending, security | Not needed: it's about the user's own account | None |
| Marketing | Onboarding day 3 and day 7, re-engagement, win-back | Opt-in | Footer 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:
- Install the provider's SDK
- Add a new branch in
src/server/email.tsforEMAIL_PROVIDER=<name> - Call
render(params.react)from@react-email/renderto get the HTML string - 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 });
}