The mailbox watcher reads a mailbox at the Orchestrator seam and turns an arriving email into work. The Robot never talks to the mail server: the platform's email capability is send-only, so the fetch lives here and materialises the attachments the way the payments workbook and the queue are staged.
#The watermark
"New" is a UID watermark, not UNSEEN. A SEARCH UNSEEN would mean "not read", so the first time a human opened the mailbox every future run would silently see nothing. The watermark lives at <stateRoot>/mailbox/<account>.json as {uidValidity, lastUid}. The fetch uses BODY.PEEK[] so monitoring never marks mail read. When the server's UIDVALIDITY changes the mailbox was rebuilt, so the watermark resets and the run says so rather than treating it as no new mail.
A first run baselines the watermark to the folder's peak and fetches nothing, because a monitor means new mail from now on. --backfill is the explicit opt-in to process existing history. The credential is read from the vault by name.
#The watcher and the monitor
npx tsx tools/mailbox-watch.ts --once --root orchestrator/state --log mailbox.log
npx tsx tools/mailbox-monitor.ts --dry-run --limit 20
npx tsx tools/mailbox-monitor.ts --init
npx tsx tools/mailbox-monitor.ts --backfill --limit 50
The watcher has three modes. idle holds an IMAP IDLE connection and reacts to arrivals as the server pushes them, with a backstop monitor run every configured interval so a dead push degrades to slow, never to silent. interval runs the monitor once and lets the scheduler own the cadence. manual runs it once on demand. The watcher writes a heartbeat to <root>/mailbox/watch-heartbeat.json every window. It takes a lock so only one watcher runs at a time, stealing the lock from a dead holder.
A monitor run saves attachments under <dataDir>/attachments/<YYYY-MM-DD>/ and appends one ledger row per attachment to attachments/processed.csv, with the columns savedAt, uid, account, from, subject, file, bytes, sha256 and status. The watermark advances only after every message is processed, so a crash leaves mail looking new rather than silently consumed. --dry-run writes nothing and advances nothing. Subjects are masked unless --show-subjects is given.
#Triage
A run's saved attachments become a triaged batch. Only .xlsx workbooks are read as invoices; a sheet named Invoices wins when there are several, otherwise the workbook must have exactly one sheet. Invoices are read through the same schema-validated reader the Excel ingest uses, requiring InvoiceNumber and Amount. Amounts arrive as text, so 14,000.00 and MYR 14000 parse, while 1.000,50, twelve thousand and an empty cell are refused by name rather than guessed.
An invoice strictly over the threshold becomes an Action with subject invoice:<id> and nothing is dispatched. One at or under the threshold passes straight through. The ingest dispatches nothing itself. One invoice, one question: an invoice whose subject ref already has an Action, of any status, or that repeats inside the batch, is skipped and reported with the Action it already has.
The human gate runs before the watermark advances and fails closed. If an invoice needs a person and no continuation is configured, the run refuses by name and the mail stays new.
#Things that catch people out
Triage needs a configured continuation, TS_ACTION_RESUME_PACKAGE, _VERSION and _QUEUE. Without it an over-threshold invoice refuses the run rather than passing with nobody asked.
--no-triage disarms the human gate for one run and says so in the log. An over-threshold invoice then passes with no human.
Non-invoice attachments are saved as evidence and skipped with a reason; they are never silently dropped.