<?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: Mani</title>
    <description>The latest articles on DEV Community by Mani (@maninampally).</description>
    <link>https://dev.to/maninampally</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3965636%2F99318a76-1282-4f8b-a5ff-ee7e94cc7123.png</url>
      <title>DEV Community: Mani</title>
      <link>https://dev.to/maninampally</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maninampally"/>
    <language>en</language>
    <item>
      <title>How I Built an AI System That Turns Gmail Into a Job Tracker</title>
      <dc:creator>Mani</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:22:14 +0000</pubDate>
      <link>https://dev.to/maninampally/how-i-built-an-ai-system-that-turns-gmail-into-a-job-tracker-2an5</link>
      <guid>https://dev.to/maninampally/how-i-built-an-ai-system-that-turns-gmail-into-a-job-tracker-2an5</guid>
      <description>&lt;p&gt;&lt;strong&gt;tags:&lt;/strong&gt; &lt;code&gt;nextjs&lt;/code&gt; &lt;code&gt;ai&lt;/code&gt; &lt;code&gt;buildinpublic&lt;/code&gt; &lt;code&gt;webdev&lt;/code&gt; &lt;code&gt;showdev&lt;/code&gt;&lt;/p&gt;




&lt;p&gt;I missed an interview because Gmail buried it under a Netflix receipt.&lt;/p&gt;

&lt;p&gt;That mistake led me to build &lt;strong&gt;&lt;a href="https://hirecanvas.in" rel="noopener noreferrer"&gt;HireCanvas&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I was applying to 100+ jobs. Using one inbox for everything — bank statements, Wi-Fi recharges, LinkedIn alerts, and somewhere buried in that chaos, a Stripe interview invite I never saw in time. The opportunity was gone before I even knew it existed.&lt;/p&gt;

&lt;p&gt;The data was &lt;em&gt;right there&lt;/em&gt;. Every recruiter reply, every status update, every "we've decided to move on" — sitting in Gmail. Just not being read systematically.&lt;/p&gt;

&lt;p&gt;So I built a system that reads it for me.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[SCREENSHOT: Landing page hero — hirecanvas.in]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fh3uemyfdet8kvra7tz8u.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.amazonaws.com%2Fuploads%2Farticles%2Fh3uemyfdet8kvra7tz8u.png" alt=" " width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the full technical breakdown. Architecture. AI extraction pipeline. Queue design. Security. CI/CD. What I'd do differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Does in One Diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;YOUR INBOX (before HireCanvas)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📧 Bank statement
📧 Netflix invoice
📧 [MISSED] "Interview invitation — Stripe" ← gone
📧 Wi-Fi recharge
📧 "Your application to Google was received"
📧 LinkedIn: 10 new jobs for you
📧 "We're moving forward with other candidates" (Meta)
📧 "Interview scheduled — Vercel"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

