DEV Community

Cover image for I Built an Offline Job Hunt Tracker That Runs a Local LLM on My Inbox
Naheel Muhammed
Naheel Muhammed

Posted on Edited on

I Built an Offline Job Hunt Tracker That Runs a Local LLM on My Inbox

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission 👻🌼

Your job search shouldn't be someone else's training data.

Job Mail Tracker is a local-first email assistant for job hunters. It reads your exported inbox (.eml files), classifies every message with an open-weight model running on your machine, and turns it into an application tracker that tells you who to nudge today. No cloud, no API key, no telemetry, it doesn't phone home.

What I Built

Here's what it does, four jobs in one loop:

  1. Sort labels every mail: application confirmation, recruiter outreach, interview invite, rejection, offer, follow-up, platform promo (job alerts, hidden by default), noise
  2. Track maintains tracker.csv: company, role, status, last contact date
  3. Nudge flags applications that went quiet after 7 days
  4. Sync (optional), python main.py --sync pulls new mail over IMAP: received into inbox/, sent into sent/, so the follow-up you wrote stops the nudge clock too

And you don't run a server to see any of it: ui/index.html opens from file://, no build step, no setup. It ships status filters (all (252) - unfiltered (602) - sent (264) - followup (188) - applied (210)), waiting-day counters, direction badges (sent / received), and one-click deep links that jump to the exact mail in Gmail.

I ran it on my own mailbox: 602/602 mails classified (100%) - 0 pending -> 272 tracker rows.

Demo

Dashboard overview: status pills, direction badges, waiting days

AI status line and follow-up view

The launch video above shows the whole loop: the hook, the dashboard, python main.py counting up to 602/602 locally, and the row that's gone quiet for 12 days. Watch that last one, it's the whole point.

Prefer to try it yourself? The README gets you from zero to a dashboard in three commands:

ollama pull llama3.2:3b # once: local model
# drop .eml files into inbox/
python main.py # parse -> classify (local) -> tracker.csv -> report
# then double-click ui/index.html
Enter fullscreen mode Exit fullscreen mode

Code

👉 github.com/naheel0/job-mail-tracker, 32 commits, 18 source files, zero dependencies (Python stdlib only).

core/ports.py interfaces services depend on (Classifier, Store)
core/models.py EmailRecord contract
services/ pure logic, no I/O, no network, no files
adapters/ eml_parser, llm_client, imap_fetch, csv_store
prompts/classify.txt the classification prompt
ui/index.html the dashboard (plain HTML/JS)
main.py wires ports -> adapters, runs the pipeline
Enter fullscreen mode Exit fullscreen mode

The heart of it is 18 lines:

class Classifier(Protocol):
 def classify(self, record: EmailRecord) -> EmailRecord:
 """Return the record with label/company/role filled in."""
 ...

class Store(Protocol):
 def save(self, records: list[EmailRecord]) -> None:
 """Persist records (CSV now, SQLite later)."""
 ...
Enter fullscreen mode Exit fullscreen mode

Services depend on the protocols, adapters implement them. That means swapping the LLM, the storage, or the mail source is one adapter file, and the pipeline never knows.

How I Built It

LLM provider as config, not code. adapters/llm_client.py is 118 lines of stdlib HTTP against any OpenAI-compatible endpoint. Out of the box it points at local Ollama, so nothing leaves your machine, but three env vars retarget it:

LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_MODEL=llama-3.1-8b-instant
LLM_API_KEY=gsk_...
Enter fullscreen mode Exit fullscreen mode

No provider is hardcoded. Model name, endpoint, key, it's all env config. That's exactly what the "Open-Source AI" theme asks for: the model is a swappable part, not a dependency.

Classify once, cache forever. Every verdict lands in .state.json. When you re-run the pipeline you don't need the model or the internet: 602 mails cost one inference pass, then the cache owns them.

CSV over SQLite. I considered SQLite and rejected it: the data is a handful of columns and a few hundred rows, csv ships in the stdlib, and you keep a file you can edit and diff by hand. The Store port is the escape hatch, if it ever needs SQL, that's one adapter.

The sent-mail clock fix. My first nudge logic measured "days since last contact" from received mail only, so every follow-up I wrote failed to stop my own timer, and the app kept nagging me to reply to myself. The fix: --sync fetches sent/ too, and each tracker row records the direction of the mail that set last_contact. The dashboard shows sent vs received badges so you can see who owes whom a reply.

Canonical keys for the merge. The same employer shows up as Glarus Technology, glarustech.com, and GLARUS-TECH. Row identity strips case, punctuation, and domain suffixes from company + role, then collapses variants into one row (newest last_contact wins on collisions). No fuzzy matching, near-misses stay separate rather than silently merging two real companies.

Contact = correspondent. Multi-recipient To: headers broke naive parseaddr on Python 3.13, so I switched contact extraction to getaddresses, which always resolves to the other side of the conversation, never your own address. Sent mail shows who you wrote to; received mail shows who wrote to you.

A dashboard that never needs a server. main.py publishes ui/data.js on every run; the page loads it eagerly and renders lazily (25 rows per page, state in the URL hash). That's why double-click works, there's no fetch, the data is a plain script tag. Row and subject links build Gmail search queries (from:(peer) subject:"..." after:... before:...) so every row jumps to the real mail.

Every claim in this post is verified: 602/602 classified, 272 rows, 0 blank direction cells, 0 mislabeled rows, file:// double-click tested, contrast WCAG AA, zero console errors.

Why Open Innovation Matters Here

  • Works fully offline, you don't need the internet after setup
  • Private by construction, your job search data never leaves your machine; .env (with mail credentials) is gitignored and never committed
  • Free to run, I paid nothing for 602 classification calls
  • Swappable, any open-weight model, any OpenAI-compatible provider, any IMAP mail host: configuration, not code

That last point is the real "open" part. The challenge's spirit, building on open models and open standards instead of rented black boxes, is literally the architecture: protocols in core/, adapters at the edges, model of your choice in between. If the AI layer vanished tomorrow, you'd still have a tracker that works on its cache.


*Built solo for Hacktoberfest 2026 - DEV Week 4, "Ghost in the Machine" *

Top comments (0)