Four emails, exactly two of which ever reach the customer. The customer gets a confirmation when
their upload finishes and the job enters the queue, and one completion notice when staff mark the job
Complete. Internal staff never receive customer-facing email; instead, the "order received" event notifies
the owning department, with an optional list of additional copy shop recipients configurable from a committed project
config file. Delivery uses Cloudflare's native Email Service Worker binding (no third-party provider), a Cloudflare
Queue for retries, and a small D1 outbox for deduplication and traceability.
docs/EMAIL_ORDER_NOTIFICATION_IMPLEMENTATION_PLAN.md (currently untracked) covers internal-only
notifications and explicitly excludes customer email. This plan supersedes it: it keeps the same
outbox + queue architecture, the same two internal trigger points, and the same deduplication strategy, but extends
scope to the two customer emails and the additional-recipients config. That file should be deleted or replaced when
implementation starts so there is one source of truth.
| # | Trigger | Recipient(s) | Notes | |
|---|---|---|---|---|
| 1 | Customer confirmation | Upload completes and POST /api/orders/:id/complete transitions the order pending → submitted ("in the queue") |
Customer's order email | Confirms receipt, order number, due date/time, file count. No file links. |
| 2 | Department: order received | Same event as #1 (submission) | Department's configured inbox plus additional copy shop recipients from config (BCC) | Full operational detail: customer contact, instructions, file names, due date. |
| 3 | Customer completion | Staff moves status In Progress → Complete |
Customer's order email | "Your order is ready" per delivery method. No file links. |
Exactly one email per row per order. No email on order initiation, individual file uploads,
New → In Progress, cancellation, or re-submission. Retries may resend a duplicate in rare failure windows
(see "Failure handling") but never create a second kind of email.
POST /api/orders/:id/complete PATCH /api/staff/orders/:id/status
(submitPendingOrder) (updateOrderStatus)
| |
+---- same D1 batch ---------------------+
| INSERT INTO email_outbox |
| (unique: order_id + kind) |
v v
enqueue outbox ID ----> Cloudflare Queue ----> queue() consumer in worker/index.ts
|
resolve recipients from config file
render template (escaped)
env.EMAIL_CUSTOMER.send(...) or env.EMAIL_INTERNAL.send(...)
|
mark outbox row sent / failed + retry
send_email Worker binding (env.EMAIL.send()), sender domain onboarded via Email Sending (SPF/DKIM/DMARC on cf-bounce subdomain, automatic).Verified against current docs: developers.cloudflare.com/email-service/api/send-emails/workers-api/ and .../configuration/send-bindings/
send_email bindings, not oneEMAIL_INTERNAL is restricted with allowed_destination_addresses listing every department
inbox and extra copy shop recipient — it can never email a customer even if routing code is wrong.
EMAIL_CUSTOMER is unrestricted so it can reach arbitrary customer addresses. Both send from the same
verified sender (e.g. orders@thecopyshoponline.com).
Open risk to verify in beta: the send-bindings doc says an unrestricted binding sends "to any
verified destination address in your account," while the Workers API examples show sending to arbitrary
customers. First beta test must confirm a send to a non-verified external address succeeds. If Cloudflare does
restrict outbound to verified destinations on this account, the fallback is a small SMTP/HTTP provider via
fetch() for the customer emails only — the rest of the design is unchanged.
Per the requirement, the additional copy shop recipients (and per-department inboxes) go in a committed file —
e.g. worker/notifications/recipients.ts — because they change rarely:
export const EMAIL_RECIPIENTS = {
from: "orders@thecopyshoponline.com",
fromName: "The Copy Shop",
departments: {
"dept-copies": "copies@thecopyshoponline.com",
"dept-graphic-design":"design@thecopyshoponline.com",
"dept-blueprints": "blueprints@thecopyshoponline.com",
"dept-offset": "offset@thecopyshoponline.com",
"dept-other": "other@thecopyshoponline.com"
},
extraOrderRecipients: ["manager@thecopyshoponline.com"],
customerEnabled: true
} as const;
Caveat: the EMAIL_INTERNAL allowlist lives in the Wrangler config, so adding a recipient is a
two-file change (config file + allowed_destination_addresses). A deployment test in
tests/deployment/ will assert the committed recipient list is a subset of the allowlist so the pair can't
drift. Per-environment overrides (e.g. a dev inbox for APP_ENV=local) stay in wrangler vars so local dev
never emails real addresses.
One email_outbox table, one row per (order, kind), unique index on (order_id, kind).
Rows are inserted in the same D1 batch as the state transition, so an order event and its notification exist
atomically. Queue messages carry only { outboxId }; the consumer loads current order data from D1 at send
time. Customer retries / duplicate route calls / redelivered queue messages all collapse onto the same row.
Canceled orders are checked at send time — a canceled order's pending outbox rows are never sent.
submitPendingOrder (worker/orders/commands.ts:117) is currently read-then-write; two racing
complete calls could both pass. The update becomes conditional
(... WHERE id = ? AND submission_state = 'pending') with row-change inspection, and the outbox insert
rides in that batch. Same pattern for the In Progress → Complete update in updateOrderStatus
(worker/orders/commands.ts:140). This closes the duplicate-email window and a real order-state race in one
change.
Subject: Your Copy Shop order {publicId} is in the queue
Subject: New {departmentName} order {publicId} — due {dueDate} {dueTime}
Subject: Your Copy Shop order {publicId} is complete
All templates live in worker/notifications/templates.ts with plain-text + small responsive HTML versions;
every order-sourced value is HTML-escaped. The customer email address is never placed in an internal
email's To/Bcc beyond what the order detail already contains, and staff addresses never leak into customer emails.
max_retries and delay; DLQ for exhausted messages; outbox row tracks pending → processing → sent | failed with attempt count and a sanitized last error.pending, enqueue ID).E_RECIPIENT_SUPPRESSED) — surfaced in logs, not retried.npm run dev can never send real email. Guard on APP_ENV plus missing-binding fail-closed behavior.| Step | Item | Note |
|---|---|---|
| 1 | Onboard sender domain to Email Sending | Dashboard: Compute → Email Service → Email Sending → Onboard Domain. DNS usually live in 5–15 min. Confirm exact sender address with you first. |
| 2 | Verify arbitrary-recipient sending | The open risk above — one manual test send before building on the assumption. |
| 3 | Create Queue + DLQ | wrangler queues create, add producer/consumer + DLQ to wrangler configs. |
| 4 | D1 migration (additive only) | npm run db:generate, apply to beta before deploying code that writes the table. |
| 5 | Deploy log-only, then enable sending | Verify outbox rows + config resolution with EMAIL_ENABLED=false first, then flip on. |
| 6 | Smoke test one order | Exactly one customer confirmation + one department email; complete the order; exactly one customer completion. Check spam placement. |
| 7 | Repeat for production | Same sequence against wrangler.production.jsonc. |
worker/notifications/recipients.ts — new committed recipient config (departments, extra recipients, sender).worker/notifications/models.ts, repository.ts, config.ts, templates.ts, consumer.ts — new outbox types/repo, per-env resolution + validation, escaped templates, queue consumer.db/schema.ts + migrations/ — email_outbox table + unique index (additive only).worker/orders/commands.ts — conditional transitions + outbox insert in the same batch.worker/routes/customer-orders.ts, worker/routes/staff-orders.ts — enqueue after successful transition (idempotent re-enqueue on retry).worker/index.ts, worker/types.ts — queue() handler, binding types.wrangler.jsonc / wrangler.production.jsonc — queue producer/consumer, DLQ, two send_email bindings, from/enable vars.scripts/generate-hosted-worker.mjs — carry bindings into the generated production bundle if it rebuilds config.tests/worker/ — fake Queue/Email bindings: exactly-one-email-per-kind, retries, escaping, no real send locally; tests/deployment/ — recipient ⊆ allowlist check.feat/email-notifications off main. Local main is 1 commit behind origin/main — fast-forward first, then branch.docs/code-review.html.orders@thecopyshoponline.com right, and is that domain in this Cloudflare account? (Required before any real send.)*.thecopyshop.local placeholders).