Skip to content
ParsedMail

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.

  1. 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.
  2. Semantic extraction. Your application does not want "the body". It wants order_number, total and delivery_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:

  1. Create an inbox in the dashboard. You get an address such as [email protected].
  2. 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.
  3. Test it against a sample email in the dashboard and adjust field names or types.
  4. Add a webhook endpoint (an https URL on a public host) and copy its signing secret, which starts with whsec_.
  5. 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, not amount_due because 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 null with 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_2 and 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_version it 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

ConcernYour own parserInbox and webhook
Receiving mailYou run or rent an inbound addressInbox address included, plus tags supported
MIME decodingStandard library, reliableHandled
Field extractionPatterns per sender and fieldSchema in plain English, read by a language model
TypesYour own conversion codeMoney, date, table and other types coerced before delivery
AttachmentsSeparate PDF and OCR toolingPDF, CSV and other text files read; scans read on paid plans
Uncertain valuesSilent wrong values or exceptionsConfidence per field, null when missing, review queue on Growth and Scale
DeliveryYour own retries and dedupSigned POST, five retries, dedup of the same email within 30 days
CostYour time and hostingFrom $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?

Yes for the structure. The standard 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?

Define a schema. In ParsedMail the keys come from the schema and keep their order, and a value that is not in the email is returned as null rather than left out.

What happens if my webhook is down?

Deliveries that do not get a 2xx response are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After that the delivery is marked failed and can be replayed from the log.

Do I need an inbox to use the API?

No. POST /v1/parse takes the email text or raw MIME plus a schema id, field list or description and returns the result synchronously.

Is there a free plan?

There is no free plan. You can try a one-time demo on the homepage without an account and a one-time sandbox of 10 emails after confirming your address.

More from the blog

See your own email as JSON

Paste one into the demo, describe the fields and look at the result. No account needed.