| name | transactional-email |
| description | How to write and send transactional emails (welcome, first-deploy, notifications) from Cloudflare Workers. Covers the preferred personal writing style (no headings, Gmail-default look, dark mode), building HTML with plain template strings, the send_email wrangler binding, previewing in light/dark mode with Playwriter, test-sending real emails via a temp worker without deploying, and one-off scripts that email specific users via the cloudflare SDK (plan changes, bug notices). ALWAYS load this skill when adding, editing, testing, or sending transactional emails in a project.
|
Transactional email on Cloudflare
Emails must look like they were manually written by a person, not designed by a marketing
team. Build them as plain HTML template strings, send them through the Cloudflare send_email
binding, and always preview them in light + dark mode before shipping.
Every email MUST set a reply_to
Sending subdomains (like tommy.akarso.co) usually have no MX or A records, so replies
to the from address silently bounce — while the email copy actively invites replies
("just reply to this email"). Every send, whether via the send_email binding or the
cloudflare SDK, MUST include a reply_to.
Rules:
- The reply-to address is a user choice — never guess it. Ask the user which email to
use, then save it in the project's AGENTS.md (see the "Record the sending domain" section)
as the preferred reply-to so future agents don't have to ask again.
- Prefer an address connected to a real inbox the user reads (usually Gmail), NOT an
address behind Cloudflare Email Routing or other forwarding — a chain of routing hops adds
failure points and hurts deliverability of the reply.
- Verify the from domain's DNS when in doubt:
dig +short MX <sending-domain>. No MX and no
A record means replies to that address bounce.
const FROM = { address: 'tommy@tommy.akarso.co', name: 'Tommy' }
const REPLY_TO = { address: 'tommy@holocron.so', name: 'Tommy' }
await client.emailSending.send({
account_id: ACCOUNT_ID,
from: FROM,
reply_to: REPLY_TO,
to,
subject,
html,
})
Writing style rules
- NO headings (
h1/h2), no logo header, no URL cards/boxes, no <hr> dividers, no branded footer.
- Only formatting a human would use in Gmail's compose box: bold, links, lists, inline code.
- Personal tone: open with "Hey,", end with "If anything looks off, just reply to this email"
and a first-name sign-off. Encourage replies — replies build trust and surface bugs.
- Subjects are plain sentences:
Your docs for owner/repo are live. No em-dashes, no
"🎉 Announcing…" style.
- Keep it short. One purpose per email: the key link, one short list of next steps, sign-off.
Gmail-default styling
Match Gmail compose defaults so the email blends in with human-written mail:
font-family: Arial, Helvetica, sans-serif; font-size: 14px; line-height: 1.5; color: #222
- Links: default blue
#15c, keep the underline (never text-decoration: none)
- Content wrapper
max-width: 600px, left-aligned, no centering chrome
- Inline code:
font-family: monospace; font-size: 0.9em; background: rgba(128,128,128,0.15); padding: 1px 4px; border-radius: 3px.
The gray-alpha background works in BOTH light and dark mode without a media query.
Dark mode
Include color-scheme metas and one small prefers-color-scheme block. Nothing else:
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<style>
body { background-color: #ffffff; }
a { color: #15c; }
@media (prefers-color-scheme: dark) {
body { background-color: #1f1f1f !important; color: #e3e3e3 !important; }
a { color: #8ab4f8 !important; }
}
</style>
#8ab4f8 is Gmail's dark-mode link blue.
Build HTML with plain template strings — never React/JSX
Never render emails with React or framework-tied renderers. renderToStaticMarkup from
spiceflow/federation only works inside the Vite RSC runtime; react-dom/server is unavailable
under the react-server condition. Framework-rendered emails cannot be previewed from node
scripts or test-sent from plain workers. Plain strings work in every runtime.
Reference template (adapt the body copy per email):
import dedent from 'string-dedent'
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
function code(text: string): string {
return `<code style="font-family: monospace; font-size: 0.9em; background-color: rgba(128, 128, 128, 0.15); padding: 1px 4px; border-radius: 3px;">${escapeHtml(text)}</code>`
}
function link(href: string, text: string): string {
return `<a href="${escapeHtml(href)}">${escapeHtml(text)}</a>`
}
export function buildWelcomeEmailHtml(data: { repo: string; url: string; branch: string }): string {
const = dedent
}
Rules:
- Escape every interpolated value with
escapeHtml (user names, repo names, URLs).
<ul> needs margin: 0 0 16px 0; padding-left: 24px to look right in mail clients.
- Validate every URL in the email with curl (expect 200) before shipping.
Sending via the Cloudflare send_email binding
wrangler.jsonc — remote: true makes the binding work in local dev / wrangler dev:
{
"send_email": [{ "name": "EMAIL", "remote": true }]
}
The binding has a builder-style send() overload — no need to construct raw MIME
EmailMessage objects. Workers use spiceflow; get env and waitUntil from
cloudflare:workers and send from inside a route handler:
import { env, waitUntil } from 'cloudflare:workers'
import { Spiceflow } from 'spiceflow'
const app = new Spiceflow().route({
method: 'POST',
path: '/api/signup',
handler: async ({ request }) => {
waitUntil(sendWelcomeEmail({ to: userEmail }))
return { ok: true }
},
})
async function sendWelcomeEmail({ to, data }: { to: string; data: WelcomeEmailData }): Promise<void> {
try {
await env.EMAIL.send({
from: { email: 'tommy@yourdomain.com', name: 'Tommy' },
replyTo: { email: 'tommy@real-inbox.com', name: 'Tommy' },
to,
subject: buildWelcomeEmailSubject(data),
html: buildWelcomeEmailHtml(data),
})
} (err) {
(err ? err : ((err)), {
: { : , : },
})
}
}
Email must be best-effort: fire it via waitUntil(), wrap in try/catch, report failures
to error tracking. Never await it in the response path and never let it throw.
The from domain must have Cloudflare Email Routing enabled with the sender address configured.
Record the sending domain in the project's AGENTS.md
The sending domain, from address, and preferred reply-to address are a user choice —
never guess them. Ask the user which sender and reply-to to use, and once they provide them,
save both in the project's AGENTS.md so future agents don't have to ask again:
## Email sending
Transactional emails send via Cloudflare Email Service. The sending domain is
`tommy.akarso.co`; the from address is `tommy@tommy.akarso.co` (name "Tommy").
Every email MUST set `reply_to` to the preferred reply-to address
`tommy@holocron.so` — the sending subdomain has no MX records, replies to it bounce.
If AGENTS.md already documents a sending domain and reply-to, use them without asking.
Previewing an email
Add a small tsx script per email that writes the rendered HTML to tmp/:
import fs from 'node:fs'
const html = buildWelcomeEmailHtml({ repo: 'owner/repo', url: 'https://example.com', branch: 'main' })
fs.mkdirSync('tmp', { recursive: true })
fs.writeFileSync('tmp/welcome-email.html', html)
Then screenshot BOTH color schemes with Playwriter and inspect them:
await state.page.goto('file:///abs/path/tmp/welcome-email.html')
await state.page.emulateMedia({ colorScheme: 'light' })
await state.page.screenshot({ path: '/tmp/email-light.png', scale: 'css' })
await state.page.emulateMedia({ colorScheme: 'dark' })
await state.page.screenshot({ path: '/tmp/email-dark.png', scale: 'css' })
Always check both screenshots yourself before telling the user the email is done.
Test-sending a real email without deploying
Use a throwaway worker with the remote binding — wrangler dev proxies send_email to the
real Cloudflare account, so the email actually sends. No deploy needed.
- Create the two files inside the project's gitignored
tmp/ dir (NOT /tmp) so wrangler's
bundler resolves spiceflow and the email builder from the project's node_modules:
{
"name": "email-test",
"main": "worker.ts",
"compatibility_date": "2026-04-14",
"send_email": [{ "name": "EMAIL", "remote": true }]
}
import { env } from 'cloudflare:workers'
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
const app = new Spiceflow().route({
method: 'POST',
path: '/',
request: z.object({ to: z.string(), subject: z.string(), html: z.string() }),
handler: async ({ request }) => {
const { to, subject, html } = await request.json()
await env.EMAIL.send({ from: { email: 'tommy@yourdomain.com', name: 'Tommy' }, to, subject, html })
return { sent: true }
},
})
export default {
fetch(request: Request) {
return app.handle(request)
},
}
- Run it with the project's wrangler (so auth/account come from the real project), in a
tuistory background session:
bunx tuistory launch "pnpm --dir <project> exec wrangler dev --config <project>/tmp/email-test/wrangler.jsonc --port 8799" -s email-test
bunx tuistory -s email-test wait "/Ready on/i" --timeout 60000
- POST the rendered HTML:
node --input-type=module -e "
import fs from 'node:fs'
const html = fs.readFileSync('tmp/welcome-email.html', 'utf8')
const res = await fetch('http://localhost:8799', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to: 'user@example.com', subject: 'Test subject', html }) })
console.log(res.status, await res.text())
"
- Clean up:
bunx tuistory -s email-test press ctrl c then bunx tuistory -s email-test close.
One-off emails to specific users from a script
When the user asks to email specific customers — a plan change that affects them, a bug
they hit, a refund notice — do it with a plain tsx script using the official cloudflare
npm SDK. No worker, no wrangler dev: client.emailSending.send() hits the Email Service
REST API (POST /accounts/{id}/email/sending/send) directly from Node.
Auth reuses the local wrangler login. On macOS with current wrangler the OAuth token lives at
~/Library/Preferences/.wrangler/config/default.toml. Tokens expire after ~1h; on a 401 just
run wrangler whoami to refresh.
import fs from 'node:fs'
import Cloudflare from 'cloudflare'
import dedent from 'string-dedent'
const ACCOUNT_ID = '<cloudflare account id>'
const FROM = { address: 'tommy@yourdomain.com', name: 'Tommy' }
const REPLY_TO = { address: 'tommy@real-inbox.com', name: 'Tommy' }
function getWranglerOAuthToken(): string {
const path = `${process.env.HOME}/Library/Preferences/.wrangler/config/default.toml`
const token = fs.readFileSync(path, 'utf8').match(/oauth_token\s*=\s*"([^"]+)"/)?.[1]
if (!token) {
console.error(`no oauth_token in ${path}, run 'wrangler login' first`)
process.exit()
}
token
}
recipients = [, ]
() {
active =
: < > = []
run<T>(: <T>): <T> {
(active >= max) <>( waiters.(resolve))
active++
{
()
} {
active--
waiters.()?.()
}
}
}
() {
client = ({ : () })
limit = ()
results = .(
recipients.(
( () => {
.()
result = client..({
: ,
: ,
: ,
to,
: ,
: (),
: dedent,
})
.()
{ to, : result. }
}),
),
)
.()
}
().( {
.(, err)
process.()
})
Rules for these scripts:
- Log progress per recipient (email + returned
message_id) so a crash mid-run shows
exactly who already got the email; the response also has delivered / queued /
permanent_bounces arrays worth logging on failure.
- SDK gotcha:
from / reply_to objects are { address, name }, NOT { email, name }.
Getting it wrong returns a vague 400 email.sending.error.invalid_request_schema.
- Send with Promise.all capped by a semaphore at 10 concurrent — fast, but bounded so a
big list doesn't blast the API. If any send rejects, the logged per-recipient lines tell
you who already got the email before resuming.
- Preview the HTML (light + dark screenshots) and send to the user's own address first for
approval before emailing customers.
- Same writing style rules as every other email: personal, short, no headings, reply-friendly.
Attachments (ICS, PDF, etc.)
The send_email binding's builder send() supports attachments natively. No hand-rolled MIME or mimetext needed:
await env.EMAIL.send({
from: { email: 'notifications@example.com', name: 'App' },
to,
subject,
html,
attachments: [
{
disposition: 'attachment',
filename: 'invite.ics',
type: 'text/calendar',
content: icsString,
},
],
})
Only use the raw new EmailMessage(from, to, rawMime) overload from cloudflare:email when you need full MIME control (e.g. inline images with Content-ID references).
Gotchas
- spiceflow/federation
renderToStaticMarkup throws outside Vite RSC — this is why emails
must be plain strings, not JSX.
- Cloudflare Email Routing may restrict which destination addresses accept mail depending on
the zone setup; if a test send errors on the destination, verify the address in Email Routing.
tmp/ preview output should be gitignored; check with git check-ignore before committing.
- Real-world reference implementation:
website/src/deploy-email.ts in the holocron repo.
remote: true can crash the vite dev worker. With send_email set to remote: true, the dev worker sometimes dies with Error: internal error; reference = ... and stops accepting connections. Restart the dev server session; nothing is wrong with the code.