SkynetLabs
Back to journal
AutomationSeptember 21, 202612 min read

The n8n Freight Quote Email Parser: One Email In, Eight Fields Out

By , Founder, SkynetLabs

An n8n workflow read 20 freight quote emails on 2026-09-18, filling 6.75 of 8 fields each with Claude Haiku 4.5 and writing one Postgres row per email.

On this page

A freight quote email goes in and eight structured fields come out: origin port, destination port, mode, gross weight, dimensions, cargo type, incoterm and deadline. On 2026-09-18 this n8n workflow read 20 sample emails and filled 6.75 of the 8 fields on average at 10.1 seconds per email, writing one Postgres row each, under n8n execution 26430. The reply it writes is a draft.

Ask a freight forwarder where the day goes and somebody will mention the retyping. Quote requests arrive as prose, written by people describing a shipment rather than filling in a form, and a human copies eight or nine values out of each one into a rating tool. The reading is not skilled work. The judgement that follows it is, which is why the reading is the part worth handing to a machine.

Episode 04 of the n8n Workflows series splits those two jobs apart. An email arrives, Claude Haiku 4.5 is forced into a fixed tool schema so its answer can only ever be the eight fields, a validator throws away anything the model guessed, Postgres takes one row keyed on the message id, and a draft reply lists every field back to the sender. Commercial terms need a person, so the draft is where the workflow stops.

If iframes are blocked where you are reading this, the video is at youtu.be/vuOnqlIa_rw.

Where the emails come from. The demo feeds 20 synthetic quote emails through a webhook, and no live mailbox was read at any point. A Gmail trigger and a Gmail draft node sit on the canvas disabled, as documented swap points for a real inbox. Every sender is fictional and every address ends in .example.

Specifications

One quote email in. Eight validated fields in a Postgres row keyed on the message id, plus a stored draft reply that echoes every field back and marks the blanks NOT FOUND. 16 nodes, of which 13 do work and 3 are sticky notes. Two of the 13 ship disabled on purpose.
Item Value
Workflow name 04 Freight Quote Parser (Claude -> table + draft reply)
Nodes 16 (13 working nodes plus 3 sticky notes), 2 of the 13 shipped disabled
Trigger Three doors: a manual trigger, a webhook at /webhook/ep04-email-in, and a disabled Gmail trigger
Data source samples/quotes-20.json, 20 synthetic quote emails in the public repo
Model claude-haiku-4-5-20251001, temperature 0, one call per email, with tool_choice forced to extract_quote
Fields extracted 8 counted: origin port, destination port, mode, weight, dimensions, cargo type, incoterm, deadline. Plus pieces, notes and a confidence score, uncounted.
Storage Postgres tables ep04_quotes and ep04_drafts, both keyed on message_id, indexed on run_id
Idempotency INSERT ... ON CONFLICT (message_id) DO UPDATE. A second pass updates and never duplicates
Caps 25 emails per run, 600 output tokens per email, 3 tries, 1 item per 400ms, 900-second workflow timeout
Output One summary item: run id, emails seen, rows written, rows inserted, rows updated, drafts written, average fields filled, average confidence, average seconds, token totals
Public JSON includes All 16 nodes, the tool schema, the validator, both upsert statements, the draft builder, schema.sql and the 20 sample emails. Credentials stripped to REPLACE_ME.
Licence MIT

How does the n8n freight quote parser read an email?

In five moves: normalise whatever arrived into one item per email, call Claude with a forced tool schema, re-check every field in code, upsert the row into Postgres, then build and store a draft reply. A final Code node emits one summary item, so the number you quote is the number the table holds.

Normalize email is the funnel that all three doors land on. It parses Name <address> into two fields, falls back to a djb2 hash of sender plus subject plus body when a feed carries no message id, drops duplicates inside a single feed, stamps arrived_ms so the timing has an honest start, and caps the run at 25 emails. Config then carries the run id, the model and the token ceiling as data, because n8n 2.x Code nodes run in task runners with no $env.

Claude extract is one HTTP POST per email to the Messages API, and it does not ask for JSON. It sends a tool called extract_quote whose input schema is the eight fields, and sets tool_choice to that tool by name, which removes prose from the set of things the model is able to return. Temperature 0, one item every 400ms, three tries.

Validate + score decides whether the row is trustworthy. It reads the tool_use block, re-checks every field independently of the model, nulls anything that came back as a hedge rather than a value, counts fields_filled out of 8, and clamps confidence into 0 to 1, or to 0 when the response could not be parsed. It and Build draft reply both run per item and read their email through $('Config').item, because a run-once-for-all node returning a re-ordered array would pair the wrong email with the wrong extraction silently.

Node Type What it does
Click to run (20 samples) Manual trigger The demo door. Starts the sample fetch.
Fetch sample emails HTTP GETs samples/quotes-20.json, response format pinned to JSON.
Email in (webhook) Webhook POST one email or an array, responds with the run summary. The door an IMAP poller points at.
Gmail trigger Gmail (disabled) Swap point. Enable it and add a credential to poll a real label.
Normalize email Code One item per email from any door. Message id fallback, in-feed dedupe, arrival stamp, 25-email cap.
Config Set run_id, model, max_tokens 600, today. Keeps the email fields.
Claude extract HTTP One Messages API call per email. Tool forced, temperature 0, 3 tries.
Validate + score Code, per item Re-checks every field, nulls the guesses, counts fields_filled, clamps confidence.
UPSERT ep04_quotes Postgres ON CONFLICT (message_id) DO UPDATE, seconds_to_row computed in SQL.
Build draft reply Code, per item Every field echoed, NOT FOUND on the blanks, a date request when the deadline was a phrase, a DRAFT footer.
Gmail create draft Gmail (disabled) Swap point. Enabled, the same text lands in a real Drafts folder.
Store draft (ep04_drafts) Postgres The full reply text, status draft, keyed on message_id.
Run summary Code The item you read off the execution panel: counts and averages.

What is real in the public JSON and what is a placeholder?

All 16 nodes, the tool schema, the validator, both upsert statements, the draft builder, schema.sql and the 20 sample emails are exactly what ran on 2026-09-18. Three credentials ship as REPLACE_ME: a Header Auth credential for your Anthropic key, a Postgres credential for the two table nodes, and an optional Gmail OAuth2 credential for the two disabled nodes.

Two more things worth knowing before you import it:

  • The tables are its own. The workflow writes to ep04_quotes and ep04_drafts and nothing else. Run schema.sql first. Both are keyed on message_id, and status never leaves draft.
  • The webhook ships with no authentication. Fine on a laptop, wrong on a public host. Add Header Auth on Email in (webhook) before you leave the workflow active anywhere reachable.

What did the measured run produce?

On 2026-09-18, under n8n execution 26430 and run_id 20260918-2138, the workflow read 20 emails, filled 6.75 of 8 fields on average, recorded an average model confidence of 0.83, took 10.1 seconds per email from arrival to written row, and stored 20 draft replies. The query behind those numbers is select run_id, count(*), round(avg(fields_filled),2), round(avg(seconds_to_row),1) from ep04_quotes group by 1;, which returned 20260918-2138 | 20 | 6.75 | 10.1.

The idempotency check is a separate run and a separate number. Feeding the same 20 emails again on 2026-09-18, under execution 26439 and run_id 20260918-2146, produced 0 rows inserted and 20 rows updated. That is the behaviour worth having on an inbox: a poller that re-delivers a message moves a row rather than adding one.

Read 6.75 out of 8 correctly: it is not an accuracy score and not a failure rate. Most of the missing fields were absent from the emails, because people asking for a rate do not always mention an incoterm or a piece count, and a field nobody wrote down comes back null by design. The 10.1 seconds is stamped in SQL from arrived_ms, so it includes the Claude call and the insert, not only the parts that look fast.

Which build gotchas cost a failed run?

raw.githubusercontent.com serves JSON as text/plain

This one killed the chain with an error that pointed at the wrong node. Fetch sample emails GETs the sample file from the repo, and raw.githubusercontent.com returns it as text/plain. The n8n HTTP node reads the content type rather than the payload, so it emitted a string. Normalize email found no array to iterate, and the run died with "no emails in the feed" three nodes away from the problem.

The fix is one line of node configuration: options.response.response.responseFormat = "json", which parses regardless of what the server claimed. A content type is a claim made by whoever serves the file, so a pipeline that branches on it has handed control of its data shape to a third party. Pin the format when you know what the file is.

With three triggers on one canvas, the Execute button picks the wrong one

This workflow has a manual trigger, a webhook and a disabled Gmail trigger. The button at the bottom of the n8n editor defaults to the webhook, so pressing it puts the canvas into test-listen and waits for a POST that is never coming. Nothing runs, nothing errors, and it reads as a hung workflow. To run the demo path, hover the manual trigger node and click its own execute button. It cost a confused ten minutes here, and it will cost them again on any multi-trigger canvas.

Force the tool, do not ask the model for JSON

