387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { workflow, node, trigger, ifElse, languageModel, newCredential, expr } from '@n8n/workflow-sdk';
|
|
|
|
const imapTrigger = trigger({
|
|
type: 'n8n-nodes-base.emailReadImap',
|
|
version: 2.1,
|
|
config: {
|
|
name: 'IMAP Quote Mailbox',
|
|
parameters: {
|
|
mailbox: 'INBOX',
|
|
postProcessAction: 'nothing',
|
|
format: 'simple',
|
|
downloadAttachments: true,
|
|
options: {
|
|
customEmailConfig: '["UNSEEN"]',
|
|
forceReconnect: 60,
|
|
trackLastMessageId: true
|
|
}
|
|
},
|
|
position: [240, 300]
|
|
},
|
|
output: [{ uid: '123', messageId: '<x@y>', from: { value: [{ address: 'x@y' }] }, subject: 's', date: '2026-06-12T00:00:00Z', textPlain: '', textHtml: '' }]
|
|
});
|
|
|
|
const parseEmail = node({
|
|
type: 'n8n-nodes-base.code',
|
|
version: 2,
|
|
config: {
|
|
name: 'Parse Email',
|
|
parameters: {
|
|
mode: 'runOnceForEachItem',
|
|
jsCode: `const item = $json;
|
|
|
|
function asArray(v) {
|
|
if (Array.isArray(v)) return v;
|
|
if (v && typeof v === 'object') {
|
|
if ('item' in v) return [v.item];
|
|
if ('item0' in v) {
|
|
const out = [];
|
|
let i = 0;
|
|
while (('item' + i) in v) { out.push(v['item' + i]); i++; }
|
|
if (out.length) return out;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractEmail(from) {
|
|
if (!from) return '';
|
|
if (typeof from === 'string') {
|
|
const m = from.match(/<([^>]+)>/);
|
|
return (m ? m[1] : from).toLowerCase().trim();
|
|
}
|
|
if (typeof from === 'object') {
|
|
const arr = asArray(from.value);
|
|
if (arr && arr.length > 0) {
|
|
const v = arr[0];
|
|
if (typeof v === 'string') return v.toLowerCase();
|
|
if (v && typeof v === 'object' && v.address) return v.address.toLowerCase();
|
|
}
|
|
if (typeof from.address === 'string') return from.address.toLowerCase();
|
|
if (typeof from.text === 'string') {
|
|
const m = from.text.match(/<([^>]+)>/);
|
|
if (m) return m[1].toLowerCase();
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function extractName(from) {
|
|
if (!from) return '';
|
|
if (typeof from === 'object') {
|
|
if (typeof from.name === 'string' && from.name) return from.name;
|
|
const arr = asArray(from.value);
|
|
if (arr && arr[0]) {
|
|
if (typeof arr[0] === 'object' && arr[0].name) return arr[0].name;
|
|
}
|
|
}
|
|
if (typeof from === 'string') {
|
|
const m = from.match(/^([^<]+)</);
|
|
return m ? m[1].trim().replace(/^"|"$/g, '') : '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function extractMessageId(field) {
|
|
if (!field) return '';
|
|
if (typeof field === 'string') return field.replace(/^[<]|[>]$/g, '');
|
|
if (typeof field === 'object') {
|
|
if (typeof field.value === 'string') return field.value.replace(/^[<]|[>]$/g, '');
|
|
const arr = asArray(field.value);
|
|
if (arr && typeof arr[0] === 'string') return arr[0].replace(/^[<]|[>]$/g, '');
|
|
if (typeof field.messageId === 'string') return field.messageId.replace(/^[<]|[>]$/g, '');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const fromEmail = extractEmail(item.from);
|
|
const fromName = extractName(item.from);
|
|
const messageId = extractMessageId(item.messageId);
|
|
const inReplyTo = extractMessageId(item.inReplyTo);
|
|
const mailbox = String(item.mailbox || 'INBOX');
|
|
const uid = String(item.uid || '');
|
|
|
|
const attachments = [];
|
|
const bin = $binary || {};
|
|
for (const key of Object.keys(bin)) {
|
|
const b = bin[key];
|
|
if (!b) continue;
|
|
attachments.push({
|
|
filename: String(b.fileName || 'attachment'),
|
|
content_type: String(b.mimeType || 'application/octet-stream'),
|
|
size: Number(b.fileSize || 0),
|
|
base64: String(b.data || '')
|
|
});
|
|
}
|
|
|
|
const rawSubject = String(item.subject || '');
|
|
const subject = rawSubject.trim() || '(no subject)';
|
|
const quoteNumberMatch = subject.match(/Q-\\d{4}-\\d{2}-\\d{3}/);
|
|
const isReply = inReplyTo.length > 0 || !!quoteNumberMatch;
|
|
|
|
const rawBodyPlain = String(item.textPlain || item.text || '');
|
|
const bodyPlain = rawBodyPlain.trim() || '(no body)';
|
|
const rawBodyHtml = String(item.textHtml || '');
|
|
const bodyHtml = rawBodyHtml.trim();
|
|
|
|
return {
|
|
json: {
|
|
from_email: fromEmail,
|
|
from_name: fromName,
|
|
subject: subject,
|
|
message_id: messageId,
|
|
in_reply_to: inReplyTo,
|
|
body_plain: bodyPlain,
|
|
body_html: bodyHtml,
|
|
received_at: String(item.date || $now.toISO()),
|
|
attachments: attachments,
|
|
has_attachments: attachments.length > 0,
|
|
is_reply: isReply,
|
|
route: isReply ? 'reply' : 'new',
|
|
uid: uid,
|
|
mailbox: mailbox
|
|
}
|
|
};`
|
|
},
|
|
position: [540, 300]
|
|
},
|
|
output: [{ from_email: 'x@y', from_name: 'X', subject: 's', message_id: 'm', in_reply_to: '', body_plain: '', body_html: '', received_at: '2026-06-12T00:00:00Z', attachments: [], has_attachments: false, is_reply: false, route: 'new', uid: '123', mailbox: 'INBOX' }]
|
|
});
|
|
|
|
const dropBlacklist = node({
|
|
type: 'n8n-nodes-base.filter',
|
|
version: 2.3,
|
|
config: {
|
|
name: 'Drop Blacklist / Auto-replies',
|
|
parameters: {
|
|
conditions: {
|
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 3 },
|
|
conditions: [
|
|
{ id: 'no-noreply', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'noreply' },
|
|
{ id: 'no-donotreply', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'donotreply' },
|
|
{ id: 'no-mailer-daemon', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'mailer-daemon' },
|
|
{ id: 'no-postmaster', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'postmaster' },
|
|
{ id: 'no-our-domain', leftValue: expr('{{ $json.from_email }}'), operator: { type: 'string', operation: 'notEndsWith' }, rightValue: '@wfsales.co.za' },
|
|
{ id: 'no-ooo', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'out of office' },
|
|
{ id: 'no-auto-reply', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'automatic reply' },
|
|
{ id: 'no-vacation', leftValue: expr('{{ $json.subject }}'), operator: { type: 'string', operation: 'notContains' }, rightValue: 'vacation' }
|
|
],
|
|
combinator: 'and'
|
|
},
|
|
options: {}
|
|
},
|
|
position: [840, 300]
|
|
},
|
|
output: [{ from_email: 'x@y', subject: 's', route: 'new', uid: '123', mailbox: 'INBOX' }]
|
|
});
|
|
|
|
const isReply = ifElse({
|
|
version: 2.2,
|
|
config: {
|
|
name: 'Is Reply?',
|
|
parameters: {
|
|
conditions: {
|
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 },
|
|
conditions: [
|
|
{ id: 'is-reply-route', leftValue: expr('{{ $json.route }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'reply' }
|
|
],
|
|
combinator: 'and'
|
|
},
|
|
options: {}
|
|
},
|
|
position: [1140, 300]
|
|
}
|
|
});
|
|
|
|
const mcpAppendReply = node({
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
version: 4.4,
|
|
config: {
|
|
name: 'MCP: quotes.append_reply',
|
|
parameters: {
|
|
method: 'POST',
|
|
url: expr("{{ $env.WFDASH_MCP_URL || 'http://localhost:3000' }}/api/mcp"),
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBearerAuth',
|
|
sendHeaders: true,
|
|
headerParameters: {
|
|
parameters: [
|
|
{ name: 'Content-Type', value: 'application/json' },
|
|
{ name: 'Accept', value: 'application/json' }
|
|
]
|
|
},
|
|
sendBody: true,
|
|
specifyBody: 'json',
|
|
jsonBody: expr('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quotes.append_reply","arguments":{"from_email":"{{ $json.from_email }}","from_name":"{{ $json.from_name }}","subject":"{{ $json.subject }}","body_plain":"{{ $json.body_plain }}","body_html":"{{ $json.body_html }}","message_id":"{{ $json.message_id }}","in_reply_to":"{{ $json.in_reply_to }}","received_at":"{{ $json.received_at }}"}}}'),
|
|
options: { response: { response: { responseFormat: 'json' } } }
|
|
},
|
|
position: [1440, 100],
|
|
credentials: { httpBearerAuth: newCredential('Bearer Auth account') }
|
|
},
|
|
output: [{ result: { content: [{ type: 'text', text: '{"matched_by":"in_reply_to","quote":{},"reply":{}}' }] } }]
|
|
});
|
|
|
|
const parseMcpReply = node({
|
|
type: 'n8n-nodes-base.code',
|
|
version: 2,
|
|
config: {
|
|
name: 'Parse MCP Reply Result',
|
|
parameters: {
|
|
mode: 'runOnceForEachItem',
|
|
jsCode: `const text = $json.result?.content?.[0]?.text || $json.content?.[0]?.text || '';
|
|
let parsed = {};
|
|
try { parsed = JSON.parse(text); } catch (e) { parsed = { matched_by: 'none' }; }
|
|
|
|
return {
|
|
json: {
|
|
matched_by: parsed.matched_by || 'none',
|
|
quote_id: parsed.quote?.id || null,
|
|
reply_id: parsed.reply?.id || null,
|
|
uid: $('Parse Email').item.json.uid,
|
|
mailbox: $('Parse Email').item.json.mailbox
|
|
}
|
|
};`
|
|
},
|
|
position: [1740, 100]
|
|
},
|
|
output: [{ matched_by: 'in_reply_to', quote_id: 'q1', reply_id: 'r1', uid: '123', mailbox: 'INBOX' }]
|
|
});
|
|
|
|
const routeReply = ifElse({
|
|
version: 2.2,
|
|
config: {
|
|
name: 'Route Reply Result',
|
|
parameters: {
|
|
conditions: {
|
|
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 },
|
|
conditions: [
|
|
{ id: 'is-matched', leftValue: expr('{{ $json.matched_by }}'), operator: { type: 'string', operation: 'notEquals' }, rightValue: 'none' }
|
|
],
|
|
combinator: 'and'
|
|
},
|
|
options: {}
|
|
},
|
|
position: [2040, 100]
|
|
}
|
|
});
|
|
|
|
// v2 main workflow has no IMAP Move step. 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 this 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 IMAP filter) handles
|
|
// inbox state on the mail server side.
|
|
|
|
const openRouterModel = languageModel({
|
|
type: '@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
|
version: 1,
|
|
config: {
|
|
name: 'OpenRouter Chat Model',
|
|
parameters: {
|
|
model: 'openai/gpt-4.1-mini',
|
|
options: { temperature: 0.1, timeout: 60000 }
|
|
}
|
|
},
|
|
credentials: { openRouterApi: newCredential('OpenRouter account') }
|
|
});
|
|
|
|
const extractQuoteItems = node({
|
|
type: '@n8n/n8n-nodes-langchain.informationExtractor',
|
|
version: 1.2,
|
|
config: {
|
|
name: 'Extract Quote Items',
|
|
parameters: {
|
|
text: expr("{{ $json.body_plain || $json.body_html || $json.subject }}"),
|
|
attributes: {
|
|
attributes: [
|
|
{ name: 'company_hint', description: 'The buyer or end-customer company name if mentioned in the email.' },
|
|
{ name: 'items', description: 'JSON array of line items extracted from the email. Each item: {description: string, quantity: number, unit?: string, notes?: string}. If no items, return an empty array.', required: true }
|
|
]
|
|
},
|
|
options: {
|
|
systemPromptTemplate: 'You are a purchase-order parser. Extract line items exactly as listed in the email body. Do not invent items, prices, or quantities. If the email has no line items, return an empty array. Return JSON only.'
|
|
}
|
|
},
|
|
subnodes: { model: openRouterModel },
|
|
position: [1440, 500]
|
|
},
|
|
output: [{ output: { company_hint: '', items: [{ description: 'd', quantity: 1 }] } }]
|
|
});
|
|
|
|
const buildCreatePayload = node({
|
|
type: 'n8n-nodes-base.code',
|
|
version: 2,
|
|
config: {
|
|
name: 'Build Create Payload',
|
|
parameters: {
|
|
mode: 'runOnceForEachItem',
|
|
jsCode: `const original = $('Parse Email').item.json;
|
|
const extracted = $json.output || {};
|
|
let items = [];
|
|
if (Array.isArray(extracted.items)) items = extracted.items;
|
|
else if (typeof extracted.items === 'string' && extracted.items.trim()) {
|
|
try { items = JSON.parse(extracted.items); } catch (_e) { items = []; }
|
|
}
|
|
|
|
return {
|
|
json: {
|
|
payload: {
|
|
from_email: original.from_email,
|
|
from_name: original.from_name,
|
|
subject: original.subject,
|
|
body_plain: original.body_plain,
|
|
body_html: original.body_html,
|
|
message_id: original.message_id,
|
|
in_reply_to: original.in_reply_to,
|
|
received_at: original.received_at,
|
|
company_hint: extracted.company_hint || '',
|
|
extracted_items: items,
|
|
attachments: original.attachments || []
|
|
},
|
|
uid: original.uid,
|
|
mailbox: original.mailbox
|
|
}
|
|
};`
|
|
},
|
|
position: [1740, 500]
|
|
},
|
|
output: [{ payload: {}, uid: '123', mailbox: 'INBOX' }]
|
|
});
|
|
|
|
const mcpCreateFromEmail = node({
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
version: 4.4,
|
|
config: {
|
|
name: 'MCP: quotes.create_from_email',
|
|
parameters: {
|
|
method: 'POST',
|
|
url: expr("{{ $env.WFDASH_MCP_URL || 'http://localhost:3000' }}/api/mcp"),
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBearerAuth',
|
|
sendHeaders: true,
|
|
headerParameters: {
|
|
parameters: [
|
|
{ name: 'Content-Type', value: 'application/json' },
|
|
{ name: 'Accept', value: 'application/json' }
|
|
]
|
|
},
|
|
sendBody: true,
|
|
specifyBody: 'json',
|
|
jsonBody: expr('{{ JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "quotes.create_from_email", arguments: $json.payload } }) }}'),
|
|
options: { response: { response: { responseFormat: 'json' } }, timeout: 60000 }
|
|
},
|
|
position: [2040, 500],
|
|
credentials: { httpBearerAuth: newCredential('Bearer Auth account') }
|
|
},
|
|
output: [{ result: { content: [{ type: 'text', text: '{"quote_number":"Q-2026-06-001"}' }] } }]
|
|
});
|
|
|
|
export default workflow('wfdash-quote-email-monitor', 'WFDASH — Quote Email Monitor')
|
|
.add(imapTrigger)
|
|
.to(parseEmail)
|
|
.to(dropBlacklist)
|
|
.to(isReply
|
|
.onTrue(mcpAppendReply.to(parseMcpReply.to(routeReply)))
|
|
.onFalse(extractQuoteItems.to(buildCreatePayload.to(mcpCreateFromEmail)))
|
|
);
|