AFTER HIRECANVAS SYNC
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ Stripe    → Interview    (pipeline updated, reminder set)
✅ Google    → Applied      (new entry created)
✅ Meta      → Rejected     (status updated)
✅ Vercel    → Interview    (pipeline updated, reminder set)
🚫 Bank / Netflix / Wi-Fi  → filtered, never hits AI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  By the Numbers
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Before the architecture — because metrics establish credibility fast.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database migrations&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions CI runs&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PRs merged to main&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ workers&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noise filter stages before AI&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI pipeline stages per email&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider fallback levels&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emails that actually reach AI&lt;/td&gt;
&lt;td&gt;~15-25% of inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per email extraction&lt;/td&gt;
&lt;td&gt;~$0.0008 (Gemini 2.5 Flash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget exceeded retry delay&lt;/td&gt;
&lt;td&gt;12 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual production deploys&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Next.js 15 App Router, React 19, TypeScript strict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Styling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tailwind CSS 4 — mint &lt;code&gt;#f0fdfb&lt;/code&gt; / teal &lt;code&gt;#14b8a6&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zustand 5 (client) + TanStack Query 5 (server)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Supabase — PostgreSQL + RLS + Realtime + Storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;BullMQ 5 + Redis (Valkey in production)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash / Claude Haiku 4.5 / GPT-4o / Ollama&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infra&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docker multi-stage, EC2, Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI/CD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Actions — lint → typecheck → audit → unit → e2e → deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Payments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stripe + webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AWS SES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AES-256-GCM token encryption, RLS everywhere, PII sanitization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Two decisions that shaped everything:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All AI work runs through a queue, never in an API route.&lt;/strong&gt; Syncing 500 emails takes minutes. Serverless timeouts would kill it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table view, not Kanban.&lt;/strong&gt; Every other job tracker uses drag-and-drop boards. A filterable table is faster to scan at 80+ applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[SCREENSHOT: Applications table — Netflix/Vercel/Meta/Google/Stripe at different stages]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fm1gf56trb41kvv8bd2ed.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.amazonaws.com%2Fuploads%2Farticles%2Fm1gf56trb41kvv8bd2ed.png" alt=" " width="799" height="431"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  System Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│           BROWSER (Next.js Client)           │
│   Zustand + TanStack Query (status polling)  │
└────────────────────┬────────────────────────┘
                     │ HTTPS / Server Actions
                     ▼
┌─────────────────────────────────────────────┐
│           NEXT.JS SERVER                    │
│   API Routes  |  Server Actions             │
└────────┬────────────────────┬───────────────┘
         │ reads/writes       │ enqueue job
         ▼                    ▼
┌─────────────────┐  ┌────────────────────────┐
│   SUPABASE      │  │   REDIS (BullMQ)       │
│   PostgreSQL    │  │   3 queues:            │
│   RLS + Auth    │◄─│   sync/extract/remind  │
│   Realtime      │  └──────────┬─────────────┘
└─────────────────┘             │
                    ┌───────────┼────────────┐
                    ▼           ▼            ▼
             ┌──────────┐ ┌──────────┐ ┌──────────┐
             │  SYNC    │ │EXTRACTION│ │ REMINDER │
             │ WORKER   │ │ WORKER   │ │ WORKER   │
             │          │ │          │ │          │
             │Gmail API │ │Gemini    │ │AWS SES   │
             │OAuth     │ │Claude    │ │Schedule  │
             │5-stage   │ │GPT-4o    │ │follow-ups│
             │filter    │ │Verifier  │ │          │
             └──────────┘ └──────────┘ └──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The key flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User hits &lt;strong&gt;Sync&lt;/strong&gt; (or daily cron fires at 10 PM)&lt;/li&gt;
&lt;li&gt;API route creates a BullMQ job and &lt;strong&gt;returns immediately&lt;/strong&gt; — no timeout risk&lt;/li&gt;
&lt;li&gt;Client polls via TanStack Query&lt;/li&gt;
&lt;li&gt;Supabase Realtime pushes live updates to &lt;code&gt;sync_status&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;User sees live progress indicator — no WebSockets needed&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[SCREENSHOT: Dashboard — KPI cards + Daily Sync Report panel]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fsbuc9daqfxgd6yq60xr7.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.amazonaws.com%2Fuploads%2Farticles%2Fsbuc9daqfxgd6yq60xr7.png" alt=" " width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Database Design
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;43 migrations. Here are the three tables with non-obvious design decisions.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;processed_emails&lt;/code&gt; — Dedup Without Storing Email Content
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_emails&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;app_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;gmail_message_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- SHA-256(sender + subject + snippet)&lt;/span&gt;
  &lt;span class="n"&gt;review_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'needs_review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;We never store email bodies. A SHA-256 hash is enough to detect duplicates. If hash exists: skip. No AI call. No DB write.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;job_status_timeline&lt;/code&gt; — State Machine as Append-Only Log
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;job_status_timeline&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;job_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;from_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;to_status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;trigger_source&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'ai_extraction' | 'manual' | 'csv_import'&lt;/span&gt;
  &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;evidence_quote&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- verbatim quote from the email&lt;/span&gt;
  &lt;span class="n"&gt;triggered_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Every status change is a new row, not an UPDATE. This gives full history for the timeline view — and enables insights like "this application sat at Interview for 12 days then went silent."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ai_usage&lt;/code&gt; — Per-User Cost Ledger
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ai_usage&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;app_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- 'classifier' | 'extractor' | 'verifier'&lt;/span&gt;
  &lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cost_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Every single AI call is logged with exact cost. This feeds the daily budget cap system.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full schema available in the &lt;a href="https://github.com/maninampally/hirecanvas" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gmail Sync Engine
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Incremental Sync — Gmail History IDs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Try incremental sync first (only what changed since last run)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastHistoryId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listFromHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastHistoryId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// History expired after ~30 days — fall back to query sync&lt;/span&gt;
      &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listFromQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Wide date ranges are sliced into 30-day chunks&lt;/span&gt;
&lt;span class="c1"&gt;// to prevent silent truncation from Gmail API limits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The 5-Stage Noise Filter
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Only ~15-25% of emails survive this. That's the entire cost model.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email arrives
     │
     ▼
[1] OUTBOUND CHECK     → sent mail? discard
     │
     ▼
[2] GMAIL LABELS       → PROMOTIONS/SOCIAL/FORUMS?
                          discard UNLESS known ATS domain
     │
     ▼
[3] SIZE GUARD         → HTML body &amp;gt; 50KB + not ATS domain?
                          discard (bulk newsletter)
     │
     ▼
[4] SHA-256 DEDUP      → hash(sender+subject+snippet) in DB?
                          skip (already processed)
     │
     ▼
[5] KEYWORD FAST-SKIP  → regex on subject + sender
                          newsletter pattern? discard
                          job pattern? proceed
     │
     ▼
  Enqueue for AI extraction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Full implementation: &lt;code&gt;src/lib/gmail/noiseFilter.ts&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3-Stage AI Extraction Pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;This is the most important engineering in the project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem with one LLM call:&lt;/strong&gt; hallucinations corrupt your data silently. A model invents a company name, misreads a rejection as an interview invite, returns confident garbage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution:&lt;/strong&gt; 3 stages. Each stage has one job. Stage 3 runs on a &lt;em&gt;different provider&lt;/em&gt; than Stage 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw Email
    │
    ▼
[SANITIZER]     Strip SSN, credit cards, API keys, passwords
                Log PII patterns fired → extraction_audit_log
    │
    ▼
[STAGE 1]       Relevance Classifier
CLASSIFIER      Model: Gemini 2.5 Flash
                Input: sender + subject + first 800 chars

                Output: {
                  is_job_lifecycle: boolean,
                  email_type: 'interview_invite' | 'rejection' | ...,
                  confidence: 0.0-1.0
                }
    │
    │ is_job_lifecycle = true (or ATS domain override)
    ▼
[STAGE 2]       Structured Extractor
EXTRACTOR       Model: Gemini 2.5 Flash
                Input: first 2500 chars of sanitized body

                Output: {
                  company, role, status, recruiter_name,
                  interview_date, salary_range, ats_vendor,
                  low_confidence_fields: string[]
                }
    │
    ▼
[STAGE 3]       Cross-Model Verifier
VERIFIER        Model: Claude Haiku 4.5 (if Stage 2 = Gemini)
                    OR GPT-4o (if Stage 2 = Claude)
                NEVER same model as Stage 2

                Checks:
                  - Does company appear in email body?
                  - Does status match context?
                  - Can it find a verbatim evidence quote?

                Output: {
                  approved: boolean,
                  status_evidence: string,   ← QUOTE PROOF CHECK
                  corrections: {}
                }
    │
    ├── confidence &amp;gt;= threshold + evidence found
    │       → AUTO-ACCEPT: DB upsert, timeline row, reminder
    │
    └── confidence &amp;lt; threshold OR evidence missing
            → HUMAN REVIEW QUEUE ("Review Pending" in UI)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Different Models for Stage 2 and Stage 3?
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Two models trained by different organizations on different data are very unlikely to hallucinate in the same way about the same input.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If Gemini invents a company name, Claude won't confirm it. Claude has no idea what Gemini was thinking. The disagreement surfaces the error.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Quote Proof Check
&lt;/h3&gt;

&lt;p&gt;This is the single most important safeguard in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The verifier must return a verbatim quote from the email&lt;/span&gt;
&lt;span class="c1"&gt;// that justifies the status it assigned.&lt;/span&gt;
&lt;span class="c1"&gt;// We then do a literal string search.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quoteExists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;emailBody&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status_evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;quoteExists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Model fabricated a quote that doesn't exist → hard fail&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;evidence_not_found_in_body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;If the quote doesn't exist word-for-word in the email, the model hallucinated its evidence. Route to human review — never silently write to DB.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Full implementation: &lt;code&gt;src/lib/queue/workers/processExtractionJob.ts&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A Real Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL:
From: recruiting@stripe.com
Subject: Interview Invitation — Engineering at Stripe

"Hi Alex, we'd like to invite you for a technical interview
with our engineering team. Scheduled for June 10th, 2PM PST via Zoom."

STAGE 1:  { is_job_lifecycle: true, type: "interview_invite", confidence: 0.98 }

STAGE 2:  { company: "Stripe", status: "interview",
            interview_date: "2026-06-10", role: null,
            low_confidence_fields: ["role"] }

STAGE 3:  { approved: true,
            status_evidence: "invite you for a technical interview" }

QUOTE CHECK: "invite you for a technical interview"
  → exists in body? ✅ YES

RESULT:
  → status updated to Interview
  → timeline row written (Applied → Interview)
  → reminder scheduled for June 9th, 9:00 AM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  LLM Router and Circuit Breakers
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;AI providers go down. Rate limits hit. Credits run out. The product shouldn't go dark.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On any provider failure:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`llm:cooldown:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Quarantined for 25 seconds, then auto-recovers&lt;/span&gt;

&lt;span class="c1"&gt;// Fallback chain:&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAvailableProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preferred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;preferred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ollama&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cooling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`llm:cooldown:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cooling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;regex_fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// last resort — no hallucinations possible&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fallback chain:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini 2.5 Flash → GPT-4o → Claude Haiku → Ollama (local, free) → Regex Parser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Ollama handles expensive tasks like resume tailoring locally — protecting paid API keys for critical extraction work.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Daily AI Budget Caps
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Without this, a user syncing 3,000 emails could generate a $50 bill in one night.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAILY_LIMITS_USD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;elite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.50&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Runs before EVERY AI job&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertWithinDailyAIBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// When exceeded — not a failure, graceful degradation:&lt;/span&gt;
&lt;span class="c1"&gt;// 1. Update sync_status with user-visible warning message&lt;/span&gt;
&lt;span class="c1"&gt;// 2. Re-enqueue job with 12-hour delay (auto-retry)&lt;/span&gt;
&lt;span class="c1"&gt;// 3. User sees toast: "Daily AI limit reached. Retrying at 10 AM."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token costs are calculated exactly per call:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input (per 1M)&lt;/th&gt;
&lt;th&gt;Output (per 1M)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;$0.30&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Full implementation: &lt;code&gt;src/lib/ai/costGuard.ts&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gmail Token Encryption
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AES-256-GCM encryption before storage&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encrypted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encryptToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oauthRefreshToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOKEN_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauthTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;encrypted&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Decrypted in-memory only during sync worker run&lt;/span&gt;
&lt;span class="c1"&gt;// Never logged. Never exposed to the browser.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  PII Sanitization Before Every AI Call
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Runs on every email before it touches any external model&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PII_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ssn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{3}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{4}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credit_card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{4}[\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]?\d{4}[\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]?\d{4}[\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]?\d{4}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openai_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="sr"&gt;/sk-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{20,}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;github_pat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="sr"&gt;/ghp_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{36}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="sr"&gt;/password&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:=&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="sr"&gt;+/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// Every pattern fired is logged to extraction_audit_log (GDPR record)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Row Level Security on Every Table
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Enforced at DB layer — not just the application layer&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"users_own_jobs"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;-- Same pattern on all 9 tables&lt;/span&gt;
&lt;span class="c1"&gt;-- Even a manipulated request returns zero rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  CI/CD Pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;main branch push
        │
        ▼
┌───────────────────────────────────────┐
│   PARALLEL JOBS                       │
│   Lint + npm audit --audit-level=high │
│   TypeScript strict check             │
│   Unit tests (Jest)                   │
└───────────────┬───────────────────────┘
                │ all pass
                ▼
         Next.js build + Docker build
                │
                ▼
     E2E tests (Playwright)
     Against REAL Supabase — not mocked
     Tests: auth flows, RLS, Stripe webhooks
                │
                ▼
     CD fires ONLY on CI success
     SSH → EC2 → docker compose up --build
     Health check: /api/health
       { status: "ok", db: true, redis: true }
                │
                ▼
            ✅ Deploy complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm audit --high&lt;/code&gt;&lt;/strong&gt; on every PR — no manual dep reviews needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real Supabase in E2E&lt;/strong&gt; — catches RLS edge cases mocks would miss&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health check gates&lt;/strong&gt; the deploy — HTTP 200 alone is not enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;89 CI runs. 28 PRs. Zero manual deploys. Zero failed health checks.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Toolkit
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[SCREENSHOT: Resumes page — drag-and-drop upload, ATS Checker, AI Cover Letter buttons]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fqvtp01vdn68o9lpb4lyi.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.amazonaws.com%2Fuploads%2Farticles%2Fqvtp01vdn68o9lpb4lyi.png" alt=" " width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resume Manager&lt;/strong&gt; solves the &lt;code&gt;resume_final_v3.pdf&lt;/code&gt; problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resumes stored in Supabase Storage — accessible from any device&lt;/li&gt;
&lt;li&gt;Each resume linked to the specific application it was used for&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ATS Checker&lt;/strong&gt; — scores resume vs job description, returns keyword gaps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Cover Letter&lt;/strong&gt; — generates a tailored letter matched to the company's tone&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;[SCREENSHOT: Interview Prep page — question bank + Get AI Feedback button]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fky1fudf3vve4y1ythyfn.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.amazonaws.com%2Fuploads%2Farticles%2Fky1fudf3vve4y1ythyfn.png" alt=" " width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interview Prep&lt;/strong&gt; — 30 questions across Behavioral, Technical, Career, Situational categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Filter by category and difficulty&lt;/li&gt;
&lt;li&gt;Type or record your practice answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get AI Feedback&lt;/strong&gt; returns specific, constructive coaching&lt;/li&gt;
&lt;li&gt;Elite tier gets this as a real-time coaching loop&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Engineering Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;These generalize beyond this project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The cheapest AI call is the one you never make.&lt;/strong&gt;&lt;br&gt;
Filter aggressively before you reach for a model. 80% of inbox emails get rejected before any LLM sees them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Queue everything expensive.&lt;/strong&gt;&lt;br&gt;
If it takes more than 2 seconds, it doesn't belong in a web request. BullMQ from day one, not as an afterthought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never let a model verify its own output.&lt;/strong&gt;&lt;br&gt;
Cross-model verification exists for a reason. Two different training pipelines won't hallucinate the same way on the same input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Human review beats silent corruption.&lt;/strong&gt;&lt;br&gt;
When confidence is low, flag it. Don't write bad data to the database. A "Review Pending" queue is more trustworthy than guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Cost controls before launch, not after.&lt;/strong&gt;&lt;br&gt;
Per-user daily budget caps, exact token cost logging, graceful degradation. Build the cost floor before users arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Least-privilege is not optional when you touch someone's inbox.&lt;/strong&gt;&lt;br&gt;
Read-only OAuth. Encrypted tokens. PII stripped before AI. RLS at every table. None of this is extra credit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chrome extension first, not last.&lt;/strong&gt;&lt;br&gt;
Gmail sync only catches jobs you've already applied to. One-click save from LinkedIn would capture the whole funnel. Wrong prioritization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini Batch API from day one.&lt;/strong&gt;&lt;br&gt;
50% cost reduction. My BullMQ queue is already set up for it. It just needs wiring. Free savings I left on the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outlook in parallel with Gmail.&lt;/strong&gt;&lt;br&gt;
Gmail-only is a real market limiter. Microsoft Graph API has comparable read-only scopes. Should have built both simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenTelemetry from day one.&lt;/strong&gt;&lt;br&gt;
I can tell you what a sync &lt;em&gt;cost&lt;/em&gt; but not where a slow extraction &lt;em&gt;spent its time&lt;/em&gt;. Structured traces from the start would have saved hours of debugging.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Chrome extension for one-click job saving from LinkedIn&lt;/li&gt;
&lt;li&gt;[ ] Outlook / Microsoft 365 integration&lt;/li&gt;
&lt;li&gt;[ ] Gemini Batch API (50% cost reduction — queue is already ready)&lt;/li&gt;
&lt;li&gt;[ ] Public API for power users&lt;/li&gt;
&lt;li&gt;[ ] PWA (groundwork already in codebase)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;If you're job hunting right now:&lt;/strong&gt; try &lt;a href="https://hirecanvas.in" rel="noopener noreferrer"&gt;hirecanvas.in&lt;/a&gt;. Free tier gets you manual tracking + interview prep. Pro ($9.99/mo) unlocks Gmail sync + AI extraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building LLM data pipelines:&lt;/strong&gt; the cross-model verification + quote proof check is the pattern worth borrowing. It has caught more silent hallucinations than any other safeguard in the system.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Never let a model verify its own output.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Evidence must exist verbatim in the source.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those two rules are what make this system trustworthy in production.&lt;/p&gt;

&lt;p&gt;Questions on any part of the implementation — ask in the comments. I read everything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Next.js 15, Supabase, BullMQ, Gemini 2.5 Flash, Claude Haiku 4.5, and too many late evenings.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Live at &lt;a href="https://hirecanvas.in" rel="noopener noreferrer"&gt;hirecanvas.in&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
