WFDASH-WEBAPP/n8n_workfows/SNIPPET-catch-and-create-uploader.js
SJCoetz 33591959c2 feat: add quote email intake and atomic numbering
This commit implements the full inbound quote email processing workflow and atomic quote numbering:
- Add PocketBase collections for monthly atomic quote counters, email attachments, and threaded replies
- Add idempotent migration script to set up new collections and add deduplication field to existing quotes table
- Implement `/api/v1/quotes/next-number` endpoint for properly formatted Q-YYYY-MM-NNN quote numbers
- Add MCP tools for idempotent quote creation, reply threading, and message ID lookup
- Add end-to-end and concurrent smoke tests for pipeline validation
- Update TypeScript types for new quote-related entities
- Update project documentation and package.json scripts
- Mark quote email intake as complete in the parity backlog
- Add n8n v2 workflows for automated email monitoring and error alerts
2026-06-22 13:58:24 +02:00

166 lines
6.2 KiB
JavaScript

// Catch and Create Uploader — SINGLE Code node that does PB auth + walk
// binary + upload each attachment + emit one item with the email context
// and PB attachment IDs.
//
// CRITICAL: This node runs IMMEDIATELY after the email source
// (IMAP Quote Mailbox OR Webhook Body Unwrap). It MUST run there
// because every other n8n node type we've tested (IF, Set, Code without
// explicit binary preservation) strips the binary `data` field. The only
// place binary with `data` exists is right after the source.
//
// On the wire it looks like this:
//
// IMAP Quote Mailbox ─┐
// ├─→ Catch and Create Uploader ─┬─→ Parse Email ─→ ...
// Webhook Body Unwrap ┘ │
// └─→ (binary already uploaded)
//
// Output: ONE item per email (not per attachment) carrying:
// - the original email context (subject, from_email, message_id, ...)
// - _attachment_record_ids: array of PB record IDs
// - _attachment_records: array of {id, filename, content_type, size_bytes}
// - has_attachments: true iff at least one attachment was uploaded
// - binary: original binary blob (preserved — Parse Email still needs metadata)
//
// On no-attachments emails: emits one item with empty attachment IDs and
// has_attachments=false. Chain continues normally.
//
// On partial upload failure: emits one item with the IDs that DID succeed.
// Downstream chain can re-attach by message_id via a separate repair job.
//
// PB credentials are hard-coded for now (TODO: move to n8n credentials).
// The user's existing v4 had them in a Set node, so this is no worse.
const inputs = $input.all();
if (inputs.length === 0) {
return [{ json: { _attachment_record_ids: [], has_attachments: false }, binary: {} }];
}
const emailItem = inputs[0];
const emailJson = emailItem.json || {};
const emailBinary = emailItem.binary || {};
// === PB credentials (TODO: move to n8n credentials) ===
const PB_URL = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
const PB_EMAIL = 'david.els1@outlook.com';
const PB_PASSWORD = '1234567890';
// === 1. PB admin auth (inline so binary survives) ===
let pbToken = '';
try {
const authResp = await this.helpers.httpRequest({
method: 'POST',
url: PB_URL + '/api/collections/_superusers/auth-with-password',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: { identity: PB_EMAIL, password: PB_PASSWORD },
json: true,
timeout: 15000,
});
pbToken = String(authResp.token || '');
if (!pbToken) {
throw new Error('PB auth returned no token: ' + JSON.stringify(authResp));
}
} catch (authErr) {
// Auth failure is fatal — without a token we can't upload.
// Surface the error so the workflow execution fails loudly.
throw new Error('PB admin auth failed in Catch and Create Uploader: ' +
(authErr.message || JSON.stringify(authErr)));
}
// === 2. Walk every binary entry on the email and upload each to PB ===
const attachmentIds = [];
const attachmentDetails = [];
let uploadIndex = 0;
for (const key of Object.keys(emailBinary)) {
const b = emailBinary[key];
if (!b) continue;
const filename = String(b.fileName || 'attachment');
const contentType = String(b.mimeType || 'application/octet-stream');
const sizeBytes = Number(b.fileSize || 0);
// Skip clearly-empty entries (e.g. inline images referenced by content-id
// where the message body, not an attachment, is what matters)
if (!b.data || (typeof b.data === 'string' && b.data.length === 0)) {
continue;
}
// === 3. Upload this one attachment to PB ===
// formData.value can be a base64 string (from webhook) or Buffer (from IMAP)
// — n8n's httpRequest helper handles both.
const fileValue = (typeof b.data === 'string')
? Buffer.from(b.data, 'base64')
: b.data;
try {
const uploadResp = await this.helpers.httpRequest({
method: 'POST',
url: PB_URL + '/api/collections/quote_attachments/records',
headers: {
'Authorization': 'Bearer ' + pbToken,
},
formData: {
filename: filename,
content_type: contentType,
size_bytes: sizeBytes,
email_message_id: String(emailJson.message_id || emailJson._effective_message_id || ''),
uploaded_at: new Date().toISOString(),
file: {
value: fileValue,
options: {
filename: filename,
contentType: contentType,
},
},
},
json: true,
timeout: 30000,
});
if (uploadResp && uploadResp.id) {
attachmentIds.push(String(uploadResp.id));
attachmentDetails.push({
id: String(uploadResp.id),
filename: filename,
content_type: contentType,
size_bytes: sizeBytes,
kind: (contentType === 'application/pdf' || /\.pdf$/i.test(filename)) ? 'pdf'
: (contentType.startsWith('image/')) ? 'image'
: 'other',
binary_key: key,
email_message_id: String(emailJson.message_id || emailJson._effective_message_id || ''),
uploaded_at: new Date().toISOString(),
});
} else {
// PB didn't return an id — likely a validation error
// Log but don't fail the whole workflow
console.error('PB upload returned no id for ' + filename + ':', JSON.stringify(uploadResp));
}
} catch (uploadErr) {
// One attachment failed — log and continue with the others
console.error('PB upload failed for ' + filename + ': ' + (uploadErr.message || uploadErr));
}
uploadIndex++;
}
// === 4. Emit ONE item with email context + attachment IDs ===
return [{
json: Object.assign({}, emailJson, {
_attachment_record_ids: attachmentIds,
_attachment_records: attachmentDetails,
_attachment_count: attachmentIds.length,
has_attachments: attachmentIds.length > 0,
pb_url: PB_URL,
pb_token: pbToken,
pb_admin_id: '', // could parse from authResp if needed
}),
// Preserve binary for Parse Email — it uses binary metadata (fileName,
// mimeType, fileSize) to populate the attachments array. The data is
// already safely in PB at this point; downstream uses IDs not bytes.
binary: emailBinary,
}];