Inbound email triage
Inbound email triage is the front door for the platform. Every email that hits one of the five Cloudflare Email Routing mailboxes flows through here first. Without this workflow, the other workflows wouldn't know they had work to do.
The cascade is deliberately small: classify, extract entities, route, log, draft response. Each step is cheap and read-only. The real work happens downstream in the workflow this dispatches to (bid_amendment_arrives, customer_invoice_dispute, vendor_cost_update, etc).
Risk level 2 (low). Misclassification is recoverable — Mike sees every triaged email on /intake.html and can re-route.
Trigger conditions
- Email lands at any of the 5 GFS routing addresses (handled by CF Email Routing).
- Manual re-triage of an email that got misclassified.
- Replay of an email after a triage logic change (rare; used for backfills).
Unknown senders get severity=warn on the not_spam check, not blocked. Mike sees them on intake.html and decides — this catches new vendors and new customers that haven't been added yet.
The 3 beats
-
01
Log inbound email
INSERT into
inbound_email_logwith email_uuid, from, to, subject, body, classification, extracted_entities, and the workflow_routed_to target. -
02
Route to downstream workflow
Based on classification, dispatch:
bid_amendment → bid_amendment_arrives;invoice_dispute → customer_invoice_dispute;vendor_cost → vendor_cost_update;spec_sheet → new_assembly_item. Other classifications surface for manual handling. -
03
Draft response (conditional)
If classification is not spam and the email requires a response, AI drafts via
propose_email_to_customer. Mike approves before send.
What's different after the workflow runs
- Every email is in
inbound_email_logwith a workflow_routed_to value. - The downstream workflow has a fresh run row tied back to email_uuid.
- Mike sees the email on /intake.html with classification + entity context.
- A draft response is waiting if the email needed one.
- reflexion_log carries an
email_triagetagged row with classification.
What can go wrong and how to recover
Mike re-classifies on /intake.html. The original log row keeps the AI's classification + Mike's correction for next-time training.
Email gets bounced back to sender by CF. No log row exists. Mike's recovery path is forwarding the email to a manual intake endpoint.
Logged with severity=warn. Surfaces on intake.html for manual triage — most often a new vendor that needs adding via new_ingredient_vendor_onboard.
Logged with classification=spam, no downstream dispatch, no response draft. Counts toward weekly anomaly trend if volumes spike.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='inbound_email_triage' |
| Routing source | Cloudflare Email Routing → src/email.ts |
| Classifier | AI prompt in src/email.ts → classify_intent |
| Log table | inbound_email_log |
| 5 mailboxes | bids@, ar@, vendors@, specs@, general@ |
| Reflexion tag | email_triage,classification:<cls> |
| Risk level | 2 |
| Expected duration | ~5 min |
| Trigger | event · sources=cf_email_routing |
Dated trail · spot stale claims
Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='inbound_email_triage' before acting on these claims.The machine-readable spec
Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · inbound_email_triage · risk_level · 2.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
email_id | integer | inbound_email_log.id. Required. |
from_email | string | Sender email. Required. |
subject | string | Required. |
body_markdown | string | Converted body. Required. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
inbound_email_log | UPDATE (classified_intent, action_taken) | Classifier result |
proposed_actions | INSERT (if route triggers a workflow) | e.g. cost update from vendor email |
events | INSERT (email.classified) | audit |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
Email Routing | incoming@globalfoodsolutions.co → src/email.ts | All 5 mailboxes route here |
POST | /api/email/reclassify/:id | Manual reclassification |
Events fired
| event_type | When | Subscribers |
|---|---|---|
email.classified | Post-classification | audit |
email.routed | Post-routing to a workflow | downstream workflow |
It broke at 2am — what now
Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.
Scenario · Email classified wrong (vendor cost as 'general')
Classifier prompt didn't recognize the pattern.
- Reclassify:
POST /api/email/reclassify/<id>with the correct intent. - Improve prompt: Add the email as a test case in eval/golden-set/email-classification.yaml.
- Verify: Re-run the classifier; should now route correctly.
Scenario · Email never appeared in inbound_email_log
Email Routing didn't trigger or the email worker threw before logging.
- Check CF Email Routing: Dashboard → Email Routing → recent deliveries
- Worker logs:
npx wrangler tailduring the next send - Verify route: All 5 mailboxes must route to the worker.
Scenario · Attachment didn't get converted to markdown
document_converter failed on the attachment.
- Check:
SELECT error FROM document_conversions WHERE email_id=<id> - Manual transcribe: Mike reads the attachment and enters key fields.
- Improve: Add attachment type to converter test set.
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
Kill switch · emergency stop
If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
See kill-switches-state-machine.html for the full state machine + recovery procedure.
Escalation
Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.
What's not done · what's uncertain
What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.
-
OPEN
Confidence threshold for auto-routing
Today the classifier auto-routes regardless of confidence. Should require > 0.8 for auto-route.
-
STUB
Multi-intent emails
Today single intent per email. Customer asks 3 questions in one email — only first detected.
-
DEFER
Reply suggestions
Beyond classification, suggest a reply. Today Mike types manually.
-
DECISION
Forwarded chain handling
When an email is forwarded with 5 prior replies, classify the latest or the chain? Today: latest.