Structured extraction
Email to JSON with AI: a JSON Schema-first guide
Turn email and attachments into typed JSON by separating MIME parsing from semantic extraction, validating output, and preserving honest failure states.
Reliable email-to-JSON processing starts with a contract, not a prompt. Define the JSON Schema your application accepts, clean and screen the email before extraction, constrain the model to that schema, then validate the result again at your webhook boundary. Missing evidence should produce no match, not a plausible guess.
This design separates four jobs that are often collapsed into one model call: parsing the message, establishing trust, extracting meaning, and deciding whether the application can use the result.
Parsing email is not extracting meaning#
An email parser can tell you that a message has an HTML part, a plain-text alternative, three attachments, and a subject. It cannot tell you whether INV-1049 is an invoice number, whether $1,842.17 includes tax, or whether "net 30" is a usable due date.
The distinction matters because the failure modes differ.
MIME parsing is structural. It deals with encodings, multipart boundaries, filenames, and content types. Semantic extraction is evidentiary. It maps what the message says into fields your software will use.
Do not ask the model to repair a broken MIME parser. Do not ask the parser to infer business meaning. Give each layer a narrow contract:
- The parser selects and decodes the content.
- The trust layer reduces it to screened, visible text.
- The extractor maps that text to a schema.
- The consumer validates the event and applies business rules.
Start with the consumer's contract#
Suppose an accounts-payable workflow needs an invoice number, supplier, total, and currency. It can use a due date when the source provides enough information, but it must not invent one.
{
"type": "object",
"additionalProperties": false,
"properties": {
"invoice_number": {
"type": "string",
"description": "Invoice identifier exactly as shown by the supplier"
},
"vendor_name": {
"type": "string",
"description": "Supplier name printed on the invoice"
},
"total": {
"type": "number",
"description": "Final amount due, including tax when the document says tax is included"
},
"currency": {
"type": "string",
"description": "ISO 4217 currency code supported by evidence in the message"
},
"due_date": {
"type": "string",
"description": "ISO 8601 date. Omit unless the date is stated or can be derived from both an issue date and explicit payment terms"
}
},
"required": ["invoice_number", "vendor_name", "total", "currency"]
}
This schema makes the business boundary visible. A message without a due date can still match. A message without an invoice number cannot.
Postfleet accepts object-root schemas with additionalProperties set to false. Recursive schemas and $ref are not supported. The restricted shape is intentional: it keeps constrained extraction predictable and prevents fields outside the contract from drifting into downstream code.
Require only decision-making fields#
Every required field raises the evidence threshold. If purchase_order_number is required but half of your valid suppliers omit it, half of your real invoices may become no-match results.
Required should mean "the workflow cannot safely proceed without this value." Optional should mean "use it when the source supports it." It should not mean "the model may fill this from general knowledge."
Describe evidence, not writing style#
Field descriptions should settle ambiguity in the source. "Final amount due" is more useful than "a nicely formatted total." "Exactly as shown by the supplier" prevents normalization that your reconciliation system may not expect.
Keep business constraints in application code when possible. A model can extract a stated total. Your ledger should decide whether that total exceeds an approval threshold.
Keep extraction behind the trust gate#
Email is untrusted even when the desired output is JSON. A schema constrains output shape; it does not stop the source text from trying to change the extraction task.
The safe order is:
MIME parse
-> hidden-content sanitization
-> visible-text cleaning
-> prompt-injection screening
-> schema-constrained extraction
-> webhook or API response
-> consumer validation
When PDF text is available to the inbound pipeline, Postfleet appends it to the cleaned body before screening. Extraction runs only after deterministic and model-based screens return low risk. The pre-sanitization body is not sent to webhooks or returned by read_email.
For the threat model behind that sequence, read Email prompt injection: how to secure an AI agent that reads email.
Use schema-constrained output, then check it#
Modern model APIs can constrain generation to a JSON Schema. Anthropic's structured output documentation explains that output_config.format uses constrained decoding to return parseable, schema-compliant JSON for normal completions.
That removes a large class of formatting failures, but it does not prove that an extracted value is true. It also does not remove every response case. A refusal or token-limit stop can take precedence over the schema.
Postfleet wraps the mailbox schema with three decision fields:
{
"classification": "invoice",
"confidence": 0.96,
"matched": true,
"data": {
"invoice_number": "INV-1049",
"vendor_name": "Northline Components",
"total": 1842.17,
"currency": "USD",
"due_date": "2026-08-11"
}
}
matched is the guard against fabricated defaults. When a required, specific value is genuinely absent, the extractor returns matched: false. Postfleet then exposes extraction: null instead of an object filled with empty strings, zeroes, or guesses.
Treat confidence as a routing hint, not a calibrated probability that every field is correct. A high score does not replace schema validation, cross-checks, or human review for high-value decisions.
Preserve failure states in the event#
A clean API should not force consumers to infer why extraction is null. Postfleet's webhook contract keeps pipeline status separate from the extracted value.
comprehension_status |
extraction |
Meaning | Consumer action |
|---|---|---|---|
complete |
Object | The message matched and extraction completed | Validate the object, then apply business rules |
complete |
null |
The message did not supply required evidence for this schema | Route elsewhere or ignore as a non-match |
partial |
null |
Screening or extraction failed | Inspect extraction_error, retry only when the error is transient |
skipped_injection_risk |
null |
Screening found high-risk content, so extraction did not run | Hold for review; do not send the body to another model as a workaround |
A successful invoice event looks like this:
{
"event_id": "evt_9f8c...",
"type": "message.received",
"message_id": "m_123...",
"mailbox_id": "b_456...",
"from": "billing@northline.example",
"subject": "Invoice INV-1049",
"body": "cleaned, sanitized message body",
"truncated": false,
"sanitization": [],
"comprehension_status": "complete",
"classification": "invoice",
"extraction": {
"invoice_number": "INV-1049",
"vendor_name": "Northline Components",
"total": 1842.17,
"currency": "USD",
"due_date": "2026-08-11"
},
"extraction_error": null,
"confidence": 0.96
}
The event is delivered at least once. Deduplicate on event_id before creating a payable, ticket, lead, or any other side effect. The webhook documentation includes the signature verifier, retry schedule, and dead-letter behavior.
Validate at the consumer boundary#
The producer's schema guarantee is useful. Your service should still validate data where it enters your domain. That check protects against version drift, a misconfigured mailbox, and code paths that did not originate in the extractor.
With a JSON Schema validator, the handler shape is small:
import Ajv from "ajv";
import invoiceSchema from "./invoice-schema.json" with { type: "json" };
const validateInvoice = new Ajv({ allErrors: true }).compile(invoiceSchema);
export function acceptInvoice(event: MessageReceivedEvent) {
if (event.comprehension_status === "skipped_injection_risk") {
return { action: "review", reason: "injection_risk" };
}
if (event.comprehension_status !== "complete") {
return { action: "retry_or_review", reason: event.extraction_error };
}
if (event.extraction === null) {
return { action: "ignore", reason: "schema_did_not_match" };
}
if (!validateInvoice(event.extraction)) {
return { action: "dead_letter", errors: validateInvoice.errors };
}
return { action: "create_payable", invoice: event.extraction };
}
Signature verification and event_id deduplication should happen before this function. Business checks happen after it. For an invoice, those checks might compare the sender domain with the vendor record, reject a currency the account does not use, or require approval above a total.
Schema design rules that hold up#
Use these rules when moving from a demo schema to a production one:
- Keep one schema focused on one downstream decision. A universal "all email" object becomes a collection of weak optional guesses.
- Require fields based on workflow safety, not on how complete an ideal document would be.
- State whether values should be copied, normalized, or derived.
- Use
additionalProperties: falseso new model output cannot silently become accepted application data. - Version breaking schema changes. Validate old and new events during the migration window.
- Test messages that almost match, including quotes with old invoice numbers, forwarded attachments, credits, statements, and invoices missing one required field.
- Record the original event ID and message ID beside the business object so a reviewer can trace the decision.
- Route low-confidence or high-value cases to review even when the JSON is valid.
What matters is the narrow, observable decision boundary between an unpredictable inbox and predictable application code.