A prompt that says "reply only with JSON" still produces code fences and a paragraph of hedging when the input is a messy forwarded thread, and then you are writing a fence-stripper. Sending a tool definition and setting tool_choice to that tool by name changes what the API is able to return: the reply arrives as a tool_use block shaped like the schema every time. The validator keeps a text-JSON fallback for models without forced tools, which never had to fire on this run.

The model was not rewarded for guessing

Every nullable field in the schema is typed ["string", "null"], and the mode and incoterm enums carry null as a permitted value. That sounds like a detail and it is the whole safety property: a schema that only permits a string tells the model a string is required, and a model that must produce one will. That is how a port nobody mentioned reaches a rating tool.

The forwarded reefer thread in the sample set is the clearest case. The sender asks for a move in "the 3rd week of November", which is not a date, and the workflow does not convert it into one: the phrase is stored as written, deadline_is_date is false, and the draft prints the sender's own words followed by a request to give a date. Deadlines are text for the same reason. "Before Eid" and "ASAP" carry information a date column would throw away, and deadline_is_date tells you which rows are sortable.

Cap Where Value
Emails per run Normalize email (MAX_EMAILS) 25, the rest are dropped and counted
Output tokens per email Config.max_tokens 600
Model Config.model claude-haiku-4-5-20251001, swapped in one place
Claude call Claude extract 60-second timeout, 3 tries, 1 item per 400ms
Execution timeout Workflow settings 900 seconds

How do I run the freight quote parser myself?

Import the free workflow JSON (MIT) from github.com/waseemnasir2k26/n8n-workflows/tree/main/workflows/04-freight-quote-parser, run schema.sql against a Postgres database, create the credentials, and press the manual trigger's own execute button. It fetches the 20 samples and fills both tables.

What you have to supply:

  1. An Anthropic API key, as a Header Auth credential with the name x-api-key. The node adds anthropic-version itself.
  2. A Postgres database with schema.sql applied. The workflow writes to ep04_quotes and ep04_drafts and nothing else.
  3. Optionally a Gmail OAuth2 credential, to enable the two disabled nodes and read a real label instead of the webhook.

To point it at real mail without Gmail, either wire an Email Trigger (IMAP) node into Normalize email, which already understands from, subject, text and messageId, or point a mail provider's inbound webhook at /webhook/ep04-email-in. On the IMAP path the drafts stay in ep04_drafts.

On running costs, in general terms: the Anthropic API call is the metered part and is billed to your own account by tokens consumed, while n8n and Postgres are infrastructure you host. One email is a short input and a capped 600-token output. Check your own usage page before raising MAX_EMAILS or pointing the webhook at a busy mailbox, because volume multiplies the metered side and nothing else.

Frequently asked questions

Can n8n read emails and extract structured data with AI?

Yes. n8n handles the reading, the storage and the reply, and the extraction is one HTTP call per email to the Claude Messages API with a forced tool schema, so the model returns the eight fields rather than prose. On 2026-09-18 this workflow processed 20 emails at 10.1 seconds each and filled 6.75 of 8 fields on average, under execution 26430.

How do I stop an LLM inventing data it cannot find?

Permit null in the schema and re-check the answer in code. Every nullable field is typed as string or null, the enums carry null as a value, and a validator node nulls anything that came back as a hedge before the row is written. A schema that only permits a string tells the model a string is required, and that is where invented ports come from.

Does this n8n workflow send the reply automatically?

No. It builds a reply that echoes every extracted field, marks each blank NOT FOUND, asks for a date when the deadline arrived as a phrase, and stores it in the ep04_drafts table with a status that never leaves draft. The Gmail draft node ships disabled, and even enabled it creates a draft rather than sending. Commercial terms need a person.

Does this workflow read a live Gmail inbox?

Not in this build. The demo feeds 20 synthetic quote emails through a webhook, and no live mailbox was read on 2026-09-18 or at any other point. A Gmail trigger and a Gmail draft node sit on the canvas disabled as documented swap points, so you add your own credential when you want a real inbox.

What do I need to run this n8n workflow?

Two credentials and one database: an Anthropic API key as a Header Auth credential named x-api-key, and a Postgres credential with schema.sql applied so ep04_quotes and ep04_drafts exist. A Gmail OAuth2 credential is optional and only needed for the two disabled nodes. All three ship as REPLACE_ME in the public JSON.

The rest of the series

One workflow per video, the JSON given away under MIT each time, built by Waseem Nasir at SkynetLabs. The other three published episodes:

WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs

Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc