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 status is 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:30d by default, or a custom date via since <date> argument
  • Inbox restriction – in:inbox to 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 update
  • skip 1, 3 – Skip specific rows by number, approve the rest
  • skip 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:

  1. job_search_tracker.csv – The status column updates, and a note appends to the notes field: 2026-07-12 gmail-sync: interview ("Schedule call")
  2. documents/applications/<company>_<role>/outcome.md – The corresponding stage checkbox is ticked, and a dated entry adds to the timeline
  3. gmail_sync/state.json – All processed message IDs (approved, skipped, unmatched) add to processed_message_ids, and last_sync updates 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-sync after 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 – .gitignore excludes gmail_sync/state.json, job_search_tracker.csv, and documents/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-sync detects 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.json ensures 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →