A demo built for the Junction 2026 Valio Aimo challenge, on synthetic data. Not the wholesaler's own shop.

web/docs/mail.md

Email to the kitchen: receipts and alerts

When an order is placed the kitchen gets a receipt by email, is told it will hear from us if anything goes wrong, and then really does get an email when something changes. Optional, off by default, and the kitchen's own choice. Sent through Resend's REST API (fetch, no SDK), after the fact: an order never waits for a mail, and a mail service that is slow, down or refusing changes nothing about an order.

PieceFile
Address, confirmation, events, caps, sending, ledger, operations viewsrc/server/notify/service.ts
The mails as HTML and plain text (pure)src/server/notify/render.ts, words in mail.text.ts (en, fi, sv)
What each mail says, as datasrc/server/notify/model.ts
Resend over fetch, and a fake for testssrc/server/notify/transport.ts
Signed, expiring links (confirm, stop)src/server/notify/links.ts
The conversation's adaptersrc/server/notify/thread.ts, words in say.text.ts
Outside agents: receiptEmail on an order's resultsrc/server/notify/outside.ts, mcp/tools.ts (place_order, get_order), rest-step.ts
The store: the address on Agent and rulesk/[kitchen]/mail-panel.tsx, /ui-api/kitchens/{kitchen}/mail
The pages a mail links to/mail/confirm, /ui-api/mail/confirm, /ui-api/mail/stop, /mail/stopped
Operations/ops/mail, /ops-api/mail
Testssrc/server/notify/notify.test.ts
Real sends to Resend's test inboxes, every mail written to diskpnpm mail:proof [--out dir] [--no-send] (scripts/mail-proof.ts)

What is sent, when

One mail per event, never twice: each event has a key, kept in the mail log and sent to Resend as its Idempotency-Key (Resend sends at most one mail per key for 24 hours, so a retry, a restart or a second process cannot send it again; a repeat Resend refuses is recorded as "already sent", not as a failure).

EventWhere it is recorded (the hook)KeySubject (English)
Address giventhe store page or the conversationconfirm:<contactId>Confirm your address for AimoPilot order emails
Order placed, by the agent alone or after sign-off: the receiptevent order_committedreceipt:<orderId>Order ord_… placed: 20,94 €, delivery Sun 15.11.2026
Waiting for sign-offevent approval_requestedsign_off:<approvalId>Waiting for Sanna Koskinen's OK: order ord_…, 139,60 €
A product on an order or a recurring order cannot comeledger watch entry, step found (the watch's finding)cannot_come:<org>:<product>:<day><product> cannot come on Mon 16.11.2026
A substitute was ordered in its placeledger watch entry, step renewal_placed with a substitutesubstituted:<orderId>Substitute ordered: <product> for <day>
A renewal held because the recurring order driftedledger watch entry, step renewal_heldrenewal_held:<org>:<family>:<day>Recurring order on hold: <product> for <day>
An order refused by the gate (as placed, or at the re-check at sign-off)ledger decision with outcome reject; event order_rejectedrefused:<orderId>Order not placed: 9,45 €
Declined by the approverevent approval_declineddeclined:<orderId>Order ord_… declined
Expired before anyone signedevent approval_expiredexpired:<orderId>Order ord_… expired before anyone signed
Taken backevents approval_withdrawn, order_withdrawnwithdrawn:<orderId>Order ord_… taken back

The receipt: order number, kitchen, when placed, delivery day, each line (name, packs × pack size at the unit price, the line total, the deposit, "Substitute for …"), goods, deposits (no VAT), VAT by rate, the total before and with VAT (when a VAT rate is not known it says so and ends on the total before VAT), who decided (the agent within its per-order limit, or the named person who signed, and when; a demo tap is said to be one), a link to the order in the store and to its Verify panel (/k/<kitchen>/orders/<id>#verify, the browser checks the wholesaler's Ed25519 signature on the order confirmation), the confirmation's SHA-256 fingerprint and the public keys, and the sentence: "We will email you if anything changes: a delay, a shortage or a substitute."

A change mail says what happened, why (the system's own sentence, money in the Finnish form), what was done, what the kitchen can do, and the figures (the amount and day; for a stock-out the good, better, best substitutes, each inside or outside the kitchen's stated preferences). A staged condition is said to be staged.

All three kinds: English, Finnish or Swedish by the language the kitchen gave the address in (the store's language at that moment, or the conversation's); numbers, money and dates the Finnish way in all three (1 280,50 €, ma 16.11.2026, klo 14.05). Product names are the catalogue's as on the order. The sender and header are AimoPilot, never the wholesaler's name or branding; the footer keeps "Demo on synthetic data, built for the Junction 2026 Valio Aimo challenge".

The HTML is built for mail clients: tables for layout, inline styles (a <style> block adds only the narrow-screen and dark-mode touches), system fonts with a fallback stack, 600 px wide and fluid below 620 px, a hidden preheader, Outlook conditionals for the fixed width, no images (nothing needs one to be understood), a plain-text alternative, numbers right-aligned in tabular figures. Petrol #005670 on white; the lime #c0e021 used once, as the bar on the card. color-scheme: light dark with dark overrides for clients that apply them.

Who gets mail

  • A kitchen may give one notification address: on Agent and rules (/k/<kitchen>/agent, the Order emails card) or in words to its agent. Only the kitchen's own signed-in person (not a call-only session, not the operations desk) can give, change or remove it.
  • It is used only once confirmed: the first and only mail to a new address is the confirmation, with a signed link that expires in 48 hours. The link opens a page with a button (a mail scanner that opens links confirms nothing). Until it is confirmed every other event is recorded as "not sent: the kitchen's address is not confirmed yet". A new address makes the earlier link stop working. A public demo cannot be used to mail strangers more than that one confirmation, and a kitchen can give an address at most 3 times an hour.
  • Every mail after that has a one-click stop link (and the List-Unsubscribe / List-Unsubscribe-Post headers, RFC 8058, so the mail client's own unsubscribe button works). Stopping deletes the address.
  • After each send the service asks Resend what happened (30 s and 3 min later). A bounce or a spam complaint stops mail to that address and deletes it; the kitchen gives one again to start.

Guardrails

GuardrailSettingDefault
Mails per kitchen per hourDOCK_MAIL_PER_KITCHEN_HOUR10
Mails for the whole service per 24 hoursDOCK_MAIL_DAILY90 (Resend's free plan sends 100 a day)
Address changes per kitchen per hourfixed3
One mail per eventthe event key, and Resend's Idempotency-Keyalways
Off until an address is given and confirmedalways
Retries3 tries on a timeout, a network error, 429 or 5xx (0.8 s, 2.5 s apart); none on a refusal (bad address, unverified sender)

Every mail sent, skipped (not confirmed, stopped, bounced, a cap, no mail service, already sent) or failed is a ledger entry of type mail with the event key, the kind, the outcome and the reason, and the order id when there is one: never the address and never the body. So are the address set, confirmed, stopped, removed or bounced. Both sides read these entries in full (core/audit.ts, kind notified). An event for a kitchen that never gave an address writes nothing (nothing was meant to be sent); the operations page counts them.

Privacy

  • The address is kept with the kitchen's own records (collection mail_contacts, PostgreSQL with DOCK_STORE=postgres) and deleted when the kitchen stops the mails, removes it, or it bounces.
  • Shown in full only to the kitchen's own signed-in person (the store's card, "which address do you have?" in the conversation). Everywhere else it is masked, m•••@example.com: the agent's sentences, the mails' own footer, /ops/mail, the order result outside agents read.
  • Never in the ledger, a URL (the links carry a signed token naming the kitchen and the address record, not the address), a log line, or the wholesaler's audit view. The mail log keeps what each mail said as data, without the address, so the operations page can render it again (with the masked address and inert links) instead of storing the body.

The sender, and what the user must do in Resend

With the default sender, AimoPilot <onboarding@resend.dev>, Resend delivers only to the Resend account's own address (and its test inboxes delivered@resend.dev, bounced@resend.dev, complained@resend.dev). Any other recipient is refused (403, "You can only send testing emails to your own email address"); that refusal is recorded as a failed mail and the order is not affected. To mail real kitchens:

  1. In Resend, Domains, add a domain you control (for example mail.aimopilot.fi) and add the DNS records it shows (SPF and DKIM; DMARC recommended). Wait until it shows Verified.
  2. Set DOCK_MAIL_FROM="AimoPilot <orders@mail.aimopilot.fi>" (any address on that domain) in web/.env.local and on the server, and redeploy.
  3. Optional: raise DOCK_MAIL_DAILY if the Resend plan sends more than 100 a day.

Without RESEND_API_KEY the app runs as before and every mail is recorded as "not sent: no mail service configured"; the agent then never claims a mail was sent. Links in the mails use DOCK_PUBLIC_URL (else http://localhost:3000). The link-signing key is derived from DOCK_ACCOUNT_KEY_SECRET; without it the links last as long as the process.

For the conversation

src/server/notify/thread.ts (the thread engine calls it; it does not change the thread):

CallReturns / does
mailFactAfterOrder(rt, run.k, order, { lang, threadId })after an order is placed or sent for an OK: one sentence to add, or undefined. "I've emailed the receipt to m•••@example.com and will email you if anything changes." / for an OK: "I've emailed m•••@example.com that it waits for an OK…" / not confirmed yet: "Once you confirm m•••@example.com from the email I sent, receipts go there." / no address, at most once per conversation: "Want the receipt by email? Tell me the address." Nothing when no mail service is configured.
mailWordsOf(words){ kind: "set", address }, { kind: "set_invalid" }, { kind: "stop" }, { kind: "which" } or undefined (en, fi, sv): "email receipts to chef@example.com", "stop the emails", "which address do you have?"
answerMailWords(rt, run.k, intent, { person: run.person, via, lang })does it and returns { reply, changed } in the kitchen's language. Setting or stopping needs the kitchen's signed-in person; setting sends only the confirmation.

Outside agents

place_order and get_order (MCP), POST /api/v1/orders and GET /api/v1/orders/{id} (REST) carry, on a committed order, receiptEmail: { emailed, to: "m•••@example.com", status }. The receipt goes after the fact, so right after place_order it is usually sending; get_order says sent once it went. Other statuses: no_address, not_confirmed, not_configured, failed, skipped.

Proof

pnpm mail:proof reads only RESEND_API_KEY from web/.env.local (nothing else: the runtime stays in memory on the seed catalogue, off every hosted service), refuses to send to anyone but Resend's test inboxes, and runs the real events: an address given and confirmed from its link, an order placed alone, one over the limit, one taken back, one the rules refuse, a staged stock-out found by the watch, a drifted recurring order held, and a Finnish kitchen's receipt. It prints each mail's outcome and Resend id and what Resend reports a few seconds later, and writes every mail as .html and .txt.