The n8n workflow `WFDASH — Quote Email Monitor (v3.1 agentic + PDF text)` is the inbound quote-request pipeline. It polls the `quotes@wfsales.co.za` mailbox every 5 minutes, runs AI extraction over each RFQ (including PDF text extraction from attachments), and creates structured `Quote` records in PocketBase via the WFDASH MCP endpoint. It also recognises replies to existing quotes and appends them as `quote_replies` rows.
A separate workflow `WFDASH — Error Handler` catches every failure in the main workflow and posts a Telegram alert so nothing falls into a black hole.
**One scheduler, one workflow, one source of truth.** No long-running process in the Next.js app, no racy counters, no fabricated cost prices, no fuzzy customer merges, no manual-review step.
> **v2 note:** as of v2 the workflow **no longer moves emails out of `INBOX`**. The n8n JS task-runner image (`n8nio/runners:2.25.7`) blocks `require('imapflow')` outright, and there is no env-var to allowlist new modules in that sandbox. Processed emails stay in `INBOX` UNSEEN; the next poll re-fires the workflow; WFDASH dedups the quote by `message_id` (or `synth-…@wfdash.local`). Re-fires are no-ops at the database level. A human (or a cPanel-side IMAP filter) handles inbox state.
> **v3.1 (this version):** replaces the v3 "Information Extractor → Code" pair with a **langchain AI Agent** (with two `toolCode` subnodes) so the LLM directly calls `quotes.create_from_email` and `attachments.upload_for_quote` per attachment. Adds a **Split Binary → Extract from File (PDF) → Aggregate** pipeline so PDF line items reach the LLM as text in the user message. Flips `passthroughBinaryImages: false` so PNG attachments survive to the upload tool (the v3 bug where `attachment_1` was eaten by LLM vision is fixed).
A reply is detected when **either** an `In-Reply-To` header is present **or** the subject matches `/Q-\d{4}-\d{2}-\d{3}/`. The `route: "reply" | "new"` field is computed in the Parse Email node and the If node is a simple string equality check.
On any unhandled error in the main workflow, n8n fires the Error Handler workflow automatically (via the workflow's `errorWorkflow` setting). The Error Trigger does **not** receive the original email payload directly — it gets the error context (subject, from_email, uid, mailbox) only because every Code node in the main workflow passes those through. **If you add a new branch, make sure to forward `subject`, `from_email`, `uid`, and `mailbox` so the Error Handler has context for its alert.**
---
## Why the Split → Extract → Aggregate pipeline (v3.1)
n8n's `Extract from File` v1.1 takes a **single binary per item** (one PDF, one output). The IMAP trigger produces **one item with N binary entries** (PDF + PNG + …). We can't point `binaryPropertyName` at both `attachment_0` and `attachment_1` at the same time.
The pattern is the canonical n8n one:
1.**Split Binary into Items** (Code, `runOnceForAllItems`) — emit N items, each with exactly one binary, always keyed `"attachment"`. The original key (`attachment_0`, `attachment_1`, …) is preserved on `$json.binary_key` for the aggregate step.
2.**Extract from File (PDF)** — `binaryPropertyName: "attachment"`, `operation: pdf`, `joinPages: true`, `onError: continueRegularOutput`. Non-PDFs (e.g. PNG banner) pass through with empty `$json.text`.
3.**Aggregate + Enrich** (Code, `runOnceForAllItems`) — group the N items back into one item per email, put the extracted text on `attachments[i].extracted_text`, and restore the binaries under their **original** keys (`attachment_0`, `attachment_1`, …) so the AI Agent's `upload_attachment_tool` can read them by `binary_key`.
The LLM sees the email body + a 2 KB preview of each PDF's extracted text in its user message. It uses that for line items. The full text is preserved on `$json.attachments[i].extracted_text` (up to 10 KB per attachment) if a follow-up tool needs more.
---
## Retry behaviour
The IMAP trigger is configured with `customEmailConfig: "[\"UNSEEN\"]"` and `postProcessAction: "nothing"`. Two consequences:
1.**Implicit retry on crash.** If the workflow dies mid-run, the email stays UNSEEN. The next 5-minute poll re-fires the workflow on the same message.
2.**No "mark as read" until success.** A failed email is left in `INBOX` until a human triages it. There is no silent loss.
**Important:** in v2+ a successful run also leaves the email in `INBOX` (UNSEEN). The success signal is the WFDASH database row (a new `quotes` row, or a new `quote_replies` row). The next poll re-fires on the same email, but WFDASH dedups by `message_id` and returns `deduped: true` with no side-effects. To keep `INBOX` tidy, a human (or a periodic cPanel IMAP filter) should move processed emails out of `INBOX` after each batch.
---
## Prerequisites
1.**PocketBase migrations applied** — `quote_attachments`, `quote_replies`, `quote_counters` collections must exist and `quotes.email_message_id` must be indexed. Run:
The auto-assigner wires up `IMAP account` (on the IMAP trigger). **You must manually create `Bearer Auth account` and `Telegram Alerts`** — n8n's auto-assigner skips HTTP Request and Telegram nodes.
The MiniMax chat model is wired via the langchain `lmChatMinimax` subnode and authenticates via its own per-credential setup; this is not the WFDASH bearer.
3.**n8n environment variables**:
| Name | Example | Purpose |
| --- | --- | --- |
| `WFDASH_MCP_URL` | `https://wfdash.example.com` | Base URL the workflow POSTs to. Falls back to `http://localhost:3000` if unset. The IMAP tool subnodes also accept the value via `Edit Fields` → `mcp server` for per-workflow override. |
| `TELEGRAM_CHAT_ID` | `-1001234567890` | The group/chat that receives alerts. Set in n8n's env (not in a credential). |
4.**Error Handler is linked.** Open the main workflow → **Settings** → **Error workflow** → pick `WFDASH — Error Handler`. (The SDK has no operation for this setting, so it's a one-time manual step after the first deploy.)
---
## IMAP folder layout
| Folder | What goes there | Populated by |
| --- | --- | --- |
| `INBOX` | Inbound queue — the trigger reads from here. Processed mail also stays here in v2+. | Inbound mail server (+ human / cPanel filter in v2+) |
The trigger reads only UNSEEN mail. There are no `INBOX.Quotes-*` sub-folders in v2+ — the n8n workflow does not create or move mail between folders.
---
## Nodes — main workflow
### 1. IMAP Quote Mailbox (trigger)
-`mailbox: "INBOX"`
-`postProcessAction: "nothing"` (don't mark as read; the WFDASH database row is the success signal)
-`downloadAttachments: true` (binary data flows into `$binary`)
-`format: "resolved"`
-`customEmailConfig: "[\"UNSEEN\"]"`
-`trackLastMessageId: true` (avoids re-firing on the same email on workflow restart)
### 2. Edit Fields (Set)
Injects two fields used by every downstream node:
-`mcp server` — the WFDASH MCP base URL. Defaults to `https://hzbtf-196-39-76-18.run.pinggy-free.link` (pinggy dev tunnel) in this build; override with the `WFDASH_MCP_URL` env or by editing the field.
-`agent_api_key` — the WFDASH `AGENT_API_KEY` bearer. Defaults to `123456` in this build; replace with the real key.
> **Security note:** the agent_api_key is a long-lived secret. Move it to a credential (`Bearer Auth account`) or an env var (`WFDASH_AGENT_API_KEY`) before this workflow leaves the dev box.
### 3. Parse Email (Code, `runOnceForEachItem`)
Coerces all the IMAP field shapes into stable strings. Specifically handles:
-`from` as a string, `{ value: [...] , name: "..." }`, `{ address: "..." }`, or `{ text: "Name <email>" }`. **Also handles the n8n-serialized array form** (`{ item: "x@y.com" }`).
-`messageId` / `inReplyTo` as a string, an object with `value: "..."`, or an array.
- Strips the `<` and `>` brackets around Message-IDs.
- Iterates `$binary` and produces an `attachments: [{ binary_key, filename, content_type, size }]` array.
- Emits a `route: "reply" | "new"` field used by the If node.
- **Returns `{json, binary: $binary || {}}` so binary survives the Code-node round-trip.** Without this, the next Code node (Carry Email Ctx) sees an empty `$binary` and the Split → Extract → Aggregate pipeline has nothing to operate on. (This was a v3 bug; fixed in v3.1.)
### 4. Drop Blacklist / Auto-replies (Filter)
7 conditions combined with `AND`:
-`from_email` does not contain `noreply`, `donotreply`, `mailer-daemon`, `postmaster`
-`subject` does not contain `out of office`, `automatic reply`, `vacation`
### 5. Is Reply? (If)
Single condition: `{{ $json.route }} equals "reply"`. String equality, not boolean.
Explicitly returns `{json, binary: $binary || {}}`. This is the second "binary preservation" node — it catches the case where Parse Email's binary output (in production) gets reshaped by intermediate nodes and makes sure the downstream Split → Extract → Aggregate pipeline has the original binary.
### 6c. Split Binary into Items (Code, `runOnceForAllItems`)
Emits one item per attachment. Each item has:
-`json.binary_key` — the original key (`attachment_0`, `attachment_1`, …) so the aggregate step can restore the binary under the same key for the upload tool.
-`json._email_*` — a flat copy of all the email context fields (from_email, from_name, subject, body_*, message_id, in_reply_to, received_at, is_reply, route, uid, mailbox, mcp server, agent_api_key) so the aggregate step can rebuild the email.
-`binary.attachment` — the single binary for this attachment, always keyed `attachment` (the fixed key Extract from File expects).
**Why `runOnceForAllItems` (not `runOnceForEachItem`):** `runOnceForEachItem` validates the return shape as a single item. Returning an array of items in that mode triggers `"A 'json' property isn't an object [item 0]"`. `runOnceForAllItems` processes the (single) input item once and returns multiple output items correctly.
If there are no attachments, the node emits one placeholder item (`{json: {binary_key: null, ...}, binary: {}}`) so the chain continues to Aggregate (which handles 0 items safely).
### 6d. Extract from File (PDF) (n8n Extract from File v1.1)
-`operation: pdf`
-`binaryPropertyName: attachment`
-`options.joinPages: true`
-`onError: continueRegularOutput` — non-PDFs (PNG banner) pass through with empty `$json.text`; the workflow does not crash.
- Groups the N split items back into one item per email.
- For each attachment, puts the extracted PDF text (truncated to 10 KB) on `attachments[i].extracted_text`.
- Restores the original binary entries under their original keys (`attachment_0`, `attachment_1`, …) so `upload_attachment_tool` can read them by key.
- Rebuilds the top-level email fields (`from_email`, `subject`, etc.) and forwards `mcp server` + `agent_api_key` for the tool subnodes.
### 6f. AI Agent: Create Quote From Email (langchain agent v3.1)
-`promptType: define`
-`text` — user message composed of email metadata + a JSON list of attachments (each with `extracted_text_preview` = first 2 KB of the PDF text) + email body.
-`options.systemMessage` — see the next section.
-`options.maxIterations: 20` — enough for one create + N uploads + summary.
-`options.passthroughBinaryImages: false` — **the v3.1 fix**. In v3 this was `true`, which let the LLM "see" the PNG via vision but consumed the binary (the upload tool then reported `error: binary not found for key attachment_1`).
-`options.returnIntermediateSteps: false` — keeps the agent's final summary short; the audit trail lives in the per-tool calls.
### 6g. MiniMax Chat Model (subnode, `lmChatMinimax` v1)
Calls `quotes.create_from_email` via the WFDASH MCP. The LLM only produces `company_hint` and `extracted_items`; every other email field is read from `$input.first().json` (the Aggregate + Enrich output) verbatim. The LLM cannot paraphrase or drop required fields. Returns a one-line summary: `quote_id=… quote_number=… items=… deduped=…`.
Calls `attachments.upload_for_quote` via the WFDASH MCP. Reads the binary from `$input.first().binary[binaryKey]`. **Belt-and-suspenders:** if upstream is empty, falls back to `$('IMAP Quote Mailbox').first().binary[binaryKey]` — the IMAP trigger's output is the source of truth for binary and never strips attachments. Converts the binary `data` to base64 and POSTs it. Returns: `uploaded=<filename> id=… kind=…`.
---
## AI Agent system prompt (verbatim)
> You are a purchase-order parser for WFDASH.
>
> You have two tools:
> - create_quote_tool: create a new quote row in PocketBase. Use it once per inbound email, after extracting line items.
> - upload_attachment_tool: upload a single attachment to an existing quote. Use it once per attachment in the email, AFTER create_quote_tool returns a quote_id.
>
> Workflow:
> 1. Read the email body (in your user message). For each attachment, also look at the extracted_text_preview field — that is the first 2KB of the PDF text. If the email body is short or empty, the line items are almost certainly in the PDF.
> 2. Extract the buyer/end-customer company name (company_hint). Empty string if unclear.
> 3. Extract the customer being quoted FOR (person_hint). **This is NOT the email sender** — when a rep (e.g. Gary, the South Africa field rep) forwards an inbound RFQ or sends a quote request for a customer, the sender is Gary and the actual customer is whoever Gary named in the subject line, the email body, or a signature. Examples:
> - Subject `Quote for John Smith at ABC Mining` → person_hint=`John Smith`, company_hint=`ABC Mining`
> - Subject `RE: Bearings enquiry — Peter Jones (Acme)` → person_hint=`Peter Jones`, company_hint=`Acme`
> - Subject `RFQ from John Smith` (Gary forwarding) → person_hint=`John Smith`
> - Subject `Quote request` with body `Please quote for Sarah at TechCo` → person_hint=`Sarah`, company_hint=`TechCo`
>
> If no specific customer is named and the email IS from the customer themselves (the from_email address is their personal / corporate domain, not a wfsales.co.za rep), set person_hint to empty string — the from_email will resolve correctly. Only set person_hint when the sender is clearly NOT the customer.
> 4. Extract the line items: an array of {description, quantity, unit?, notes?}. Look BOTH in the email body AND in extracted_text_preview of each attachment. DO NOT invent items, prices, or quantities.
> 5. Call create_quote_tool. Pass ALL the email metadata VERBATIM. Do NOT paraphrase. Include `person_hint` and `company_hint` in the call.
> 6. Capture the quote_id from the tool result.
> 7. For each entry in the email's attachments array, call upload_attachment_tool with that entry's binary_key, filename, content_type, size, the captured quote_id, and the email's message_id. Call it once per attachment.
> 8. After all uploads, reply with a one-line summary: quote_number=... items=... uploaded=... failed=... person_hint=...
> - attachments metadata only (no base64) — the binary lives in the workflow's binary store; only upload_attachment_tool knows how to read it.
---
## MCP tools this workflow calls
| Tool | Branch | Purpose |
| --- | --- | --- |
| `quotes.create_from_email` | new (via `create_quote_tool`) | Idempotent: dedupes on `email_message_id` (or synthesized `synth-…@wfdash.local`). Creates customer/company/quote/items. |
| `attachments.upload_for_quote` | new (via `upload_attachment_tool`) | Stores the file in the `quote_attachments` collection, linked to the quote_id. |
| `quotes.append_reply` | reply | Resolves the target quote (In-Reply-To first, then subject regex), appends a `quote_replies` row, flips `has_new_reply`. |
| `quotes.find_by_message_id` | (helper, not used in v3.1) | Look up a quote by the original `Message-ID`. Use this when the subject has been stripped by the mail client. |
The full MCP tool list and their schemas live in [api-mcp-layer.md](api-mcp-layer.md).
---
## Testing
Both workflows are pinned in n8n so you can dry-run with synthetic data without hitting IMAP or MCP.
**Main workflow pinned nodes:** `IMAP Quote Mailbox`, `MCP: quotes.append_reply` (and any HTTP-Request or credentialed node). Logic nodes (Code, If, Extract from File, AI Agent subnodes) execute normally.
**Error workflow pinned nodes:** none — it uses an Error Trigger, which can't be pinned in the same way; dry-run the main workflow with a known-failing MCP call to exercise it.
### Scenario 1 — new RFQ with PDF + PNG attachment
Expected: Parse Email emits `route: "new"`, Is Reply? goes false, Carry Email Ctx → Split → Extract (text for PDF, empty for PNG) → Aggregate (both binaries restored) → AI Agent. A new `quotes` row appears in WFDASH; both attachments appear in `quote_attachments` (PDF as `application/pdf`, PNG as `image/png`); the email remains in `INBOX` (UNSEEN).
"textPlain": "Please quote on 20mm cable ties qty 5 and cable gland M20 qty 10.",
"date": "2026-06-12T10:00:00.000Z"
}
}]
}
```
Expected: Parse Email → Is Reply? false → Carry Email Ctx → Split Binary emits one placeholder (no attachments) → Extract from File (no-op) → Aggregate → AI Agent. `create_quote_tool` runs once with `extracted_items` from the email body; no `upload_attachment_tool` calls.
Expected: Parse Email emits `route: "reply"`, Is Reply? goes true, `append_reply` called, `matched_by: "in_reply_to"` parsed. A new `quote_replies` row appears; `has_new_reply` flips to true; the email remains in `INBOX` (UNSEEN).
### Scenario 4 — subject-only reply (no In-Reply-To)
Same as Scenario 3 but `inReplyTo: ""` and subject `"Quote Q-2026-06-001 follow up"`. The subject regex catches it, `route: "reply"` is set, `append_reply` is called. The MCP server resolves by quote number and reports `matched_by: "subject"`.
### Scenario 5 — unmatched reply
Same as Scenario 3 but `inReplyTo: ""` and subject has no `Q-…` in it. `append_reply` returns `matched_by: "none"`, Route Reply Result goes false, the workflow ends. Nothing is moved; nothing is created. The email remains in `INBOX` (UNSEEN); a human triages it.
### Scenario 6 — error path
Pinned MCP node returns an error. n8n fires the Error Handler. Build Alert formats the message; Telegram posts to the chat. The email is still in `INBOX`; the human triages.
---
## Runbook — "an email didn't create a quote"
| Symptom | Where to look | Fix |
| --- | --- | --- |
| Nothing in the workflow execution list | n8n not running, or the workflow isn't activated | Open the workflow in n8n → toggle **Active**. Verify the IMAP trigger has `trackLastMessageId` so the same email isn't skipped. |
| Execution shows the email but it stopped at Filter | Filter rejected it | Inspect the data: sender is blacklisted, subject contains `out of office`, etc. Loosen the filter or whitelist the sender. |
| Telegram alert fires | Something in the main workflow threw | Open the error execution in n8n → see the failed node. The email is still in `INBOX` (UNSEEN); a human triages. |
| `INBOX` keeps growing with already-processed emails | n8n executions all show `success` for the same email, but the email is still in `INBOX` | **Expected in v2+.** n8n no longer moves mail out of `INBOX` — the JS task-runner sandbox cannot load `imapflow`. WFDASH dedups re-fires by `message_id` / `synth-…@wfdash.local`, so the duplicate workflow runs are no-ops. Move processed mail out of `INBOX` manually (or via a periodic cPanel IMAP filter). |
| Email has no `Message-ID` header | `Parse Email.message_id: ""` | Handled: the MCP server synthesizes a stable `synth-XXXXXXXX@wfdash.local` id from the sender + subject + received_at. The quote still gets created and a retry dedups correctly. The synthetic id is visible in the `quotes.email_message_id` field — clear indicator for the quoter. |
| Email has no `From:` header (malformed mail, BCC-only headers) | `Parse Email.from_email: ""` | Handled: WFDASH links the quote to a sentinel "Unknown Sender" customer (`unknown-sender@no-from.wfdash.local`) so the create succeeds. The quote's `email_sender` field stores the literal empty string. The quoter reassigns the customer in the existing quote-editing form if/when the real sender is known. |
| Email has no `Subject:`, no body, no `From:`, no `Message-ID:` (blank RFC 5321 send) | `Parse Email.subject: '(no subject)'`, `body_plain: '(no body)'`, `from_email: ""` | Handled: Parse Email stamps placeholders; WFDASH accepts them and creates the quote (linked to the sentinel customer, no line items). The office triages manually. |
| `upload_attachment_tool` returns `error: binary not found for key attachment_1` (PDF was uploaded, PNG was not) | This was the v3 bug. Fixed in v3.1. | Verify `AI Agent: Create Quote From Email` has `options.passthroughBinaryImages: false`. If a future agent option is added, also verify `upload_attachment_tool`'s IMAP-trigger fallback (`$('IMAP Quote Mailbox').first().binary[binaryKey]`) is still in place. |
| Quote appears with no line items | AI extraction returned empty array (email was a description with no quantities) | **Correct behaviour in v3.1.** Per the user's standing instruction, the workflow does not route unparseable emails to a manual-review queue — the office's quoters fill in the items in the WFDASH UI. Verify by opening the quote. |
| LLM invents line items (e.g. items not in the email body or the PDF text) | System prompt drift | The system prompt says `DO NOT invent items, prices, or quantities. Look BOTH in the email body AND in extracted_text_preview of each attachment.` Verify both source fields are populated in the user message. If the LLM is hallucinating, reduce `temperature` further or change to a more conservative model. |
| PDF text never reaches the LLM | `extracted_text_preview` is empty in the user message | Check that `Extract from File (PDF)` is wired between Split and Aggregate; check its `binaryPropertyName: "attachment"`; check `onError: continueRegularOutput` so PNGs don't kill the run. |
| `AI Agent` returns `quote_number=ERROR items=0 uploaded=0` | DNS failure on the MCP URL | The `MCP: quotes.create_from_email` URL is unreachable (e.g. pinggy tunnel expired). Verify the URL resolves: `nslookup hzbtf-196-39-76-18.run.pinggy-free.link`. Re-establish the tunnel or update `Edit Fields` → `mcp server`. |
| `Bearer Auth account` not wired | `MCP: quotes.append_reply` returns 401 | Manual UI step: open the node → Credentials → pick `Bearer Auth account`. The SDK has no operation for HTTP Request credential auto-assignment. |
---
## Anti-patterns this workflow explicitly rejects
(Adapted from the old Django app. None of these are allowed to creep back in.)
| Old problem | New constraint |
|---|---|
| Four competing schedulers | **One** IMAP trigger. No background processes in Next.js. |
| `cost_price = 0.8 × selling_price` (fabricated) | Cost price left at 0; `included_in_quote = false` until a human sets it. |
| `_needs_review = True` hard-coded | New quotes always have `status: "new"`. AI-extracted items are suggestions, never auto-included. |
| 40% fuzzy word match on company | `findOrCreateCustomer` / `findOrCreateCompany` use exact match on email, then normalised domain match. **Never** fuzzy merge. New records tagged via `customer.description = "Auto-created from email intake"`. |
| `last.split('-').pop() + 1` race | Server-side `generateQuoteNumberServer()` uses the atomic `quote_counters` collection with `last_seq+` increment. |
| Replies as text blob in `notes` | `quote_replies` collection. The reply tool appends a structured row, **never** mutates `notes` directly. |
| `notifications_email = "@wfsales.co.za"` placeholder | No such env var. Errors come from the Error Handler workflow → Telegram. |
| LLM paraphrases / drops required email metadata | The LLM is told in the system prompt to copy metadata byte-for-byte; `create_quote_tool` reads the email fields from upstream `$json` as a backstop, so even if the LLM misformats, the create call has the right data. |
| LLM invents line items | System prompt says `DO NOT invent` and points at `extracted_text_preview` as the source of truth. |
| LLM consumes PNG binary as vision input | `passthroughBinaryImages: false` on the AI Agent (v3.1). PNGs now survive to the upload tool. |
| PDF text never reaches the LLM | New Split → Extract → Aggregate pipeline puts a 2 KB preview of each PDF's text into the user message. |
| Manual-review queue for unparseable emails | **No manual-review queue.** Per the user's standing instruction, if the LLM can't parse the email, it still creates the quote (possibly with empty `extracted_items`) and the office's quoters fill in the items in the WFDASH UI. |
| Non-PDF attachments (PNG) break the Extract from File node | `onError: continueRegularOutput` makes the node pass through; the aggregate shows empty `extracted_text` and the upload still happens. |
| Silent loss on workflow crash | IMAP filter `UNSEEN` + `postProcessAction: "nothing"` means a crash re-tries on the next poll. The WFDASH database row is the success signal. |
| "Marked as read" used as audit signal | Real database rows in PocketBase (`quotes` / `quote_replies`) are the audit signal. n8n does **not** mutate the IMAP mailbox. |
| Subject regex fallback for `company_hint` | Company is left `null` if the AI returns no `company_hint`. A human fills it in. |
| Fabricated `synth-…@wfdash.local` ids pretending to be real | Synth ids are clearly marked with the `synth-` prefix and `@wfdash.local` suffix so they're impossible to confuse with a real RFC 5322 Message-ID. |
---
## Quoter can verify the source email
The quote detail page now has an **Original Email** card between the Items card and the Notes card. The quoter uses it to verify the AI's extracted line items against what the original RFQ email actually said.
Three sub-sections in one Card:
1.**Email metadata** — subject, sender, received_at. Three-column grid with small icons.
2.**Email body** — plain text in a scrollable `<pre>` (max-h-96). This is `quotes.email_body` populated by `Parse Email` / the intake code, so it is the exact text the AI saw.
3.**Attachments** — list of files with kind icon, humanized size, and a download link. Download uses `pb.files.getUrl(attachment, attachment.file, { query: { token: pb.authStore.token } })` — the user's auth token is embedded in the query string because PB's file API won't accept the cookie that the auth provider uses.
The card renders `null` (no card at all) when `email_body` is empty **and** there are no attachments, so manually-created quotes (no email intake) stay clean. The Email Status card at the bottom of the page (Delivered / Opened / Sender / Subject) is unchanged — it remains the structured status summary; the Original Email card is the **content** the AI saw.
HTML email rendering is out of scope for V1 — we throw the HTML body away on intake and keep only `body_plain`. The plain text is what the AI saw, which is what the quoter needs to verify. Add a "Show rendered HTML" toggle if the user later asks for it.
Component: [src/components/quote-email-card.tsx](../src/components/quote-email-card.tsx). Helper: `getQuoteAttachments(quoteId)` in [src/lib/pb-helpers.ts](../src/lib/pb-helpers.ts) (separate `getFullList` against the `quote_attachments` collection rather than a reverse-relation expand, because PB expand syntax doesn't easily cover reverse relations).
---
## Security notes
- **Credentials live in n8n's vault**, never in the Next.js env. The `AGENT_API_KEY` is a long-lived secret — rotate it like any other token.
- The workflow uses `Authorization: Bearer` against WFDASH. The same key also protects the REST API. If you need finer scoping, replace the single `AGENT_API_KEY` with a per-purpose key (e.g. `WFDASH_N8N_KEY`).
- Pin data is **not encrypted** in the n8n DB. Avoid putting real customer PII or attachment contents into pin data when dry-running.
- The IMAP mailbox password is the highest-value secret here. Use a dedicated mailbox password, scoped to the `quotes@` IMAP folder only.
- The Telegram bot token belongs in the `Telegram Alerts` credential. **Do not** put it in env vars or in this repo. Set the chat ID in `TELEGRAM_CHAT_ID` env (a public value — a chat ID is not a secret).
---
## Versioning
- Main workflow ID: `VOrEd4OQKO0KMnJj` (v3.1: langchain AI Agent + tool subnodes, Split → Extract → Aggregate for PDF text, `passthroughBinaryImages: false` to preserve PNG binary)
- Main workflow ID: `8zjoryZvn0OZzToE` (v3: AI Agent v3.1 + Information Extractor pair, two toolCode subnodes — superseded by v3.1, archived)
- Main workflow ID: `DGN5fslHfdkIyLKb` (v2: tolerates empty `Message-ID`, tolerates empty IMAP `uid`, tolerates empty `From:` (sentinel "Unknown Sender"), tolerates empty Subject and empty body (stamps `(no subject)` / `(no body)`), no IMAP Move step — superseded, archived)
- Error workflow ID: `kVylcetxy858PcfP` (no change in v3.1)
- Project: David Els <david.els1@outlook.com> (personal)