diff --git a/docs/n8n/po-email-intake.md b/docs/n8n/po-email-intake.md new file mode 100644 index 0000000..a8d00e7 --- /dev/null +++ b/docs/n8n/po-email-intake.md @@ -0,0 +1,161 @@ +# PO email intake — ai@megainfra.co.za → MEGA + +**Status: built and tested. INACTIVE — waiting on two things from your IT admin (steps 1–2 below).** + +Workflow: [MEGA - PO Email Intake (Microsoft Graph)](https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0) +Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuildable from this file) + +--- + +## What it does + +Every 5 minutes the workflow: + +1. lists the inbox of `ai@megainfra.co.za` through Microsoft Graph, +2. finds emails carrying a purchase-order document (PDF / JPEG / PNG / WEBP), +3. works out which MEGA site the PO belongs to, +4. files the document into the **same PocketBase queue the browser upload button uses** — a + `purchase_orders` row with `processing_status: pending`, the file attached, `file_hash` set, + and a unique `PENDING-…` placeholder number, +5. archives the source email once the row is filed. + +Step 4 is the important part: the existing **MEGA - Purchase Order Ingest** workflow +(`005iI4jX5mq9DsTR`) already polls that queue, so OCR, supplier matching, line-item extraction +and finalisation all happen in the pipeline that is already running and trusted. **No changes to +the dashboard app were needed** — the email path is just another way into the same door. + +If a PO cannot be matched to a site, the email is tagged `PO Needs Review` in Outlook and left in +the inbox. Nothing is filed against the wrong site and nothing is silently dropped. + +## Why this is not an IMAP workflow + +`megainfra.co.za` is a **Microsoft 365 / Exchange Online** custom domain +(`autodiscover.megainfra.co.za → autodiscover.outlook.com`, SPF includes +`spf.protection.outlook.com`). The tenant has password-based mail protocols switched off: + +| Endpoint | Result | +| --- | --- | +| `outlook.office365.com:993` (IMAP) | `Basic authentication is disabled.` | +| `smtp.office365.com:587` (SMTP) | `535 5.7.139 … SmtpClientAuthentication is disabled for the Tenant.` | + +So no password-based mail client, n8n IMAP node or script can read this mailbox, whatever you +type into a sign-in form. Reading it needs OAuth2 against Microsoft Graph. There is also no +fallback IMAP route: `mail.megainfra.co.za` does not resolve, and HostH's +`securemail.synaq.com` mail ports are unreachable from the app servers. + +--- + +## Step 1 — your IT admin creates the app registration (once) + +In the Azure portal → **App registrations** → **New registration**: + +- Name: `MEGA PO Email Intake` +- Supported account types: **Accounts in this organizational directory only** +- Redirect URI: leave blank (this is a client-credentials app, no sign-in redirect) + +Then: + +- **Certificates & secrets** → **New client secret** → copy the secret value (shown once). +- **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions** → + add **Mail.ReadWrite** → **Grant admin consent**. + - `Mail.Read` would only allow reading. We also move and tag messages, so **`Mail.ReadWrite` + (application)** is the one to grant. +- Copy **Tenant ID**, **Application (client) ID** and the **client secret value**. + +Send those three values to the person wiring up n8n. The account password is not needed and is +not stored anywhere. + +## Step 2 — create the n8n credential (once) + +In n8n → **Credentials** → **New** → **OAuth2 API**: + +| Field | Value | +| --- | --- | +| Name | `Microsoft Graph (MEGA PO)` | +| Grant Type | **Client Credentials** | +| Token URL | `https://login.microsoftonline.com//oauth2/v2.0/token` | +| Client ID | Application (client) ID from the app registration | +| Client Secret | the client secret value | +| Scopes | `https://graph.microsoft.com/.default` | +| Auth Style | Header → `Bearer` | + +## Step 3 — bind the credential and switch it on + +The six Microsoft Graph nodes still need the credential selected: + +`List Inbox POs` · `Download Attachment` · `Archive Message` · +`Archive Message (already filed)` · `Flag Needs Review` · +`Flag Needs Review (no site)` + +The three PocketBase nodes (`Read Sites`, `Duplicate Check`, `Create PO Row`) are already bound +to the existing **`MEGAINFRASTRUCTURE`** credential — no secret sits inline in the workflow. + +Once the credential is selected on those six nodes, activate the workflow (the schedule trigger +starts polling every 5 minutes). + +--- + +## How a PO is matched to a site + +This is the one design decision worth understanding. + +MEGA's finalisation step deliberately **never re-maps the site after OCR** — +`src/app/api/purchase-orders/process-batch/route.ts` states it outright: *"`site` was set at +upload by the person who uploaded the … and is NEVER touched here"*, because OCR only matches a +real project code on roughly 8% of documents. The old +`src/app/api/purchase-orders/ingest/route.ts`, which used to match the site from +`project_code` server-side, is deprecated (see +`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`). + +So the site has to be known when the row is created, and the workflow reads it out of the email +itself, in this order: + +1. exact project code — `C26/09`, `C26/13`, `C26/14`, `001`, `HEAD` +2. project code appearing anywhere in subject + body preview + attachment filename +3. site name — *The Hills*, *Cedar Gate*, *Munyaka 2*, *Nellmapius Ext 25*, *Mega Head Office* + +If none of those match, the email is tagged `PO Needs Review` and left in the inbox for a human. +It is never filed against a guessed site. + +> If you would rather have the OCR decide the site, that needs a change to the +> `process-batch` route (or to the queue) so the site can be corrected after extraction. That is +> a small change to the app, not to this workflow — say the word and it can be done. + +## Duplicate protection + +Before filing, the workflow looks for an existing `purchase_orders` row with the **same file hash +at the same site** (same rule the browser upload uses). A re-send of the same document is +archived rather than filed twice. The placeholder `purchase_order_number` is derived from the +file hash, so PocketBase's unique index is a second line of defence. + +## What it deliberately does not do + +- Only the inbox is read — not other folders. 25 most recent messages per run. +- Only PDF / JPEG / PNG / WEBP attachments are considered. Everything else is ignored. +- Messages already tagged `PO Needs Review` or `PO Ingested` are skipped on later runs, so a + flagged email is not re-processed every 5 minutes. +- Non-PO attachments that merely look PO-ish are flagged for review, not filed — MEGA's own + document-kind check then refuses anything that turns out to be an invoice. + +## Timing + +| Step | When | +| --- | --- | +| Email picked up | within 5 minutes of arriving | +| OCR + finalisation | the existing MEGA workflow wakes every 3 minutes and claims one pending row per run, so a PO reaches the dashboard roughly 3–8 minutes after the email lands | +| Archived email | as soon as the row is filed | + +## Rollback + +Deactivate the workflow (n8n → the workflow → **Deactivate**). Nothing else changes and the +dashboard keeps working. Rows already filed by email are ordinary `purchase_orders` rows and +behave exactly like browser uploads. + +## Troubleshooting + +| Symptom | Where to look | +| --- | --- | +| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the Graph credential is wrong or admin consent was not granted | +| POs tagged `PO Needs Review` | the email did not name a site; add the site/project code to the subject or check the site table in `Read Sites` | +| Row created but never finalised | the existing `MEGA - Purchase Order Ingest` workflow — check its `Fetch Pending Batches` / `Escalate Failure` nodes | +| Same PO filed twice | `Duplicate Check` compares `file_hash` + `site`; a different site for the same document is deliberately not treated as a duplicate | diff --git a/docs/n8n/po-email-intake.workflow.js b/docs/n8n/po-email-intake.workflow.js new file mode 100644 index 0000000..32447e8 --- /dev/null +++ b/docs/n8n/po-email-intake.workflow.js @@ -0,0 +1,463 @@ +/** + * MEGA - PO Email Intake (Microsoft Graph) + * ----------------------------------------- + * n8n Workflow SDK source. Rebuild with the n8n MCP `create_workflow_from_code` tool + * (validate first), or rebuild by hand in the n8n UI using + * docs/n8n/po-email-intake.md as the wiring reference. + * + * Live workflow: https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0 + * + * What it does: polls ai@megainfra.co.za for purchase-order emails, downloads the PO + * attachment, and files it into the same PocketBase pending queue the browser upload + * button uses, so the existing "MEGA - Purchase Order Ingest" workflow OCRs and + * finalises it. The source email is archived once the row is filed; anything that + * cannot be matched to a site is tagged "PO Needs Review" in Outlook instead. + * + * Credentials referenced (none of them contain an inline secret): + * - "Microsoft Graph (MEGA PO)" (oAuth2Api) -> created by the admin, see the runbook + * - "MEGAINFRASTRUCTURE" (pocketbaseHttpApi) -> already exists on this instance + * + * Set the Graph credential on these six nodes after creating it: + * List Inbox POs, Download Attachment, Archive Message, + * Archive Message (already filed), Flag Needs Review, Flag Needs Review (no site) + */ + +import { workflow, node, trigger, ifElse, splitInBatches, nextBatch, newCredential, expr } from '@n8n/workflow-sdk'; + +const MAILBOX = 'ai@megainfra.co.za'; +const GRAPH = 'https://graph.microsoft.com/v1.0'; +const MSG = GRAPH + '/users/' + MAILBOX + '/messages/'; + +const GRAPH_CRED = { oAuth2Api: newCredential('Microsoft Graph (MEGA PO)') }; +const PB_CRED = { pocketbaseHttpApi: newCredential('MEGAINFRASTRUCTURE') }; + +const schedule = trigger({ + type: 'n8n-nodes-base.scheduleTrigger', + version: 1.3, + config: { + name: 'Every 5 minutes', + parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } } + } +}); + +const readSites = node({ + type: 'n8n-nodes-pocketbase.pocketbaseHttp', + version: 1, + config: { + name: 'Read Sites', + parameters: { + resource: 'sites', + operation: 'search', + parameters: { allElements: true, elementsPerPage: 50, fields: ['name', 'project_code'] } + }, + credentials: PB_CRED + } +}); + +const collectSites = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Collect Sites', + parameters: { + mode: 'runOnceForAllItems', + jsCode: `const items = $input.all(); +const sites = []; +for (const it of items) { + const j = it && it.json ? it.json : {}; + if (j && j.id) sites.push({ id: j.id, name: j.name || '', project_code: j.project_code || '' }); +} +return [{ json: { sites: sites } }];` + } + } +}); + +const listInbox = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'List Inbox POs', + parameters: { + method: 'GET', + url: GRAPH + '/users/' + MAILBOX + '/mailFolders/inbox/messages', + sendQuery: true, + queryParameters: { + parameters: [ + { name: '$select', value: 'id,subject,from,bodyPreview,receivedDateTime,hasAttachments,categories' }, + { name: '$expand', value: 'attachments' }, + { name: '$orderby', value: 'receivedDateTime desc' }, + { name: '$top', value: '25' } + ] + }, + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'json', neverError: true } } } + }, + credentials: GRAPH_CRED + } +}); + +const selectDocs = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Select PO Documents', + parameters: { + mode: 'runOnceForAllItems', + jsCode: `const body = $input.first() ? $input.first().json : {}; +const messages = body && Array.isArray(body.value) ? body.value : []; +let sites = []; +try { sites = ($('Collect Sites').item.json.sites) || []; } catch (e) { sites = []; } +const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']; +const POISH = /(\\bp\\/?o\\b|\\bpo\\b|purchase\\s*order|\\border\\b)/i; +function norm(s) { return String(s || '').toUpperCase().replace(/\\s+/g, ' ').trim(); } +function matchSite(text) { + const t = norm(text); + if (!t) return null; + const flat = t.replace(/[^A-Z0-9]/g, ''); + for (const s of sites) { const c = norm(s.project_code); if (c && t === c) return s; } + for (const s of sites) { const c = norm(s.project_code); if (c && c.length >= 3 && t.indexOf(c) >= 0) return s; } + for (const s of sites) { const n = norm(s.name); if (n && n.length >= 4 && t.indexOf(n) >= 0) return s; } + for (const s of sites) { const c = norm(s.project_code).replace(/[^A-Z0-9]/g, ''); if (c && c.length >= 3 && flat.indexOf(c) >= 0) return s; } + return null; +} +const out = []; +for (const m of messages) { + if (!m || !m.id || m.hasAttachments !== true) continue; + const cats = Array.isArray(m.categories) ? m.categories : []; + if (cats.indexOf('PO Needs Review') >= 0 || cats.indexOf('PO Ingested') >= 0) continue; + const atts = Array.isArray(m.attachments) ? m.attachments : []; + const subject = m.subject || ''; + const preview = m.bodyPreview || ''; + const from = (m.from && m.from.emailAddress && m.from.emailAddress.address) || ''; + for (const a of atts) { + if (!a || a.isInline === true) continue; + const ct = String(a.contentType || '').toLowerCase(); + const name = String(a.name || ''); + if (DOC_TYPES.indexOf(ct) < 0) continue; + const hay = subject + ' ' + preview + ' ' + name; + const site = matchSite(hay); + const poish = POISH.test(hay); + if (!site && !poish) continue; + out.push({ json: { + messageId: m.id, + attachmentId: a.id, + filename: name, + contentType: ct, + size: a.size || 0, + subject: subject, + bodyPreview: preview, + from: from, + received: m.receivedDateTime || '', + ready: !!site, + siteId: site ? site.id : '', + siteCode: site ? site.project_code : '', + siteName: site ? site.name : '' + } }); + } +} +return out;` + } + } +}); + +const loop = splitInBatches({ + version: 3, + config: { name: 'Loop Documents', parameters: { batchSize: 1 } } +}); + +const siteResolved = ifElse({ + version: 2.2, + config: { + name: 'Site Resolved?', + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, + conditions: [{ leftValue: expr('{{ $json.ready }}'), operator: { type: 'boolean', operation: 'equals' }, rightValue: true }], + combinator: 'and' + } + } + } +}); + +const download = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'Download Attachment', + parameters: { + method: 'GET', + url: expr('"' + MSG + '" + $json.messageId + "/attachments/" + $json.attachmentId + "/$value"'), + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'file', outputPropertyName: 'data' } } } + }, + credentials: GRAPH_CRED + } +}); + +const capture = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Capture File', + parameters: { + mode: 'runOnceForAllItems', + jsCode: `const items = $input.all(); +const out = []; +for (const item of items) { + let meta = {}; + try { meta = $('Select PO Documents').item.json || {}; } catch (e) { meta = {}; } + if (!meta.messageId) { try { meta = $('Loop Documents').item.json || {}; } catch (e) { meta = {}; } } + const bin = (item.binary && item.binary.data) || {}; + const b64 = typeof bin.data === 'string' ? bin.data : ''; + let fileHash = ''; + try { + const crypto = require('crypto'); + if (b64) fileHash = crypto.createHash('sha256').update(Buffer.from(b64, 'base64')).digest('hex'); + } catch (e) { fileHash = ''; } + const stamp = Date.now().toString(36).toUpperCase(); + out.push({ + json: { + messageId: meta.messageId || '', + attachmentId: meta.attachmentId || '', + filename: bin.fileName || meta.filename || 'purchase-order.pdf', + contentType: bin.mimeType || meta.contentType || 'application/pdf', + fileHash: fileHash, + siteId: meta.siteId || '', + siteCode: meta.siteCode || '', + purchaseOrderNumber: fileHash ? ('PENDING-' + fileHash.slice(0, 12).toUpperCase()) : ('PENDING-' + stamp), + emailMeta: { + source: 'email', + message_id: meta.messageId || '', + subject: meta.subject || '', + from: meta.from || '', + received: meta.received || '', + site_code: meta.siteCode || '', + file_name: bin.fileName || meta.filename || '' + } + }, + binary: item.binary + }); +} +return out;` + } + } +}); + +const dupCheck = node({ + type: 'n8n-nodes-pocketbase.pocketbaseHttp', + version: 1, + config: { + name: 'Duplicate Check', + alwaysOutputData: true, + parameters: { + resource: 'purchase_orders', + operation: 'search', + parameters: { + elementsPerPage: 1, + filter: expr('"=file_hash=\'" + $("Capture File").item.json.fileHash + "\' && site=\'" + $("Capture File").item.json.siteId + "\' && processing_status!=\'failed\'"') + } + }, + credentials: PB_CRED + } +}); + +const alreadyFiled = ifElse({ + version: 2.2, + config: { + name: 'Already Filed?', + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' }, + conditions: [{ leftValue: expr('{{ $json.id }}'), operator: { type: 'string', operation: 'notEmpty' }, rightValue: '' }], + combinator: 'and' + } + } + } +}); + +const createPo = node({ + type: 'n8n-nodes-pocketbase.pocketbaseHttp', + version: 1, + config: { + name: 'Create PO Row', + onError: 'continueRegularOutput', + parameters: { + resource: 'purchase_orders', + operation: 'create', + bodyType: ['fields', 'binaryData'], + binaryPropertyName: 'data', + binaryFieldName: 'purchase_order_file', + fields: { + assignments: [ + { id: 'f1', name: 'purchase_order_number', value: expr('{{ $("Capture File").item.json.purchaseOrderNumber }}'), type: 'string' }, + { id: 'f2', name: 'site', value: expr('{{ $("Capture File").item.json.siteId }}'), type: 'string' }, + { id: 'f3', name: 'processing_status', value: 'pending', type: 'string' }, + { id: 'f4', name: 'attempts', value: 0, type: 'number' }, + { id: 'f5', name: 'is_verified', value: false, type: 'boolean' }, + { id: 'f6', name: 'file_hash', value: expr('{{ $("Capture File").item.json.fileHash }}'), type: 'string' }, + { id: 'f7', name: 'date_uploaded', value: expr('{{ $now.toISO() }}'), type: 'string' }, + { id: 'f8', name: 'raw_ocr_data', value: expr('{{ $("Capture File").item.json.emailMeta }}'), type: 'object' } + ] + } + }, + credentials: PB_CRED + } +}); + +const checkResult = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Check Result', + parameters: { + mode: 'runOnceForAllItems', + jsCode: `const j = $input.first() ? $input.first().json : {}; +let outcome = 'failed'; +let detail = ''; +let poId = ''; +if (j && j.id) { + outcome = 'filed'; + poId = j.id; +} else { + const err = j && (j.error || j.message) ? String(j.error || j.message) : JSON.stringify(j).slice(0, 300); + detail = err; +} +return [{ json: { outcome: outcome, detail: detail, poId: poId } }];` + } + } +}); + +const filedOk = ifElse({ + version: 2.2, + config: { + name: 'Filed OK?', + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' }, + conditions: [{ leftValue: expr('{{ $json.outcome }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'filed' }], + combinator: 'and' + } + } + } +}); + +const archiveNew = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'Archive Message', + parameters: { + method: 'POST', + url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'), + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={"destinationId":"archive"}', + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'json', neverError: true } } } + }, + credentials: GRAPH_CRED + } +}); + +const archiveDup = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'Archive Message (already filed)', + parameters: { + method: 'POST', + url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'), + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={"destinationId":"archive"}', + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'json', neverError: true } } } + }, + credentials: GRAPH_CRED + } +}); + +const flagReview = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'Flag Needs Review', + parameters: { + method: 'PATCH', + url: expr('"' + MSG + '" + $("Capture File").item.json.messageId'), + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={"categories":["PO Needs Review"]}', + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'json', neverError: true } } } + }, + credentials: GRAPH_CRED + } +}); + +const flagNoSite = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.5, + config: { + name: 'Flag Needs Review (no site)', + parameters: { + method: 'PATCH', + url: expr('"' + MSG + '" + $json.messageId'), + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={"categories":["PO Needs Review"]}', + authentication: 'genericCredentialType', + genericAuthType: 'oAuth2Api', + options: { response: { response: { responseFormat: 'json', neverError: true } } } + }, + credentials: GRAPH_CRED + } +}); + +const summary = node({ + type: 'n8n-nodes-base.code', + version: 2, + config: { + name: 'Run Summary', + parameters: { + mode: 'runOnceForAllItems', + jsCode: `const items = $input.all(); +return [{ json: { finished: true, processed: items.length } }];` + } + } +}); + +export default workflow('mega-po-email-intake', 'MEGA - PO Email Intake (Microsoft Graph)') + .add(schedule) + .to(readSites) + .to(collectSites) + .to(listInbox) + .to(selectDocs) + .to(loop + .onDone(summary) + .onEachBatch(siteResolved + .onTrue(download + .to(capture) + .to(dupCheck) + .to(alreadyFiled + .onTrue(archiveDup.to(nextBatch(loop))) + .onFalse(createPo + .to(checkResult) + .to(filedOk + .onTrue(archiveNew.to(nextBatch(loop))) + .onFalse(flagReview.to(nextBatch(loop))))))) + .onFalse(flagNoSite.to(nextBatch(loop)))) + ); diff --git a/docs/n8n/purchase-order-ingest.md b/docs/n8n/purchase-order-ingest.md index 46df5d3..c5f212f 100644 --- a/docs/n8n/purchase-order-ingest.md +++ b/docs/n8n/purchase-order-ingest.md @@ -186,10 +186,12 @@ Test cases: 4. **Duplicate PO** — upload the same PO twice. First call 200, second call 409. 5. **Unknown project_code** — expect 200 with `warnings: ["site_unmatched"]`. -## Future: email ingestion +## Email ingestion — built -The same `/api/purchase-orders/ingest` callback will be reused when email ingestion is built. Add a second n8n workflow with an **Email Trigger** (IMAP / Gmail / Outlook node) that: -1. Downloads the PO attachment from the email. -2. Runs the same OCR + parser + callback flow. +Email ingestion exists now: **MEGA - PO Email Intake (Microsoft Graph)** +(`bQ5PioGTSKnsWVh0`). See [po-email-intake.md](./po-email-intake.md). -No changes needed on the Next.js side. +It does **not** use `/api/purchase-orders/ingest` — that route is deprecated (see +`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`). Instead the email workflow writes +the PO document straight into the same `purchase_orders` pending queue this workflow polls, so +the OCR + parser + callback flow above is reused unchanged. No changes on the Next.js side.