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.
.example.
Specifications
| 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_quotesandep04_draftsand nothing else. Runschema.sqlfirst. Both are keyed onmessage_id, andstatusnever leavesdraft. -
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:
-
An Anthropic API key, as a Header Auth credential with the name
x-api-key. The node addsanthropic-versionitself. -
A Postgres database with
schema.sqlapplied. The workflow writes toep04_quotesandep04_draftsand nothing else. - 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:
- Episode 01, n8n Shorts Factory: one topic string in, a captioned 9:16 Short out, with the publish step deliberately switched off.
- Episode 02, n8n speed-to-lead: a form fill gets an AI reply in about two seconds, a second model qualifies the follow-up and proposes a visit window.
- Episode 03, n8n Maps lead harvest: a trade and a city in, a deduped lead table out, keyed on place id, written to Postgres.
WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs
Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc