Email & Newsletter

11 min readUpdated 27 Apr 2026

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).

.env.local
# 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=
bash

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.

src/lib/email/send.ts (pattern)
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) };
  }
}
typescript

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 fileTriggered by
verification-email.tsxsignup.ts on account creation
welcome-email.tsx/auth/verify page after email confirm
magic-link-email.tsxsendMagicLink() in magic-link.ts
trial-ending-email.tsxhandleTrialWillEnd Stripe webhook (3 days before)
trial-day1-email.tsxTrial lifecycle cron (day 1)
trial-day7-email.tsxTrial lifecycle cron (day 7)
payment-failed-email.tsxhandleInvoicePaymentFailed Stripe webhook
subscription-cancelled-email.tsxhandleSubscriptionDeleted Stripe webhook
subscription-refunded-email.tsxhandleChargeRefunded Stripe webhook
team-invite-email.tsxTeam invite server action
password-reset-email.tsxforgotPasswordAction

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).

DNS records for Resend
# 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]
bash

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

FileResponsibility
src/lib/email/client.tsgetEmailTransport(), getFromAddress()
src/lib/email/send.tsTyped per-template send helpers
src/lib/email/transport.tsEmailTransport, EmailPayload, SendResult types
src/lib/email/smtp.tsnodemailer transporter factory (server-only)
src/lib/email/env-schema.tsZod discriminated union for EMAIL_TRANSPORT validation
src/emails/All React Email template components
src/app/api/health/email/route.tsTransport status + test send endpoint