Mailsift: Extracting structured data from email messages
As a casual user of Gmail, I find its ability to recognize when I’ve received tickets, invoices, and flight confirmations, and to display this information in a structured way, very useful. Flights appear directly in Google Calendar, parcel delivery statuses are tracked, and reservations are easy to find at a glance.
However, my main email address is not Gmail, and I don’t want to migrate to Gmail for a number of reasons (privacy, data sovereignty, vendor lock-in). At the same time, I missed having that automatic extraction: having flight and train bookings appear in my calendar without manual copy-pasting, keeping track of incoming parcels, and having invoices neatly organized.
JSON-LD and schema.org
There is an established standard format for structured data called JSON-LD, which is used by Google and other search engines to extract structured data from web pages. JSON-LD represents linked data using JSON, and it can be embedded directly in HTML documents.
Email messages can also contain structured data in the form of JSON-LD, which can link the information in the email to external data sources. There are standardized schemas for structured data in email messages, such as the schema.org vocabulary, which defines types and properties for describing entities including flight reservations, train trips, hotel bookings, parcel deliveries, and invoices.
For example, an email containing a flight reservation might embed the following JSON-LD snippet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | { "@context": "https://schema.org", "@type": "FlightReservation", "reservationNumber": "ABC123", "underName": { "@type": "Person", "name": "John Doe" }, "reservationFor": { "@type": "Flight", "flightNumber": "AA123", "departureAirport": { "@type": "Airport", "name": "John F. Kennedy International Airport" }, "arrivalAirport": { "@type": "Airport", "name": "Los Angeles International Airport" }, "departureTime": "2026-11-01T10:00:00-05:00", "arrivalTime": "2026-11-01T13:00:00-08:00" } } |
When this is present, reading email data is clean and straightforward. The reality, however, is that only a fraction of vendors actually include schema.org markup in their emails. Most airlines, train operators, couriers, and web shops still send regular HTML or plain-text messages with arbitrary layouts that change over time.
Mailsift
To bridge this gap on my own infrastructure, I built mailsift. Written in Rust, it is a daemon and CLI tool that monitors incoming email, extracts structured information, and routes it to standard files and services.
Rather than locking data into an internal database, mailsift embraces the Unix philosophy: it delegates extraction to small, isolated programs and files the resulting structured artifacts into standard formats and protocols:
- Calendar events & reservations: Turned into iCalendar (.ics) events and pushed directly to a CalDAV server (such as Xandikos) or stored in a local directory.
- Bills & invoices: Extracted as structured JSON records, organized by year, and optionally synced to a personal finance manager like Firefly III.
- Parcels: Extracted as delivery records keyed by tracking number, merged across status update emails, and optionally registered with tracking APIs like 17track or Karrio.
- Tickets & boarding passes: Saved as PDFs, Apple Wallet passes (.pkpass), or images, accompanied by JSON metadata sidecars linking them to the booking.
- Receipts & orders: Filed as JSON records, synced over WebDAV, or forwarded via email.
How extractors work
Mailsift decouples the pipeline that handles emails from the logic that parses individual vendors. It discovers extractors from a directory of manifests.
The contract between mailsift and an extractor is deliberately simple:
- Input: The raw RFC822 message is fed to the extractor on stdin.
- Execution: The extractor runs in a fresh, temporary working directory, with a strict timeout (typically 10 seconds).
- Output: The extractor writes zero or more named artifact files into its working directory and exits 0.
Extractors can be written in any programming language. In practice, most are small Python scripts that parse HTML with regexes or BeautifulSoup, using a small helper library (mailsift_extractor) that handles Unicode normalization and HTML body stripping.
Filtering and DKIM verification
Spawning a subprocess for every single email in an active inbox would be wasteful. Each extractor therefore has a YAML manifest declaring prefiltering criteria:
1 2 3 4 5 6 7 8 9 10 | name: easyjet order: 50 from_domains: - easyjet.com - "*.easyjet.com" subject_regex: "(?i)easyJet booking reference" requires: - html require_dkim: - easyjet.com |
Before running an extractor, mailsift evaluates the message headers and MIME structure - possibly converting it to an IMAP filter. If the sender domain or subject doesn’t match, or the required MIME parts are missing, the extractor is skipped immediately.
Crucially, the manifest can also require a valid DKIM signature via require_dkim. Mailsift inspects the Authentication-Results: header produced by the receiving mail server. This guarantees that an extractor only processes messages that genuinely originated from the vendor’s domain, preventing forged emails from injecting fake calendar events or bills.
Generic vs. vendor extractors
Extractors fall into two categories:
- Generic extractors: schema-ld inspects the HTML for any <script type="application/ld+json"> blocks conforming to schema.org reservations. Another extractor, ics-passthrough, extracts standard iCalendar attachments.
- Vendor-specific extractors: For vendors that don’t include structured markup, dedicated extractors parse the message text. For instance, the easyjet extractor looks for booking references and flight tables, producing a standard FlightReservation artifact:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | reservation = { "@context": "https://schema.org", "@type": "FlightReservation", "reservationNumber": f"easyjet-{reference}-{flight_no}", "reservationFor": { "@type": "Flight", "flightNumber": flight_no, "departureAirport": {"@type": "Airport", "name": origin}, "arrivalAirport": {"@type": "Airport", "name": dest}, "departureTime": dep.isoformat(), "arrivalTime": arr.isoformat(), }, } Path(f"{slug}.reservation.json").write_text(json.dumps(reservation)) |
The collection of extractors is maintained in a separate repository, mailsift-extractors, and currently includes nearly 60 extractors covering airlines (British Airways, KLM, EasyJet, Transavia, Eurostar), transport (NS, SNCF Connect, FlixBus), package carriers (Royal Mail, DPD, DHL, UPS, Evri, PostNL), online orders, and utility bills.
Handling forwarded mail
In real life, travel bookings or receipts are often forwarded by family, friends, or colleagues. By default, forwarded mail would fail sender checks, and DKIM signatures on inline forwards are broken by mail clients.
Mailsift handles this via a trusted_forwarders configuration. When an email arrives from a trusted forwarder, mailsift unwraps the forwarded message:
- Attachment forwards: If the original email was attached as message/rfc822, mailsift extracts the original message and re-evaluates the vendor’s DKIM signature on the inner message.
- Inline forwards: Mailsift reconstructs a synthetic RFC822 message from the client’s forwarded quotation header block and quotes, deriving a deterministic Message-ID.
This means hotel confirmations or flight tickets forwarded by a partner still end up in the family calendar automatically.
Running mailsift
Mailsift fits into existing email setups in several ways:
Dovecot Sieve
For local delivery, Sieve provides an elegant integration using the sieve_extprograms plugin. Because Sieve runs during local delivery after Dovecot has validated DKIM and added Authentication-Results:, require_dkim checks work as expected:
1 2 | require ["vnd.dovecot.pipe"]; pipe :copy "mailsift"; |
The :copy flag ensures the email continues to the user’s inbox while a copy is piped through mailsift.
IMAP watching
If you don’t run your own mail server, or want to process mail on an existing remote mailbox, imap-scan connects over IMAP:
$ mailsift imap-scan imaps://[email protected]/INBOX \
--password-file ~/.config/mailsift/imap.pass \
--watch
With --watch, mailsift uses IMAP IDLE (RFC 2177) to wait for new messages and process them as they arrive. It supports standard passwords, Kerberos (SASL GSSAPI), and OAuth2 (XOAUTH2) for providers like Gmail and Microsoft 365 via an interactive mailsift imap-auth flow.
Other modes
- Postfix Milter: mailsift milter runs as an MTA filter, processing messages in real-time as they pass through Postfix.
- Maildir scan: mailsift maildir-scan walks an on-disk Maildir folder, useful for backfilling historical mail.
- Replay: mailsift replay message.eml runs extractors on a single saved message file, which is invaluable when writing or testing an extractor.
Web UI and REST API
In addition to routing data to CalDAV and disk, mailsift includes an optional read-only web dashboard (mailsift web):
$ mailsift web --listen 127.0.0.1:8088
The dashboard provides an overview of upcoming trips, arriving packages, unpaid bills, and recent orders. It rescans the artifact directories on request, so it stays in sync with incoming mail without needing a separate database. JSON endpoints (such as /api/parcels.json or /api/bills.json) are also available for custom status scripts or home automation dashboards.
Status and getting involved
Mailsift has been running reliably on my mail server for over a year, and having flights, hotel bookings, and train journeys seamlessly sync into my calendar without relying on proprietary cloud services has been very satisfying.
The project is free software under the GPL-3.0:
- mailsift (the Rust core daemon and CLI)
- mailsift-extractors (the collection of extractor scripts)
Adding support for new vendors only requires writing a small YAML manifest and a Python script with a couple of regexes. If you have vendors you’d like to extract data from, contributions of new extractors are very welcome!