Objective

Keep the Gmail inbox clean by auto-labeling + archiving recruiter mail (and other categories) reliably, without sweeping in transactional/promo mail as false positives.

Current state (2026-08-04): two cooperating filters drive the Recruiters label. (1) A tightened content-phrase filter: the “reach out” phrases and “your experience at” are removed, and known transactional senders (OpenTable, Resy, Air Canada, OpenAI, Substack, GitHub, python.org) are excluded. (2) A recreated agency allow-list filter (dotted-alias to: + 15 observed recruiter domains). The original 26-domain allow-list filter had silently disappeared at some point, which is what caused the July recall misses.

Context

Two Gmail addresses feed one inbox: amanschhina@gmail.com (primary) and aman.s.chhina@gmail.com (dotted alias, frequently used by recruiters). Gmail ignores dots at delivery, but to: filter matching is literal against the header, so the dotted variant can be targeted explicitly in a to: clause. The dotless primary cannot: a to: clause on it would match all mail.

Recruiters label ID: Label_2661975697823705100.

Key tooling quirk: searching by label name (label:Recruiters) works; searching by raw label ID (label:Label_2661975697823705100) returns empty. Always use the name form when querying via the Gmail MCP.

Chronological Log

2026-06-17 — Initial audit + historical backlog sweep

  • Audited existing Recruiters threads; fixed 2 mislabels (Uniqlo promo, AI Agent Meetup invite).
  • Multi-session sweep of unlabeled recruiter threads back to May 2021; ~90+ threads labeled (2021–2026).
  • Discovered many older recruiter threads slipped through because they were addressed to the dotted alias.
  • Implemented a combined filter: from:(domain list) OR to:aman.s.chhina@gmail.com in the Has the words field, action = skip inbox + apply Recruiters.
  • Recruiter agency domain list grew iteratively to 26+ as new agencies appeared.
  • Concluded the label + filter + archive approach is the right setup (vs Multiple Inboxes).

2026-06-25 — Content-phrase layer added

  • 5 recruiter threads had slipped past the domain/alias filter.
  • Root cause: domain allow-list only covered known domains; the to: catch-all targeted only the dotted variant while the missed mail went to the dotless variant.
  • Researched content-based Gmail filters; analyzed language in the missed emails.
  • Added a phrase-based layer matching subject/body terms like “opportunity”, “your background”, “reach out”, “compensation”, “base + equity”.
  • Validated against ~60 labeled recruiter threads (true positives) and ~50 non-recruiter threads (false positives) — found only 1 false positive at the time (an OSU PhD student outreach).
  • Pending item noted but not done: fix the dotless to: address on the original domain filter.

2026-07-04 — False-positive cleanup

  • Noticed non-recruiter mail landing in Recruiters (archived + labeled). Examples surfaced: USPS eReceipt (DoNotReply@ereceipt.usps.gov), Anker support (support@anker.com).
  • Confirmed the culprit is the content-phrase layer — e.g. Anker’s “we wanted to reach out … we haven’t heard back” tripped “reach out”.
  • Learned the phrase filter tags individual messages, so false positives can sit as a single labeled message buried inside an otherwise-normal thread.
  • Swept the label via label:Recruiters + targeted from: searches for known transactional/promo senders.
  • Stripped ~130 false positives spanning 2018–2026: Spotify promos (largest cluster), Indeed job alerts (alert@indeed.com, match.indeed.com, myresume@indeed.com), Hack the North event/newsletter mail, Triangle Rewards (Canadian Tire), Air Canada promos/customs, Resy reservations, Cathay statements, Google account/Play/Dev/Code Jam/Coding Competitions/GCP notices, PSEG, Dunkin, Wise promos, UHC benefits, plus one-offs (Domino’s, Starbucks, US Mobile, UNIQLO, Hilton, Hyatt, Glassdoor, TOPKART, LifeStance settlement, substacks).
  • Preserved genuine recruiter mail, including 2 real Google recruiting threads (Carlos Smits / abalosg — AR role; darrylblake — application follow-up) and Amazon recruiter threads (satkahlo, juexinc).
  • Not done yet: tightening the filter itself, and fixing the dotless to: item still outstanding from 2026-06-25.

