Skip to main content

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

FieldValue
FromSMTP_FROM_ADDRESS (default orders@lucille.localhost)
Tosuppliers.config.emailAddress
Ccsuppliers.config.ccAddresses, and the header is omitted entirely when the list is empty
SubjectPurchase Order – {restaurantName} – {orderNumber}
BodyMultipart: 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:

BlockContent
Heading<h1>Purchase Order {orderNumber}</h1> then the restaurant name
Intro"Please supply the following items for the restaurant below."
DetailsDeliver to and Placed by rows — each omitted when its value is absent, the whole table with it
Line tableFive columns: Item, Item code, Qty (right-aligned), Unit, Unit size
Count3 line items, singular for one
Delivery notesA 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 escapeHtml from @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 &lt;script&gt;… 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, and formatTimestamp formats in UTC regardless of the pod's timezone. Identical input produces byte-identical output, which is what makes the template testable.
  • Quantities. formatQuantity trims trailing zeros — 4, not 4.000 — and a missing unitSize renders 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"]
}
KeyRequiredNotes
emailAddressyesMust be a valid email address. Provided by Costco Business Center
ccAddressesno, 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):

VariableDefault in config/index.tsNotes
SMTP_HOSTlocalhostmailpit in docker-compose.yml
SMTP_PORT1025587 for STARTTLS or 465 for SMTPS in production
SMTP_SECUREfalsetrue for implicit TLS on 465
SMTP_USERNAME / SMTP_PASSWORDunsetAuth is configured only when both are present
SMTP_FROM_ADDRESSorders@lucille.localhostMust be a verified sender on the relay
ADMIN_ALERT_EMAILunsetWhere 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

FailureClassificationBehaviour
ECONNRESET, ECONNREFUSED, ETIMEDOUT, ESOCKET, ECONNECTION, EDNS, EAI_AGAINRetryableMailErrorRetried by BullMQ — DISPATCH_ATTEMPTS attempts, exponential backoff
SMTP reply code 4xx (greylisting, mailbox busy)RetryableMailErrorRetried
SMTP reply code 5xx, or no recipient acceptedPermanentMailErrorjob.discard(), dispatch failed on this attempt — no backoff spent on a certainty
Missing or invalid suppliers.configSupplierConfigErrorPermanent, same path
Anything unclassifiableRetryableMailErrorThe safe default: losing an order a retry would have delivered is the worse mistake
All retries exhaustedDispatch 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 the SUPPLIER_SUBMITTED event are asserted. A second case seeds a supplier with config: {} 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 already submitted or confirmed short-circuits before the transport runs, while a dispatch whose previous attempt failed does not.
  • apps/worker/tests/handlers/failure.test.ts — a relay answering RCPT TO with 550 fails 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 reaches confirmed, 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