How Gmail-Sync Detects Application Status from Emails in ai-job-search
The /gmail-sync command in ai-job-search detects job-application status from Gmail by building targeted search queries, fetching full thread bodies, and classifying messages against known company names using key phrase matching for signals like interviews, offers, and rejections.
The ai-job-search repository provides a privacy-first, CLI-driven workflow for tracking job applications. Its /gmail-sync command automates status detection by reading your Gmail inbox through the Claude Code Gmail connector, matching messages to open applications, and proposing updates—without ever writing data until you explicitly approve. This article breaks down exactly how the detection algorithm works, from query construction to final write-back.
How Gmail-Sync Builds Its Search Query
The detection process starts with a carefully constructed Gmail search string defined in .claude/commands/gmail-sync.md. The command assembles this query dynamically based on your current pipeline state.
Query Components
The final search string combines five elements:
- User-defined label – Optional custom label (e.g.,
label:job-search) for pre-filtered mail - Open company names – Normalized names from rows where
statusis not final (applied, phone screen, interview, etc.) - ATS domains – Hardcoded common sources:
greenhouse.io,lever.co,myworkday.com,workable.com,ashbyhq.com - Time bound –
newer_than:30dby default, or a custom date viasince <date>argument - Inbox restriction –
in:inboxto exclude sent mail and archives
A generated query looks like:
in:inbox newer_than:30d (from:acme.com OR from:bigco.lever.co OR from:jobs@startup-greenhouse.io) label:job-search
The search_threads Gmail API endpoint executes this query. The command then filters results against gmail_sync/state.json, skipping any thread whose message IDs appear in the processed_message_ids array.
Message Retrieval and Classification Pipeline
Once candidate threads are identified, /gmail-sync fetches full content for accurate analysis—never relying on truncated Gmail snippets.
Step 1: Fetch Complete Thread Bodies
For each new thread, the command calls get_thread to retrieve the full message body. This ensures classification has complete context rather than subject-line-only heuristics.
Step 2: Match Messages to Applications
The classifier correlates each message to an open application by comparing normalized fields:
| Field | Comparison Method |
|---|---|
| Sender domain | Normalized and matched against company domains |
| Display name | Stripped of common ATS prefixes |
| Subject line | Scanned for company name mentions |
| Body text | Full content searched for company and role references |
Multiple signals can match; ties are resolved by most recent message and strongest signal confidence.
Step 3: Detect Status Signals
The command identifies signal types through keyword phrase matching. Each signal maps to a status transition:
| Signal | Key Phrases | Proposed Status |
|---|---|---|
| Assessment | "coding assessment", "take-home", "technical exercise", "hirevue" | applied → assessment |
| Interview invite | "schedule a call", "interview availability", "chat with the team" | applied → interview or advance stage |
| Offer extended | "offer letter", "pleased to offer", "compensation package", "join us" | current → offer |
| Rejection | "move forward with other candidates", "not moving forward", "regret to inform" | current → rejected |
| Withdrawn/declined | "withdrawn application", "position filled", "on hold" | flagged for manual review |
Conflicting signals—such as a rejection email arriving after an offer letter—are not auto-proposed. These appear in a separate "Conflicts" section requiring manual resolution.
The Proposal-First Write Model
/gmail-sync operates on a strict read-only default with explicit user approval. This prevents accidental corruption of your application history.
What Gets Shown Before Any Write
The command presents a markdown table with these columns:
| # | Company | Role | Signal | Current → Proposed Status | Source Email (date) |
Additional sections include:
- Unmatched emails – Messages that passed filters but couldn't correlate to any open application
- Conflicts – Contradictory signals requiring manual handling
- Stale applications – Open applications with no Gmail activity for 30+ days
Approval Commands
The user responds with one of:
approve all– Accept every proposed updateskip 1, 3– Skip specific rows by number, approve the restskip all– Reject the entire batch; nothing is written and message IDs are still marked processed
Only after explicit approval does the command proceed to file modifications.
File Updates on Approval
Approved updates write to three locations atomically per row:
job_search_tracker.csv– Thestatuscolumn updates, and a note appends to thenotesfield:2026-07-12 gmail-sync: interview ("Schedule call")documents/applications/<company>_<role>/outcome.md– The corresponding stage checkbox is ticked, and a dated entry adds to the timelinegmail_sync/state.json– All processed message IDs (approved, skipped, unmatched) add toprocessed_message_ids, andlast_syncupdates to today's date
This append-only approach preserves complete audit history. Rows are never reordered or deleted; status changes accumulate as dated notes.
Idempotency and Duplicate Prevention
The state management in gmail_sync/state.json guarantees idempotent runs. The stored processed_message_ids array ensures:
- The same email is never proposed twice, even if it still matches search criteria
- Re-running
/gmail-syncafter approval only surfaces genuinely new messages - Skipped messages are recorded as processed, preventing re-prompting
The 30-day default look-back (configurable via since <date>) balances completeness with performance, typically scanning 8-15 threads per sync for active job seekers.
Privacy and Security Architecture
Several design choices protect sensitive data:
- No Gmail modification – The connector has read-only scope; no labeling, archiving, or deletion occurs
- Git-ignored personal data –
.gitignoreexcludesgmail_sync/state.json,job_search_tracker.csv, anddocuments/applications/** - Local-first storage – All state lives in your repository clone, not external databases
- User-controlled approval – No automation bypasses explicit consent for any write operation
Code Example: Running Gmail-Sync
Invoke with optional filters:
# Default 30-day scan of all open applications
> /gmail-sync
# Limit to specific company
> /gmail-sync AcmeCorp
# Override look-back window
> /gmail-sync since 2026-06-01
# Combined filter
> /gmail-sync "Data Engineer" since 2026-05-15
Typical output before approval:
## Gmail Sync - Proposed Updates - 2026-07-26
Scanned 12 threads (8 new messages) since newer_than:30d.
### Proposed Changes (reply "approve all", or list which to skip)
| # | Company | Role | Signal | Current → Proposed Status | Source Email (date) |
|---|---------|------|--------|---------------------------|----------------------|
| 1 | Acme | Data Engineer | Interview invite | applied → interview | "Schedule interview" (2026-07-10) |
| 2 | BigCo | Backend Dev | Offer extended | interview → offer | "Offer letter" (2026-07-12) |
### Conflicts (manual review required)
- GammaSoft: Offer email (2026-07-11) vs. rejection email (2026-07-13)
### Stale Applications (>30 days no activity)
- DeltaAI (last update: 2026-06-15)
Summary
/gmail-syncdetects application status by constructing targeted Gmail queries, fetching full message bodies, and classifying against known company names and signal phrases- The proposal-first model requires explicit user approval before any file writes, preventing accidental data corruption
- Idempotent processing via
gmail_sync/state.jsonensures emails are never processed twice - Conflicting signals are flagged for manual handling rather than auto-resolved
- All personal data is git-ignored and stored locally, with read-only Gmail access
Frequently Asked Questions
How does Gmail-sync avoid duplicate processing of the same email?
The command maintains processed_message_ids in gmail_sync/state.json. Every message ID from scanned threads—approved, skipped, or unmatched—is appended to this list. Subsequent runs filter against this array before classification, guaranteeing the same email never generates multiple proposals.
What happens if I receive conflicting signals about the same application?
Conflicts such as a rejection after an offer are excluded from the proposed updates table. Instead, they appear in a dedicated "Conflicts" section with both messages cited. The user must manually resolve these in job_search_tracker.csv or outcome.md; the sync command will not auto-transition status on contradictory signals.
Can Gmail-sync modify or delete emails in my inbox?
No. The integration uses read-only Gmail MCP connector scopes. The command never labels, archives, moves, or deletes messages. It only reads thread content and metadata to generate proposals. All writes are local to the repository: job_search_tracker.csv, outcome.md files, and state.json.
How does the command match emails to specific job applications?
Matching uses four normalized fields: sender domain, display name, subject line, and full body text. These are compared against the company column values from open rows in job_search_tracker.csv. The algorithm tolerates ATS intermediaries (e.g., no-reply@lever.co sending for BigCo) by checking display names and body mentions alongside raw domains.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →