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
Recruitersthreads; 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.comin the Has the words field, action = skip inbox + applyRecruiters. - 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+ targetedfrom: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
Recruitersto 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 + applyRecruiters. The original 26-domain list is unrecoverable; this restarts it from senders observed June–August.
- Phrase filter tightened: removed “reach out to you”, “wanted to reach out”, “your experience at”; appended exclusions
- 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 underHealth/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 allMailing-Lists/*labels for the archive. - Filter-review pass. Added the two in-house recruiter addresses
nick.klein@doordash.comandjaki.gonzalez@pendo.ioto 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-aliasto:). 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.xmlis the intermediate export. - Decision: dropped the “fix the dotless
to:” next action.amanschhina@gmail.comis the primary address; ato: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 downloadimports existing filters). Chosen over a custom uv script. - Installed via new HM module
nix/home-manager/modules/gmailctl.nix(dotfiles commitd04c380);gmailctl initscaffolded~/.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”, downloadedcredentials.jsoninto~/.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; scopesgmail.labels+gmail.settings.basic);token.jsonsaved. - Config now lives in the dotfiles:
gmailctl downloadseedednix/home-manager/files/gmailctl/config.jsonnet(16 labels + 11 rules), deployed to~/.gmailctl/config.jsonnetviahome.fileingmailctl.nix(commit610b7b9).gmailctl diffverified 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) andSciPy-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.orgdraft from Gmail, received ezmlm’s confirmation, and sent the confirming reply to thedev-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 withquoted/anyOfhelpers;gmailctl diff -fconfirmed 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.comclause 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 realgmailctl apply): the allow-list rule now appends-{from:njtransit.com}(newrecruiterAliasExclusionslist; 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 diffempty 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, DKIMd=ibt.uber.com, envelopeib_bounces-...@ibt.uber.com), and notably noList-Unsubscribeand noList-ID, so Gmail’s Unsubscribe button and thelist:operator can’t touch these. Gmail filters can’t match arbitrary headers, so the dependable handles arefrom:noreply@uber.complus 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, newReceiptslabel. Applied withgmailctl 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
Receiptslabel 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.xmland-premerge.xmlmoved 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 ingmailctl.nix, script atnix/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-devgooglegroups 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/04for 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; explicitfrom:domain exclusions are the dependable form. The mixedfrom:(...) OR to:addresssyntax 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
Recruiterslabel ID:Label_2661975697823705100- Addresses:
amanschhina@gmail.com(primary),aman.s.chhina@gmail.com(dotted alias) - Gmail filter management: Settings → Filters and Blocked Addresses (+
mailFilters.xmlimport/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); uselabel:<name>. - False positives from the phrase filter attach to single messages, so thread-level
label:search can under-report them; targetedfrom:sender searches within the label are the reliable way to find transactional false positives.