A complete Worker: it accepts a POST with a recipient’s address and first name, renders a template from KV at the edge, and sends it. No API key for Maildeno, no network call for rendering, nothing to deploy beside the Worker itself.
What you will build
POST / { "email": "…", "firstName": "…" }
│
├─ read template JSON from a KV binding
├─ render() — local Wasm, no network
└─ POST to your ESP
│
└─ 202 Accepted
Prerequisites
-
A Cloudflare account and
wranglerinstalled -
An ESP account with a verified sending domain — every provider requires this
-
A template exported from the editor as JSON
1. Create the project
npm create cloudflare@latest maildeno-worker -- --type=hello-world
cd maildeno-worker
npm install @maildeno/renderer
Then whichever your ESP needs:
npm install resend # resend.ts only
npm install aws4fetch # aws-ses.ts only
# Postmark needs nothing beyond @maildeno/renderer
2. Configure the KV binding
wrangler kv namespace create TEMPLATES
Add the returned id to wrangler.jsonc:
{
"name": "maildeno-worker",
"main": "src/index.ts",
"compatibility_date": "2026-01-01",
"kv_namespaces": [
{ "binding": "TEMPLATES", "id": "<the id wrangler printed>" }
]
}
Nothing needs adding for @maildeno/renderer itself. Wrangler sets the workerd export condition automatically, and the engine is embedded in the bundle.
|
3. Upload the template
wrangler kv key put --binding=TEMPLATES welcome --path=./templates/welcome.json
| Store templates in KV rather than bundling them when non-engineers change them. Bundle them when they change on the same cadence as your code — a JSON import is simpler and has no runtime failure mode. |
4. Set secrets
wrangler secret put stores these encrypted and separate from wrangler.jsonc, so they are never committed:
wrangler secret put RESEND_API_KEY # resend
wrangler secret put POSTMARK_SERVER_TOKEN # postmark
wrangler secret put AWS_ACCESS_KEY_ID # ses
wrangler secret put AWS_SECRET_ACCESS_KEY # ses
5. The Worker
Resend
Resend’s SDK is fetch-based and works in Workers directly.
// src/index.ts
import { render, RenderError } from "@maildeno/renderer"
import { Resend } from "resend"
interface Env {
TEMPLATES: KVNamespace
RESEND_API_KEY: string
}
interface Body { email: string; firstName: string; plan?: string }
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 })
}
let body: Body
try {
body = await request.json()
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 })
}
if (!body.email || !body.firstName) {
return Response.json(
{ error: "email and firstName are required" },
{ status: 400 },
)
}
// KV's "json" type parses for us, so this goes straight into render().
const template = await env.TEMPLATES.get("welcome", "json")
if (!template) {
return Response.json({ error: "Template not found" }, { status: 404 })
}
let html: string
try {
html = await render(template, {
mergeTags: {
text: { first_name: body.firstName },
url: { cta: "https://app.example.com/start" },
},
context: { plan: body.plan ?? "free" },
})
} catch (err) {
if (err instanceof RenderError) {
// The code distinguishes a bad template from a broken engine —
// one is your data, the other is an incident.
console.error("render failed", err.code, err.message)
return Response.json({ error: err.code }, { status: 500 })
}
throw err
}
const resend = new Resend(env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: "hello@example.com", // must be a verified domain
to: body.email,
subject: `Welcome, ${body.firstName}`,
html,
})
if (error) {
console.error("send failed", error)
return Response.json({ error: "Send failed" }, { status: 502 })
}
return new Response(null, { status: 202 })
},
}
Postmark
Postmark’s official SDK is built around Node’s https module, which is not available at the edge. Its REST API is a single plain POST, so call it with fetch() directly.
import { render } from "@maildeno/renderer"
interface Env {
TEMPLATES: KVNamespace
POSTMARK_SERVER_TOKEN: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { email, firstName } = await request.json<{
email: string; firstName: string
}>()
const template = await env.TEMPLATES.get("welcome", "json")
if (!template) return new Response("Template not found", { status: 404 })
const html = await render(template, {
mergeTags: { text: { first_name: firstName } },
})
const res = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
"X-Postmark-Server-Token": env.POSTMARK_SERVER_TOKEN,
},
body: JSON.stringify({
From: "hello@example.com",
To: email,
Subject: `Welcome, ${firstName}`,
HtmlBody: html,
MessageStream: "outbound",
}),
})
return new Response(null, { status: res.ok ? 202 : 502 })
},
}
Amazon SES
AWS SDK v3 has documented problems in restricted runtimes — it reaches for DOMParser, among other assumptions. Use aws4fetch, a small fetch plus Web Crypto request signer that Cloudflare’s own documentation recommends for this situation.
import { render } from "@maildeno/renderer"
import { AwsClient } from "aws4fetch"
interface Env {
TEMPLATES: KVNamespace
AWS_ACCESS_KEY_ID: string
AWS_SECRET_ACCESS_KEY: string
}
const REGION = "us-east-1"
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { email, firstName } = await request.json<{
email: string; firstName: string
}>()
const template = await env.TEMPLATES.get("welcome", "json")
if (!template) return new Response("Template not found", { status: 404 })
const html = await render(template, {
mergeTags: { text: { first_name: firstName } },
})
const aws = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
service: "ses",
region: REGION,
})
const res = await aws.fetch(
`https://email.${REGION}.amazonaws.com/v2/email/outbound-emails`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
FromEmailAddress: "hello@example.com",
Destination: { ToAddresses: [email] },
Content: {
Simple: {
Subject: { Data: `Welcome, ${firstName}` },
Body: { Html: { Data: html } },
},
},
}),
},
)
return new Response(null, { status: res.ok ? 202 : 502 })
},
}
6. Deploy and test
npx wrangler deploy
curl -X POST https://maildeno-worker.<subdomain>.workers.dev \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","firstName":"Ada"}'
Local development:
npx wrangler dev
Use wrangler dev, not a plain Node server. wrangler dev runs the real workerd runtime and therefore the real edge build. A Node dev server resolves the Node build, which accepts a path — so a bug where you pass a path instead of an object will not appear until deploy.
|
Production considerations
Cache the template
Every request currently reads from KV. KV reads are fast and cached at the edge, but you can skip them entirely for a hot template by caching the parsed object in module scope:
let cached: unknown | null = null
async function getTemplate(env: Env) {
if (cached) return cached
cached = await env.TEMPLATES.get("welcome", "json")
return cached
}
Module scope persists for the life of the isolate, so this amortises across many requests. It also means a template update takes effect when isolates recycle rather than immediately — fine for most cases, wrong if you need read-after-write. Add a version suffix to the KV key if you need a deterministic cutover.
Authenticate the endpoint
The Worker above will send email to any address anyone POSTs. At minimum:
if (request.headers.get("authorization") !== `Bearer ${env.INTERNAL_TOKEN}`) {
return new Response("Unauthorized", { status: 401 })
}
Do the send after the response
If the caller does not need to know whether delivery succeeded, waitUntil returns sooner and keeps the send alive:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// … render …
ctx.waitUntil(sendEmail(html, email, env))
return new Response(null, { status: 202 })
},
}
Weigh that against losing the ability to report a send failure to the caller.
Queue high volume
For a batch send, put recipients on a Cloudflare Queue and render per message in the consumer. Rendering is cheap; the ESP’s rate limit is what you are pacing against.
export default {
async queue(batch: MessageBatch<Recipient>, env: Env) {
const template = await getTemplate(env)
for (const msg of batch.messages) {
const html = await render(template, {
mergeTags: { text: { first_name: msg.body.firstName } },
context: { plan: msg.body.plan },
})
await send(html, msg.body.email, env)
msg.ack()
}
},
}
Bundle size
The edge build embeds the engine as base64, roughly 90 KB brotli-compressed. Check it:
npx wrangler deploy --dry-run --outdir=dist
If you are near the limit, @maildeno/renderer/core with Wrangler’s native .wasm import uploads the engine as a separate module — see Bundle size.
Where to go next
-
Browsers and edge runtimes — Vercel Edge, Deno, and the browser
-
@maildeno/renderer— the full API reference -
Serverless — Lambda and other Node-based serverless