Overview
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
Tools & Costs
| Tool | Purpose | Monthly |
|---|---|---|
| AWS EC2 t3.medium | Host n8n + existing services + picker page | ~$30 (+$15 upgrade) |
| n8n (self-hosted) | Workflow automation | Free |
| OpenAI API (GPT-4.1) | PDF parsing, email classification | ~$3 |
| Google Sheets | Staging + review queue | Free |
| FileMaker Data API | Read/write FM records | Free (included) |
| Gmail OAuth2 | Trigger on new emails, send replies | Free |
| Google Apps Script | Buttons in Sheets | Free |
| EC2 Node.js (new routes) | DST picker + image proxy | Free (same server) |
EC2 Upgrade Procedure
- EC2 Console → Actions → Image → Create Image
- Stop the EC2 instance
- Actions → Instance Settings → Change Instance Type → t3.medium
- Start the instance
- SSH in →
pm2 listto confirm services running npm install -g n8n→pm2 start n8n --name n8n
Downtime: ~3 minutes. Existing services auto-restart via PM2.
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]"
FileMaker Schema & API
Tables Involved
| Table | Key Field | Purpose |
|---|---|---|
| Order | ITEM_ID_MATCH_FIELD (auto) | Main PO record — DB: Order_managementUI (UI) / Order_managementDB (data), Layout: Order Details |
| Ship_To | PrimaryKey (auto), ITEM_ID_MATCH_FIELD | Shipping address — portal: portal.Ship_To — multiple rows supported |
| Customer_Master_QB | company_id | Customer lookup |
| order_Customer_Master_Personnel | company_id, PersonnelID | Contacts |
| Dst_ref | dst_ref_id, ITEM_ID_MATCH_FIELD | Links order to DST files — portal: portal.DST_ref |
| Dst_file_master | dst_ref_id | DST design library + images — Layout: DST file master |
Key Field Rules
| Field | Rule |
|---|---|
item_quantity_1 | Garment count as shown on PO — NOT multiplied by placements (e.g. 33) |
DST_ref::Quantity | Total sewing operations = garments × placements (e.g. 33 × 2 = 66) |
status | n8n sets to "Waiting for product" when creating Sheets row |
customer_pickup_date | Today + 10 business days (skip weekends + US federal holidays), calculated by n8n |
Description_2 | Combined 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_description | Free 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):
| Field | Value |
|---|---|
Ship_To::ThirdPartyAcct | Copy from Customer Master QB::ThirdPartyAcct |
Ship_To::Bill To PostalCode | Copy from Customer Master QB::billingPostalCode |
Ship_To::Bill To Country | Hardcode "United States" |
Instructions Field
Instructions is a ¶-separated string built by n8n from detected conditions. Instructions Display = Substitute(Instructions; "¶"; " ")
| Instruction Item | Trigger |
|---|---|
Split Shipping | Multiple shipping addresses on PO |
Folded & Bagged | PO mentions "individual bag", "poly bag", or "polybagging" |
Bagged By Name | PO mentions "bagged by name", "bag by name", or "polybag by employee" |
Digitized | PO mentions "logo setup", "digitize", or "digitized" |
Sew Out | PO mentions "sew out" or "sewout" |
Personalization | PO mentions "names" or "personalization" |
Seam Seal | PO mentions "seam seal" |
3rd Party | Customer Master QB::ThirdPartyAcct <> "" (not from PO text) |
Pick-Up | PO explicitly mentions "pickup", "will call", or "courier" — OR no shipping address is present at all |
UPS Ship | PO has an explicit shipping address with street, city, state, and zip. If no address present, UPS Ship is false. |
RUSH | PO 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.PO Type Handling
PDF Classification Rule
If "Esquire Embroidery" in VENDOR → Embroidery PO — process
DST File Resolution
| Style | Example | Resolution |
|---|---|---|
| Explicit file code | BluErth: FILE NAME: JPS4441 | Auto — exact match |
| Descriptive name | Clove & Twine: SH Black logo | Auto if 1 result |
| Dual colorway | DT Logos: Black on lights / White on darks | Auto — 2 searches, 2 Dst_ref rows (different files/colorways) |
| Wilcom PDF attached | smlLtr-CHCOpedOrth.pdf | Auto — design name extracted |
| Repeat with PO ref | Repeat order 222017-CUORTHO | Auto — lookup by PO# |
| "Same as previous" | Any customer | Auto — most recent order by company |
| 2+ keyword matches | — | Picker popup |
| 0 matches | — | Flag 🔴 — manual FM lookup |
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).dst_ref_id, placement, and ref_description are copied. All other fields (ship-to, quantities, dates, instructions) come from the new PO.Google Sheets Structure
All Tabs at a Glance
| # | Tab Name | Purpose | Who uses it |
|---|---|---|---|
| 1 | PO Queue | Incoming orders — one row per PO. Main review screen. | Everyone |
| 2 | DST Items | DST file references — one row per design per order. | Everyone |
| 3 | Ship To Items | Multiple shipment destinations — only used when a PO ships to 2+ addresses. | Everyone |
| 4 | Customer Master | Company lookup table synced from FileMaker. Fields: company_id, billingCompanyName, ThirdPartyAcct, billingPostalCode. | n8n Customer Master Sync |
| 5 | DST File Master | DST design library — incremental daily sync from FileMaker at 7:45am. Reference only. | Reference |
| 9 | Personnel | Contact 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 |
| 6 | Questions | Customer questions logged here. No FileMaker action. | Team review |
| 7 | Supply Orders | Garment supply PDFs (Sanmar, Alphabroder etc.) logged here. No FileMaker action. | Reference |
| 8 | Needs Review | Emails n8n couldn't confidently classify. Human decides what to do. | Team review |
Tab 1 — PO Queue Primary
| Column | Source | Notes |
|---|---|---|
PO_Row_ID | Auto | Links to DST Items tab |
Status | n8n sets "Waiting for product" on creation; human updates | Pending / Approved / Error / Sent ✓ |
PO_Number | OpenAI | |
PO_Date | OpenAI | |
company_master_id | Lookup → Customer Master | Flagged if not found |
company_name | OpenAI | |
contact_email | OpenAI | Used for Gmail reply |
customer_pickup_date | n8n | Today + 10 business days (skip weekends + US federal holidays) |
Description | OpenAI | |
Description2 | n8n | "Hard due date: YYYY-MM-DD" — only when RUSH applies; otherwise blank |
Instructions | n8n | ¶-separated list built from PO keywords + Customer Master rules (see Section 4) |
Instructions_Display | n8n | Substitute(Instructions; "¶"; " ") |
item_quantity_1 | OpenAI | Garment count as shown on PO — NOT multiplied by placements |
Ship_To_* | OpenAI | 6 address fields |
DST_Status | Auto | All resolved ✓ / N items need review |
Duplicated_From_PO | Auto | Filled if repeat order |
FM_Record_ID | After push | ITEM_ID_MATCH_FIELD from FM |
| [Send to FileMaker] | Apps Script button | Blocked if DST not fully resolved |
Tab 2 — DST Items Primary
| Column | Source | Notes |
|---|---|---|
PO_Row_ID | Auto | Foreign key → PO Queue |
search_term | OpenAI | Keyword to find DST file |
colorway | OpenAI | "black" / "white" / blank |
dst_ref_id | Auto/human | Blank = needs resolution |
dst_file_name | Auto | From Dst_file_master after match |
placement | OpenAI | Mapped to FM value list |
ref_description | OpenAI | e.g. "x2 per parka, outer and inner" |
resolved_status | Auto | 🟢 Auto / ⚠ Confirm / 🔴 Not found |
| [🔍 Pick DST] | Apps Script button | Shown 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.
| Column | Source | Notes |
|---|---|---|
PO_Row_ID | Auto | Foreign key → PO Queue |
Ship_To_Company | OpenAI | |
Ship_To_Name | OpenAI | |
Ship_To_Street1 | OpenAI | |
Ship_To_Street2 | OpenAI | |
Ship_To_City | OpenAI | |
Ship_To_Postal | OpenAI | |
Ship_To_Country | OpenAI | |
Ship_To_Phone | OpenAI |
Tabs 4–8 — Reference & Safety Logs
| # | Tab Name | Content | Updated by |
|---|---|---|---|
| 4 | Customer Master | company_id, billingCompanyName, ThirdPartyAcct, billingPostalCode — lookup source for company_master_id | n8n Customer Master Sync (on demand) |
| 9 | Personnel | personnel_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) |
| 5 | DST File Master | dst_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) |
| 6 | Questions | Customer questions. No FM action. Team reads and replies manually. | n8n auto |
| 7 | Supply Orders | Garment supply PDFs (Sanmar, Alphabroder etc.). No FM action. Logged for reference. | n8n auto |
| 8 | Needs Review | Emails n8n couldn't classify with ≥80% confidence. Human reads and decides action. | n8n auto |
DST Picker Popup
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)
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.xlsx → build-thread-colors.js → thread-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
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
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
/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.Email Safety Net
Gmail Labels Applied After Every Email
| Label | Meaning |
|---|---|
n8n/PO-processed | Confirmed PO, written to Sheets |
n8n/Question | Customer question, logged |
n8n/Needs-Review | Unclear classification, flagged in Sheets |
n8n/Supply-Order | Garment 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
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
Implementation Phases
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
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Gmail Trigger | Gmail Trigger | Fires on n8n/pending label. Simplify OFF. |
| 2 | Edit Fields | Edit Fields | Extracts: messageId, emailSubject, emailText, emailFrom, received_date (from Gmail headers.date) |
| 3 | SW2 PDF Handler | Execute Workflow | Downloads PDF attachment → returns hasPdf + pdfData (URL-safe base64) + filename |
| 4 | Code: Merge + Convert Base64 | Code (JS) | Merges SW2 output. Converts URL-safe base64 → standard base64 (- → +, _ → /). Mode: Run Once for All Items. |
| 5 | IF: hasPdf? | IF | Branches on hasPdf: true → send PDF to OpenAI; false → text-only path |
| 6a | Classify (PDF) | HTTP Request | POST /v1/responses, gpt-4.1. Sends PDF as data:application/pdf;base64,{pdfData}. Static JSON body — only pdfData is dynamic. |
| 6b | Classify (text) | HTTP Request | POST /v1/responses, gpt-4.1. Sends email text only (no PDF). |
| 7 | Code3: Parse + Classify | Code (JS) | Parses response.output[0].content[0].text → JSON.parse. If confidence <80 → overrides category to "Needs Review". Returns category + confidence + related_po_number. |
| 8 | Switch: Route by Category | Switch | Branches: PO / Question / Supply Order / Revision / Needs Review |
| 9 | PO branch: SW3 | Execute Workflow | Calls SW3 with hasPdf + pdfData + email metadata → returns structured PO fields |
| 10 | PO branch: Code | Code (JS) | Merges SW3 output + email metadata + confidence |
| 11 | SW4: Write to Sheets | Execute Workflow | PO branch: writes PO Queue + DST Items. Other branches: writes to Questions / Supply Orders / Needs Review with related_po_number. |
| 12 | SW5: Label Manager | Execute Workflow | All branches — removes n8n/pending, adds result label (n8n/PO-processed, n8n/Question, n8n/Supply-Order, n8n/Needs-Review) |
SW1 — Classification (inlined in SW0)
| Node | Type | What it does |
|---|---|---|
| Classify (hasPdf=true) | HTTP Request | POST 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 Request | POST https://api.openai.com/v1/responses, gpt-4.1. Text-only input. |
| Code3: Parse | Code (JS) | output[0].content[0].text → JSON.parse. confidence <80 overrides category to "Needs Review". Returns: category, confidence, related_po_number. |
related_po_number = the document's own PO number.SW2 — PDF Handler
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Trigger | When called by workflow | Receives Gmail message ID + attachment metadata |
| 2 | Get Message Parts | HTTP Request | GET Gmail API → /messages/{id}?format=full → full message payload |
| 3 | Extract Attachment Info | Code (JS) | Finds mimeType: application/pdf → extracts attachmentId + filename. Non-PDF attachment (e.g. PNG) → returns hasPdf=false, attachmentId=null. |
| 4 | IF: hasPdf? | IF | true → proceed to Download PDF. false → Set node returns { hasPdf: false, pdfData: null, filename: null } cleanly without crashing. |
| 5 | Download PDF | HTTP Request | GET Gmail API → /messages/{id}/attachments/{attachmentId} → URL-safe base64. Only runs when hasPdf=true. |
| 6 | Package Output | Code (JS) | Returns hasPdf + filename + pdfData (URL-safe base64) |
SW3 — Extract PO Data
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Trigger | When called by workflow | Receives hasPdf + pdfData (URL-safe base64) + emailText + emailSubject + emailFrom from SW2 |
| 2 | Has PDF? | IF | Branches on hasPdf: true → PDF extraction path, false → email body extraction path |
| 3 | Convert Base64 | Code (JS) | Converts URL-safe base64 to standard base64: replace - with +, _ with / |
| 4 | Extract from PDF | HTTP Request | POST 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. |
| 5 | Parse Response | Code (JS) | response.output[0].content[0].text → JSON.parse → returns all 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, notesdst_items[] — one entry per embroidery location:
search_term, placement, colorway, ref_description, quantityship_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
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Trigger | When called by workflow | Receives all extracted data from SW3 + email metadata. Mode: Run Once for All Items. |
| 2 | Read Cache | HTTP Request | GET http://localhost:3000/lookup-cache → returns { companies: [...], personnel: [...] } from local cache files. |
| 3 | Lookup IDs | Code (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. |
| 4 | Build PO Row | Code (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. |
| 5 | Append PO Queue | Google Sheets | Appends single row to PO Queue tab. |
| 6 | Expand DST Items | Code (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. |
| 7 | Append DST Items | Google Sheets | Runs once per item — appends one row per embroidery location to DST Items tab. |
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 ✓
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Trigger | When called by workflow | Receives messageId + category. Note: Message ID field must reference $json.messageId — not $json.category. |
| 2 | Remove Pending Label | Gmail | Resource: Message, Operation: Remove Label. Message ID: {{ $('When Executed by Another Workflow').item.json.messageId }} |
| 3 | Switch: Route by Category | Switch | Branches on category: PO / Question / Supply Order / Revision / Needs Review |
| 4 | Add Result Label | Gmail (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.
_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.| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Manual Trigger | Manual | Run on demand |
| 2 | Clear Customer Master | Google Sheets | Operation: Clear, Range: A2:Z — clears Tab 4 before refill. Runs first (quota fresh = 1 write). |
| 3 | Clear Personnel | Google Sheets | Operation: Clear, Range: A2:Z — clears Tab 9 Personnel. 1 write request. |
| 4 | FM Login | HTTP Request | POST /sessions, Basic Auth n8n-reader. Returns session token. |
| 5 | Get Companies | HTTP Request | GET @Customer Master QB/records?_limit=1000. Auth: Bearer {{ $('FM Login').item.json.response.token }} |
| 6 | Process Companies | Code (JS) | Maps fieldData → individual items: company_id, billingCompanyName, ThirdPartyAcct, billingPostalCode |
| 7 | Append Companies | Google Sheets | Appends ~388 rows to Customer Master tab. |
| 8 | Wait 90s | Wait | Pauses 90 seconds to let Google Sheets write quota reset before personnel operations. |
| 9 | Get Personnel | HTTP Request | POST @Customer Master QB Personnel/_find, body: {"query":[{"email":"*"}],"limit":"2000"} — returns only records with email addresses. |
| 10 | Process Personnel | Code (JS) | Maps fieldData → individual items: personnel_id, company_id, email, contact_person_FN, Phone Direct. Filters out QB_Contact=1 (invoice-only contacts). |
| 11 | Append Personnel | Google Sheets | Appends filtered personnel rows to Personnel tab. |
| 12 | FM Logout | HTTP Request | DELETE /sessions/{token}. On Error: Continue (token may expire during long sync). |
SW6 — Notify
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Trigger | When called by workflow | Receives category + email subject + from + sheet row link |
| 2 | Route by Type | Switch | Question / Needs Review / Duplicate PO |
| 3 | Send Alert | Gmail | Sends immediate email to chi@esqembroidery.com with subject + link to Sheets row |
SW7 — Daily Digest (Standalone)
| # | Node | Type | What it does |
|---|---|---|---|
| 1 | Schedule Trigger | Schedule | Fires at 8:00am daily |
| 2 | Read Yesterday's Rows | Google Sheets | Gets all rows from PO Queue, Questions, Supply Orders, Needs Review where received_date = yesterday |
| 3 | Build Summary | Code (JS) | Counts by category, lists subject lines |
| 4 | Send Digest | Gmail | Sends summary email to chi@esqembroidery.com |
Risks & Rollback
| Risk | Likelihood | Mitigation | Rollback |
|---|---|---|---|
| EC2 resize breaks services | Very Low | AMI snapshot first; PM2 auto-restarts | Resize back (3 min) |
| n8n crash affects other services | Very Low | PM2 isolation — restarts n8n only | pm2 delete n8n |
| Gmail sends wrong reply | Low | Dedicated orders Gmail account | Revoke OAuth (30 sec) |
| FileMaker bad records | Low | Order Push validates completeness first; error rolls back @Order/@Ship_To/@DST_ref | Delete bad records manually — n8n-writer currently lacks delete privileges, so the automatic rollback can fail silently (known gap, not yet fixed) |
| Missed PO email | Medium | All emails go to Sheets; Gmail labels; content-based intake filter widened as gaps are found | Check Needs Review tab; manually label + use SW0-Backfill to reprocess |
| Wrong DST file | Medium | Human confirms all; picker shows image + PO history | Button blocked until confirmed |
| RAM pressure | Low | t3.medium has 4GB; all services ~1.5GB combined | Add swap file |
FileMaker _find returns stale/wrong data | Medium | Confirmed on @Customer Master QB, @Ship_To, and @Order — always verify via GET, never trust _find results on these layouts for verification/debugging | N/A — workaround is procedural (use GET) |
| Email never reaches the pipeline | Medium | Intake filter requires "PO"/"PO#"/"Purchase Order" in the subject — a bare number (e.g. "56348") is invisible to the whole system, not just misclassified | Use "Force Process Email as PO" (/force-po/) to search + manually process the missed email |
| n8n Form Trigger node | N/A — avoided | Confirmed 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 tool | Build web forms as plain pages on the portal server instead, calling n8n via a plain Webhook node if needed |
Open Questions
item_quantity_1 = garment count as shown on PO. NOT multiplied by placements. DST_ref::Quantity = garments × placements (total sewing operations).ref_description = "x2 per parka, outer and inner". No duplicate row needed.Description_2 = "Hard due date: YYYY-MM-DD".customer_pickup_date = today + 10 business days, skipping weekends and US federal holidays, calculated by n8n.Order_managementUI, Order layout = Order Details. ⚠ Still needed: layout names for Ship_To and Dst_ref.n8n-writer (write: Order Details, Ship_To, Dst_ref) and n8n-reader (read-only: Dst_file_master for picker page).chi@esqembroidery.com | Production: orders@esqembroidery.comchi@esqembroidery.com | Production: chi@ + orders@esqembroidery.comOrder Details layout. Portal objects: portal.Ship_To and portal.DST_ref. Single API call with portalData handles all tables.Production Setup Checklist
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 create | Purpose |
|---|---|
n8n/pending | Auto-applied to every incoming email — triggers n8n |
n8n/PO-processed | Applied after confirmed PO is written to Sheets |
n8n/Question | Applied after customer question is logged |
n8n/Supply-Order | Applied after garment supply PDF is logged |
n8n/Needs-Review | Applied when classification confidence <80% or revision email |
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:
- Gmail → Settings → Filters and Blocked Addresses → Create a new filter
- In the To field enter:
orders@esqembroidery.com - Click Create filter
- Check Apply the label → select
n8n/pending - Check Also apply filter to matching conversations (optional — applies to existing inbox)
- Click Create filter
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.
- Go to console.cloud.google.com
- Select the correct Google Cloud project
- Left menu → IAM & Admin → Service Accounts
- Click + Create Service Account
- Name it
n8n-sheets-reader→ click Create and Continue - Skip optional role steps → click Done
- Click the new service account in the list → Keys tab → Add Key → Create new key → JSON → Create
- A JSON key file downloads automatically — keep it safe
Share the Google Sheet with the service account:
- Open the PO Automation Google Sheet
- Click Share
- Paste the service account email (looks like
n8n-sheets-reader@your-project.iam.gserviceaccount.com) - Set permission to Editor (n8n needs to append rows)
- Uncheck "Notify people" → click Share
Add the credential in n8n:
- Open any workflow that has a Google Sheets node (e.g. SW4)
- Click on a Google Sheets node
- Click the Credential dropdown → Create new credential
- Search for Google API
- Paste the entire contents of the downloaded JSON key file
- Save — name it
Google Sheets Service Account - Update all Google Sheets nodes in SW4, Customer Master Sync, and any other workflow that reads/writes Sheets to use this new credential
Step 4 — Switch n8n Gmail Trigger to Production Inbox
In SW0 (Main Orchestrator):
- Open the Gmail Trigger node
- Change the connected Gmail credential from
chi@esqembroidery.com→orders@esqembroidery.com - Confirm the label filter is still set to
n8n/pending - Save and re-activate SW0
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:
| Setting | Test value | Production value |
|---|---|---|
| FM Server URL | https://54.172.214.224 | https://esqserver.com |
| SSL verification | OFF (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.
- Open the Customer Master Sync FM → Sheets workflow in n8n
- Click Execute Workflow
- 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.
@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):
| Field | Purpose |
|---|---|
id | Primary key — becomes dst_ref_id downstream |
available | Filter — only sync where available = 1 |
dst_name | Design/logo name |
dst_file_name | DST file name |
dst_stich_count | Stitch count (note: field name has a typo baked in, no "t" — keep as-is) |
dst_thread_change_count | Number of thread changes required |
Width, Height | Design dimensions |
dst_description | Text description (currently holds a computed size string, e.g. "W 3.05 in x H 1.58 in") |
dst_thread_color1 – dst_thread_color10 | Thread colors (field supports up to 15, but only 1–10 are used in practice) |
company_master_id | FK to Customer Master — links a DST design to the customer that owns it |
dst_image | Container field — image only, never synced directly (served via a proxy URL instead) |
Account / privilege setup:
- Confirm the
n8n-readeraccount exists under File → Manage → Security → Accounts in productionOrder_managementDB(accounts are per-file — the dev account does not carry over) - 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)
- Confirm the assigned Privilege Set has: View only access to records/layouts/value lists, and Access via FileMaker Data API enabled
- Grant read access to the
@DST file n8nlayout specifically
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.