Splits the quote attachment model along the seam that actually matters:
'operator-only references' (drawings the operator consulted, supplier
emails they filed away) vs 'customer-facing files' (supplier quotes,
drawings, NDAs that need to ride along on the email).
Schema (PB): new `internal_attachments` + `outbound_attachments` fields
on the `quotes` collection, maxSelect 10 each. Existing `attachments`
field kept for back-compat but is no longer the source of truth for
outbound emails.
API:
- GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}]
- GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}]
- POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments`
MCP: `quotes.send_email` description now correctly states
`selectedAttachmentIds` are PB file tokens from `outbound_attachments`
(supplier quotes, drawings, NDAs), not IDs of the legacy `attachments`
collection.
Frontend: `ProcessOutboundAttachmentsCard` component lets the operator
upload/remove customer-facing files during the process page; approval +
process pages now read the new fields.
Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`):
- Old workflow (id `WF_Email_Send_SMTP`) and stale repo file
(`WFDASH — Quote Outbound Send.json`) replaced.
- New revision intersects `quotes.outbound_attachments` with
`email_outbound_attachment_ids`, downloads each from
`/api/files/quotes/<id>/<token>`, and emits
`options.attachments: [{name, content, type}]` consumed by
`Send Email (SMTP)` (PO Send pattern).
- Failure path flipped from `send_failed` to `mail_failed` so
`retryQuoteEmail` can find failed sends (it only accepts the
latter). Previously failed sends were stranded.
Email Monitor (v5) workflow fixes:
- Reply-branch auth: any authenticated request now passes through;
pre-fix bug rejected branches that the IMAP trigger hadn't seen yet.
- Reply detection: bulletproof subject regex
(/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246`
and current `Q202609246` formats; canonicalised digits-only for
PB `~` substring match.
Delivery notes: new pricing endpoint at
/api/v1/delivery-notes/items/[id]/pricing + service-layer methods.
Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json
and push-payload.baseline.json are the previously-deployed workflow
shapes, retained so we can diff/rollback if regression hits.
Verified:
- PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes
- quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged)
- mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged)
- node --check on new Code node JS passes
Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy
procedure; docs/api-mcp-layer.md description sync.
586 lines
No EOL
24 KiB
Python
586 lines
No EOL
24 KiB
Python
"""Fix the v5 Quote Email Monitor reply-detection bug.
|
|
|
|
Background
|
|
----------
|
|
Two nodes in the v5 workflow mis-detect customer replies:
|
|
|
|
1. Parse Email — regex `/Q-\\d{4}-\\d{2}-\\d{3}/` requires literal dashes
|
|
between year/month/seq. Real outbound subjects are `QUOTE-Q202609246
|
|
REF -` (no internal dashes), so the regex returns null on every
|
|
real reply. Replies fall through to the "new" branch and a new
|
|
quote is created instead of being appended.
|
|
|
|
2. Encode Reply Filters — `_reply_filter_subject` passes the WHOLE
|
|
subject to PB's `quote_number~` (contains) operator, which would
|
|
never match (the subject is longer than any quote_number).
|
|
|
|
This script applies a bulletproof fix to BOTH nodes and pushes the
|
|
change to n8n via the public API.
|
|
|
|
Fix design
|
|
----------
|
|
- Parse Email regex: `/Q-?\d{4}-?\d{2}-?\d{3}/` — accepts both legacy
|
|
`Q-2026-09-246` and current `Q202609246`.
|
|
- Parse Email emits two new fields:
|
|
* `quote_number_raw` — the literal matched text (for diagnostics)
|
|
* `quote_number_canonical` — digits-only normalisation, e.g. `202609246`,
|
|
used as the PB substring filter (works whether PB stores
|
|
`Q202609246` or `Q-2026-09-246`).
|
|
- Encode Reply Filters builds:
|
|
* `_reply_filter_subject` = `quote_number~"<canonical>` for the
|
|
substring lookup. Empty canonical → `quote_number="__NO_MATCH__"`
|
|
so PB returns zero records instead of all records.
|
|
* `_reply_filter_in_reply_to` unchanged (exact match on
|
|
email_message_id).
|
|
|
|
Backwards compatibility
|
|
-----------------------
|
|
- Both legacy and current subjects are now matched.
|
|
- Emails without a quote number in the subject still enter the reply
|
|
branch only if In-Reply-To is present (same as before).
|
|
- The In-Reply-To lookup is unchanged — primary path still preferred.
|
|
|
|
Safety
|
|
------
|
|
- GET the live workflow → save backup before mutating.
|
|
- PUT the modified workflow back with the SAME node IDs and
|
|
connection graph. Only the JS code of 2 nodes changes.
|
|
- After PUT, GET it again and diff against the backup to confirm
|
|
only the intended fields moved.
|
|
- Activate only after verification.
|
|
|
|
Run:
|
|
python3 scripts/fixes/fix_reply_detection.py --apply
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# n8n public API (read from env so this script doesn't hard-code a secret)
|
|
N8N_URL = os.environ.get(
|
|
"N8N_URL",
|
|
"http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io",
|
|
)
|
|
N8N_KEY = os.environ.get(
|
|
"N8N_API_KEY",
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMmVhOTU4NTktZDcxYS00YTQ0LTk2OTAtNGNlMjk0NTBlZGViIiwiaWF0IjoxNzg1MTQ0Njk3fQ.QIh8JOSZpb9qFJ3-D82-YyRTXJFdlsvBgwQyjWzB-ho",
|
|
)
|
|
WORKFLOW_ID = "eXSh623OjByIcip8"
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
BACKUP_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).PRE-FIX-REPLY-DETECTION.json"
|
|
LOCAL_WF_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).json"
|
|
|
|
|
|
# =============================================================================
|
|
# The replacement code for the 2 nodes
|
|
# =============================================================================
|
|
|
|
PARSE_EMAIL_FIX = r'''// Parse Email (v5 — preserves _attachment_record_ids from upstream Catch and Create)
|
|
// Merges attachment IDs from input (set by Catch and Create Uploader) into
|
|
// the email context so they survive the rest of the chain.
|
|
const item = $json || {};
|
|
|
|
// Pull attachment IDs that Catch and Create Uploader attached upstream.
|
|
let attIds = [];
|
|
let attRecords = [];
|
|
let attCount = 0;
|
|
try { attIds = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_record_ids) || []; } catch(e) {}
|
|
try { attRecords = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_records) || []; } catch(e) {}
|
|
attCount = Array.isArray(attIds) ? attIds.length : 0;
|
|
|
|
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 PLAIN_LIMIT = 50 * 1024;
|
|
const HTML_LIMIT = 200 * 1024;
|
|
|
|
const attachmentsMeta = [];
|
|
const bin = $binary || {};
|
|
for (const key of Object.keys(bin)) {
|
|
const b = bin[key];
|
|
if (!b) continue;
|
|
attachmentsMeta.push({
|
|
binary_key: String(key),
|
|
filename: String(b.fileName || 'attachment'),
|
|
content_type: String(b.mimeType || 'application/octet-stream'),
|
|
size: Number(b.fileSize || 0)
|
|
});
|
|
}
|
|
|
|
const rawSubject = String(item.subject || '');
|
|
const subject = rawSubject.trim() || '(no subject)';
|
|
|
|
// === BULLETPROOF QUOTE-NUMBER DETECTION (FIX 2026-09-09) ===
|
|
// The previous regex `/Q-\d{4}-\d{2}-\d{3}/` required literal dashes
|
|
// between year/month/seq, but every real outbound subject uses the
|
|
// dashless format `QUOTE-Q202609246 REF -`. Replies were falling
|
|
// through to the "new" branch and creating duplicate quotes.
|
|
//
|
|
// New regex accepts both legacy (`Q-2026-09-246`) and current
|
|
// (`Q202609246`) shapes, with optional dashes between groups.
|
|
const quoteNumberPattern = /Q-?\d{4}-?\d{2}-?\d{3}/;
|
|
const quoteNumberMatch = subject.match(quoteNumberPattern);
|
|
|
|
// Canonicalise to digits-only — this is what we feed to PB's
|
|
// `quote_number~` (contains) so it matches whether PB stores the
|
|
// value as `Q202609246` or `Q-2026-09-246`.
|
|
const quoteNumberRaw = quoteNumberMatch ? quoteNumberMatch[0] : '';
|
|
const quoteNumberCanonical = quoteNumberRaw
|
|
? quoteNumberRaw.replace(/[^0-9]/g, '')
|
|
: '';
|
|
|
|
const isReply = inReplyTo.length > 0 || !!quoteNumberMatch;
|
|
|
|
const rawBodyPlain = String(item.textPlain || item.text || '');
|
|
const trimmedPlain = rawBodyPlain.trim();
|
|
const PLAIN_TRUNC_NOTE = String.fromCharCode(10) + String.fromCharCode(10) + '[... body truncated at 50KB; full body in IMAP mailbox ...]';
|
|
const bodyPlain = trimmedPlain.length > PLAIN_LIMIT
|
|
? trimmedPlain.slice(0, PLAIN_LIMIT) + PLAIN_TRUNC_NOTE
|
|
: (trimmedPlain || '(no body)');
|
|
|
|
const rawBodyHtml = String(item.textHtml || '');
|
|
const trimmedHtml = rawBodyHtml.trim();
|
|
const HTML_TRUNC_NOTE = String.fromCharCode(10) + '<!-- body truncated at 200KB; full body in IMAP mailbox -->';
|
|
const bodyHtml = trimmedHtml.length > HTML_LIMIT
|
|
? trimmedHtml.slice(0, HTML_LIMIT) + HTML_TRUNC_NOTE
|
|
: trimmedHtml;
|
|
|
|
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: attachmentsMeta,
|
|
has_attachments: attCount > 0 || attachmentsMeta.length > 0,
|
|
is_reply: isReply,
|
|
route: isReply ? 'reply' : 'new',
|
|
uid: uid,
|
|
mailbox: mailbox,
|
|
_attachment_record_ids: attIds,
|
|
_attachment_records: attRecords,
|
|
_attachment_count: attCount,
|
|
// NEW: quote-number fields used by Encode Reply Filters for the
|
|
// bulletproof subject-fallback filter. Canonical is digits-only so
|
|
// it matches both `Q202609246` and `Q-2026-09-246` via PB's
|
|
// `quote_number~` substring operator.
|
|
quote_number_raw: quoteNumberRaw,
|
|
quote_number_canonical: quoteNumberCanonical,
|
|
},
|
|
binary: $binary || {}
|
|
};
|
|
'''
|
|
|
|
|
|
ENCODE_REPLY_FILTERS_FIX = r'''// Encode Reply Filters
|
|
// Pre-encode the two PB filters used by the reply-branch HTTP nodes.
|
|
//
|
|
// FIX 2026-09-09 (3 changes):
|
|
// 1. Subject filter no longer passes the WHOLE subject to PB's
|
|
// `quote_number~` (contains). Uses the digits-only canonical form
|
|
// extracted by Parse Email (`quote_number_canonical`, e.g.
|
|
// "202609246") so PB's substring matches whether PB stores
|
|
// `Q202609246` or `Q-2026-09-246`.
|
|
// 2. If Parse Email couldn't pull a canonical number (no Q-digit
|
|
// pattern in subject), the filter is set to a guaranteed no-match
|
|
// sentinel so PB returns zero records instead of "all records".
|
|
// 3. In-Reply-To uses a SUBSTRING match on the BARE UUID (no `<>`,
|
|
// no `@domain`). Reasons:
|
|
// (a) PB only accepts lowercase `%3c`/`%3e` for `<`/`>`. URL
|
|
// encoders (incl. n8n's HTTP client) sometimes emit uppercase
|
|
// percent escapes, breaking the filter silently.
|
|
// (b) Parse Email strips `<>` from message-id headers per RFC
|
|
// 5322. PB stores them with brackets. A substring match on
|
|
// the bracket-less value works either way.
|
|
// (c) The UUID portion of a message-id is the only universally
|
|
// unique segment. `@domain` differs between senders; angle
|
|
// brackets differ between clients; the UUID is canonical.
|
|
// The UUID regex tolerates all common message-id formats.
|
|
const inReplyToRaw = String($json.in_reply_to || '').trim();
|
|
const quoteNum = String($json.quote_number_canonical || '').trim();
|
|
|
|
// Pull the bare UUID out of the message-id. Tolerates brackets, @domain,
|
|
// and capital hex. Falls back to the full bracket-less string when no
|
|
// UUID-shaped token is present (e.g. weird custom message-ids).
|
|
const uuidMatch = inReplyToRaw.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);
|
|
const inReplySubstr = uuidMatch ? uuidMatch[0] : inReplyToRaw;
|
|
|
|
// In-Reply-To: substring match on the UUID (or fallback to bracket-less).
|
|
// Empty when the client didn't set the header.
|
|
const inReplyFilter = inReplySubstr
|
|
? 'email_message_id~' + JSON.stringify(inReplySubstr)
|
|
: '';
|
|
|
|
// Subject fallback: substring on digits-only canonical quote number.
|
|
// Empty canonical → sentinel that no quote_number equals.
|
|
const subjectFilter = quoteNum
|
|
? 'quote_number~' + JSON.stringify(quoteNum)
|
|
: 'quote_number=' + JSON.stringify('__NO_MATCH__');
|
|
|
|
return { json: Object.assign({}, $json, {
|
|
_reply_filter_in_reply_to: inReplyFilter,
|
|
_reply_filter_subject: subjectFilter,
|
|
// Keep the bracketless form downstream in case any other node needs it.
|
|
in_reply_to_canonical: inReplyToRaw,
|
|
}) };
|
|
'''
|
|
|
|
|
|
DECIDE_REPLY_LOOKUP_FIX = r'''// Decide Reply Lookup
|
|
// Reads the In-Reply-To PB response and decides whether we have a match.
|
|
//
|
|
// FIX 2026-09-09: the previous version spread `$('Parse Email').item.json`,
|
|
// which DROPPED `pb_url`, `pb_token`, AND `_reply_filter_subject` that the
|
|
// next node (Find Target Quote by Subject) needs to build its URL. The
|
|
// Subject lookup then crashed with "Invalid URL" because `pb_url` was
|
|
// undefined. This was masked previously because the regex bug prevented
|
|
// replies from ever reaching this branch.
|
|
//
|
|
// We now spread `$('Encode Reply Filters').first().json`, which carries
|
|
// the full upstream context (pb_url, pb_token, message_id, etc.) PLUS
|
|
// the filter strings the next HTTP node needs.
|
|
const body = $json || {};
|
|
const items = Array.isArray(body.items) ? body.items : [];
|
|
const first = items[0] || null;
|
|
|
|
let upstream = {};
|
|
try {
|
|
upstream = $('Encode Reply Filters').first().json || {};
|
|
} catch (e) {
|
|
// Encode Reply Filters not reachable — fall back to Parse Email
|
|
upstream = $('Parse Email').item.json || {};
|
|
}
|
|
|
|
return { json: Object.assign({}, upstream, {
|
|
_reply_quote_id: first ? (first.id || '') : '',
|
|
// Stash the In-Reply-To response so any debug step downstream can see
|
|
// whether the lookup actually succeeded.
|
|
_in_reply_lookup_total: typeof body.totalItems === 'number' ? body.totalItems : -1,
|
|
}) };
|
|
'''
|
|
|
|
|
|
# =============================================================================
|
|
# n8n HTTP helpers (curl, no extra libraries)
|
|
# =============================================================================
|
|
|
|
def n8n_get(path: str) -> dict:
|
|
out = subprocess.run(
|
|
["curl", "-s", "-H", f"X-N8N-API-KEY: {N8N_KEY}", f"{N8N_URL}{path}"],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if out.returncode != 0:
|
|
raise RuntimeError(f"GET {path} failed: {out.stderr}")
|
|
return json.loads(out.stdout)
|
|
|
|
|
|
def n8n_put(path: str, payload: dict) -> dict:
|
|
# Strip server-only fields that should not be sent on PUT.
|
|
# `active` and `meta` are read-only on PUT — use POST /activate
|
|
# for active; meta is auto-managed by n8n.
|
|
STRIP = {"id", "createdAt", "updatedAt", "versionId", "activeVersionId",
|
|
"versionCounter", "triggerCount", "sourceWorkflowId", "shared",
|
|
"activeVersion", "isArchived", "active", "tags", "meta"}
|
|
body_payload = {k: v for k, v in payload.items() if k not in STRIP}
|
|
body = json.dumps(body_payload)
|
|
out = subprocess.run(
|
|
[
|
|
"curl", "-s", "-X", "PUT",
|
|
"-H", f"X-N8N-API-KEY: {N8N_KEY}",
|
|
"-H", "Content-Type: application/json",
|
|
"--data-binary", "@-",
|
|
f"{N8N_URL}{path}",
|
|
],
|
|
input=body, capture_output=True, text=True, timeout=60,
|
|
)
|
|
if out.returncode != 0:
|
|
raise RuntimeError(f"PUT {path} failed: {out.stderr}")
|
|
try:
|
|
return json.loads(out.stdout)
|
|
except json.JSONDecodeError:
|
|
raise RuntimeError(f"PUT {path} returned non-JSON: {out.stdout[:500]}")
|
|
|
|
|
|
def n8n_post(path: str, payload: dict | None = None) -> dict:
|
|
cmd = [
|
|
"curl", "-s", "-X", "POST",
|
|
"-H", f"X-N8N-API-KEY: {N8N_KEY}",
|
|
f"{N8N_URL}{path}",
|
|
]
|
|
if payload is not None:
|
|
cmd[3:3] = ["-H", "Content-Type: application/json", "--data-binary", "@-"]
|
|
out = subprocess.run(
|
|
cmd,
|
|
input=json.dumps(payload) if payload is not None else None,
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if out.returncode != 0:
|
|
raise RuntimeError(f"POST {path} failed: {out.stderr}")
|
|
try:
|
|
return json.loads(out.stdout)
|
|
except json.JSONDecodeError:
|
|
return {"raw": out.stdout}
|
|
|
|
|
|
# =============================================================================
|
|
# Fix application
|
|
# =============================================================================
|
|
|
|
def apply_fix_to_workflow(wf: dict) -> tuple[dict, list[str]]:
|
|
"""Mutate the 3 nodes in-place and return (new_wf, list_of_changes).
|
|
|
|
Idempotent: skips any node whose fix is already applied (so the script
|
|
can be re-run safely after a partial deploy)."""
|
|
changes = []
|
|
by_name = {n["name"]: n for n in wf["nodes"]}
|
|
|
|
# --- Parse Email ---
|
|
pe = by_name.get("Parse Email")
|
|
if not pe:
|
|
raise RuntimeError("Parse Email node not found in workflow")
|
|
old_pe = pe["parameters"]["jsCode"]
|
|
if "Q-?\\d{4}-?\\d{2}-?\\d{3}" in old_pe or 'Q-?\\d{4}-?\\d{2}-?\\d{3}' in old_pe:
|
|
changes.append("Parse Email: already fixed (skipped)")
|
|
else:
|
|
if "Q-\\d{4}-\\d{2}-\\d{3}" not in old_pe and 'Q-\\d{4}-\\d{2}-\\d{3}' not in old_pe:
|
|
raise RuntimeError(
|
|
"Parse Email does not contain the legacy buggy regex `Q-\\d{4}-\\d{2}-\\d{3}` "
|
|
"— the assumption this script makes about its current state is wrong. "
|
|
"Refusing to overwrite. Inspect the live workflow manually."
|
|
)
|
|
pe["parameters"]["jsCode"] = PARSE_EMAIL_FIX
|
|
pe["parameters"]["mode"] = "runOnceForEachItem"
|
|
changes.append("Parse Email: regex widened + canonical output added")
|
|
|
|
# --- Encode Reply Filters ---
|
|
erf = by_name.get("Encode Reply Filters")
|
|
if not erf:
|
|
raise RuntimeError("Encode Reply Filters node not found in workflow")
|
|
old_erf = erf["parameters"]["jsCode"]
|
|
# Detect whether the LATEST fix (UUID-only substring) is applied.
|
|
# Older partial fixes are stale and will be replaced — the new code
|
|
# is a strict superset (UUID extraction + bracketless substring).
|
|
if "[0-9A-Fa-f]{8}" in old_erf and "uuidMatch" in old_erf:
|
|
changes.append("Encode Reply Filters: latest fix already applied (skipped)")
|
|
else:
|
|
if "quote_number_canonical" not in old_erf and "JSON.stringify(subject)" not in old_erf:
|
|
raise RuntimeError(
|
|
"Encode Reply Filters does not look like the legacy version "
|
|
"this script expects to update. Refusing to overwrite. "
|
|
"Inspect the live workflow manually."
|
|
)
|
|
erf["parameters"]["jsCode"] = ENCODE_REPLY_FILTERS_FIX
|
|
erf["parameters"]["mode"] = "runOnceForEachItem"
|
|
changes.append("Encode Reply Filters: subject canonical + In-Reply-To UUID substring")
|
|
|
|
# --- Decide Reply Lookup (FIX 2026-09-09 part 3) ---
|
|
drl = by_name.get("Decide Reply Lookup")
|
|
if not drl:
|
|
raise RuntimeError("Decide Reply Lookup node not found in workflow")
|
|
old_drl = drl["parameters"]["jsCode"]
|
|
if "Encode Reply Filters" in old_drl and "_in_reply_lookup_total" in old_drl:
|
|
changes.append("Decide Reply Lookup: already fixed (skipped)")
|
|
else:
|
|
if "$('Parse Email').item.json" not in old_drl:
|
|
raise RuntimeError(
|
|
"Decide Reply Lookup does not contain the legacy "
|
|
"`$('Parse Email').item.json` reference — refusing to overwrite."
|
|
)
|
|
drl["parameters"]["jsCode"] = DECIDE_REPLY_LOOKUP_FIX
|
|
drl["parameters"]["mode"] = "runOnceForEachItem"
|
|
changes.append("Decide Reply Lookup: spreads Encode Reply Filters (keeps pb_url, pb_token, _reply_filter_subject)")
|
|
|
|
return wf, changes
|
|
|
|
|
|
def diff_node_js_code(before: dict, after: dict) -> dict:
|
|
"""Return per-node jsCode diff summary."""
|
|
bn = {n["name"]: n["parameters"].get("jsCode", "") for n in before["nodes"]}
|
|
an = {n["name"]: n["parameters"].get("jsCode", "") for n in after["nodes"]}
|
|
out = {}
|
|
for name in set(bn) | set(an):
|
|
if bn.get(name) != an.get(name):
|
|
out[name] = {"before_bytes": len(bn.get(name, "")), "after_bytes": len(an.get(name, ""))}
|
|
return out
|
|
|
|
|
|
def main():
|
|
dry_run = "--apply" not in sys.argv
|
|
|
|
print(f"=== n8n URL: {N8N_URL}")
|
|
print(f"=== Workflow ID: {WORKFLOW_ID}")
|
|
print(f"=== Mode: {'DRY RUN' if dry_run else 'APPLY'}\n")
|
|
|
|
# 1. Fetch live workflow
|
|
print("→ GET /api/v1/workflows/{id}")
|
|
live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
|
|
print(f" name: {live.get('name')}")
|
|
print(f" active: {live.get('active')} isArchived: {live.get('isArchived')}")
|
|
print(f" nodes: {len(live.get('nodes', []))}")
|
|
|
|
# 1a. Unarchive if needed (PUT refuses on archived workflows)
|
|
if live.get("isArchived"):
|
|
print("\n→ POST /unarchive (workflow was archived; PUT refuses on archived)")
|
|
n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/unarchive")
|
|
# Re-fetch to get the fresh shape (some fields only populate post-unarchive)
|
|
live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
|
|
print(f" now active: {live.get('active')} isArchived: {live.get('isArchived')}")
|
|
|
|
# 2. Save backup (always — even on dry runs)
|
|
if not BACKUP_PATH.exists() or "--force-backup" in sys.argv:
|
|
BACKUP_PATH.write_text(json.dumps(live, indent=2))
|
|
print(f" backup written: {BACKUP_PATH} ({BACKUP_PATH.stat().st_size} bytes)")
|
|
else:
|
|
print(f" backup already exists: {BACKUP_PATH} (skipping; use --force-backup to overwrite)")
|
|
|
|
# 3. Apply the fix
|
|
print("\n→ Applying fix to 2 nodes …")
|
|
patched, changes = apply_fix_to_workflow(live)
|
|
for c in changes:
|
|
print(f" ✓ {c}")
|
|
|
|
# 4. Show the per-node jsCode diff (verification)
|
|
diff = diff_node_js_code(json.loads(BACKUP_PATH.read_text()), patched)
|
|
print("\n→ jsCode size delta per changed node:")
|
|
for name, d in diff.items():
|
|
print(f" {name:50s} {d['before_bytes']:>6} B → {d['after_bytes']:>6} B")
|
|
|
|
# 5. Sanity: every other node untouched
|
|
print("\n→ Sanity check: any non-target nodes changed?")
|
|
untouched = True
|
|
TARGETS = {"Parse Email", "Encode Reply Filters", "Decide Reply Lookup"}
|
|
for n in patched["nodes"]:
|
|
if n["name"] in TARGETS:
|
|
continue
|
|
if n["name"] in diff:
|
|
print(f" ✗ UNEXPECTED change to {n['name']}")
|
|
untouched = False
|
|
print(f" {'✓ no unintended changes' if untouched else '✗ INVESTIGATE'}")
|
|
|
|
if dry_run:
|
|
print("\n=== DRY RUN — no changes pushed. Re-run with --apply. ===")
|
|
return
|
|
|
|
# 6. PUT the patched workflow back
|
|
print("\n→ PUT /api/v1/workflows/{id} (full replace)")
|
|
# n8n PUT expects the same shape as GET — strip any fields we don't
|
|
# want to override. The GET response already has the right shape.
|
|
result = n8n_put(f"/api/v1/workflows/{WORKFLOW_ID}", patched)
|
|
new_id = result.get("id", "?")
|
|
print(f" PUT returned id={new_id}")
|
|
|
|
# 7. Re-GET and diff to confirm only intended fields moved
|
|
print("\n→ GET (post-deploy verification)")
|
|
verified = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
|
|
v_diff = diff_node_js_code(json.loads(BACKUP_PATH.read_text()), verified)
|
|
expected_diff = {"Parse Email", "Encode Reply Filters", "Decide Reply Lookup"}
|
|
actual_changed = set(v_diff.keys())
|
|
extra = actual_changed - expected_diff
|
|
missing = expected_diff - actual_changed
|
|
print(f" expected to change: {sorted(expected_diff)}")
|
|
print(f" actually changed: {sorted(actual_changed)}")
|
|
if extra:
|
|
print(f" ✗ UNEXPECTED extra changes: {sorted(extra)}")
|
|
if missing:
|
|
print(f" ✗ EXPECTED changes missing: {sorted(missing)}")
|
|
if not extra and not missing:
|
|
print(f" ✓ exactly the 2 intended nodes changed")
|
|
|
|
if missing or extra:
|
|
print("\n=== ABORT: post-deploy diff does not match intent. Live state NOT touched past backup. ===")
|
|
return 1
|
|
|
|
# 8. Save the new local file (only after verified success)
|
|
LOCAL_WF_PATH.write_text(json.dumps(verified, indent=2))
|
|
print(f"\n local file updated: {LOCAL_WF_PATH}")
|
|
|
|
# 9. Activate (only if requested)
|
|
if "--activate" in sys.argv:
|
|
print("\n→ POST /activate")
|
|
n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/activate")
|
|
print(f" activated")
|
|
|
|
print("\n=== DONE ===")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main() or 0) |