Tutorials / Structured data

Tutorial · Python or TypeScript · 15 minutes

Turn emails into typed Pydantic objects

You already have a model for the data you want. Give it to a parser and every email arrives at your webhook as that model, nested lists included, checked against your schema before it's delivered. No regexes, no prompt to maintain, no guessing whether a field is there.

▶ Video walkthroughs on YouTube

  1. Your modelPydantic or Zod
  2. Schema parserits JSON Schema, versioned
  3. Email arrivesread by Claude in your shape
  4. Checkedevery rule in your schema
  5. Your objectvalid: true, typed

1. Describe what you want

Write the model as you would for any API. Constraints are part of the contract: they're checked on every email.

# models.py
from datetime import date
from pydantic import BaseModel, Field

class LineItem(BaseModel):
    sku: str = Field(max_length=12, description="The product code")
    qty: int = Field(ge=1)
    unit_price: float = Field(ge=0)

class Invoice(BaseModel):
    invoice_number: str = Field(pattern=r"^INV-\d+$")
    supplier: str
    total: float = Field(ge=0, description="The amount due, in pounds")
    due_date: date | None = None          # optional: not every invoice says
    po_number: str | None = None
    lines: list[LineItem]

Say what can be missing. A required field that can't be null forces the AI to put something there. Make a field optional (| None = None, .nullable()) if some emails won't have it: you'll get null instead of a guess. We warn you about required fields when you save.

2. Make it a parser

The CLI reads your model and creates a schema parser named after it. Run it again after changing the model and you get a new version; the old ones are kept.

export EMAIL_PARSER_API_KEY=ek_live_…
npx @wildye/email-parser-cli schema push models.py:Invoice

✓ Created Invoice prs_8f2k… · schema v1

Prefer clicking? Paste the JSON Schema on the dashboard's Parsers → New parser → Your JSON Schema.

3. Try it on a real email

Save any invoice email as a .eml file and run it through the parser. Nothing is delivered; it uses one AI extraction.

npx @wildye/email-parser-cli schema test prs_8f2k… --eml invoice.eml
✓ valid: passed every check in the schema (v1)
{
  "invoice_number": "INV-20977",
  "supplier": "Lumen Office Supplies",
  "total": 312,
  "due_date": "2026-11-14",
  "lines": [{ "sku": "PLT-L-TERRA", "qty": 2, "unit_price": 156 }]
}

If the answer breaks your schema, Claude gets one more try with the reasons. If it still doesn't pass, you see exactly why, for example /lines/0/qty must be >= 1.

4. Use it for a domain

Choose the parser for one address, or for a whole domain (every address without its own parser):

curl -X PATCH https://ghostparse.com/v1/domains/dom_… -H "Authorization: Bearer $EMAIL_PARSER_API_KEY" \
  -H "Content-Type: application/json" -d '{"parser_id":"prs_8f2k…"}'

Each email to the domain now carries data.extracted:

"extracted": {
  "method": "schema", "schema_version": 1, "schema_sha256": "9f2c…",
  "valid": true, "errors": [],
  "data": { "invoice_number": "INV-20977", "supplier": "Lumen Office Supplies", "total": 312, … }
}

5. Receive it as your type

Verify the webhook, then turn data back into your model. It's checked again on your side, so a parser pointed at a different schema can't slip through.

from fastapi import FastAPI, Request, Response
from email_parser import verify_webhook, WebhookVerificationError
from models import Invoice

app = FastAPI()

@app.post("/webhooks/email")
async def email_hook(request: Request):
    try:
        event = verify_webhook(await request.body(), request.headers, SECRET)
    except WebhookVerificationError:
        return Response(status_code=401)
    email = event.email
    if email.extracted_valid:
        invoice = email.extracted_as(Invoice)          # an Invoice, typed
        accounts.add_bill(invoice.supplier, invoice.total, invoice.due_date, invoice.lines)
    else:
        review_queue.add(email.subject, email.raw["extracted"]["errors"])
    return Response(status_code=200)

Good to know

  • Invalid answers still arrive, marked valid: false. Route them elsewhere with a routing rule, or handle them in code as above. Mail is never held because of a schema.
  • Descriptions help. Field(description=…) and .describe() reach the AI, as do the parser's instructions ("Amounts are in pounds").
  • Not supported: recursive models, dict[str, …] / z.record (use a list of objects with a key field), and references to other files.
  • Versions: every webhook says which schema_version it used, so you can deploy a model change before or after the parser change.
  • Cost: one AI extraction per email, including the retry.

Next