PO Automation System

End-to-end implementation spec for automating inbound purchase order processing via Gmail, n8n, OpenAI, Google Sheets, and FileMaker. Looking for how to actually use the system day-to-day? See the How to Use PO Automation guide instead.

Phase 1 Complete ✓ Phase 2 In Progress ~$19/mo new spend
1

Overview

ℹ️
Current: Receive PO emails → manually read PDFs → manually enter into FileMaker
Target: Gmail → n8n → OpenAI → Google Sheets (human review) → button → FileMaker → auto-reply

Goals

  • Parse PO PDFs automatically regardless of customer format
  • Resolve DST files from FileMaker automatically where possible
  • Human reviews staged data before anything touches FileMaker
  • One-click push from Sheets to FileMaker once approved
  • Auto-reply to customer after push
  • Never silently miss an email

Key Design Decisions

  • Self-hosted n8n on EC2 to consolidate all tools on one server
  • Phased rollout — FileMaker writes only after parsing accuracy is validated
  • Dedicated Gmail account for orders (not main business account)
  • Dedicated FileMaker API user with limited write access only
  • DST picker popup shows embroidery design images from FM container fields
2

Tools & Costs

ToolPurposeMonthly
AWS EC2 t3.mediumHost n8n + existing services + picker page~$30 (+$15 upgrade)
n8n (self-hosted)Workflow automationFree
OpenAI API (GPT-4.1)PDF parsing, email classification~$3
Google SheetsStaging + review queueFree
FileMaker Data APIRead/write FM recordsFree (included)
Gmail OAuth2Trigger on new emails, send repliesFree
Google Apps ScriptButtons in SheetsFree
EC2 Node.js (new routes)DST picker + image proxyFree (same server)
Total new monthly spend~$19/mo

EC2 Upgrade Procedure

⚠️
Do this first. Take an AWS AMI snapshot before resizing — this is your full rollback option.
  1. EC2 Console → Actions → Image → Create Image
  2. Stop the EC2 instance
  3. Actions → Instance Settings → Change Instance Type → t3.medium
  4. Start the instance
  5. SSH in → pm2 list to confirm services running
  6. npm install -g n8npm2 start n8n --name n8n

Downtime: ~3 minutes. Existing services auto-restart via PM2.

3

System Flow

Inbound — Email to Sheets

