Costco Business Center — order email
Costco Business Center is the simplest of the three channels and deliberately so: it was assessed as low
risk during discovery, in contrast to Sysco's EDI integration. When an order contains lines mapped to a
costco-type supplier, the worker composes one email — a structured HTML table plus a real plain-text
alternative — and sends it through the SMTP relay with Nodemailer.
There is no API, no portal automation and no inbound monitoring. The system's contract with Costco is "a correctly structured email was accepted by the relay for at least one recipient", and that is what gets recorded.
The dispatch handler
The shared processor does the bookkeeping; handlers/costco.ts does nothing but build and send.
supplier-dispatch job, supplierType = 'costco'
│
├─ supplierDispatchJobSchema.parse(job.data) → invalid: permanent, job.discard()
├─ load the supplier_dispatches row with supplier + order + linked line items
├─ status already 'submitted' or 'confirmed'? → { skipped: true, reason: 'already_submitted' }
├─ intersect payload line items with the linked rows; empty → permanent failure
├─ resolveEmailSupplierConfig(supplier) → emailAddress, ccAddresses
├─ buildCostcoOrderEmail(...) → { subject, html, text }
├─ sendMail({ to, cc?, subject, html, text }) → { messageId, accepted, rejected }
└─ markSubmitted() in one transaction:
supplier_dispatches.status = 'submitted', reference = messageId,
submittedAt = now, attempt, failureReason = null
order_line_items.status = 'submitted' (this dispatch's lines only)
then, outside the transaction:
audit_events SUPPLIER_SUBMITTED { supplierId, attempt, subject,
recipients: accepted, reference, supplierType }
recomputeOrderStatus(orderId)
Nothing marks the dispatch submitted except markSubmitted, and it runs only after sendMail resolves.
sendMail itself throws PermanentMailError when the relay accepted no recipients, so an accepted
send always means at least one mailbox took the message. The dispatch row and its line items are updated
in one transaction, so a dispatch is never submitted while its lines still say pending.
Message shape
| Field | Value |
|---|---|
| From | SMTP_FROM_ADDRESS (default orders@lucille.localhost) |
| To | suppliers.config.emailAddress |
| Cc | suppliers.config.ccAddresses, and the header is omitted entirely when the list is empty |
| Subject | Purchase Order – {restaurantName} – {orderNumber} |
| Body | Multipart: the HTML table plus a plain-text alternative |
The subject separator is an en dash (U+2013), not a hyphen, and orderEmailSubject is the only
place that builds it. It carries the human-facing orders.order_number — not the order UUID — so a
human reading Costco's inbox can sort by it and a reply can be traced back without opening the message.
The subject is not HTML-escaped, because it is not markup: Salt & Pepper stays Salt & Pepper.
No Reply-To header is set. placedByEmail appears in the body as a "Placed by" line, but a reply goes
to the From address.
Plain-text alternative
This is real output from buildCostcoOrderEmail, not a sketch:
Purchase Order – Lucille Downtown – PO-2001
===========================================
Please supply the following items for the restaurant below.
Deliver to: 123 Main St, Springfield, IL 62704
Placed by: chef@lucille.test
Item Item code Qty Unit Unit size
-------------- --------- --- ------ ---------
Chicken Breast 1234567 4 case 6x10oz
Olive Oil 7654321 2.5 gallon 4x1gal
Sea Salt 1112223 12 each -
3 line items
Delivery notes:
Deliver before 10:00.
Please confirm availability by reply.
Generated 2026-08-13 09:01 UTC by Lucille Order Center.
The column widths are computed from the data, so the table lines up in a fixed-width mail client. This
is not stripped HTML — some corporate mail gateways drop text/html entirely, and an order that arrives
as an empty message is indistinguishable from no order at all.
HTML body
The HTML is a complete <!doctype html> document with every style inline, emitted as a single line. Its
blocks, in order:
| Block | Content |
|---|---|
| Heading | <h1>Purchase Order {orderNumber}</h1> then the restaurant name |
| Intro | "Please supply the following items for the restaurant below." |
| Details | Deliver to and Placed by rows — each omitted when its value is absent, the whole table with it |
| Line table | Five columns: Item, Item code, Qty (right-aligned), Unit, Unit size |
| Count | 3 line items, singular for one |
| Delivery notes | A bordered block with white-space:pre-line, omitted when there are no notes |
| Footer | "Please confirm availability by reply. Questions: reply to this email." then the generated-at line |
There is no line-number column and no Costco item code heading — that column is titled Item code
in the email. (The PDF purchase order does have a # column; the email does not.) The column set is one
COLUMNS array shared by the HTML and text renderers, so the two can never disagree:
const COLUMNS: readonly TableColumn[] = [
{ heading: 'Item', value: (item) => item.name },
{ heading: 'Item code', value: (item) => item.supplierSku },
{ heading: 'Qty', numeric: true, value: (item) => formatQuantity(item.quantity) },
{ heading: 'Unit', value: (item) => item.unit },
{ heading: 'Unit size', value: (item) => item.unitSize?.trim() || '-' },
];
Structurally, with the inline style attributes elided for readability:
<h1>Purchase Order PO-2001</h1>
<p>Lucille Downtown</p>
<p>Please supply the following items for the restaurant below.</p>
<table>
<tr>
<td>Deliver to</td>
<td>123 Main St, Springfield, IL 62704</td>
</tr>
<tr>
<td>Placed by</td>
<td>chef@lucille.test</td>
</tr>
</table>
<table>
<thead>
<tr>
<th>Item</th>
<th>Item code</th>
<th>Qty</th>
<th>Unit</th>
<th>Unit size</th>
</tr>
</thead>
<tbody>
<tr>
<td>Chicken Breast</td>
<td>1234567</td>
<td>4</td>
<td>case</td>
<td>6x10oz</td>
</tr>
</tbody>
</table>
<p>3 line items</p>
Three properties worth naming:
- Escaping. Every interpolated value goes through
escapeHtmlfrom@lucille/api/mail, which handles&,<,>,"and'. Item names and delivery notes are chef-supplied free text, so an item called<script>alert("xss")</script>renders as<script>…in the HTML part and appears verbatim in the text part, which is not markup. - Determinism. The timestamp is injected as
generatedAt; nothing in the template reads the clock, andformatTimestampformats in UTC regardless of the pod's timezone. Identical input produces byte-identical output, which is what makes the template testable. - Quantities.
formatQuantitytrims trailing zeros —4, not4.000— and a missingunitSizerenders as-. A non-finite quantity renders as?.
The Item code column is catalog_supplier_mappings.supplier_sku — Costco's own product number, not
the Lucille catalog item ID. This is the column that determines what actually arrives, so it is worth
re-reading the administrator guide note about verifying supplier SKUs
against the vendor's catalog: a wrong code transmits successfully and delivers the wrong product, which
is a worse outcome than a failed send.
Order notes are included verbatim. They are the chef's only channel to the supplier, and because the
dispatch payload carries orders.notes to every group, they travel to every supplier on the order.
Configuration
Supplier-level, in suppliers.config, validated by costcoSupplierConfigSchema:
{
"emailAddress": "orders@costcobusinessdelivery.example",
"ccAddresses": ["purchasing@lucille.example"]
}
| Key | Required | Notes |
|---|---|---|
emailAddress | yes | Must be a valid email address. Provided by Costco Business Center |
ccAddresses | no, defaults to [] | Every entry must be a valid email address. Keeps Lucille's buyers in the loop |
facilityName is a Central Kitchen key only; the Costco schema does not accept it. resolveEmailSupplierConfig
trims values and treats an empty string as absent, and drops non-string ccAddresses entries before
validating. There is no environment fallback for emailAddress: a missing one raises
SupplierConfigError, which the dispatch handler treats as permanent, so the dispatch fails on attempt
1 of 3 with a message naming the key.
Transport-level, from the environment (Vault-injected in the cluster):
| Variable | Default in config/index.ts | Notes |
|---|---|---|
SMTP_HOST | localhost | mailpit in docker-compose.yml |
SMTP_PORT | 1025 | 587 for STARTTLS or 465 for SMTPS in production |
SMTP_SECURE | false | true for implicit TLS on 465 |
SMTP_USERNAME / SMTP_PASSWORD | unset | Auth is configured only when both are present |
SMTP_FROM_ADDRESS | orders@lucille.localhost | Must be a verified sender on the relay |
ADMIN_ALERT_EMAIL | unset | Where dispatch-failure alerts go; unset logs a warning and sends nothing |
The transport is built once and memoised, with connectionTimeout: 15_000, greetingTimeout: 10_000
and socketTimeout: 30_000. Certificate verification (tls.rejectUnauthorized) is on in production
only, so a local relay with a self-signed certificate does not need special handling. Nodemailer is used
rather than a provider SDK precisely so the relay is swappable: SendGrid, SES or an internal relay all
work behind the same configuration.
Failure handling
| Failure | Classification | Behaviour |
|---|---|---|
ECONNRESET, ECONNREFUSED, ETIMEDOUT, ESOCKET, ECONNECTION, EDNS, EAI_AGAIN | RetryableMailError | Retried by BullMQ — DISPATCH_ATTEMPTS attempts, exponential backoff |
| SMTP reply code 4xx (greylisting, mailbox busy) | RetryableMailError | Retried |
| SMTP reply code 5xx, or no recipient accepted | PermanentMailError | job.discard(), dispatch failed on this attempt — no backoff spent on a certainty |
Missing or invalid suppliers.config | SupplierConfigError | Permanent, same path |
| Anything unclassifiable | RetryableMailError | The safe default: losing an order a retry would have delivered is the worse mistake |
| All retries exhausted | — | Dispatch failed, lines dispatch_failed, order rolled up to partial or failed, alert to ADMIN_ALERT_EMAIL, Sentry event, failed job retained 24 hours |
The message ID returned by the relay is stored in supplier_dispatches.reference, and the accepted
recipient list in the SUPPLIER_SUBMITTED audit payload. That is the only receipt this channel produces,
and it is what an engineer correlates against the relay's own delivery logs when Costco says they never
received an order.
Automated test coverage
apps/worker/tests/email/templates.test.ts— the template itself: the exact subject with its en dashes, the five table headings, every line item in both parts, the fixed-width text table, optional blocks appearing and disappearing, HTML escaping of every interpolated field, refusal to build an email with no line items, and determinism.apps/worker/tests/handlers/costco.test.ts— the handler end to end against the real Mailpit relay and a seeded database: the message is read back as a receiving MTA parsed it (To,Cc,From, subject, both body parts), exactly one message is sent, and the dispatch, its line items and theSUPPLIER_SUBMITTEDevent are asserted. A second case seeds a supplier withconfig: {}and asserts the failure is discarded rather than retried, terminal on attempt 1 of 3.apps/worker/tests/handlers/idempotency.test.ts— running the same job twice sends exactly one Costco email, and a dispatch alreadysubmittedorconfirmedshort-circuits before the transport runs, while a dispatch whose previous attempt failed does not.apps/worker/tests/handlers/failure.test.ts— a relay answeringRCPT TOwith550fails the dispatch on the first attempt without consuming the retry budget, and a failed admin alert does not fail the job.
Nodemailer is not stubbed in any of them. See testing.
Why there is no inbound path
Discovery explicitly settled this: for Costco and Central Kitchen, the proof of concept only records that the order email was accepted. No inbox monitoring, no confirmation parsing, no reply handling. Costco may well send an automated acknowledgment; nothing in this system reads it.
The practical consequences:
- A Costco line item's terminal status is
submitted. It never reachesconfirmed, because nothing can confirm it. - A Costco back order cannot be detected automatically. If one is communicated out of band, an administrator records it and the reroute path takes over.
- Costco can still serve as an alternate supplier for a Sysco item. The reroute is triggered by Sysco's 855, and dispatching to Costco works exactly as a primary dispatch does — the reroute job just adds a dedup suffix so it cannot collide with the order's original Costco dispatch.
Adding inbound support later means an IMAP poller and a parser, plus a vendor_documents row per
message. The data model already accommodates it: vendor_documents.supplier_id is not Sysco-specific,
and the raw content column holds an email body as happily as an EDI interchange.
Local development
Mailpit captures everything. Place an order containing a Costco-mapped item, then open
http://localhost:8025 — the message appears immediately, with the HTML body rendered, the plain-text
alternative viewable, and full headers available. Compose sets MP_SMTP_AUTH_ACCEPT_ANY: 1 and
MP_SMTP_AUTH_ALLOW_INSECURE: 1, so the local relay never rejects a send for authentication.
To exercise the retry path, point SMTP_PORT at a closed port: ECONNREFUSED classifies as retryable,
so the job burns all three attempts before the dispatch fails and the alert email is queued.
Where to go next
- Central Kitchen PDF — the other email channel, with an attachment.
- Supplier integrations — routing, the shared retry policy and classification.
- Local development — running Mailpit and the rest of the stack.