Email System — Implementation Plan

Copy Shop Order System · Cloudflare Email Service + Queues · Planned Aug 29, 2026 · Private review document — do not commit

Summary

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.

Relationship to the Existing Plan Doc

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.

Email Matrix

#EmailTriggerRecipient(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.

Architecture

  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

Cloudflare-native Default Cloudflare features used (no third-party service)

Verified against current docs: developers.cloudflare.com/email-service/api/send-emails/workers-api/ and .../configuration/send-bindings/

Design Decisions

Decision Two send_email bindings, not one

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

Decision Recipient config lives in a committed project file, mirrored into the binding allowlist

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.

Decision D1 outbox is the single dedup guard; the Queue only carries an ID

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.

Decision Concurrency hardening comes with this change

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.

Emails and Templates

1 — Customer confirmation (submitted)

Subject: Your Copy Shop order {publicId} is in the queue

2 — Department: order received

Subject: New {departmentName} order {publicId} — due {dueDate} {dueTime}

3 — Customer completion

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.

Failure Handling and Observability

Deployment Sequence (beta first, then production)

StepItemNote
1Onboard sender domain to Email SendingDashboard: Compute → Email Service → Email Sending → Onboard Domain. DNS usually live in 5–15 min. Confirm exact sender address with you first.
2Verify arbitrary-recipient sendingThe open risk above — one manual test send before building on the assumption.
3Create Queue + DLQwrangler queues create, add producer/consumer + DLQ to wrangler configs.
4D1 migration (additive only)npm run db:generate, apply to beta before deploying code that writes the table.
5Deploy log-only, then enable sendingVerify outbox rows + config resolution with EMAIL_ENABLED=false first, then flip on.
6Smoke test one orderExactly one customer confirmation + one department email; complete the order; exactly one customer completion. Check spam placement.
7Repeat for productionSame sequence against wrangler.production.jsonc.

Implementation Checklist by File

Branch and Safety

Open Questions for You