Email arrives in orders Gmail
  → n8n: fetch email + all PDF attachments
  → OpenAI classifies email + PDF:
       "PO"            → process (Esquire in VENDOR section)
       "Supply Order"  → log to Supply Orders tab (Esquire in SHIP-TO, or Sanmar/Alphabroder/S&S vendor)
       "Question"      → log to Questions tab
       "Revision"      → log to Needs Review tab for manual handling
       "Needs Review"  → confidence <80% → flag to Needs Review tab
  → For Embroidery POs:
       Extract structured data (PO#, dates, ship-to, DST items)
       Detect "Repeat order XXXX" or "same as previous"
         YES → query FileMaker for previous Dst_ref rows
       Build Instructions field (¶-separated) from PO keywords + Customer Master
         (Split Shipping, Folded & Bagged, Bagged By Name, Digitized, Sew Out,
          Personalization, Seam Seal, 3rd Party, Pick-Up, UPS Ship, RUSH)
         If RUSH → also set Description_2 = "Hard due date: YYYY-MM-DD"
       Detect Wilcom worksheet attachment
         YES → extract design name directly
       Resolve DST files (see Section 5)
  → Write to Google Sheets (PO Queue + DST Items tabs)
  → Apply Gmail label: n8n/PO-processed
  → Send daily digest at 8am

Outbound — Sheets to FileMaker

Human reviews PO Queue row
  → Resolves any flagged DST Items
  → Clicks [Send to FileMaker] button

n8n webhook fires:
  Step 1 → Single POST to Order Details layout
             portalData includes all Ship_To rows + all Dst_ref rows
             → FileMaker creates Order + related records atomically
  Step 2 → Send Gmail reply to customer
  Step 3 → Mark Sheets row "Sent to FileMaker ✓"

If Step 1 fails:
  → No partial records created (atomic FM operation)
  → Mark row "Error: [FM error message]"
4

FileMaker Schema & API

Tables Involved

TableKey FieldPurpose
OrderITEM_ID_MATCH_FIELD (auto)Main PO record — DB: Order_managementUI (UI) / Order_managementDB (data), Layout: Order Details
Ship_ToPrimaryKey (auto), ITEM_ID_MATCH_FIELDShipping address — portal: portal.Ship_To — multiple rows supported
Customer_Master_QBcompany_idCustomer lookup
order_Customer_Master_Personnelcompany_id, PersonnelIDContacts
Dst_refdst_ref_id, ITEM_ID_MATCH_FIELDLinks order to DST files — portal: portal.DST_ref
Dst_file_masterdst_ref_idDST design library + images — Layout: DST file master

Key Field Rules

FieldRule
item_quantity_1Garment count as shown on PO — NOT multiplied by placements (e.g. 33)
DST_ref::QuantityTotal sewing operations = garments × placements (e.g. 33 × 2 = 66)
statusn8n sets to "Waiting for product" when creating Sheets row
customer_pickup_dateToday + 10 business days (skip weekends + US federal holidays), calculated by n8n
Description_2Combined field — can contain multiple values separated by newline: "Hard due date: YYYY-MM-DD" when RUSH; "Polybag by employee: [name]" when polybag by employee is specified. Both values included if both apply.
Dst_ref::ref_descriptionFree text from PO (e.g. "x2 per parka, outer and inner")

ThirdPartyAcct Rule

If Customer Master QB::ThirdPartyAcct <> "" (looked up from Google Sheets Customer Master tab):

FieldValue
Ship_To::ThirdPartyAcctCopy from Customer Master QB::ThirdPartyAcct
Ship_To::Bill To PostalCodeCopy from Customer Master QB::billingPostalCode
Ship_To::Bill To CountryHardcode "United States"

Instructions Field

Instructions is a -separated string built by n8n from detected conditions. Instructions Display = Substitute(Instructions; "¶"; "    ")

Instruction ItemTrigger
Split ShippingMultiple shipping addresses on PO
Folded & BaggedPO mentions "individual bag", "poly bag", or "polybagging"
Bagged By NamePO mentions "bagged by name", "bag by name", or "polybag by employee"
DigitizedPO mentions "logo setup", "digitize", or "digitized"
Sew OutPO mentions "sew out" or "sewout"
PersonalizationPO mentions "names" or "personalization"
Seam SealPO mentions "seam seal"
3rd PartyCustomer Master QB::ThirdPartyAcct <> "" (not from PO text)
Pick-UpPO explicitly mentions "pickup", "will call", or "courier" — OR no shipping address is present at all
UPS ShipPO has an explicit shipping address with street, city, state, and zip. If no address present, UPS Ship is false.
RUSHPO mentions a hard due date or in-hands date. "ASAP" alone does NOT count. → add RUSH to Instructions + set Description_2 = "Hard due date: YYYY-MM-DD"
ℹ️
Pick-Up and UPS Ship are mutually exclusive. "x2 per parka" style orders get ONE Dst_ref row — the x2 detail goes in ref_description, not a duplicate row.
5

PO Type Handling

PDF Classification Rule

📋
If "Esquire Embroidery" in SHIP-TOGarment Supply — ignore
If "Esquire Embroidery" in VENDOREmbroidery PO — process
📌
DT Logos pattern: Sends two PDFs with the same PO number — one Sanmar garment supply (ignore) and one embroidery PO (process). Expected, not a conflict.

DST File Resolution

StyleExampleResolution
Explicit file codeBluErth: FILE NAME: JPS4441Auto — exact match
Descriptive nameClove & Twine: SH Black logoAuto if 1 result
Dual colorwayDT Logos: Black on lights / White on darksAuto — 2 searches, 2 Dst_ref rows (different files/colorways)
Wilcom PDF attachedsmlLtr-CHCOpedOrth.pdfAuto — design name extracted
Repeat with PO refRepeat order 222017-CUORTHOAuto — lookup by PO#
"Same as previous"Any customerAuto — most recent order by company
2+ keyword matchesPicker popup
0 matchesFlag 🔴 — manual FM lookup
📦
Multiple shipments: Some orders ship to more than one address. Each destination becomes a separate row in portal.Ship_To. OpenAI detects multiple destinations in the PO and extracts each. In Google Sheets, a Ship To Items sub-tab handles multi-shipment orders (same pattern as DST Items tab).
"Same as Previous" rule: Only dst_ref_id, placement, and ref_description are copied. All other fields (ship-to, quantities, dates, instructions) come from the new PO.
6

Google Sheets Structure

All Tabs at a Glance

#Tab NamePurposeWho uses it
1PO QueueIncoming orders — one row per PO. Main review screen.Everyone
2DST ItemsDST file references — one row per design per order.Everyone
3Ship To ItemsMultiple shipment destinations — only used when a PO ships to 2+ addresses.Everyone
4Customer MasterCompany lookup table synced from FileMaker. Fields: company_id, billingCompanyName, ThirdPartyAcct, billingPostalCode.n8n Customer Master Sync
5DST File MasterDST design library — incremental daily sync from FileMaker at 7:45am. Reference only.Reference
9PersonnelContact lookup table synced from FileMaker. Fields: personnel_id, company_id, email, contact_person_FN, Phone Direct. Only records with email addresses.n8n Customer Master Sync
6QuestionsCustomer questions logged here. No FileMaker action.Team review
7Supply OrdersGarment supply PDFs (Sanmar, Alphabroder etc.) logged here. No FileMaker action.Reference
8Needs ReviewEmails n8n couldn't confidently classify. Human decides what to do.Team review
ℹ️
Tabs 1, 2, and 3 are the active working tabs. Tabs 4–5 are reference data. Tabs 6–8 are audit/safety logs.

Tab 1 — PO Queue Primary

ColumnSourceNotes
PO_Row_IDAutoLinks to DST Items tab
Statusn8n sets "Waiting for product" on creation; human updatesPending / Approved / Error / Sent ✓
PO_NumberOpenAI
PO_DateOpenAI
company_master_idLookup → Customer MasterFlagged if not found
company_nameOpenAI
contact_emailOpenAIUsed for Gmail reply
customer_pickup_daten8nToday + 10 business days (skip weekends + US federal holidays)
DescriptionOpenAI
Description2n8n"Hard due date: YYYY-MM-DD" — only when RUSH applies; otherwise blank
Instructionsn8n¶-separated list built from PO keywords + Customer Master rules (see Section 4)
Instructions_Displayn8nSubstitute(Instructions; "¶"; "    ")
item_quantity_1OpenAIGarment count as shown on PO — NOT multiplied by placements
Ship_To_*OpenAI6 address fields
DST_StatusAutoAll resolved ✓ / N items need review
Duplicated_From_POAutoFilled if repeat order
FM_Record_IDAfter pushITEM_ID_MATCH_FIELD from FM
[Send to FileMaker]Apps Script buttonBlocked if DST not fully resolved

Tab 2 — DST Items Primary

ColumnSourceNotes
PO_Row_IDAutoForeign key → PO Queue
search_termOpenAIKeyword to find DST file
colorwayOpenAI"black" / "white" / blank
dst_ref_idAuto/humanBlank = needs resolution
dst_file_nameAutoFrom Dst_file_master after match
placementOpenAIMapped to FM value list
ref_descriptionOpenAIe.g. "x2 per parka, outer and inner"
resolved_statusAuto🟢 Auto / ⚠ Confirm / 🔴 Not found
[🔍 Pick DST]Apps Script buttonShown when 2+ candidates

Tab 3 — Ship To Items Multi-shipment orders only

Only used when a PO has more than one delivery address. Single-shipment orders (the majority) keep ship-to fields inline in Tab 1.

ColumnSourceNotes
PO_Row_IDAutoForeign key → PO Queue
Ship_To_CompanyOpenAI
Ship_To_NameOpenAI
Ship_To_Street1OpenAI
Ship_To_Street2OpenAI
Ship_To_CityOpenAI
Ship_To_PostalOpenAI
Ship_To_CountryOpenAI
Ship_To_PhoneOpenAI

Tabs 4–8 — Reference & Safety Logs

#Tab NameContentUpdated by
4Customer Mastercompany_id, billingCompanyName, ThirdPartyAcct, billingPostalCode — lookup source for company_master_idn8n Customer Master Sync (on demand)
9Personnelpersonnel_id, company_id, email, contact_person_FN, Phone Direct — lookup source for personnel_id. Only records with email addresses synced.n8n Customer Master Sync (on demand)
5DST File Masterdst_ref_id, dst_name, dst_file_name, dst_stitch_count, Size_Calc, dst_description, dst_image_url — incremental sync, new/updated records only. Container images stored as URL pointer only (served via /dst-image proxy).n8n incremental daily sync at 7:45am (last-synced timestamp in cell A1)
6QuestionsCustomer questions. No FM action. Team reads and replies manually.n8n auto
7Supply OrdersGarment supply PDFs (Sanmar, Alphabroder etc.). No FM action. Logged for reference.n8n auto
8Needs ReviewEmails n8n couldn't classify with ≥80% confidence. Human reads and decides action.n8n auto
7

DST Picker Popup

Built & live — 2026-07-18. Implemented as a standalone page rather than the originally-planned Apps Script popup; architecture below reflects what's actually running.

Live at tools.esquireembroidery.com/dst-picker/. All backend logic (search, image proxy, write-back) runs on the existing EC2 portal server (filemaker-node-uploader/server.js) — no n8n webhook involved for the picker itself.

URL Pattern

https://tools.esquireembroidery.com/dst-picker/?po_row_id=1779637260959&search_term=Bobcat&po_number=56089

po_row_id + search_term together identify the exact DST Items row to write back to (matched by value, not a literal row number — more robust against row insertions/reordering).

Card Preview (one per candidate)

🧵 Embroidery design image (from dst_image container, via proxy)
NameBobcat of the Rockies
FileBobcat_black_LC.dst
Stitches8,200
Size3.5" W × 1.62" H
Thread colors■■■ (color swatches, see below)
Previously used
2026-03-01  PO# 1231JPS
2025-11-15  PO# 222017-CUORTHO
2025-08-20  PO# D24890-1
Select This File

Company Search & Preferred Logos

An optional company field (autocomplete against Customer Master, GET /dst-companies?q=) narrows and re-sorts results: candidates already linked to that company are badged "This company," and staff can pin a specific design as the go-to pick for a company + keyword combo (e.g. "Wagner" → a specific design), stored in /home/ec2-user/dst-preferences.json. Preferred picks always sort first and show a "★ Preferred" badge.

Thread Color Swatches

Numeric thread codes (e.g. 1801) are resolved to actual color swatches via a chart parsed from an uploaded Excel file (uploads/Color Chart.xlsxbuild-thread-colors.jsthread-color-chart.json, keyed by code). Codes with no chart match fall back to a plain text chip. Rebuild after uploading a new chart via the "Rebuild Color Chart" button on /dst-sync/.

Image Proxy

🔒
Container images are never served directly to the browser. Node.js on EC2 fetches with FM auth headers and streams binary to the browser, caching to disk on first fetch (designs are edited rarely, so cache-once is safe).

GET /dst-image?record_id=123 → Node.js logs into FM, follows FM's container-fetch redirect (requires forwarding the Set-Cookie session key alongside the Bearer token — the token alone returns 401) → caches & streams the image.

Zero Results

🔴
A "None of these — flag for manual FileMaker lookup" button (POST /dst-picker/no-match) sets match_status = "No Match — check FileMaker manually" on the row and leaves dst_ref_id blank for human lookup in FileMaker.

Not Yet Wired

⚠️
The picker page itself is fully functional and tested standalone. What's still pending: SW4 wiring — automatically searching FM (via the same /dst-search endpoint) when a PO is processed, auto-filling dst_ref_id on a single match, and writing a ready-to-click picker link into the picker_link column when there are 2+ candidates. Until that's wired, the picker must be reached via a manually-constructed URL.
8

Email Safety Net

Core principle: No email is ever silently discarded. Every email goes somewhere visible.

Gmail Labels Applied After Every Email

LabelMeaning
n8n/PO-processedConfirmed PO, written to Sheets
n8n/QuestionCustomer question, logged
n8n/Needs-ReviewUnclear classification, flagged in Sheets
n8n/Supply-OrderGarment supply PDF, logged

How to See Labels in Gmail

Labels appear in the left sidebar of Gmail. Because the label names use /, Gmail nests them automatically as a group:

▼ n8n
    PO-processed
    Question
    Needs-Review
    Supply-Order

Click any label name to see all emails tagged with it — works like a folder.

Finding Emails With No Label (Safety Audit)

To catch any email n8n never touched, type this in the Gmail search bar:

has:nouserlabels in:inbox

Any result here = n8n was down or failed silently when that email arrived.

Read / Unread Behaviour

Option A confirmed: n8n marks each email as read after processing. This makes unread count a natural health indicator — unread emails sitting for more than a few minutes during business hours means n8n may be down.
Inbox
  [unread]  New email just arrived       ← n8n hasn't run yet (normal, <5 min)
  [read]    PO from BluErth         🏷 n8n/PO-processed
  [read]    Question from DT Logos  🏷 n8n/Question
  [read]    Sanmar supply order     🏷 n8n/Supply-Order

If you see unread emails sitting for more than 5–10 minutes during business hours → SSH into the server and run pm2 list to check n8n is running.

Daily Digest (8am)

n8n sends a summary email to the team each morning: counts + subject lines of all emails processed the previous day.

Confidence Thresholds

  • ≥80% PO confidence → PO Queue tab
  • ≥80% Question confidence → Questions tab
  • <80% either way → Needs Review tab
  • "Esquire Embroidery" in SHIP-TO → always Supply Orders
9

Implementation Phases

Phase 1Infrastructure✓ Complete — 2026-05-20
Phase 2Gmail → Sheets Only⚙ In Progress — 2026-05-27
Phase 3DST Resolution + PickerComplete — 2026-07-19
Phase 3bPersonnel PickerComplete — 2026-07-19
Phase 3cMaster SyncComplete — 2026-07-19
Phase 4FileMaker PushComplete — 2026-07-20
Phase 5Production Go-LiveComplete — 2026-07-20
Phase 6Split-Shipping SupportComplete — 2026-07-20
Phase 7Post-Launch Bug Fixes + Force Process as POComplete — 2026-07-22
10

Sub-workflow Design

The Phase 2 n8n workflow is split into independent sub-workflows for easier testing, debugging, and replacement. Each sub-workflow starts with a "When called by another workflow" trigger and can be tested in isolation with sample data.

Architecture Overview

Gmail Trigger (SW0 — Main Orchestrator)
  → Edit Fields                   → messageId, emailSubject, emailText, emailFrom, received_date
  → SW2: PDF Handler              → hasPdf + pdfData (URL-safe base64) + filename
  → Code: Merge + Convert Base64  → standard base64 (replace -/_ with +//)
  → IF: hasPdf?
      true  → HTTP Request (OpenAI /v1/responses, PDF inline)
      false → HTTP Request (OpenAI /v1/responses, text only)
  → Code3: Parse + Classify       → category + confidence (confidence <80 → Needs Review)
  → Switch: Route by Category
      PO             → SW3 → Code (merge) → SW4 → SW5
      Question       → Sheets Append → SW5
      Supply Order   → Sheets Append → SW5
      Revision       → Sheets Append → SW5
      Needs Review   → Sheets Append → SW5

SW6 (Notify) and SW7 (Daily Digest) not yet built. SW7 runs on its own schedule — not called by the main orchestrator.

SW0 — Main Orchestrator

#NodeTypeWhat it does
1Gmail TriggerGmail TriggerFires on n8n/pending label. Simplify OFF.
2Edit FieldsEdit FieldsExtracts: messageId, emailSubject, emailText, emailFrom, received_date (from Gmail headers.date)
3SW2 PDF HandlerExecute WorkflowDownloads PDF attachment → returns hasPdf + pdfData (URL-safe base64) + filename
4Code: Merge + Convert Base64Code (JS)Merges SW2 output. Converts URL-safe base64 → standard base64 (- → +, _ → /). Mode: Run Once for All Items.
5IF: hasPdf?IFBranches on hasPdf: true → send PDF to OpenAI; false → text-only path
6aClassify (PDF)HTTP RequestPOST /v1/responses, gpt-4.1. Sends PDF as data:application/pdf;base64,{pdfData}. Static JSON body — only pdfData is dynamic.
6bClassify (text)HTTP RequestPOST /v1/responses, gpt-4.1. Sends email text only (no PDF).
7Code3: Parse + ClassifyCode (JS)Parses response.output[0].content[0].text → JSON.parse. If confidence <80 → overrides category to "Needs Review". Returns category + confidence + related_po_number.
8Switch: Route by CategorySwitchBranches: PO / Question / Supply Order / Revision / Needs Review
9PO branch: SW3Execute WorkflowCalls SW3 with hasPdf + pdfData + email metadata → returns structured PO fields
10PO branch: CodeCode (JS)Merges SW3 output + email metadata + confidence
11SW4: Write to SheetsExecute WorkflowPO branch: writes PO Queue + DST Items. Other branches: writes to Questions / Supply Orders / Needs Review with related_po_number.
12SW5: Label ManagerExecute WorkflowAll branches — removes n8n/pending, adds result label (n8n/PO-processed, n8n/Question, n8n/Supply-Order, n8n/Needs-Review)

SW1 — Classification (inlined in SW0)

ℹ️
Classification is NOT a separate sub-workflow. It runs inline in SW0 via HTTP Request nodes (steps 6a/6b above). SW2 runs first so PDF content is available for classification — this is what allows Sanmar supply PDFs to be classified correctly.
NodeTypeWhat it does
Classify (hasPdf=true)HTTP RequestPOST https://api.openai.com/v1/responses, gpt-4.1. Body type: JSON (not Raw). Static body with {{ $json.pdfData }} as only dynamic value. Sends PDF inline as data:application/pdf;base64,{pdfData}.
Classify (hasPdf=false)HTTP RequestPOST https://api.openai.com/v1/responses, gpt-4.1. Text-only input.
Code3: ParseCode (JS)output[0].content[0].text → JSON.parse. confidence <80 overrides category to "Needs Review". Returns: category, confidence, related_po_number.
⚠️
Supply Order classification rule: If Esquire Embroidery is in the SHIP-TO section OR the vendor is a garment wholesaler (Sanmar, Alphabroder, S&S Activewear) → always classify as Supply Order, even if the document looks like a PO format. related_po_number = the document's own PO number.

SW2 — PDF Handler

#NodeTypeWhat it does
1TriggerWhen called by workflowReceives Gmail message ID + attachment metadata
2Get Message PartsHTTP RequestGET Gmail API → /messages/{id}?format=full → full message payload
3Extract Attachment InfoCode (JS)Finds mimeType: application/pdf → extracts attachmentId + filename. Non-PDF attachment (e.g. PNG) → returns hasPdf=false, attachmentId=null.
4IF: hasPdf?IFtrue → proceed to Download PDF. false → Set node returns { hasPdf: false, pdfData: null, filename: null } cleanly without crashing.
5Download PDFHTTP RequestGET Gmail API → /messages/{id}/attachments/{attachmentId} → URL-safe base64. Only runs when hasPdf=true.
6Package OutputCode (JS)Returns hasPdf + filename + pdfData (URL-safe base64)

SW3 — Extract PO Data

#NodeTypeWhat it does
1TriggerWhen called by workflowReceives hasPdf + pdfData (URL-safe base64) + emailText + emailSubject + emailFrom from SW2
2Has PDF?IFBranches on hasPdf: true → PDF extraction path, false → email body extraction path
3Convert Base64Code (JS)Converts URL-safe base64 to standard base64: replace - with +, _ with /
4Extract from PDFHTTP RequestPOST https://api.openai.com/v1/responses, body type JSON, gpt-4.1. Sends PDF as data:application/pdf;base64,{pdfData} inline in request. Returns structured JSON.
5Parse ResponseCode (JS)response.output[0].content[0].text → JSON.parse → returns all fields
ℹ️
SW3 extracts top-level fields: po_number, customer_name, item_quantity, item_description, customer_pickup_date, rush, description_2, split_shipping, folded_and_bagged, bagged_by_name, digitized, sew_out, personalization, seam_seal, pick_up, ups_ship, repeat_order, repeat_order_ref, notes

dst_items[] — one entry per embroidery location: search_term, placement, colorway, ref_description, quantity
ship_to[] — one entry per shipping address: ship_to_company (main receiver name), ship_to_name (attention line), ship_to_address, ship_to_address2, ship_to_city, ship_to_state, ship_to_zip. All ship_to fields blank when pick_up=true.

RUSH rule: explicit in-hands date → rush=true, description_2="Hard due date: YYYY-MM-DD". "ASAP" alone does NOT trigger rush.
Pick-up rule: explicit mention of pickup/will call/courier OR no shipping address present → pick_up=true, ship_to fields blank.
UPS Ship rule: only true when explicit street+city+state+zip present in PO.
description_2 combines: hard due date + polybag by employee name (newline-separated if both apply).
item_description: populated when PO mentions customer will drop off garments or garments are customer-provided.
Non-rush customer_pickup_date: email received_date + 10 business days (skips weekends + US federal holidays).

Placement abbreviations: ULC=LC, URC=RC, Left bicep=LS, Right bicep=RS.
Customer exceptions: Brand Agents "ECU workwear logo" → search_term "Elevation".

Instruction flags are individual true/false fields. The third_party flag is NOT extracted by GPT — determined in SW4 via Customer Master lookup. The ¶-separated Instructions string for FileMaker is built in SW4.

SW4 — Write to Sheets Updated 2026-06-13

#NodeTypeWhat it does
1TriggerWhen called by workflowReceives all extracted data from SW3 + email metadata. Mode: Run Once for All Items.
2Read CacheHTTP RequestGET http://localhost:3000/lookup-cache → returns { companies: [...], personnel: [...] } from local cache files.
3Lookup IDsCode (JS)Reads email_from + email_cc from trigger. Looks up company_master_id (customer_name vs billingCompanyName, exact then contains). Looks up personnel_id (email contains match, scoped to matched company first). Scoring: emailFrom+CC match = 3, emailFrom only = 2, CC only = 1. Tie-break: fewer emails in record = more specific.
4Build PO RowCode (JS)Generates PO_Row_ID (Date.now()), unpacks ship_to[0] into flat fields, constructs ship_to_detail ("PO:po_number:qty"), builds ¶-separated Instructions string from flags. Reads company_master_id + personnel_id from Lookup IDs output. Returns ONE item.
5Append PO QueueGoogle SheetsAppends single row to PO Queue tab.
6Expand DST ItemsCode (JS)Reads dst_items[] from trigger + PO_Row_ID from Build PO Row node. Returns one item per embroidery location. Mode: Run Once for All Items.
7Append DST ItemsGoogle SheetsRuns once per item — appends one row per embroidery location to DST Items tab.
ℹ️
Lookup cache: companies-cache.json + personnel-cache.json at /home/ec2-user/. Refreshed by running curl http://localhost:3000/refresh-cache after Customer Master Sync. Server reads from Google Sheets directly via googleapis + service account key at /home/ec2-user/service-account-key.json.

FM query filters: Customer Master: Available=1 only (~378 records). Personnel: QB_Contact=0 only (~724 records — excludes invoice contacts).

Field names: input.email_from and input.email_cc (snake_case, not camelCase).

SW5 — Label Manager Complete ✓

#NodeTypeWhat it does
1TriggerWhen called by workflowReceives messageId + category. Note: Message ID field must reference $json.messageId — not $json.category.
2Remove Pending LabelGmailResource: Message, Operation: Remove Label. Message ID: {{ $('When Executed by Another Workflow').item.json.messageId }}
3Switch: Route by CategorySwitchBranches on category: PO / Question / Supply Order / Revision / Needs Review
4Add Result LabelGmail (per branch)Adds n8n/PO-processed, n8n/Question, n8n/Supply-Order, or n8n/Needs-Review

Customer Master Sync Complete ✓

Standalone n8n workflow. Run on demand to refresh company and personnel lookup data from FileMaker dev server into Google Sheets.

ℹ️
Quota fix: Clear nodes run first (before FM Login) while Google Sheets write quota is fresh. Wait 90s node between company Append and personnel Get lets quota reset. Personnel filtered to email-only records via FileMaker _find endpoint, then further filtered to exclude QB_Contact=1 (invoice-only) in Process Personnel. Field-set bug: the @Customer Master QB layout's _find endpoint silently returns a smaller field set than GET /records (missing Available/ThirdPartyAcct/billingPostalCode) — Get Companies now uses GET /records?_limit=1000 with Available=1 filtering moved into Process Companies to work around this.
#NodeTypeWhat it does
1Manual TriggerManualRun on demand
2Clear Customer MasterGoogle SheetsOperation: Clear, Range: A2:Z — clears Tab 4 before refill. Runs first (quota fresh = 1 write).
3Clear PersonnelGoogle SheetsOperation: Clear, Range: A2:Z — clears Tab 9 Personnel. 1 write request.
4FM LoginHTTP RequestPOST /sessions, Basic Auth n8n-reader. Returns session token.
5Get CompaniesHTTP RequestGET @Customer Master QB/records?_limit=1000. Auth: Bearer {{ $('FM Login').item.json.response.token }}
6Process CompaniesCode (JS)Maps fieldData → individual items: company_id, billingCompanyName, ThirdPartyAcct, billingPostalCode
7Append CompaniesGoogle SheetsAppends ~388 rows to Customer Master tab.
8Wait 90sWaitPauses 90 seconds to let Google Sheets write quota reset before personnel operations.
9Get PersonnelHTTP RequestPOST @Customer Master QB Personnel/_find, body: {"query":[{"email":"*"}],"limit":"2000"} — returns only records with email addresses.
10Process PersonnelCode (JS)Maps fieldData → individual items: personnel_id, company_id, email, contact_person_FN, Phone Direct. Filters out QB_Contact=1 (invoice-only contacts).
11Append PersonnelGoogle SheetsAppends filtered personnel rows to Personnel tab.
12FM LogoutHTTP RequestDELETE /sessions/{token}. On Error: Continue (token may expire during long sync).

SW6 — Notify

#NodeTypeWhat it does
1TriggerWhen called by workflowReceives category + email subject + from + sheet row link
2Route by TypeSwitchQuestion / Needs Review / Duplicate PO
3Send AlertGmailSends immediate email to chi@esqembroidery.com with subject + link to Sheets row

SW7 — Daily Digest (Standalone)

#NodeTypeWhat it does
1Schedule TriggerScheduleFires at 8:00am daily
2Read Yesterday's RowsGoogle SheetsGets all rows from PO Queue, Questions, Supply Orders, Needs Review where received_date = yesterday
3Build SummaryCode (JS)Counts by category, lists subject lines
4Send DigestGmailSends summary email to chi@esqembroidery.com
11

Risks & Rollback

RiskLikelihoodMitigationRollback
EC2 resize breaks servicesVery LowAMI snapshot first; PM2 auto-restartsResize back (3 min)
n8n crash affects other servicesVery LowPM2 isolation — restarts n8n onlypm2 delete n8n
Gmail sends wrong replyLowDedicated orders Gmail accountRevoke OAuth (30 sec)
FileMaker bad recordsLowOrder Push validates completeness first; error rolls back @Order/@Ship_To/@DST_refDelete bad records manually — n8n-writer currently lacks delete privileges, so the automatic rollback can fail silently (known gap, not yet fixed)
Missed PO emailMediumAll emails go to Sheets; Gmail labels; content-based intake filter widened as gaps are foundCheck Needs Review tab; manually label + use SW0-Backfill to reprocess
Wrong DST fileMediumHuman confirms all; picker shows image + PO historyButton blocked until confirmed
RAM pressureLowt3.medium has 4GB; all services ~1.5GB combinedAdd swap file
FileMaker _find returns stale/wrong dataMediumConfirmed on @Customer Master QB, @Ship_To, and @Order — always verify via GET, never trust _find results on these layouts for verification/debuggingN/A — workaround is procedural (use GET)
Email never reaches the pipelineMediumIntake filter requires "PO"/"PO#"/"Purchase Order" in the subject — a bare number (e.g. "56348") is invisible to the whole system, not just misclassifiedUse "Force Process Email as PO" (/force-po/) to search + manually process the missed email
n8n Form Trigger nodeN/A — avoidedConfirmed via direct testing (2026-07-22) that Form Trigger's webhook never registers in this n8n install, regardless of CLI or UI activation — do not use this node type for any future toolBuild web forms as plain pages on the portal server instead, calling n8n via a plain Webhook node if needed
12

Open Questions

🎉
All questions resolved. Phase 1 can begin. See answers below for reference.
✓ Resolved: item_quantity_1 = garment count as shown on PO. NOT multiplied by placements. DST_ref::Quantity = garments × placements (total sewing operations).
✓ Resolved: One Dst_ref row. ref_description = "x2 per parka, outer and inner". No duplicate row needed.
✓ Resolved: Instructions field is a ¶-separated string built by n8n from 11 keyword triggers + Customer Master rules. See Section 4 for full trigger table. RUSH adds Description_2 = "Hard due date: YYYY-MM-DD".
✓ Resolved: DST File Master sync = Option B incremental at 7:45am daily. Container images stored as URL pointer only, not binary data in Sheets.
✓ Resolved: customer_pickup_date = today + 10 business days, skipping weekends and US federal holidays, calculated by n8n.
✓ Partially resolved: DB = Order_managementUI, Order layout = Order Details.  ⚠ Still needed: layout names for Ship_To and Dst_ref.
✓ Resolved: Two FM accounts recommended — n8n-writer (write: Order Details, Ship_To, Dst_ref) and n8n-reader (read-only: Dst_file_master for picker page).
✓ Resolved: Testing: chi@esqembroidery.com  |  Production: orders@esqembroidery.com
✓ Resolved: Testing: chi@esqembroidery.com  |  Production: chi@ + orders@esqembroidery.com
✓ Resolved: Both are portals on Order Details layout. Portal objects: portal.Ship_To and portal.DST_ref. Single API call with portalData handles all tables.
13

Production Setup Checklist

⚠️
Do this only after Phase 2 testing is validated. Each step below switches the system from the test environment to production. Work through them in order.

Step 1 — Gmail Labels (orders@esqembroidery.com)

Log into orders@esqembroidery.com and create the following labels. Because the names use /, Gmail nests them automatically under an n8n group.

Label to createPurpose
n8n/pendingAuto-applied to every incoming email — triggers n8n
n8n/PO-processedApplied after confirmed PO is written to Sheets
n8n/QuestionApplied after customer question is logged
n8n/Supply-OrderApplied after garment supply PDF is logged
n8n/Needs-ReviewApplied when classification confidence <80% or revision email
ℹ️
Gmail Settings → Labels → Create new label. Type n8n/pending (include the slash) and Gmail will create the nested group automatically.

Step 2 — Gmail Filter (orders@esqembroidery.com)

Create a filter that auto-labels all incoming emails with n8n/pending:

  1. Gmail → Settings → Filters and Blocked Addresses → Create a new filter
  2. In the To field enter: orders@esqembroidery.com
  3. Click Create filter
  4. Check Apply the label → select n8n/pending
  5. Check Also apply filter to matching conversations (optional — applies to existing inbox)
  6. Click Create filter
🔴
Never manually remove the n8n/pending label — let n8n do it. Any email still labeled n8n/pending after a few minutes = n8n processing failure.

Step 3 — Google Service Account

The production n8n instance needs a Service Account credential to read Google Sheets reliably (no OAuth expiry, no re-verification required). This replaces the OAuth2 Google Sheets credential used during testing.

ℹ️
Why Service Account? OAuth2 tokens expire and require a human to re-approve them. Service Account uses a static JSON key that never expires — ideal for server-to-server access.
  1. Go to console.cloud.google.com
  2. Select the correct Google Cloud project
  3. Left menu → IAM & AdminService Accounts
  4. Click + Create Service Account
  5. Name it n8n-sheets-reader → click Create and Continue
  6. Skip optional role steps → click Done
  7. Click the new service account in the list → Keys tab → Add KeyCreate new keyJSONCreate
  8. A JSON key file downloads automatically — keep it safe

Share the Google Sheet with the service account:

  1. Open the PO Automation Google Sheet
  2. Click Share
  3. Paste the service account email (looks like n8n-sheets-reader@your-project.iam.gserviceaccount.com)
  4. Set permission to Editor (n8n needs to append rows)
  5. Uncheck "Notify people" → click Share

Add the credential in n8n:

ℹ️
Depending on your n8n version, there may not be a global Credentials panel in the sidebar. Use the node-based approach below instead — it works in all versions.
  1. Open any workflow that has a Google Sheets node (e.g. SW4)
  2. Click on a Google Sheets node
  3. Click the Credential dropdown → Create new credential
  4. Search for Google API
  5. Paste the entire contents of the downloaded JSON key file
  6. Save — name it Google Sheets Service Account
  7. Update all Google Sheets nodes in SW4, Customer Master Sync, and any other workflow that reads/writes Sheets to use this new credential
The JSON key never expires. Once set up in n8n, no maintenance required.

Step 4 — Switch n8n Gmail Trigger to Production Inbox

In SW0 (Main Orchestrator):

  1. Open the Gmail Trigger node
  2. Change the connected Gmail credential from chi@esqembroidery.comorders@esqembroidery.com
  3. Confirm the label filter is still set to n8n/pending
  4. Save and re-activate SW0
⚠️
Also update SW5 Label Manager — the Gmail nodes that add/remove labels must use the orders@esqembroidery.com credential, not chi@.

Step 5 — Switch FileMaker to Production Server

During testing, all FM API calls point to the dev server (54.172.214.224). Switch to production before go-live:

SettingTest valueProduction value
FM Server URLhttps://54.172.214.224https://esqserver.com
SSL verificationOFF (self-signed cert)ON

Update the FM URL in: Customer Master Sync (FM Login node), and any future FM write nodes added in Phase 4.

Step 6 — Run Customer Master Sync

After switching to the production FM server, run the Customer Master Sync workflow manually once to populate the Google Sheets Customer Master and Personnel tabs with production data.

  1. Open the Customer Master Sync FM → Sheets workflow in n8n
  2. Click Execute Workflow
  3. Verify Tab 4 (Customer Master) and Tab 9 (Personnel) are populated

Step 7 — DST File Master Layout Setup (Order_managementDB)

The DST File Master sync (Phase 3) reads from a dedicated FileMaker layout built specifically for the n8n Data API integration. This layout must be recreated in the production Order_managementDB file before the DST sync can be switched over — it does not exist there by default.

⚠️
Two layouts were found sharing the identical name @DST file during dev setup, which made the Data API resolve to the wrong one non-deterministically. When recreating this in production, give it a unique name from the start.
  • Database: Order_managementDB
  • Layout name: @DST file n8n (must be unique — no other layout may share this exact name)
  • Base table: DST file

Required fields on the layout (confirmed working against dev, 2026-07-18):

FieldPurpose
idPrimary key — becomes dst_ref_id downstream
availableFilter — only sync where available = 1
dst_nameDesign/logo name
dst_file_nameDST file name
dst_stich_countStitch count (note: field name has a typo baked in, no "t" — keep as-is)
dst_thread_change_countNumber of thread changes required
Width, HeightDesign dimensions
dst_descriptionText description (currently holds a computed size string, e.g. "W 3.05 in x H 1.58 in")
dst_thread_color1dst_thread_color10Thread colors (field supports up to 15, but only 1–10 are used in practice)
company_master_idFK to Customer Master — links a DST design to the customer that owns it
dst_imageContainer field — image only, never synced directly (served via a proxy URL instead)

Account / privilege setup:

  1. Confirm the n8n-reader account exists under File → Manage → Security → Accounts in production Order_managementDB (accounts are per-file — the dev account does not carry over)
  2. Confirm the account is Active, has a permanent password set (not "must change password at next login" — the Data API cannot complete a forced password change)
  3. Confirm the assigned Privilege Set has: View only access to records/layouts/value lists, and Access via FileMaker Data API enabled
  4. Grant read access to the @DST file n8n layout specifically
ℹ️
Query used by the sync: POST /fmi/data/v1/databases/Order_managementDB/layouts/@DST file n8n/_find with body {"query":[{"available":"1"}],"portal":[]}. The "portal":[] is required — without it, a large unrelated lookup portal on the base table gets included in every record and bloats the response.

Step 8 — Final Verification

ChecklistBefore going live on orders@esqembroidery.com