/** * WFDASH — Purchase Order Outbound Send (Manual / Webhook Trigger) * * Operator-triggered PO send. Fires from Next.js * `/api/v1/purchase-orders/[id]/send-email` after the route validates, * persists the composition, resolves the current PO PDF, and builds * the full send payload (recipient + subject + body + PDF * attachment URL). * * Companion workflow `WFDASH — Purchase Order Send (Cron).js` handles * the scheduled 15-minute pass for catching missed sends. * * Inputs (POST body) — every field is pushed by the dashboard route, * n8n does NOT need to read from PocketBase directly: * - trigger: 'manual_send' (informational, surfaced in logs) * - poId: string (required; used to POST the outcome back) * - poNumber: string (used for email subject fallback + log line) * - supplierName: string (used in the log line) * - recipient: string (the resolved `to` — operator override or * supplier.email fallback) * - cc, bcc: comma-separated strings (optional) * - subject: string * - body: string (plain text or HTML — caller decides) * - attachments: array of { filename, url, contentType } for the * PDF proxy fetch (built by the route) * - agentApiKey: string (Bearer token for the outcome callback) * - dashboardBaseUrl: string (base URL the route lives at) * - fromEmail: string (used as the From header) * * Outcome callback: * The SMTP node runs and the workflow POSTs the result back to * `/api/v1/purchase-orders/[id]/send-email/outcome` with * `{outcome, messageId?, errorMessage?}`. The dashboard route owns * the canonical write-back (status flip + order_items item_status * flip) using its own admin PB auth. n8n never carries an admin * token. * * Why push-payload instead of read-from-PB: * n8n-worker runs in a separate container from PB. PB is behind a * Coolify reverse proxy that only forwards a specific Host header; * the worker container can't reach it directly. By pushing the * entire render-ready payload from the route (which does live near * PB), the workflow stays free of DNS/HTTP plumbing entirely. * * Reply tracking is OUT OF SCOPE for this version. When the IMAP * poller is built it will write inbound messages to a future * `purchase_order_replies` collection, keyed on * `in_reply_to == purchase_orders.email_message_id`. */ import { workflow, trigger, node } from '@n8n/workflow-sdk'; const webhook = trigger({ type: 'n8n-nodes-base.webhook', version: 2, config: { name: 'PO Send Webhook', parameters: { httpMethod: 'POST', path: 'wf-po-send', responseMode: 'lastNode', options: {}, }, }, }); const sendEmail = node({ type: 'n8n-nodes-base.emailSend', version: 2.1, config: { name: 'Send PO email (SMTP)', parameters: { fromEmail: '={{ $json.body.fromEmail || "orders@wfdash.local" }}', toEmail: '={{ $json.body.recipient }}', ccEmail: '={{ $json.body.cc }}', bccEmail: '={{ $json.body.bcc }}', subject: '={{ $json.body.subject }}', text: '={{ $json.body.body }}', html: '={{ $json.body.body }}', options: { // The Fetch PDF bytes step populates `$json.attachmentsResolved` // with `{ name, content (base64), type }` entries. Read it as an // expression so the resolved PDF actually attaches. attachments: '={{ $json.attachmentsResolved && $json.attachmentsResolved.length ? $json.attachmentsResolved : [] }}', }, }, }, }); /** * Resolve any attachment URLs into `{ name, content (base64), type }` * entries the n8n emailSend node accepts. Returns the original input * shape unchanged when there are no attachments. * * n8n Code node: no `fetch` global. Use this.helpers.httpRequest(). */ const fetchAttachments = node({ type: 'n8n-nodes-base.code', version: 2, config: { name: 'Fetch PDF attachment bytes', parameters: { jsCode: `// Resolve any attachment URLs into { name, content (base64), type } // entries the n8n emailSend node accepts. Returns the original input // shape unchanged when there are no attachments. // // n8n Code node: no $env.* access in this environment // (N8N_BLOCK_ENV_ACCESS_IN_NODE=true in docker-compose). The dashboard // route pushes everything we need (incl. the agentApiKey Bearer // credential) on the webhook body. The file proxy whitelist now // includes purchase_orders so the GET succeeds without further // auth headers from the cookie-less n8n-worker. const first = ($input.first() || {}).json || {}; const body = first.body || first; const attachments = body.attachments || []; const agentApiKey = body.agentApiKey || ''; if (!Array.isArray(attachments) || attachments.length === 0) { return [{ json: { ...first, body: { ...body, attachments: [] }, attachmentsResolved: [] } }]; } const resolved = []; for (const att of attachments) { try { const res = await this.helpers.httpRequest({ method: 'GET', url: att.url, headers: agentApiKey ? { 'Authorization': 'Bearer ' + agentApiKey } : {}, encoding: 'base64', returnBinary: false, timeout: 15000, }); resolved.push({ name: att.filename || 'attachment.pdf', content: typeof res === 'string' ? res : (res && res.data) || '', type: att.contentType || 'application/pdf', }); } catch (err) { return [{ json: { ...first, body: { ...body, attachments: [], _attachmentFetchError: 'PDF fetch failed: ' + (err.message || String(err)), }, attachmentsResolved: [], } }]; } } return [{ json: { ...first, body: { ...body, attachments: [] }, attachmentsResolved: resolved, } }]; `, }, }, }); const recordOutcome = node({ type: 'n8n-nodes-base.code', version: 2, config: { name: 'Record outcome to PB', parameters: { jsCode: `// Resolve the SMTP result from the Send PO email (SMTP) node, then // POST the outcome back to the dashboard's // /api/v1/purchase-orders/[id]/send-email/outcome route. The dashboard // owns the canonical write (status flip + order_items.item_status flip) // using its own admin PB auth. n8n never carries the admin token. // // The Bearer key and dashboard base URL are pushed on the webhook body // (not $env.*) because the docker-compose has // N8N_BLOCK_ENV_ACCESS_IN_NODE=true — Code nodes cannot read env vars // at runtime. const first = ($input.first() || {}).json || {}; const body = first.body || first; const agentApiKey = body.agentApiKey || ''; const dashboardBase = (body.dashboardBaseUrl || '').replace(/\\/$/, ''); const poId = body.poId; if (!agentApiKey) { return [{ json: { poId, ok: false, error: 'agentApiKey missing from webhook body — route did not push it', } }]; } if (!dashboardBase) { return [{ json: { poId, ok: false, error: 'dashboardBaseUrl missing from webhook body — route did not push it', } }]; } const sendResult = ($('Send PO email (SMTP)').first().json) || {}; const ok = !sendResult.error && sendResult.ok !== false; const messageId = sendResult.messageId || sendResult.message_id || ''; const errorMessage = ok ? '' : (sendResult.error || sendResult.response || 'Unknown SMTP error').toString().slice(0, 990); try { const out = await this.helpers.httpRequest({ method: 'POST', url: dashboardBase + '/api/v1/purchase-orders/' + poId + '/send-email/outcome', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + agentApiKey, }, json: true, body: { outcome: ok ? 'success' : 'failure', messageId, errorMessage, }, }); return [{ json: { poId, ok, messageId, error: errorMessage, outcome: out } }]; } catch (err) { return [{ json: { poId, ok, messageId, error: 'Outcome callback failed: ' + (err.message || String(err)), } }]; } `, }, }, }); export default workflow('wf-po-send', 'WFDASH — Purchase Order Send') .add(webhook) .to(fetchAttachments) .to(sendEmail) .to(recordOutcome);