feat(po-email): native Outlook node build + Mail.app read route
Rebuilds the PO email intake on n8n's native Microsoft Outlook node so the mailbox can be connected with a single sign-in instead of an Azure app registration, and documents reading the same mailbox from Mail.app via AppleScript for one-off backfills. Records the real-world site-matching gap: the site often appears only inside the PDF, not in the email.
This commit is contained in:
parent
2c2d493e12
commit
de85bf897e
2 changed files with 240 additions and 184 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# 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).**
|
||||
**Status: built and tested. INACTIVE — waiting on one thing from you (step 1 below).**
|
||||
|
||||
Workflow: [MEGA - PO Email Intake (Microsoft Graph)](https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0)
|
||||
Workflow: [MEGA - PO Email Intake (Microsoft Outlook)](https://wfdash.n8n.apphub.co.za/workflow/ARfOsAOy2KDUm1rp)
|
||||
Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuildable from this file)
|
||||
|
||||
---
|
||||
|
|
@ -11,7 +11,7 @@ Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuilda
|
|||
|
||||
Every 5 minutes the workflow:
|
||||
|
||||
1. lists the inbox of `ai@megainfra.co.za` through Microsoft Graph,
|
||||
1. lists the inbox of `ai@megainfra.co.za` through n8n's native **Microsoft Outlook** node,
|
||||
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
|
||||
|
|
@ -45,53 +45,80 @@ fallback IMAP route: `mail.megainfra.co.za` does not resolve, and HostH's
|
|||
|
||||
---
|
||||
|
||||
## Step 1 — your IT admin creates the app registration (once)
|
||||
## Reading the mailbox from the Mac instead (Mail.app)
|
||||
|
||||
In the Azure portal → **App registrations** → **New registration**:
|
||||
`ai@megainfra.co.za` is also set up in **Mail.app** on this Mac, and Mail can be read without
|
||||
touching Full Disk Access, through Mail's own AppleScript interface:
|
||||
|
||||
- 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)
|
||||
```bash
|
||||
# accounts Mail knows about
|
||||
osascript -e 'tell application "Mail" to get name of every account'
|
||||
|
||||
Then:
|
||||
# raw MIME of a message (attachments come back base64-encoded inside it)
|
||||
osascript -e 'tell application "Mail" to get source of message 1 of mailbox "Inbox" of account "ai@megainfra.co.za"' > msg1.eml
|
||||
```
|
||||
|
||||
- **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**.
|
||||
Then parse `msg1.eml` with any MIME parser to pull out the PDFs (see
|
||||
[`po-email-intake.workflow.js`](./po-email-intake.workflow.js) for the same attachment filter
|
||||
the workflow uses).
|
||||
|
||||
Send those three values to the person wiring up n8n. The account password is not needed and is
|
||||
not stored anywhere.
|
||||
Two things to know:
|
||||
|
||||
## Step 2 — create the n8n credential (once)
|
||||
- macOS blocks **direct** reads of Mail's private store (`~/Library/Mail` and the
|
||||
`com.apple.mail` container) from a sandboxed shell — you get `Operation not permitted`.
|
||||
AppleScript works because Mail itself hands the data over; it asks once for Automation
|
||||
permission, which is far less than Full Disk Access.
|
||||
- This route is **Mac-local**. It only works while the Mac is on and Mail is signed in, so it is
|
||||
good for a one-off backfill or for checking what is arriving — not for unattended intake.
|
||||
The n8n workflow below is the route that runs on its own.
|
||||
|
||||
In n8n → **Credentials** → **New** → **OAuth2 API**:
|
||||
---
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `Microsoft Graph (MEGA PO)` |
|
||||
| Grant Type | **Client Credentials** |
|
||||
| Token URL | `https://login.microsoftonline.com/<TENANT_ID>/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 1 — connect the mailbox in n8n (once, about a minute)
|
||||
|
||||
## Step 3 — bind the credential and switch it on
|
||||
In n8n → **Credentials** → **New** → search for **Microsoft Outlook** → **Connect**.
|
||||
|
||||
The six Microsoft Graph nodes still need the credential selected:
|
||||
n8n opens a Microsoft sign-in page. **Sign in as `ai@megainfra.co.za`** and approve the mail
|
||||
permissions it asks for (read, move, tag). n8n stores the tokens and refreshes them for you —
|
||||
there is no password, no client secret and no Azure app registration to create. Name the
|
||||
credential `Microsoft Outlook (MEGA PO)` so it lines up with the workflow.
|
||||
|
||||
`List Inbox POs` · `Download Attachment` · `Archive Message` ·
|
||||
`Archive Message (already filed)` · `Flag Needs Review` ·
|
||||
`Flag Needs Review (no site)`
|
||||
> If your browser is already signed in to that account, it is one click. If the account is
|
||||
> already open in your mail app, the same Microsoft session usually carries over — which is
|
||||
> exactly why this route works without an IT admin.
|
||||
|
||||
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.
|
||||
## Step 2 — bind the credential (once)
|
||||
|
||||
Once the credential is selected on those six nodes, activate the workflow (the schedule trigger
|
||||
starts polling every 5 minutes).
|
||||
Select `Microsoft Outlook (MEGA PO)` on these six nodes:
|
||||
|
||||
`List Inbox POs` · `List Attachments` · `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.)
|
||||
|
||||
## Step 3 — switch it on
|
||||
|
||||
Activate the workflow. The schedule trigger starts polling every 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading to an unattended service principal (optional, later)
|
||||
|
||||
The delegated sign-in above runs as a person. That is fine for getting started, but for a
|
||||
production flow you may prefer an app that needs no human at all. n8n's Outlook node supports
|
||||
three authentication modes, and the swap is a setting change on the same six nodes:
|
||||
|
||||
| Mode | Credential | Use when |
|
||||
| --- | --- | --- |
|
||||
| `microsoftOutlookOAuth2Api` | Microsoft Outlook (hosted OAuth) | **default here** — one sign-in, no admin |
|
||||
| `microsoftOAuth2Api` | Microsoft OAuth2 API (client credentials) | you want an app registration and a client secret |
|
||||
| `microsoftEntraServicePrincipalApi` | Entra service principal | you want app-only access, with the `mailbox` field set to `ai@megainfra.co.za` |
|
||||
|
||||
For the service-principal route your IT admin creates an Azure app registration with
|
||||
**Mail.ReadWrite** (application) + admin consent, and hands over Tenant ID, Application
|
||||
(client) ID and the client secret value. Then change the `authentication` dropdown on the six
|
||||
nodes and set the mailbox on each. No rebuild is needed — the workflow logic is unchanged.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -111,15 +138,24 @@ So the site has to be known when the row is created, and the workflow reads it o
|
|||
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
|
||||
2. project code appearing anywhere in subject + body preview + sender
|
||||
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.
|
||||
|
||||
**Known limitation, seen in real mail:** the site often appears only *inside* the PDF, not in the
|
||||
email. A real example from the mailbox — the subject was *"RE: Bushveld Joy x 12 Traffic Light
|
||||
Quote."* with no site anywhere in the subject, body or filename, while the PDF itself said
|
||||
*"MEGCREQ11911 The Hills Ext12"* and the PO was for **The Hills**. That email gets flagged for
|
||||
review rather than filed. The other three POs that week named their site in the subject
|
||||
(*"Nellmapius Ext 25 PO Tools/cement/Bricks"*, *"Cedar Gate Cement PO"*) and matched fine.
|
||||
|
||||
> 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.
|
||||
> a small change to the app, not to this workflow — say the word and it can be done. It would
|
||||
> also close the gap above, because the PO's own `Subject` line (`MEGCREQ11913 (Nellmapius EXT25)`)
|
||||
> is far more reliable than the email text.
|
||||
|
||||
## Duplicate protection
|
||||
|
||||
|
|
@ -155,7 +191,7 @@ behave exactly like browser uploads.
|
|||
|
||||
| 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 |
|
||||
| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the credential is not connected or the consent was refused |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* MEGA - PO Email Intake (Microsoft Graph)
|
||||
* -----------------------------------------
|
||||
* MEGA - PO Email Intake (Microsoft Outlook)
|
||||
* ------------------------------------------
|
||||
* 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
|
||||
* Live workflow: https://wfdash.n8n.apphub.co.za/workflow/ARfOsAOy2KDUm1rp
|
||||
*
|
||||
* 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
|
||||
|
|
@ -14,21 +14,19 @@
|
|||
* 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
|
||||
* - "Microsoft Outlook (MEGA PO)" (microsoftOutlookOAuth2Api) -> n8n's hosted Microsoft
|
||||
* OAuth: create it in n8n and sign in as ai@megainfra.co.za. No app registration.
|
||||
* Swap to microsoftEntraServicePrincipalApi for unattended app-only access.
|
||||
* - "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,
|
||||
* Set the Microsoft credential on these seven nodes after creating it:
|
||||
* List Inbox POs, List Attachments, 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 GRAPH_CRED = { microsoftOutlookOAuth2Api: newCredential('Microsoft Outlook (MEGA PO)') };
|
||||
const PB_CRED = { pocketbaseHttpApi: newCredential('MEGAINFRASTRUCTURE') };
|
||||
|
||||
const schedule = trigger({
|
||||
|
|
@ -73,42 +71,38 @@ return [{ json: { sites: sites } }];`
|
|||
});
|
||||
|
||||
const listInbox = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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 } } }
|
||||
resource: 'message',
|
||||
operation: 'getAll',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
returnAll: false,
|
||||
limit: 25,
|
||||
output: 'simple',
|
||||
filtersUI: {
|
||||
values: {
|
||||
filterBy: 'filters',
|
||||
filters: { hasAttachments: true, readStatus: 'both', foldersToInclude: ['inbox'] }
|
||||
}
|
||||
}
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const selectDocs = node({
|
||||
const selectMsgs = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Select PO Documents',
|
||||
name: 'Select PO Messages',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const body = $input.first() ? $input.first().json : {};
|
||||
const messages = body && Array.isArray(body.value) ? body.value : [];
|
||||
jsCode: `const items = $input.all();
|
||||
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) {
|
||||
|
|
@ -122,39 +116,29 @@ function matchSite(text) {
|
|||
return null;
|
||||
}
|
||||
const out = [];
|
||||
for (const m of messages) {
|
||||
if (!m || !m.id || m.hasAttachments !== true) continue;
|
||||
for (const it of items) {
|
||||
const m = it && it.json ? it.json : {};
|
||||
if (!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 : ''
|
||||
} });
|
||||
}
|
||||
const hay = subject + ' ' + preview + ' ' + from;
|
||||
const site = matchSite(hay);
|
||||
const poish = POISH.test(hay);
|
||||
if (!site && !poish) continue;
|
||||
out.push({ json: {
|
||||
messageId: m.id,
|
||||
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;`
|
||||
}
|
||||
|
|
@ -163,7 +147,7 @@ return out;`
|
|||
|
||||
const loop = splitInBatches({
|
||||
version: 3,
|
||||
config: { name: 'Loop Documents', parameters: { batchSize: 1 } }
|
||||
config: { name: 'Loop Messages', parameters: { batchSize: 1 } }
|
||||
});
|
||||
|
||||
const siteResolved = ifElse({
|
||||
|
|
@ -180,17 +164,66 @@ const siteResolved = ifElse({
|
|||
}
|
||||
});
|
||||
|
||||
const listAtts = node({
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'List Attachments',
|
||||
parameters: {
|
||||
resource: 'messageAttachment',
|
||||
operation: 'getAll',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const selectAtts = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Select PO Attachments',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
let messageId = '';
|
||||
try { messageId = $('Select PO Messages').item.json.messageId || ''; } catch (e) { messageId = ''; }
|
||||
if (!messageId) { try { messageId = $('Loop Messages').item.json.messageId || ''; } catch (e) { messageId = ''; } }
|
||||
const out = [];
|
||||
for (const it of items) {
|
||||
const a = it && it.json ? it.json : {};
|
||||
if (!a.id) continue;
|
||||
if (a.isInline === true) continue;
|
||||
const ct = String(a.contentType || '').toLowerCase();
|
||||
const name = String(a.name || '');
|
||||
if (DOC_TYPES.indexOf(ct) < 0) continue;
|
||||
out.push({ json: {
|
||||
messageId: messageId,
|
||||
attachmentId: a.id,
|
||||
filename: name,
|
||||
contentType: ct,
|
||||
size: a.size || 0
|
||||
} });
|
||||
}
|
||||
return out;`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const download = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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' } } }
|
||||
resource: 'messageAttachment',
|
||||
operation: 'download',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' },
|
||||
attachmentId: { __rl: true, mode: 'id', value: '={{ $json.attachmentId }}' },
|
||||
binaryPropertyName: 'data'
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
|
|
@ -206,9 +239,14 @@ const capture = node({
|
|||
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 = {}; } }
|
||||
// Site and message metadata live on the message node; attachment details on the
|
||||
// attachment node. Reading site info from the attachment node leaves siteId empty
|
||||
// and PocketBase rejects the row (site is a required relation).
|
||||
let msgMeta = {};
|
||||
try { msgMeta = $('Select PO Messages').item.json || {}; } catch (e) { msgMeta = {}; }
|
||||
if (!msgMeta.messageId) { try { msgMeta = $('Loop Messages').item.json || {}; } catch (e) { msgMeta = {}; } }
|
||||
let attMeta = {};
|
||||
try { attMeta = $('Select PO Attachments').item.json || {}; } catch (e) { attMeta = {}; }
|
||||
const bin = (item.binary && item.binary.data) || {};
|
||||
const b64 = typeof bin.data === 'string' ? bin.data : '';
|
||||
let fileHash = '';
|
||||
|
|
@ -219,22 +257,22 @@ for (const item of items) {
|
|||
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',
|
||||
messageId: msgMeta.messageId || attMeta.messageId || '',
|
||||
attachmentId: attMeta.attachmentId || '',
|
||||
filename: bin.fileName || attMeta.filename || 'purchase-order.pdf',
|
||||
contentType: bin.mimeType || attMeta.contentType || 'application/pdf',
|
||||
fileHash: fileHash,
|
||||
siteId: meta.siteId || '',
|
||||
siteCode: meta.siteCode || '',
|
||||
siteId: msgMeta.siteId || '',
|
||||
siteCode: msgMeta.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 || ''
|
||||
message_id: msgMeta.messageId || attMeta.messageId || '',
|
||||
subject: msgMeta.subject || '',
|
||||
from: msgMeta.from || '',
|
||||
received: msgMeta.received || '',
|
||||
site_code: msgMeta.siteCode || '',
|
||||
file_name: bin.fileName || attMeta.filename || ''
|
||||
}
|
||||
},
|
||||
binary: item.binary
|
||||
|
|
@ -344,84 +382,64 @@ const filedOk = ifElse({
|
|||
});
|
||||
|
||||
const archiveNew = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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 } } }
|
||||
resource: 'message',
|
||||
operation: 'move',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
|
||||
folderId: { __rl: true, mode: 'id', value: 'archive' }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const archiveDup = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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 } } }
|
||||
resource: 'message',
|
||||
operation: 'move',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
|
||||
folderId: { __rl: true, mode: 'id', value: 'archive' }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const flagReview = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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 } } }
|
||||
resource: 'message',
|
||||
operation: 'update',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
|
||||
updateFields: { categories: ['PO Needs Review'], isRead: true }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const flagNoSite = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
type: 'n8n-nodes-base.microsoftOutlook',
|
||||
version: 2,
|
||||
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 } } }
|
||||
resource: 'message',
|
||||
operation: 'update',
|
||||
authentication: 'microsoftOutlookOAuth2Api',
|
||||
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' },
|
||||
updateFields: { categories: ['PO Needs Review'], isRead: true }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
|
|
@ -440,24 +458,26 @@ return [{ json: { finished: true, processed: items.length } }];`
|
|||
}
|
||||
});
|
||||
|
||||
export default workflow('mega-po-email-intake', 'MEGA - PO Email Intake (Microsoft Graph)')
|
||||
export default workflow('mega-po-email-intake-v2', 'MEGA - PO Email Intake (Microsoft Outlook)')
|
||||
.add(schedule)
|
||||
.to(readSites)
|
||||
.to(collectSites)
|
||||
.to(listInbox)
|
||||
.to(selectDocs)
|
||||
.to(selectMsgs)
|
||||
.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)))))))
|
||||
.onTrue(listAtts
|
||||
.to(selectAtts)
|
||||
.to(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))))
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue