How to parse email to JSON
To parse an email to JSON, split the MIME message into headers, body and attachments, then map the values you need to named, typed keys. You can write that mapping yourself in Python, or forward the email to a parsing inbox that posts finished JSON to your webhook within seconds.
What parsing an email to JSON actually means
There are two layers to the job, and most tutorials only cover the first one.
- Structural parsing. An email is a MIME document. It has headers (From, To, Subject, Date, Message-ID), one or more body parts (plain text, HTML) and optional attachments. Bodies may be encoded as base64 or quoted-printable and use different character sets. Turning that into a dictionary of headers, a text body and a list of files is well understood and every major language has a library for it.
- Semantic extraction. Your application does not want "the body". It wants
order_number,totalanddelivery_date, with the total as a number and the date in a fixed format. Finding those values in free text written by many different senders is the hard part.
A useful target looks like this, one object per email, the same keys every time:
{
"order_number": "NW-48213",
"customer_name": "Dana Ortiz",
"total": {"amount": 500.5, "currency": "USD"},
"delivery_date": "2026-10-21"
}
The rest of this guide shows how to get there on your own, and then how to get there with an inbox and a webhook.
The do-it-yourself route with Python
Python's standard library includes the email package, which handles the structural layer well. With the modern policy you get decoded text and attachments in a few lines:
import email
from email import policy
def parse_mime(raw_bytes):
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
body = msg.get_body(preferencelist=("plain", "html"))
attachments = []
for part in msg.iter_attachments():
attachments.append({
"filename": part.get_filename(),
"content_type": part.get_content_type(),
"size": len(part.get_payload(decode=True) or b""),
})
return {
"from": msg["From"],
"subject": msg["Subject"],
"message_id": msg["Message-ID"],
"date": msg["Date"],
"text": body.get_content() if body else "",
"attachments": attachments,
}
That already gives you valid JSON with json.dumps(parse_mime(raw)). It does not give you business fields. For those, the usual next step is pattern matching per sender:
import re
def extract_order(text):
m_num = re.search(r"Order (?:number|#)\s*:?\s*([A-Z]{2}-\d+)", text)
m_tot = re.search(r"Total\s*:?\s*\$([\d,]+\.\d{2})", text)
return {
"order_number": m_num.group(1) if m_num else None,
"total": float(m_tot.group(1).replace(",", "")) if m_tot else None,
}
This works for one sender with a stable layout. It is a reasonable choice when you control the sending system, or when you only ever receive one kind of email from one source.
Where the do-it-yourself parser starts to cost time
The structural code above rarely breaks. The extraction code breaks often, for predictable reasons:
- Wording drift. "Total" becomes "Amount due" or "Order total (incl. tax)". Each variation needs a new pattern.
- HTML tables. Values sit in table cells, and converting HTML to text can put the label and the value on different lines.
- Replies and forwards. A forwarded email contains the original message plus a header block, and sometimes several quoted replies with older, wrong values.
- Attachments. The data you need is often in a PDF, which needs a separate text extraction step, and scanned PDFs need OCR.
- Types. Dates arrive as "Oct 21", "21/10/2026" and "2026-10-21". Amounts arrive with currency symbols, thousands separators and different decimal marks.
- Operations. You still need an address to receive mail, storage for the raw message, retries when your code fails, and protection against processing the same email twice.
None of this is impossible, and for a single stable source it may never matter. When the number of senders grows, the pattern file grows with it. This is the point where teams usually look at an email parser instead of extending their own.
The inbox and webhook route in ten minutes
The alternative is to hand both layers to a service and receive finished JSON. With ParsedMail the steps are:
- Create an inbox in the dashboard. You get an address such as
[email protected]. - Describe the fields in plain English, for example "order number, customer name, products with quantity and price, total, delivery date". This becomes a typed schema you can edit, with types such as string, money, date and table.
- Test it against a sample email in the dashboard and adjust field names or types.
- Add a webhook endpoint (an https URL on a public host) and copy its signing secret, which starts with
whsec_. - Forward real mail with a Gmail filter, an Outlook rule, a mail server alias or a BCC.
Each email then arrives at your endpoint as a POST with a JSON body. The parsed values are under data, and the per-field confidence scores are under confidence:
{
"id": "evt_8Qm2...",
"type": "email.parsed",
"created": "2026-10-14T09:12:44Z",
"data": {
"id": "eml_3Hc...",
"object": "email",
"status": "parsed",
"subject": "Order #NW-48213 confirmed",
"data": {
"order_number": "NW-48213",
"total": {"amount": 500.5, "currency": "USD"},
"delivery_date": "2026-10-21"
},
"confidence": {"order_number": 0.99, "total": 0.98, "delivery_date": 0.94},
"confidence_avg": 0.97,
"needs_review": false,
"warnings": []
}
}
The full field list is in the webhook documentation, and the quickstart in the docs walks through the dashboard screens.
A minimal receiver that verifies the signature
Every delivery carries a ParsedMail-Signature header in the form t=<unix>,v1=<hex>. The v1 value is an HMAC-SHA256 of the string "<t>.<raw body>" using your endpoint secret. Verify it against the raw bytes, before you parse the JSON:
import hmac, hashlib, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["PARSEDMAIL_WEBHOOK_SECRET"].encode()
@app.post("/hooks/parsedmail")
def receive():
raw = request.get_data()
header = request.headers.get("ParsedMail-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
abort(400)
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(400)
event = json.loads(raw)
email = event["data"]
save_order(email["id"], email["data"]) # your code, keyed by email id
return "", 204
Three details matter in production. Reject timestamps older than five minutes. Respond quickly, because the sender waits at most 15 seconds and does not follow redirects. Store by the email id or the Idempotency-Key header so a retried delivery does not create a second record. The article on inbound email webhooks covers retries and queueing in more depth.
Parsing an email you already have
Sometimes the email is already in your system, for example in a support tool or a database. You do not need an inbox for that. The synchronous endpoint takes the email and a schema and returns the result in the same request:
import os, requests
resp = requests.post(
"https://parsedmail.com/api/v1/parse",
headers={"Authorization": f"Bearer {os.environ['PARSEDMAIL_KEY']}"},
json={
"description": "invoice number, due date, total",
"email": open("invoice.eml").read(),
},
timeout=60,
)
result = resp.json()
print(result["data"], result["confidence_avg"])
You can pass an existing schema id instead of a description, and the raw MIME as raw instead of pasted text. Each call counts as one email on your plan. Errors come back as {"error": {"type", "message"}}, for example empty_email or plan_limit. The email parsing API page summarizes the endpoints.
Designing a schema that holds up
Whichever route you take, the shape of the JSON decides how much work your application does later. A few habits keep a schema stable as more senders arrive:
- Name fields after meaning, not after labels. Use
total, notamount_duebecause one sender writes "Amount due". The next sender will write something else. - Pick the strictest type that fits. A total is money, a quantity is an integer, a status with three possible values is an enum. Strict types catch extraction mistakes early, because a value that cannot be converted becomes
nullwith a warning instead of a wrong string. - Use tables for repeating data. Line items, passengers or shipments belong in one table field with typed columns, not in
item_1,item_2and so on. - Mark what is required. If an order without an order number is useless to you, say so. A missing required field drops the confidence for that field to 0 and can send the email to review.
- Change it deliberately. Schemas in ParsedMail are versioned. Every email records the
schema_versionit was parsed with, and you can replay stored emails after a change. A replay of an email that was already counted is not counted again.
Keep the field list short at first. It is easier to add a field after a week of real mail than to remove one your downstream code already depends on. You can export each schema as JSON Schema (draft 2020-12) and validate payloads against it in your own tests.
Comparing the two routes
| Concern | Your own parser | Inbox and webhook |
|---|---|---|
| Receiving mail | You run or rent an inbound address | Inbox address included, plus tags supported |
| MIME decoding | Standard library, reliable | Handled |
| Field extraction | Patterns per sender and field | Schema in plain English, read by a language model |
| Types | Your own conversion code | Money, date, table and other types coerced before delivery |
| Attachments | Separate PDF and OCR tooling | PDF, CSV and other text files read; scans read on paid plans |
| Uncertain values | Silent wrong values or exceptions | Confidence per field, null when missing, review queue on Growth and Scale |
| Delivery | Your own retries and dedup | Signed POST, five retries, dedup of the same email within 30 days |
| Cost | Your time and hosting | From $49 a month, or $24 a month billed yearly |
If you parse one predictable notification from your own system, write the code. If you receive many layouts from senders you do not control, the second route usually pays for itself in maintenance time.
Frequently asked questions
Can Python convert an email to JSON on its own?
email package gives you headers, decoded bodies and attachments, which you can dump as JSON. Business fields such as totals or order numbers still need extraction code on top.
How do I keep the same keys for every email?
null rather than left out.
What happens if my webhook is down?
Do I need an inbox to use the API?
POST /v1/parse takes the email text or raw MIME plus a schema id, field list or description and returns the result synchronously.