ShipAgent

Email

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

  1. Copy a template in emails/ and change its props and text.
  2. Call sendEmail from server code with react set to the new component.
  3. Send from DEFAULT_ADMIN_EMAIL, and handle result.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; with NEXT_PUBLIC_EMAIL_NORMALIZATION_ENABLED=true it also normalizes provider-specific variants), sends newsletter-welcome.tsx and 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