Skip to content

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

email-worker

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

Features

  • Producer + consumer in one Worker (Cloudflare-recommended pattern)
  • Cloudflare Queues with retries + dead-letter queue
  • Upstash rate limiting (REST, Worker-friendly):
    • 1 request / hour / IP
    • 1 request / hour / sender email
    • 100 requests / day globally
  • API key auth for child apps (Authorization: Bearer or X-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)

Setup

1. Install

Requires Bun.

bun install

2. Create queues

bunx wrangler queues create email-queue
bunx wrangler queues create email-queue-dlq

3. Local secrets

cp .dev.vars.example .dev.vars

Fill 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 submissions
  • CONTACT_FROM_EMAILSoraLabs Contact <noreply@soralabs.io.vn> (the domain must be verified in Resend)
  • ALLOWED_ORIGINS* or a comma-separated origin list
  • ALLOWED_APPS — optional allowlist of app ids (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.

Reply-To

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.

4. Dev

bun run dev

5. Deploy

# 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 deploy

If /api/contact returns 503 with missing_api_keys, the secret is not bound to the running Worker yet.

API

GET /health

{ "ok": true, "service": "email-worker", "time": "..." }

POST /api/contact

Headers

Authorization: Bearer <API_KEY>
Content-Type: application/json

Body

{
  "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

  • 202 accepted & queued
  • 400 validation error
  • 401 missing/invalid API key
  • 403 app not in allowlist
  • 429 rate limited (Retry-After header set)
  • 502 queue 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"
  }'

POST /api/newsletter

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

  • 201 created (or already subscribed — alreadyExists: true)
  • 400 validation error
  • 401 missing/invalid API key
  • 403 app not in allowlist
  • 429 rate limited
  • 502 Resend 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"}'

Use from a child app

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

Cloudflare notes

  • Producer returns quickly (202) after EMAIL_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 / 5xx are retried with delay; after max_retries messages land in email-queue-dlq.
  • Upstash pending promises are flushed with ctx.waitUntil so analytics/sync work finishes on the isolate.
  • Rate-limit ephemeral cache reduces Redis calls for repeated blocked clients on the same isolate.

About

Cloudflare Worker email service: HTTP contact API, Queues retries/DLQ, Resend, and Upstash rate limiting.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages