#+TITLE: Triage Intake — Personal Gmail Source #+AUTHOR: Craig Jennings #+DATE: 2026-05-26 # Source plugin for the triage-intake engine. See triage-intake.org for the # contract and the Phase A-D orchestration. This file declares ONE source. * Source: personal-gmail :PROPERTIES: :ORDER: 20 :ENABLED: mcp google-docs-personal present :ANCHOR: epoch :SUBAGENT_OVER: 50 :END: ** Scan Personal Gmail unread in the inbox since the anchor: #+begin_src text mcp__google-docs-personal__listMessages q="is:unread in:inbox after:" maxResults=100 #+end_src ⚠ *Express every anchor cutoff as the literal UNIX epoch* — =after:1784177122= and =before:1784177122= for the same anchor, never the =YYYY/MM/DD= form. This governs *both* anchored queries: the scan above and the backlog-residue probe below. They must meet at the same instant or mail falls between them permanently. Gmail's day-resolution operators fail two different ways: =after:YYYY/MM/DD HH:MM:SS= is not valid syntax at all — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail — while =before:YYYY/MM/DD= is valid but excludes the named day entirely, so pairing it with a second-resolution scan leaves the whole anchor day covered by neither query. The engine supplies == because this source declares =ANCHOR: epoch=. The rule binds the *anchor* windows only. The date-slice walk below deliberately uses =before:= at day resolution — safe there because consecutive slices overlap and get deduped by message id. ⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly. ⚠ *Verify the account binding before trusting the scan.* =mcp__google-docs-personal= must resolve to =craigmartinjennings@gmail.com=. Several Gmail-capable MCPs are connected and they bind to *different* accounts — =mcp__claude_ai_Gmail= is bound to the DeepSat *work* account, and its name gives no hint of that. A wrong-account scan returns a plausible mailbox that is the wrong person's, and every hygiene action in the close then fires on the wrong inbox (this happened 2026-07-23: a sweep used =claude_ai_Gmail= and pulled 201 unread *DeepSat work* messages instead of personal). Guard, every scan: confirm a sample result's =to:= is =craigmartinjennings@gmail.com= before classifying. If it isn't, or if =google-docs-personal= is unavailable, do NOT reach for another MCP — use the local mu mirror: sync first (=mbsync gmail && mu index=; the index lags), then =mu find 'maildir:/gmail/INBOX AND flag:unread AND date:..now'=. The three accounts and their maildirs: =gmail= = craigmartinjennings@gmail.com, =cmail= = c@cjennings.net, =dmail= = craig.jennings@deepsat.com (work — out of scope from a home session, whichever tool reaches it). ⚠ *The MCP caps at =maxResults=100= and exposes NO =pageToken= parameter.* The response carries a =nextPageToken=, but the tool can't consume it, so a pile over 100 is silently truncated — the tail below the cap never gets classified, and every later anchored sweep skips it (it predates the new anchor). This is exactly how a 300+ backlog accumulated invisibly by 2026-07-08. Two consequences: - *Never treat a 100-row result as complete.* When a scan returns exactly 100, walk the tail in *date slices*: re-query with =before:= (day resolution), repeat until a page returns fewer than 100, dedupe by message id across slices (the day-resolution boundary overlaps). - *Never report =resultSizeEstimate= as a count.* It's unreliable — observed stuck at "201" across three different queries whose real union exceeded 300. *** Backlog-residue check (every sweep — cheap, mandatory) The anchored scan is blind to anything unread from *before* the anchor. After it, run one probe for pre-anchor residue: #+begin_src text mcp__google-docs-personal__listMessages q="is:unread in:inbox before:" maxResults=5 #+end_src The cutoff is the epoch, matching the scan's =after:= — see the epoch rule above. If it returns any messages, surface one loud line in the sweep summary: "Backlog: unread predating the anchor exists (N+ shown; date-slice to inventory)" and offer a backlog sweep. Never fold the residue into a quiet sweep — an anchored "no changes" claim is only true for the window the scan saw. (Added 2026-07-08 after ~300 pre-anchor unread accumulated unseen; the probe returns actual messages, so it works where the estimate lies. Shipped with a day-resolution cutoff that hid the entire anchor day; fixed to epoch 2026-07-16 after a home sweep reported the backlog clear while two July-15 messages sat unread.) ** Classify Bias: *trash-leaning* — personal Gmail is high noise volume. - *Noise-trash:* newsletters, Substacks, retail/SaaS marketing, social digests, redundant aggregator digests (Notion/Miro daily), wrong-recipient mail, past-event calendar artifacts. - *Noise-keep:* receipts, order confirmations, statements — low value but worth the audit trail. - *FYI:* substantive personal mail with no action owed. - *Action:* an explicit ask, a reply owed, a time-sensitive personal matter. ** Render #+begin_example **Personal Gmail — N unread.** - Action: - FYI: - Noise: N trash candidates, M keep #+end_example Omit the block if zero unread. ** Actions - trash :: =mcp__google-docs-personal__trashMessage= id= (recoverable from Gmail Trash for 30 days) - mark-read :: =mcp__google-docs-personal__modifyMessageLabels= id= removeLabelIds=["UNREAD"] - star+read :: =mcp__google-docs-personal__modifyMessageLabels= id= addLabelIds=["STARRED"] removeLabelIds=["UNREAD"] - attach-fetch:: =.ai/scripts/gmail-fetch-attachments.py --profile personal --message-id --output-dir =