"""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~"` 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(/^([^<]+)]$/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) + ''; 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)