<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Naheel Muhammed</title>
    <description>The latest articles on DEV Community by Naheel Muhammed (@naheel0).</description>
    <link>https://dev.to/naheel0</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4212510%2F1468861b-c026-4a54-b6d8-e2ff851573a2.png</url>
      <title>DEV Community: Naheel Muhammed</title>
      <link>https://dev.to/naheel0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/naheel0"/>
    <language>en</language>
    <item>
      <title>I Built an Offline Job Hunt Tracker That Runs a Local LLM on My Inbox</title>
      <dc:creator>Naheel Muhammed</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:31:34 +0000</pubDate>
      <link>https://dev.to/naheel0/i-built-an-offline-job-hunt-tracker-that-runs-a-local-llm-on-my-inbox-5182</link>
      <guid>https://dev.to/naheel0/i-built-an-offline-job-hunt-tracker-that-runs-a-local-llm-on-my-inbox-5182</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Your job search shouldn't be someone else's training data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Job Mail Tracker&lt;/strong&gt; is a local-first email assistant for job hunters. It reads your exported inbox (&lt;code&gt;.eml&lt;/code&gt; files), classifies every message with an &lt;strong&gt;open-weight model running on your machine&lt;/strong&gt;, 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.&lt;/p&gt;



&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Here's what it does, four jobs in one loop:&lt;/p&gt;

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

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

&lt;p&gt;I ran it on my own mailbox: &lt;strong&gt;602/602 mails classified (100%) - 0 pending -&amp;gt; 272 tracker rows.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmiozt7k0ulgfycemyn4e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmiozt7k0ulgfycemyn4e.png" alt="Dashboard overview: status pills, direction badges, waiting days" width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6b3v606f8ph2boxmf0va.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6b3v606f8ph2boxmf0va.png" alt="AI status line and follow-up view" width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The launch video above shows the whole loop: the hook, the dashboard, &lt;code&gt;python main.py&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Prefer to try it yourself? The README gets you from zero to a dashboard in three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pull&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;llama3.2:3b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# once: local model&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# drop .eml files into inbox/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;main.py&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# parse -&amp;gt; classify (local) -&amp;gt; tracker.csv -&amp;gt; report&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# then double-click ui/index.html&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;👉 github.com/naheel0/job-mail-tracker&lt;/strong&gt;, 32 commits, 18 source files, &lt;strong&gt;zero dependencies&lt;/strong&gt; (Python stdlib only).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 -&amp;gt; adapters, runs the pipeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heart of it is 18 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailRecord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EmailRecord&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the record with label/company/role filled in.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
 &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EmailRecord&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Persist records (CSV now, SQLite later).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
 &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;LLM provider as config, not code.&lt;/strong&gt; &lt;code&gt;adapters/llm_client.py&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_MODEL=llama-3.1-8b-instant
LLM_API_KEY=gsk_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classify once, cache forever.&lt;/strong&gt; Every verdict lands in &lt;code&gt;.state.json&lt;/code&gt;. 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The sent-mail clock fix.&lt;/strong&gt; My first nudge logic measured "days since last contact" from &lt;em&gt;received&lt;/em&gt; 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: &lt;code&gt;--sync&lt;/code&gt; fetches &lt;code&gt;sent/&lt;/code&gt; too, and each tracker row records the &lt;strong&gt;direction&lt;/strong&gt; of the mail that set &lt;code&gt;last_contact&lt;/code&gt;. The dashboard shows &lt;code&gt;sent&lt;/code&gt; vs &lt;code&gt;received&lt;/code&gt; badges so you can see who owes whom a reply.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;A dashboard that never needs a server.&lt;/strong&gt; &lt;code&gt;main.py&lt;/code&gt; publishes &lt;code&gt;ui/data.js&lt;/code&gt; 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 (&lt;code&gt;from:(peer) subject:"..." after:... before:...&lt;/code&gt;) so every row jumps to the real mail.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why Open Innovation Matters Here
&lt;/h2&gt;

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

&lt;p&gt;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 &lt;code&gt;core/&lt;/code&gt;, 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.&lt;/p&gt;




&lt;p&gt;*Built solo for Hacktoberfest 2026 - DEV Week 4, "Ghost in the Machine" *&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>python</category>
    </item>
  </channel>
</rss>
