Valcraven Docs
Features

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

PiecePath
Transport + all sendersapps/web/lib/email.ts
Email templates (React Email)apps/web/emails/*.tsx
Self-hosted open/click trackingapps/web/lib/email-tracking.ts
Delivery / bounce pollerapps/web/lib/email-delivery.ts
Workers binding configapps/web/wrangler.toml[[send_email]] name = "EMAIL"

The sendEmail() transport

All mail funnels through one function with a three-tier fallback, chosen at runtime:

  1. Workers binding (env.EMAIL.send) — production on Cloudflare Workers. Zero config, no API tokens; the binding authenticates itself.
  2. REST API — for non-Workers environments (Docker, a VPS) where CF_EMAIL_API_TOKEN + CLOUDFLARE_ACCOUNT_ID are set.
  3. 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():

FunctionSent 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, call sendEmail() 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:

VariablePurposeDefault
APP_NAMEDisplay name used in subjects and templatesValcraven
EMAIL_FROMThe From: address${APP_NAME} <noreply@YOUR_DOMAIN>
APP_URLBase URL for links inside emailshttps://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_FROM must be a real, verified sender on your domain. If you leave it unset it falls back to the noreply@YOUR_DOMAIN placeholder, 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

  1. Create a React Email template in apps/web/emails/, following an existing one (e.g. welcome.tsx).
  2. Add a sender to lib/email.ts using 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.

On this page