Email & Newsletter
Email in VeloCMS covers two distinct surfaces: transactional email (verification, magic links, payment receipts, team invitations) and newsletter blasts (bulk campaigns to blog subscribers). Transactional email uses a pluggable transport abstraction that works with Resend by default and any SMTP relay for self-hosted deployments. Newsletter infrastructure runs through the newsletter_campaigns and newsletter_events collections.
Transport selection
The EMAIL_TRANSPORT environment variable switches the active transport at startup. Changing it requires no code modification — only the env var. Resend is the default for velocms.org (SaaS). Self-hosted deployments typically point to a local relay (Mailpit for development, Postfix or a managed SMTP provider for production).
# Default: Resend managed delivery
EMAIL_TRANSPORT=resend
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=[email protected]
# Alternative: SMTP relay (self-hosted, Mailpit, Postfix, Gmail SMTP)
EMAIL_TRANSPORT=smtp
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER= # blank = unauthenticated relay
SMTP_PASS=
SMTP_SECURE=false # true = TLS from connect (port 465)
SMTP_FROM_EMAIL=[email protected]
# Mailpit local dev (no auth):
# SMTP_HOST=localhost SMTP_PORT=1025 SMTP_USER= SMTP_PASS=How email sending works
All email goes through the sendEmail() internal helper in src/lib/email/send.ts. It renders the React Email component to an HTML string using @react-email/render, then passes the HTML to the active transport via getEmailTransport(). Each template has a typed helper function (sendVerificationEmail, sendMagicLinkEmail, etc.) that callers import directly — no transport decision at the call site.
import { getEmailTransport, getFromAddress } from "./client";
import { render } from "@react-email/render";
import MagicLinkEmail from "@/emails/magic-link-email";
export async function sendMagicLinkEmail(params: {
to: string;
blogName: string;
signInUrl: string;
}): Promise<SendResult> {
try {
const html = await render(<MagicLinkEmail {...params} />);
const transport = getEmailTransport();
return transport.send({
from: getFromAddress(),
to: params.to,
subject: `Sign in to ${params.blogName}`,
html,
tags: [{ name: "template", value: "magic-link" }], // Resend analytics only
});
} catch (err) {
// Email failures are non-fatal — never throw from send helpers
log.error("email: send exception", { tag: "magic-link", error: String(err) });
return { success: false, error: String(err) };
}
}React Email templates
Every template in src/emails/ is a React component with a typed props interface. Templates use @react-email/components primitives (Html, Head, Body, Section, Text, Button, etc.) which render consistently across email clients. The rendered HTML is the same regardless of transport — only the delivery path differs.
| Template file | Triggered by |
|---|---|
| verification-email.tsx | signup.ts on account creation |
| welcome-email.tsx | /auth/verify page after email confirm |
| magic-link-email.tsx | sendMagicLink() in magic-link.ts |
| trial-ending-email.tsx | handleTrialWillEnd Stripe webhook (3 days before) |
| trial-day1-email.tsx | Trial lifecycle cron (day 1) |
| trial-day7-email.tsx | Trial lifecycle cron (day 7) |
| payment-failed-email.tsx | handleInvoicePaymentFailed Stripe webhook |
| subscription-cancelled-email.tsx | handleSubscriptionDeleted Stripe webhook |
| subscription-refunded-email.tsx | handleChargeRefunded Stripe webhook |
| team-invite-email.tsx | Team invite server action |
| password-reset-email.tsx | forgotPasswordAction |
Adding a new template
- Create src/emails/<name>-email.tsx with a typed props interface
- Use @react-email/components primitives for HTML structure
- Add a sendXxxEmail() typed helper in src/lib/email/send.ts
- Import the component and call the shared sendEmail() helper
- Call the helper from the relevant flow (webhook handler, server action)
- Test locally by rendering the HTML: await render(<YourTemplate />)
Deliverability setup
For Resend: verify your sending domain in the Resend dashboard (add SPF and DKIM records to your DNS). Resend provides the exact DNS record values. Once verified, emails sent from that domain have strong deliverability. The production API key should be a send-only restricted key (re_...) — it cannot list domains or read webhook state, but send-only scope is safer (P-020).
# Add these records to your domain DNS (values from Resend dashboard):
Type Name Value
TXT @ v=spf1 include:amazonses.com ~all
CNAME resend._domainkey resend._domainkey.yourdomain.com.dkim.resend.com
# For SMTP: ensure your server has PTR records matching the sending IP.
# Gmail and Yahoo require DMARC:
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:[email protected]Double opt-in
Tenants can enable double opt-in for newsletter subscriptions in Admin > Settings > Newsletter. When enabled, new members start with newsletter_subscribed = false. After the magic link email, a second confirmation email is sent containing a confirm URL. The /member/confirm-newsletter route consumes the newsletter_confirm token and flips newsletter_subscribed to true. Existing members who re-login are not affected — their subscription state is preserved.
Suppression list
Members can unsubscribe via the HMAC-signed link in every newsletter email (/member/unsubscribe?token=... ). The token is computed as HMAC-SHA256 of email + tenant_id using a per-tenant secret. On valid token, the handler sets newsletter_subscribed = false on the blog_members record without requiring the member to be logged in. The suppression is permanent until the member re-subscribes.
Newsletter blast system
Newsletter campaigns are stored in the newsletter_campaigns collection. Each campaign has a subject, html body, and a status (draft, scheduled, sending, sent, failed). The blast cron reads campaigns with status = scheduled and scheduled_at <= now, iterates over subscribed members (newsletter_subscribed = true, tier != banned), sends each email via the send.ts helper, and writes a newsletter_events record per delivery. Delivery events are used to compute open rates and click rates.
Health check endpoint
GET /api/health/email returns the current email transport status (resend or smtp) and the last-used from address. POST /api/health/email sends a live test email to a specified address. The endpoint is protected and useful for confirming that a new email configuration actually delivers before going live with a campaign.
Reference
| File | Responsibility |
|---|---|
| src/lib/email/client.ts | getEmailTransport(), getFromAddress() |
| src/lib/email/send.ts | Typed per-template send helpers |
| src/lib/email/transport.ts | EmailTransport, EmailPayload, SendResult types |
| src/lib/email/smtp.ts | nodemailer transporter factory (server-only) |
| src/lib/email/env-schema.ts | Zod discriminated union for EMAIL_TRANSPORT validation |
| src/emails/ | All React Email template components |
| src/app/api/health/email/route.ts | Transport status + test send endpoint |