2026-08-04 — Verification audit + filter fixes (this session)

  • Precision check (label:Recruiters after:2026/07/04): 13 of 28 newly-labeled threads were real recruiters; ~15 false positives had re-accumulated in the month since the cleanup. Dominated by OpenTable (8 threads — review-request emails contain “your feedback about your experience at:”, tripping the “your experience at” phrase), plus Resy and Air Canada (repeat offenders from the 7/04 cleanup), OpenAI dev news, a Liquid AI Substack, a python.org core-mentorship thread, and one GitHub notification message buried in a PR thread. list:* in Doesn’t have demonstrably failed to exclude the mailing-list and Substack mail.
  • Recall check: 6 recruiter senders slipped through to the inbox unlabeled — doordash.com, pendo.io, hires-lumos.com, evolvegrp.io, kythhire.com, and the first acquireme.io message (its follow-ups got phrase-labeled, splitting the thread). All were mail to the dotless primary from domains not on any allow-list.
  • Discovery: the 26-domain allow-list filter no longer exists. Only the phrase filter remained in Settings — the combined domain/alias filter from 2026-06-17 had disappeared (likely lost during a past edit). This, not the dotless to: gap, explains the recall misses.
  • Cleaned labels via Gmail MCP: stripped all ~15 false positives (message-level unlabel for the GitHub one), applied Recruiters to the 6 missed threads (left in inbox as unread).
  • Fixed filters via claude-in-chrome driving the Settings UI:
    • Phrase filter tightened: removed “reach out to you”, “wanted to reach out”, “your experience at”; appended exclusions from:opentable OR from:resy.com OR from:aircanada OR from:openai.com OR from:substack.com OR from:github.com OR from:python.org.
    • Allow-list filter recreated: {to:aman.s.chhina@gmail.com from:usemantel.com from:candidatelabs.com from:amya.agency from:hires-lumos.com from:evolvegrp.io from:kythhire.com from:acquireme.io from:tardis-group.com from:joveinternational.com from:sisutechpartners.com from:january.com from:introfromscout.net from:reloadsearches.com from:tessera.co from:inkorp-group.com}, action = skip inbox + apply Recruiters. The original 26-domain list is unrecoverable; this restarts it from senders observed June–August.
  • Follow-up: 2FA codes no longer archived by Health filters. Patient-portal validation codes (e.g. “Validation Code 997640” from noreply@advancedmd.com — AdvancedMD serves both Harmony Bay and NJ Behavioral, so all codes were landing under Health/Harmony-Bay) were being skip-inboxed. Added -{"validation code" "verification code" "security code" "one-time passcode"} as Doesn’t have on both the advancedmd filter and the vsee/phreesia/lifestance filter (the latter also marks as read, which would have been worse); code emails now stay in the inbox unlabeled.
  • Mailing-list pruning. Unsubscribed (drafts prepared for user to send) from Spark-Dev (dev-unsubscribe@spark.apache.org), Numpy-Discussions and Scikit-Learn (<list>-leave@python.org), and the PyData/PyStatsModel Google Groups (<group>+unsubscribe@googlegroups.com). Deleted those 5 filters; kept Core-Mentorship and Python-Dev subscriptions/filters, and kept all Mailing-Lists/* labels for the archive.
  • Filter-review pass. Added the two in-house recruiter addresses nick.klein@doordash.com and jaki.gonzalez@pendo.io to the allow-list filter (specific addresses, not domains, to avoid catching DoorDash receipts / Pendo product mail). Made the Bloomberg Newsletters filter skip the inbox for consistency with the other newsletter filters. Declined for now: folding OpenAI dev news, Liquid AI Substack, and LinkedIn editorial digests into the Newsletters filter (they land in the inbox unclaimed since the recruiter-filter exclusions).
  • Backup + full allow-list recovery. Exported all 11 filters to ~/Downloads/mailFilters-2026-08-04.xml. In the process found ~/Downloads/mailFilters.xml, a June 5 export that still contained the “lost” original allow-list filter (23 agency domains + dotted-alias to:). Merged the 18 missing domains back into the live allow-list filter (now 34 domains + 2 addresses + alias), then re-exported so the dated backup reflects final state. The June 5 file is kept as the historical record; mailFilters-2026-08-04-premerge.xml is the intermediate export.
  • Decision: dropped the “fix the dotless to:” next action. amanschhina@gmail.com is the primary address; a to: clause on it would match every message. The allow-list is the correct mechanism for dotless-addressed recruiter mail.

2026-08-04 (later) — Adopting gmailctl for filters-as-code (completed)

  • Researched programmatic filter management; found gmailctl (declarative Jsonnet config, diff/apply via the Gmail API, builtin query simplifier for the ~1500-char cap, gmailctl download imports existing filters). Chosen over a custom uv script.
  • Installed via new HM module nix/home-manager/modules/gmailctl.nix (dotfiles commit d04c380); gmailctl init scaffolded ~/.gmailctl.
  • Full setup completed via claude-in-chrome driving the GCP console: created project gmailctl-504600, enabled the Gmail API, configured the OAuth consent screen (External), created Desktop OAuth client “gmailctl”, downloaded credentials.json into ~/.gmailctl/ (mode 600), and pushed the app to Production so refresh tokens don’t expire after 7 days. Completed the OAuth grant (unverified-app warning is expected because the “developer” is the account itself; Advanced, then continue; scopes gmail.labels + gmail.settings.basic); token.json saved.
  • Config now lives in the dotfiles: gmailctl download seeded nix/home-manager/files/gmailctl/config.jsonnet (16 labels + 11 rules), deployed to ~/.gmailctl/config.jsonnet via home.file in gmailctl.nix (commit 610b7b9). gmailctl diff verified empty against live Gmail. Future filter edits: edit the Jsonnet in the repo, hm switch, gmailctl diff, gmailctl apply. The vanished-allow-list failure mode is closed: the config is git-versioned and diffable.
  • Verified the 5 mailing-list filter deletions stuck (11 filters live). Left the PyData and pystatsmodels Google Groups directly via groups.google.com (group page, membership, “Leave group”; both now show “Join group”), so no unsubscribe email is needed for those two and their drafts are obsolete. Also still a member of googlegroups mirrors python-dev (dev-python@googlegroups.com) and SciPy-user (never raised; left untouched). The remaining 3 unsubscribe drafts (spark, numpy, scikit-learn) still must be sent by email, and until then that list mail lands in the inbox.

2026-08-04 (evening) — Remaining unsubscribes + first real gmailctl apply

  • Left SciPy-user via groups.google.com (same web flow). Spark done end-to-end: sent the dev-unsubscribe@spark.apache.org draft from Gmail, received ezmlm’s confirmation, and sent the confirming reply to the dev-uc.<token> address (verified in Sent). All Google Groups memberships are now gone (pydata, pystatsmodels, python-dev mirror untouched? — no: python-dev mirror remains, deliberately) and Spark is confirmed out. Remaining: numpy + scikit-learn drafts (python.org Mailman) still unsent.
  • Refactored config.jsonnet (commit 44e5ddf): recruiter phrases, phrase exclusions, agency senders, and the verification-code carve-out are now named Jsonnet arrays with quoted/anyOf helpers; gmailctl diff -f confirmed the refactor was byte-identical to live filters before deploying.
  • NJ Transit incident (dotted-alias false positive). The user registered a new NJ Transit MyTix account with the dotted alias; the allow-list filter’s to:aman.s.chhina@gmail.com clause recruiter-labeled and archived 4 messages (verification code, password-reset security code, password update, purchase receipt). The 2 messages to the dotless address were untouched, which pinpointed the alias clause. Restored all 4 to the inbox and unlabeled them.
  • Fix applied via the new workflow (commit bfb73d7, first real gmailctl apply): the allow-list rule now appends -{from:njtransit.com} (new recruiterAliasExclusions list; every alias-registered service needs an entry) plus the same verification-code carve-out the health filters use, so codes from any future alias-registered service stay in the inbox. gmailctl diff empty after apply.

2026-08-04 (late) — Uber receipts filter (12th rule, new Receipts label)

  • Inspected the raw headers of an Uber trip receipt via Gmail’s “Show original”: sent by Iterable (X-IB-* headers, DKIM d=ibt.uber.com, envelope ib_bounces-...@ibt.uber.com), and notably no List-Unsubscribe and no List-ID, so Gmail’s Unsubscribe button and the list: operator can’t touch these. Gmail filters can’t match arbitrary headers, so the dependable handles are from:noreply@uber.com plus the stable subject template "trip with Uber" (shared by both the charge summary and the receipt, catching Uber’s 2-emails-per-trip duplication).
  • Added rule via gmailctl (commit d82c462): from + subject → archive, new Receipts label. Applied with gmailctl apply; diff clean.
  • Retroactively filed ~200 historical Uber trip threads (back to 2022) via Gmail search UI bulk actions (select all matching → label Receipts → archive). Gmail processes the bulk in async batches. One 2026-06 thread includes a forward to receipts@concur.com (expense reports), archived with the rest.
  • The Receipts label is general-purpose: future candidates (ParkMobile, Amex Send echoes, restaurant feedback) from the 2026-08-04 inbox survey can reuse it.

2026-08-05 — Unsubscribes finished, 3 more filters, drift alerting

  • Sent the numpy and scikit-learn unsubscribe emails (python.org Mailman); each list emails back a confirmation link the user must click. The obsolete pydata/pystatsmodels drafts remain in Drafts for the user to discard.
  • 3 new filters (commit dcee24c, applied): ParkMobile session chatter → Receipts (excluding the “Log in” magic-link mail via -subject:"Log in"); Resy/OpenTable "wants your feedback" surveys → archive + mark read; Amex Send transaction echoes (from:AmericanExpress@welcome.americanexpress.com + body "Amex Send") → Receipts. Backfilled the inbox via Gmail UI bulk actions: 3 ParkMobile, 11 Resy (some dating to 2023), 38 Amex Send threads archived.
  • Retired the superseded XML exports: mailFilters-2026-08-04.xml and -premerge.xml moved to Trash (git config is the authoritative backup); kept ~/Downloads/mailFilters.xml (2026-06-05) as the historical record.
  • Daily drift check (commit 43a3cac): gmailctl-drift-check (writeShellApplication in gmailctl.nix, script at nix/scripts/gmailctl-drift-check.sh) runs via launchd daily at 12:15, compares live filters to the deployed config, and publishes drift or diff failures (e.g. expired token) to the athena ntfy topic, reusing the SigNoz webhook credentials from ~/.n/signoz.env (URL query stripped for plain-text publish). Logs to ~/.local/share/gmailctl/drift-check.log. Test publish verified delivered end-to-end.

Next Actions

  • Click the Mailman confirmation links that numpy-discussion and scikit-learn will email back; discard the obsolete pydata/pystatsmodels drafts. All other list memberships are resolved (Spark confirmed out via ezmlm; PyData/pystatsmodels/SciPy-user left via the Groups web UI).
  • Optional: decide whether to also leave the python-dev googlegroups mirror (dev-python@googlegroups.com, still subscribed).
  • Optional: unsubscribe from the never-read promo senders (Hilton, Hyatt, Starbucks, Domino’s, inKind) rather than filtering them.
  • Re-check precision/recall in a few weeks (label:Recruiters after:2026/08/04 for false positives + an inbox sweep for misses) to validate the 2026-08-04 changes.
  • Periodically harvest new recruiter agency domains from misses and append them to the allow-list filter.
  • Optional: spot-check the label after filter changes, since Gmail’s skip-inbox archive action doesn’t always fire on replies to existing threads.

Filter Design Notes

  • Layer 1 (highest precision, zero false positives): sender-domain allow-list of known recruiter agencies. Keep and extend, don’t replace. Recreated 2026-08-04 after the original filter vanished.
  • Layer 2 (high signal): to:aman.s.chhina@gmail.com (dotted alias) — rarely used for anything but recruiters. Do not extend to the dotless variant: it is the primary address and would match everything.
  • Layer 3 (high recall, source of false positives): content phrases. Tightened 2026-08-04; watch “quick chat”/“quick call”/“set up a time” as the next-most-generic survivors if false positives recur.
  • Gmail filter mechanics: prefer OR / { } over || (symbols behave non-deterministically); filters have a per-field character cap, so split across multiple cooperating filters if needed; the { } curly-brace syntax = OR in filter fields; list:* in the Doesn’t have field is unreliable — it failed to exclude python.org and Substack list mail; explicit from: domain exclusions are the dependable form. The mixed from:(...) OR to:address syntax must go in the Has the words field, not the structured From field.
  • Filters are managed via XML import/export (mailFilters.xml), which already holds filters for GitHub, Newsletters, Health, and Mailing-Lists labels. Worth re-exporting after each filter change so the allow-list can’t silently vanish again.
  • Retroactive application (“Also apply to matching conversations”) processes in batches with a several-minute lag for large backlogs.

Resources

  • Recruiters label ID: Label_2661975697823705100
  • Addresses: amanschhina@gmail.com (primary), aman.s.chhina@gmail.com (dotted alias)
  • Gmail filter management: Settings → Filters and Blocked Addresses (+ mailFilters.xml import/export)
  • Filter backups: ~/Downloads/mailFilters-2026-08-04.xml (current, post-recovery), ~/Downloads/mailFilters.xml (2026-06-05 export; source of the recovered allow-list)
  • Note: Gmail filter rules are not editable via the Gmail MCP — label/thread operations only. Filter edits go through the Settings UI, which claude-in-chrome browser automation can drive end-to-end (verified 2026-08-04).

Tooling Limitation (Gmail MCP)

  • Can read/search threads, list labels, and apply/remove labels on threads and individual messages.
  • Cannot create or edit Gmail filters or settings — those go through the Settings UI (automatable via claude-in-chrome).
  • Label-ID search is broken (label:<id> returns empty); use label:<name>.
  • False positives from the phrase filter attach to single messages, so thread-level label: search can under-report them; targeted from: sender searches within the label are the reliable way to find transactional false positives.