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
166 lines
6.2 KiB
JavaScript
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,
|
|
}];
|