The sendEmail function, the templates in emails/, which events send which mail, the newsletter and unsubscribe flow, and Discord notifications.
All mail goes through one function, sendEmail() in lib/mail/index.ts. It renders a React template to HTML, adds a plain-text version, and sends through Resend or Cloudflare Email Sending. Every mail the app sends comes from DEFAULT_ADMIN_EMAIL.
Setting up the sender domain and key is the setup-email skill (Email). This page covers the code.
| Layer | Files |
|---|---|
| Entry point | lib/mail/index.ts (sendEmail, getDefaultEmailProvider) |
| Providers | lib/mail/providers/resend.ts, lib/mail/providers/cloudflare.ts |
| Rendering | lib/mail/render.tsx |
| Retry | lib/mail/retry.ts |
| Contact list | lib/mail/contacts.ts |
| Templates | emails/*.tsx (react-email) |
| Payment mails | lib/billing/notify.ts |
| Newsletter | actions/newsletter/index.ts, app/[locale]/(basic-layout)/unsubscribe/newsletter/ |
| Discord | lib/discord/notifications.ts |
sendEmail
import { sendEmail } from '@/lib/mail'
import { UserWelcomeEmail } from '@/emails/user-welcome'
const result = await sendEmail({
to: 'someone@example.com', // string or string[]
from: { email: process.env.DEFAULT_ADMIN_EMAIL!, name: 'My Site' },
subject: 'Welcome',
react: UserWelcomeEmail, // or html: '<p>…</p>'
reactProps: { name: 'Ada', email: 'someone@example.com' },
addToContacts: true, // optional
})
if (!result.success) console.error(result.error)
sendEmail(options, provider?) takes a SendEmailOptions object and an optional provider name that overrides the default:
| Field | Type | Notes |
|---|---|---|
to |
string or string[] |
required |
from |
string or { email, name? } |
required |
subject |
string |
required |
react, reactProps |
a component and its props, or a React element | rendered to HTML; html wins when both are given |
html, text |
string |
text is generated from the HTML when omitted |
replyTo, cc, bcc, headers, attachments |
passed to the provider | |
addToContacts |
boolean |
after a successful send, add the first recipient to the contact list |
It never throws. It returns { success, provider, messageId?, error? }; the Cloudflare provider also returns delivered, queued and bounced lists. A provider call that fails with 429 or a 5xx status is retried once after 2 seconds.
lib/mail imports server-only, so it cannot run in a plain script. scripts/check-email.ts repeats its steps for the setup-email check.
Providers
DEFAULT_EMAIL_PROVIDER |
Needs | Contact list |
|---|---|---|
resend (the default, also when unset) |
RESEND_API_KEY |
Resend contacts, mirrored to a Redis set email:contacts |
cloudflare |
CLOUDFLARE_EMAIL_API_TOKEN (permission Account → Email Sending → Send) and CLOUDFLARE_ACCOUNT_ID |
Redis set only |
The Cloudflare provider sends through Cloudflare's REST API with the official cloudflare SDK. Without the provider's key, sendEmail returns success: false with an error naming the missing variable. Contact writes are best-effort and never fail a send; without Redis the Redis half is skipped.
The sender
Every mail is sent from DEFAULT_ADMIN_EMAIL, with the name DEFAULT_ADMIN_NAME or, when that is empty, siteConfig.name from config/site.ts. The domain of that address must be verified with the provider. Admin alerts go to the same address.
When DEFAULT_ADMIN_EMAIL is empty:
- magic link, sign-in code and team invitations throw, so those flows fail with an error;
- the welcome mail is skipped with a warning;
- payment alerts to the admin and payment mails to users are skipped;
- newsletter sign-up returns an error.
Which events send which mail
| Event | Template in emails/ |
To | Sent from |
|---|---|---|---|
| Magic link requested | magic-link-email.tsx (link valid 5 min) |
the user | lib/auth/index.ts |
| Sign-in code requested | otp-code-email.tsx (6 digits, valid 10 min) |
the user | lib/auth/index.ts |
| A new account is created (any method) | user-welcome.tsx, with an unsubscribe link; adds the user to contacts |
the user | lib/auth/index.ts, user.create.after hook |
| A team invites someone | organization-invitation.tsx (link to /accept-invitation/<id>, valid 48 h) |
the invitee | lib/auth/organization.ts |
| Newsletter sign-up | newsletter-welcome.tsx, with List-Unsubscribe headers; adds to contacts |
the subscriber | actions/newsletter |
A Stripe renewal payment fails (invoice.payment_failed) |
invoice-payment-failed.tsx |
the user | lib/billing/notify.ts |
| A paid order could not be fulfilled (any provider) | credit-upgrade-failed.tsx, subject "CRITICAL: Credit grant failed"; at most once per order per 24 h with Redis |
the admin | lib/billing/notify.ts |
Stripe Radar early fraud warning, email set |
fraud-warning-admin.tsx |
the admin | lib/billing/notify.ts |
Stripe Radar early fraud warning, refund set |
fraud-refund-user.tsx |
the buyer | lib/billing/notify.ts |
Mails sent from lib/billing/notify.ts never fail the webhook: an error is logged and the webhook still answers normally.
One mail does not use lib/mail: user feedback alerts (lib/alerts/feedback.ts) call Resend directly with their own ALERT_RESEND_KEY, ALERT_FROM and ALERT_TO, and are off unless all three are set. They are separate so a site can receive feedback alerts without RESEND_API_KEY turning on email sign-in and welcome mails.
Adding a mail
- Copy a template in
emails/and change its props and text. - Call
sendEmailfrom server code withreactset to the new component. - Send from
DEFAULT_ADMIN_EMAIL, and handleresult.success === false.
skills/reference/email.md has template patterns. Its examples that import @/lib/resend show an older layout; call sendEmail() instead.
Newsletter and unsubscribe
actions/newsletter/index.ts:
subscribeToNewsletter(email, locale?): rate-limited to 10 calls per IP per day, validates and lowercases the address (lib/email.ts; withNEXT_PUBLIC_EMAIL_NORMALIZATION_ENABLED=trueit also normalizes provider-specific variants), sendsnewsletter-welcome.tsxand adds the address to contacts.unsubscribeFromNewsletter(token, locale?): decodes the address and removes it from Resend contacts and the Redis set. Same rate limit.
The unsubscribe link in the welcome and newsletter mails is <NEXT_PUBLIC_SITE_URL>/unsubscribe/newsletter?token=<token>, where the token is the address encoded in base64. It is not signed: anyone who knows an address can unsubscribe it. Sign it if that matters for your product.
The starter collects contacts but has no screen for sending a newsletter. Send campaigns from the Resend dashboard to the contacts the app created.
Discord notifications
lib/discord/notifications.ts posts embeds to a Discord webhook. Nothing in it throws, so a failed notification never fails a payment webhook.
| Function | Fired when |
|---|---|
notifyNewOrder |
a paid order lands (through announceNewOrder in lib/billing/notify.ts, after the transaction commits) |
notifyDuplicateSubscription |
a second live subscription was cancelled and refunded |
notifyPendingPayment |
a PayPal capture is pending (eCheck) |
notifyPaymentDenied |
a pending PayPal capture is denied |
notifyDispute |
a Creem dispute is opened |
notifySignupGiftWithheld |
the signup-gift gate withheld welcome credits |
The webhook URL is DISCORD_ORDER_WEBHOOK_URL when set, else DISCORD_WEBHOOK_URL. Only DISCORD_WEBHOOK_URL is in .env.example; the order channel variable is read by the code but optional. To send your own message:
import { sendDiscordNotification } from '@/lib/discord/notifications'
await sendDiscordNotification({
webhookUrl: '', // empty falls back to DISCORD_WEBHOOK_URL
payload: { content: 'Something happened' },
})
When it fails
| Symptom | Likely cause | Fix |
|---|---|---|
RESEND_API_KEY is not set in the log |
The default provider is Resend and the key is missing | Run the setup-email skill |
| Resend answers that the domain is not verified | DEFAULT_ADMIN_EMAIL is on a domain without the DNS records |
Add the records (Domain) and wait for Resend to verify |
CLOUDFLARE_EMAIL_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are required |
DEFAULT_EMAIL_PROVIDER=cloudflare without both values |
Set both, or switch back to resend |
| No welcome mail, sign-in works | DEFAULT_ADMIN_EMAIL empty (warning in the log) |
Set it |
| No Discord messages | Neither webhook variable is set | Set DISCORD_WEBHOOK_URL |