A self-hosted email sending service on Cloudflare Workers, built on the
native send_email binding (Cloudflare Email Service Workers API). No third-party
email provider, no SMTP credentials — Cloudflare sends the mail directly from
your Worker.
- Web UI — compose emails (to/cc/bcc, HTML + plain text, file attachments), manage templates, and browse send history. Dark/light auto theme.
- REST API —
POST /api/sendwith a single Bearer token. - Templates — stored in KV with
{{variable}}substitution. - Send history — bounded log in KV (newest first), bcc kept as count only.
- Rate limiting — per-token fixed window (default 60/hour).
- Full validation — recipient/attachment/size limits checked before send,
Cloudflare
E_*error codes surfaced back to the caller.
Single Worker, no build step:
| File | Role |
|---|---|
src/index.js |
Router, auth, error mapping, send handler |
src/validation.js |
Payload validation + Cloudflare limits |
src/templates.js |
KV templates + {{var}} rendering |
src/history.js |
KV send log |
src/ratelimit.js |
Per-token fixed-window limiter |
src/html.js / src/app.js |
Single-page UI |
The send_email binding can only send from a domain onboarded to Email
Service. This is a one-time dashboard step:
- Cloudflare dashboard → Compute → Email Service → Email Sending
- Onboard Domain → pick your domain → Done
Cloudflare adds the required DNS records (MX/SPF/DKIM on cf-bounce.<domain>,
DMARC on _dmarc.<domain>). Propagation is usually 5-15 min on Cloudflare DNS.
Until this is done, /api/send returns E_INTERNAL_SERVER_ERROR /
E_SENDER_DOMAIN_NOT_AVAILABLE.
npm install
wrangler kv namespace create POSTQ_KV # paste id into wrangler.toml
wrangler secret put POSTQ_API_TOKEN # your API / UI token
wrangler deployAdjust SEND_DOMAIN, DEFAULT_FROM_LOCAL, and the rate-limit vars in
wrangler.toml.
All /api/* routes require Authorization: Bearer <POSTQ_API_TOKEN>.
curl -X POST https://postq.YOUR-SUBDOMAIN.workers.dev/api/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@yourdomain.com",
"to": ["alice@example.com"],
"cc": ["bob@example.com"],
"subject": "Hello",
"html": "<h1>Hi</h1>",
"text": "Hi"
}'Using a template:
curl -X POST .../api/send -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"a@example.com","template":"welcome","vars":{"name":"Ada"}}'from defaults to DEFAULT_FROM_LOCAL@SEND_DOMAIN if omitted. Explicit fields
override template output. Attachments use base64 content + filename + type.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/info |
domain + limits |
| GET | /api/templates |
list templates |
| PUT | /api/templates |
create/update {name,subject,html,text} |
| DELETE | /api/templates/:name |
delete template |
| GET | /api/history |
recent sends |
| DELETE | /api/history |
clear history |
| GET | /healthz |
public health check |
Validation and Cloudflare send errors return {success:false, code, error} with
an appropriate HTTP status (400 validation, 401 auth, 429 rate/daily limit,
422 suppressed, 500 internal). See Cloudflare's
error code reference.
- Max 50 combined recipients (to + cc + bcc)
- Max 32 attachments
- Max 5 MiB total message size
node dev-server.mjs # http://localhost:8791 (token: devtoken)Mocks the EMAIL binding (logs instead of sending) and uses in-memory KV.
npm test # 27 tests: validation, templates, routing, rate limitMIT