Cloudflare Worker email service for child apps: accept contact requests over HTTP, enqueue them, then send via Resend.
POST /api/contact POST /api/newsletter
↓ ↓
Queue (email-queue) Resend Contacts API
↓
Queue Consumer
↓
Resend Email API
- Producer + consumer in one Worker (Cloudflare-recommended pattern)
- Cloudflare Queues with retries + dead-letter queue
- Upstash rate limiting (REST, Worker-friendly):
1request / hour / IP1request / hour / sender email100requests / day globally
- API key auth for child apps (
Authorization: BearerorX-API-Key) - Resend Node SDK (
emails.send+contacts.create) with idempotency keys for queue retries - CORS for browser-facing child apps
- Newsletter signup via Resend Contacts (
POST /api/newsletter)
Requires Bun.
bun installbunx wrangler queues create email-queue
bunx wrangler queues create email-queue-dlqcp .dev.vars.example .dev.varsFill in:
| Secret | Description |
|---|---|
API_KEYS |
Comma-separated keys your child apps use |
RESEND_API_KEY |
From Resend |
UPSTASH_REDIS_REST_URL |
From Upstash Redis |
UPSTASH_REDIS_REST_TOKEN |
From Upstash Redis |
Non-secret vars live in wrangler.jsonc (required at deploy time — Wrangler ships them to the Worker). They are already set for SoraLabs production:
CONTACT_TO_EMAIL— inbox that receives submissionsCONTACT_FROM_EMAIL—SoraLabs Contact <noreply@soralabs.io.vn>(the domain must be verified in Resend)ALLOWED_ORIGINS—*or a comma-separated origin listALLOWED_APPS— optional allowlist ofappids (empty = allow any)RESEND_NEWSLETTER_SEGMENT_ID— optional Resend segment to add newsletter contacts to (empty = global contact only)
Forks should change those values before deploying.
Outbound mail uses:
- From:
noreply@soralabs.io.vn(the Resend sending address) - Reply-To: the form submitter’s email
Press Reply in Gmail and the response goes to the submitter. Their address is also included in the message body.
bun run dev# Worker name is `email` (see wrangler.jsonc)
bunx wrangler secret put API_KEYS --name email
bunx wrangler secret put RESEND_API_KEY --name email
bunx wrangler secret put UPSTASH_REDIS_REST_URL --name email
bunx wrangler secret put UPSTASH_REDIS_REST_TOKEN --name email
bunx wrangler secret list --name email
bun run deployIf /api/contact returns 503 with missing_api_keys, the secret is not bound to the running Worker yet.
{ "ok": true, "service": "email-worker", "time": "..." }Headers
Authorization: Bearer <API_KEY>
Content-Type: application/jsonBody
{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "Hello from the landing page",
"subject": "Website inquiry",
"app": "landing"
}| Field | Required | Notes |
|---|---|---|
name |
yes | max 100 |
email |
yes | reply-to on the outbound mail |
message |
yes | max 5000 |
subject |
no | default New contact message |
app |
no | tags the email; default default |
Responses
202accepted & queued400validation error401missing/invalid API key403app not in allowlist429rate limited (Retry-Afterheader set)502queue enqueue failed
Example:
curl -X POST https://email-worker.<account>.workers.dev/api/contact \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane",
"email": "jane@example.com",
"message": "Hi",
"app": "landing"
}'Adds the address to Resend Contacts (unsubscribed: false). Does not send mail. Uses separate rate limits (5 / hour / IP, 3 / hour / email) so it does not consume the contact-form budget.
Only email is required. Name fields may be omitted or empty.
Headers — same as /api/contact.
Body
{ "email": "jane@example.com" }| Field | Required | Notes |
|---|---|---|
email |
yes | subscriber address |
firstName |
no | Resend first_name; omit or "" is fine |
lastName |
no | Resend last_name; omit or "" is fine |
name |
no | split into first/last if firstName is omitted |
app |
no | child-app id for allowlist / logs; default default |
Responses
201created (or already subscribed —alreadyExists: true)400validation error401missing/invalid API key403app not in allowlist429rate limited502Resend create failed
Example:
curl -X POST https://email-worker.<account>.workers.dev/api/newsletter \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com"}'await fetch("https://email-worker.example.workers.dev/api/contact", {
method: "POST",
headers: {
Authorization: `Bearer ${EMAIL_SERVICE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
email,
message,
app: "my-app",
}),
});Keep the API key on the server side of each child app (never expose it in the browser unless you also lock ALLOWED_ORIGINS tightly and accept the risk).
- Producer returns quickly (
202) afterEMAIL_QUEUE.send— Resend latency does not block the client. - Consumer uses small batches (
max_batch_size: 5) and short timeout for low contact-form latency. - Failed Resend calls with
429/5xxare retried with delay; aftermax_retriesmessages land inemail-queue-dlq. - Upstash
pendingpromises are flushed withctx.waitUntilso analytics/sync work finishes on the isolate. - Rate-limit ephemeral cache reduces Redis calls for repeated blocked clients on the same isolate.