# How Gmail-Sync Detects Application Status from Emails in ai-job-search

> Discover how /gmail-sync in ai-job-search identifies job application status from your emails. Learn about targeted queries, message classification, and status detection.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: internals
- Published: 2026-08-05

---

**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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.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:

```text
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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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:

```markdown
| # | 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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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:

```bash

# 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:

```markdown

## 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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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`](https://github.com/MadsLorentzen/ai-job-search/blob/main/outcome.md) files, and [`state.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/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.