Transactional email
Send welcome, verification, password-reset and other transactional email through Cloudflare Email Service with a single sendEmail() transport.
Transactional email
Valcraven sends transactional email — welcome, email verification, password reset, subscription and payment notices, waitlist invites — through Cloudflare Email Service. Everything goes through one transport, sendEmail(), and a set of ready-made sender functions in lib/email.ts. Templates are React Email components under emails/.
You don't wire up an SMTP server or a third-party API: on Cloudflare Workers the email binding handles delivery with zero config.
Where it lives
| Piece | Path |
|---|---|
| Transport + all senders | apps/web/lib/email.ts |
| Email templates (React Email) | apps/web/emails/*.tsx |
| Self-hosted open/click tracking | apps/web/lib/email-tracking.ts |
| Delivery / bounce poller | apps/web/lib/email-delivery.ts |
| Workers binding config | apps/web/wrangler.toml → [[send_email]] name = "EMAIL" |
The sendEmail() transport
All mail funnels through one function with a three-tier fallback, chosen at runtime:
- Workers binding (
env.EMAIL.send) — production on Cloudflare Workers. Zero config, no API tokens; the binding authenticates itself. - REST API — for non-Workers environments (Docker, a VPS) where
CF_EMAIL_API_TOKEN+CLOUDFLARE_ACCOUNT_IDare set. - Console fallback — dev mode with no provider configured. The email (and any links, like a verification URL) is printed to stdout so you can click it locally.
import { sendEmail, FROM } from "@/lib/email";
await sendEmail({
from: FROM,
to: "user@example.com",
subject: "Hello",
html: "<p>Hi there</p>",
});sendEmail() returns { messageId?: string }. The message id is best-effort (from the binding result or parsed from the REST response) and is used only to correlate delivery/bounce status later — it is never required for a send to succeed.
Built-in senders
lib/email.ts exports ready-to-use functions, each of which renders its template and calls sendEmail():
| Function | Sent when |
|---|---|
sendWelcomeEmail(email) | A user signs up |
sendVerificationEmail(email, url) | Email verification (Better Auth) |
sendPasswordResetEmail(email, url) | Password reset request |
sendLifetimePurchaseEmail(email) | A lifetime purchase completes |
sendWaitlistInviteEmail(email, inviteCode, unsubscribeToken?) | An admin invites a waitlisted user |
sendSubscriptionConfirmationEmail(email, plan, unsubscribeToken?) | A subscription starts |
sendSubscriptionCancelledEmail(email, unsubscribeToken?) | A subscription is cancelled |
sendPaymentFailedEmail(email, unsubscribeToken?) | A payment fails |
These senders never throw. Each wraps its work in a try/catch and logs failures with
console.error— a failed email won't break the request that triggered it. If you need to know whether a send succeeded, callsendEmail()directly and inspect the result.
The lifecycle emails accept an optional unsubscribeToken. When present, sendEmail() adds List-Unsubscribe / List-Unsubscribe-Post headers (RFC 8058 one-click unsubscribe) and the template renders an unsubscribe link. See Waitlist & subscribers for how those tokens are issued.
Configuration
Three env vars drive the sender identity and links:
| Variable | Purpose | Default |
|---|---|---|
APP_NAME | Display name used in subjects and templates | Valcraven |
EMAIL_FROM | The From: address | ${APP_NAME} <noreply@YOUR_DOMAIN> |
APP_URL | Base URL for links inside emails | https://YOUR_DOMAIN |
On Cloudflare Workers, the only prerequisite is to onboard your sending domain at Email Sending in the Cloudflare dashboard. Outside Workers, set CF_EMAIL_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to use the REST transport.
EMAIL_FROMmust be a real, verified sender on your domain. If you leave it unset it falls back to thenoreply@YOUR_DOMAINplaceholder, and every send silently fails (the senders swallow the error). Set it as a Worker var/secret before expecting mail to arrive.
What Cloudflare Email Service does and doesn't do
It is transactional-only, pull-based, and daily-rate-limited. Concretely:
- It tracks delivery only (Sent → Delivered → Delivery-failed → Rejected → Failed), exposed through the GraphQL Analytics API (dataset
emailSendingAdaptive, 31-day retention) — not webhooks. - It has no open/click tracking and no audiences/contacts API.
- Treat it as a channel for transactional mail and newsletters to your own opted-in subscribers — not a bulk marketing platform.
Because of that, Valcraven owns the engagement layer itself. See Email campaigns for self-hosted open/click tracking and the delivery/bounce poller.
Adding a new email
- Create a React Email template in
apps/web/emails/, following an existing one (e.g.welcome.tsx). - Add a sender to
lib/email.tsusing the existing pattern:
import { render } from "@react-email/components";
import YourEmail from "@/emails/your-email";
export async function sendYourEmail(email: string, data: YourData): Promise<void> {
try {
const html = await render(YourEmail({ appName: APP_NAME, appUrl: APP_URL, ...data }));
await sendEmail({ from: FROM, to: email, subject: `…`, html });
} catch (err) {
console.error("[email] sendYourEmail failed:", err);
}
}Delivery tracking
Since Cloudflare doesn't push delivery webhooks, an hourly cron poller (syncEmailDelivery() in lib/email-delivery.ts) reads the GraphQL Analytics API and records delivered / bounced / rejected status into the email_events table. A hard bounce also auto-unsubscribes the address. This requires CF_ANALYTICS_API_TOKEN (Zone Analytics: Read) + CLOUDFLARE_ZONE_ID; if unset, the poller logs "skipped" and the delivery tiles simply stay at zero. The full mechanism (and open/click tracking) is covered in Email campaigns.
Marketing automation (optional)
lib/marketing.ts is a provider-agnostic hook for a drip/lifecycle tool (Loops is wired). It activates only when LOOPS_API_KEY is set; otherwise trackSignup(), trackSubscriptionChange(), removeContact() and friends are no-ops. All calls are fire-and-forget.
Related
- Email campaigns — marketing sends, audiences, and engagement tracking
- Waitlist & subscribers — invites, newsletter subscribe/unsubscribe
- Authentication — verification and password-reset emails
- Admin dev wiki: Email & Campaign Tracking
Voice I/O
Talk to the AI assistant and have replies read back aloud — speech-to-text in the browser (free) and optional ElevenLabs text-to-speech that degrades gracefully.
Email campaigns
Admin email marketing on top of Cloudflare Email Service — tag-based audiences, a draft/scheduled/sending lifecycle, queued batched sends, and self-hosted open/click tracking.