Skip to content

FlightWrapped: Turning Gmail into a 3D Travel Story with On-Device AI

How I built FlightWrapped, a privacy-first flight visualizer that runs an LLM in your browser to extract flights from Gmail exports and render them on a 3D globe.

I shipped FlightWrapped today and I wanted to write about how it works under the hood. In short: you upload a Gmail export, and an LLM running entirely in your browser extracts your flight history, plots it on an interactive 3D globe, and surfaces cool insights about your overall travel journeys. No servers. No databases. No API keys. Your email data never leaves your device.

Why I Built This

I travel a lot. Over the years I’ve taken hundreds of flights for work and personal trips, and all those confirmation emails are just sitting in my Gmail collecting dust. I wanted to see my travel history on a map. Not just a list of airports, but a real visual story: the arcs across continents, the hub airports I keep returning to, the random one-off trips I’d forgotten about.

Spotify Wrapped does this beautifully for music. I wanted the same thing for flights. So I built it.

The catch is that flight confirmation emails are messy. Every airline formats them differently. Booking platforms throw in their own templates. There’s no standard schema, no universal JSON-LD, nothing consistent. You can’t just regex your way through a decade of flight emails and expect good results.

That’s where the on-device LLM comes in.

The Google Takeout Decision

My first instinct was to use the Gmail API directly. OAuth, read-only scope, pull down the emails, done. I had a working prototype using PKCE auth flow within a day.

Then I discovered that gmail.readonly is classified as a “restricted” scope by Google. To let anyone beyond my test accounts actually sign in, I’d need to pass a CASA Tier 2 security assessment. The cost? Somewhere between $4,500 and $75,000 depending on the assessor.

For a free, open-source side project. Yeah, no.

Google Takeout turned out to be the pragmatic choice. Users export their Gmail as an .mbox file, and FlightWrapped parses it locally. It’s not as seamless as OAuth, but it costs nothing to ship and the privacy story is actually better since the data never touches any server at all.

Parsing Mbox Files in the Browser

An mbox file is basically a giant text file where each email starts with a From line (that’s the literal word “From” followed by a space, at the beginning of a line). Simple format, potentially huge files. My personal Gmail export is several gigabytes.

Loading the entire thing into memory would be a terrible idea. So FlightWrapped uses a streaming parser that reads the file through the browser’s File.stream() API and processes one email at a time. Memory stays constant regardless of file size. The parser handles mboxrd escaping (where >From in email bodies gets unescaped back to From ), splits on the envelope sender lines, and hands each raw message off to the pipeline.

All of this runs in a Web Worker. The main thread stays responsive while the worker churns through thousands of emails in the background. File buffers are transferred to the worker using structured cloning, so there’s no copying overhead either.

Filtering Before Extraction

Here’s a practical problem: most people have tens of thousands of emails in their inbox. Running an LLM on every single one would take hours. The vast majority are newsletters, shopping receipts, and spam that have nothing to do with flights.

FlightWrapped maintains a whitelist of 190+ sender domains that are likely to contain flight information. Airlines (United, Delta, Emirates, Singapore Airlines, the whole lot), booking platforms (Expedia, Kayak, Google Flights, Hopper), travel management tools (Concur, Navan), and loyalty programs. After parsing each email with postal-mime (a proper RFC 5322 MIME parser), I check the sender domain against this list. Only matching emails go to the LLM.

This single optimization cuts the extraction workload by roughly 95% for a typical inbox.

On-Device LLM Extraction

This is the part I’m most proud of. FlightWrapped runs Phi-3.5-mini (Microsoft’s 3.8B parameter model, quantized to 4-bit) entirely in the browser using WebLLM. The model weighs about 2 GB and gets cached in IndexedDB after the first download, so subsequent visits skip the network cost. We also call navigator.storage.persist() to make sure the browser doesn’t evict that cache.

For each filtered email, I truncate the body to 2,000 characters (flight details are almost always near the top of the email), construct a structured prompt asking for origin, destination, date, airline, and flight number, and let the model extract them as JSON. Temperature is set to 0.1 for near-deterministic output. Max tokens capped at 500 since we only need a small JSON object back.

Why not regex or heuristic-based extraction? Because the long tail is brutal. I spent a weekend trying to write parsers for different airline email formats and quickly realized it’s a losing game. Delta’s confirmation looks nothing like Ryanair’s. Expedia’s itinerary format changed three times in the last five years. And that’s before you get into localized templates, multi-leg itineraries, and codeshare bookings. An LLM handles all of this naturally without any of that brittle pattern matching.

To guard against hallucinations, every extracted IATA airport code gets validated against a database of 5,500+ airports. If the model returns a code that doesn’t exist, we toss it. The combination of low temperature and post-extraction validation keeps quality high enough that I trust the results for my own data.

The Deduplication Problem

If you’ve ever booked a flight, you know what happens next. You get the booking confirmation. Then the itinerary email. Then the check-in reminder. Then the boarding pass. That’s potentially four emails for the same flight, sometimes from different senders (the airline and the booking platform).

Naive extraction would count that as four flights. Not great.

FlightWrapped deduplicates using a two-key strategy. The primary key is flight number plus date. If two extractions share the same flight number on the same day, they get merged. When flight numbers are missing (which happens with some budget carriers), we fall back to origin + destination + date as the key. Routes are also normalized as undirected pairs: JFK-LAX and LAX-JFK both become the same key, so round trips don’t inflate route counts.

The merge itself is confidence-based. We keep the higher-confidence extraction as the base and fill in any missing fields from the lower-confidence duplicate. Same flight, best available data.

Privacy by Design

I want to be upfront about the privacy model because it matters to me.

Nothing leaves your browser. FlightWrapped has no backend. There is no server, no database, no analytics, no tracking pixels. The Content Security Policy locks scripts down to the app’s own origin. The only external network requests go to Hugging Face (to pull the LLM model on first use) and GitHub’s CDN for the airport database.

Raw email content is never persisted anywhere. IndexedDB stores only the extracted flight records (origin, destination, date, airline, flight number) and a timestamp of when you last imported. If you clear your browser storage, everything disappears.

The app ships as a PWA with a service worker, so after the first load you can run it fully offline. The LLM model itself is cached with a CacheFirst strategy. Once you’ve downloaded it once, you don’t need the internet again.

The Globe and the Fun Stuff

Once flights are extracted and deduplicated, FlightWrapped crunches the numbers. Distance calculations use the Haversine formula for proper geographic accuracy (no flat-earth approximations here). CO2 estimates use EPA emission factors. Total flight hours assume a 500 mph average cruise speed.

The 3D globe uses react-globe.gl (Three.js under the hood) with a night-sky Earth texture showing city lights. Flight arcs animate as dashed blue lines. Airports appear as gold dots. The globe auto-rotates and zooms to center on your flight data when the dashboard loads. I had to be careful about WebGL context cleanup on unmount, calling renderer.dispose() and forceContextLoss() to prevent GPU memory leaks when navigating between views.

I also built a set of “travel archetypes” loosely inspired by those personality quizzes people love sharing. Based on your patterns, you might be “The Explorer” (10+ unique airports, no single dominant route), “The Commuter” (one route making up more than 40% of your flights), “The Road Warrior” (30+ flights total), or “The Long Hauler” (average flight distance over 2,000 miles). There are conditional insights too, like “Weekend Warrior” if over half your flights land on Fridays, Saturdays, or Sundays.

Small touch. But honestly, it’s the thing people ask about first.

What I’d Change

A few honest notes on what I’d do differently.

The airport database is a beefy 880 KB JSON file that currently loads eagerly in the main bundle. It should be lazy-loaded only when flight data actually exists. Straightforward fix, just haven’t gotten around to it.

WebGPU inference works best when the browser is in crossOriginIsolated mode, which requires specific HTTP headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy). GitHub Pages doesn’t let you set custom headers, so Chrome falls back to WASM-based CPU inference. Everything still works, just slower than it could be. Deploying to a platform with header control (Cloudflare Pages, Vercel, even a simple Nginx setup) would unlock the full GPU path.

I’d also love to build a “story mode” that walks you through your travel history year by year in a Wrapped-style card sequence. The data pipeline already supports it. It’s really just a UI project at this point.

Try It

FlightWrapped is free and open source. You can try it right now with sample data (no file upload required) or bring your own Gmail export.

samsel.github.io/FlightWrapped

If you’re curious about the code, it’s all on GitHub.

AI Browser React Privacy WebLLM


Next
GPT Car: How I Made ChatGPT Drive My Son’s Toy Car

Related Posts