From 18e92e3b2444edc68ce309dfff73e73ca795cd46 Mon Sep 17 00:00:00 2001 From: Siddhant Kalra Date: Sun, 29 Mar 2026 15:47:32 -0400 Subject: [PATCH 1/2] Add gmail-inbox-cleaner to partner-built Systematic Gmail inbox overhaul plugin for Claude Cowork. Four-stage methodology: - Preparation: tool check, full audit (all subject lines + bodies), unread review - Action: bulk delete by content analysis, sender-by-sender review loop - Future Proofing: label/filter system, unsubscribe sweep - Safety Review: bin audit, restore plan, final report Includes: - 8 Python scripts for Gmail API operations (OAuth, batch actions, filter/label management, trash search, unsubscribe URL extraction) - 4 slash commands (/gmail-prepare, /gmail-cleanup, /gmail-future-proof, /gmail-bin-audit) - Gmail MCP connector with 3-tier graceful degradation - Decision framework and unsubscribe pattern references --- .claude-plugin/marketplace.json | 11 +- .../.claude-plugin/plugin.json | 13 + partner-built/gmail-inbox-cleaner/.mcp.json | 8 + .../gmail-inbox-cleaner/CONNECTORS.md | 65 +++++ partner-built/gmail-inbox-cleaner/LICENSE | 21 ++ partner-built/gmail-inbox-cleaner/README.md | 81 +++++++ .../gmail-inbox-cleaner/commands/bin-audit.md | 76 ++++++ .../gmail-inbox-cleaner/commands/cleanup.md | 64 +++++ .../commands/future-proof.md | 68 ++++++ .../gmail-inbox-cleaner/commands/prepare.md | 83 +++++++ .../scripts/batch_action.py | 119 +++++++++ .../scripts/build_sender_index.py | 136 +++++++++++ .../scripts/get_unsubscribe_url.py | 110 +++++++++ .../scripts/gmail_service.py | 36 +++ .../scripts/manage_filters.py | 170 +++++++++++++ .../scripts/manage_labels.py | 89 +++++++ .../scripts/oauth_setup.py | 90 +++++++ .../scripts/search_trash.py | 113 +++++++++ .../skills/inbox-cleanup/SKILL.md | 56 +++++ .../references/decision-framework.md | 170 +++++++++++++ .../inbox-cleanup/references/methodology.md | 227 ++++++++++++++++++ .../references/unsubscribe-patterns.md | 128 ++++++++++ 22 files changed, 1933 insertions(+), 1 deletion(-) create mode 100644 partner-built/gmail-inbox-cleaner/.claude-plugin/plugin.json create mode 100644 partner-built/gmail-inbox-cleaner/.mcp.json create mode 100644 partner-built/gmail-inbox-cleaner/CONNECTORS.md create mode 100644 partner-built/gmail-inbox-cleaner/LICENSE create mode 100644 partner-built/gmail-inbox-cleaner/README.md create mode 100644 partner-built/gmail-inbox-cleaner/commands/bin-audit.md create mode 100644 partner-built/gmail-inbox-cleaner/commands/cleanup.md create mode 100644 partner-built/gmail-inbox-cleaner/commands/future-proof.md create mode 100644 partner-built/gmail-inbox-cleaner/commands/prepare.md create mode 100644 partner-built/gmail-inbox-cleaner/scripts/batch_action.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/build_sender_index.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/get_unsubscribe_url.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/gmail_service.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/manage_filters.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/manage_labels.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/oauth_setup.py create mode 100644 partner-built/gmail-inbox-cleaner/scripts/search_trash.py create mode 100644 partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/SKILL.md create mode 100644 partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/decision-framework.md create mode 100644 partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/methodology.md create mode 100644 partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/unsubscribe-patterns.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9170ab82..5b4931ce 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -324,6 +324,15 @@ "name": "pdf-viewer", "source": "./pdf-viewer", "description": "View, annotate, and sign PDFs in a live interactive viewer. Mark up contracts, fill forms with visual feedback, stamp approvals, and place signatures \u2014 then download the annotated copy." + }, + { + "name": "gmail-inbox-cleaner", + "source": "./partner-built/gmail-inbox-cleaner", + "description": "Systematic Gmail inbox overhaul \u2014 four-stage cleanup with Claude reading every email, intelligent bulk delete, sender-by-sender review, label/filter system, unsubscribe sweep, and bin safety audit. Works for any inbox.", + "author": { + "name": "Siddhant Kalra", + "url": "https://github.com/siddhantkalra" + } } ] -} +} \ No newline at end of file diff --git a/partner-built/gmail-inbox-cleaner/.claude-plugin/plugin.json b/partner-built/gmail-inbox-cleaner/.claude-plugin/plugin.json new file mode 100644 index 00000000..eb9c1134 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "gmail-inbox-cleaner", + "version": "0.3.0", + "description": "Systematic Gmail inbox overhaul — audit, intelligent bulk delete, sender-by-sender review, label/filter system, unsubscribe sweep, and bin safety audit. Works for any inbox, any user.", + "author": { + "name": "Siddhant Kalra", + "url": "https://github.com/siddhantkalra" + }, + "repository": "https://github.com/siddhantkalra/gmail-inbox-cleaner-plugin", + "homepage": "https://github.com/siddhantkalra/gmail-inbox-cleaner-plugin", + "license": "MIT", + "keywords": ["gmail", "inbox", "email", "cleanup", "filters", "labels", "unsubscribe"] +} diff --git a/partner-built/gmail-inbox-cleaner/.mcp.json b/partner-built/gmail-inbox-cleaner/.mcp.json new file mode 100644 index 00000000..e61f84f8 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "gmail": { + "type": "http", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} diff --git a/partner-built/gmail-inbox-cleaner/CONNECTORS.md b/partner-built/gmail-inbox-cleaner/CONNECTORS.md new file mode 100644 index 00000000..481b0bd4 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/CONNECTORS.md @@ -0,0 +1,65 @@ +# Connectors + +## How tool references work + +Plugin files use `~~category` as a placeholder for whatever tool the user connects in that category. This plugin is Gmail-specific by design, but references `~~browser` for any browser automation that may be needed. + +## Connectors for this plugin + +| Category | Placeholder | Included | Required? | +|----------|-------------|----------|-----------| +| Email | `~~email` | Gmail (via MCP) | Yes — core functionality | +| Browser | `~~browser` | Claude in Chrome | For unsubscribe clicks and bulk UI actions | + +## Capability Tiers + +The plugin degrades gracefully based on what's connected. `/gmail-prepare` reports exactly what's available before starting. + +### Tier 1 — Gmail MCP only +- Inbox audit (counts, top senders by volume) +- Reading email subjects and bodies for classification +- Unread audit and flagging actionable emails +- Searching inbox and trash + +**Not available**: Bulk delete, filter management, label creation, mark-as-read operations, unsubscribes requiring button clicks + +### Tier 2 — Gmail MCP + Claude in Chrome +Everything in Tier 1, plus: +- Bulk delete via Gmail web UI (select all → trash by search) +- Unsubscribe button clicks on pages that require `isTrusted: true` events +- OAuth authorization flow (reading auth code from browser URL bar) + +**Not available**: Filter management (create/delete), precise bulk label operations at scale + +### Tier 3 — Gmail MCP + Claude in Chrome + Python Gmail API +Full capability: +- All Tier 1 and Tier 2 features +- Sender index building (batch metadata fetch — much faster than MCP search loops) +- Precise bulk operations via `batchModify` (1000 messages per call) +- Filter create, backup, audit, delete via `gmail.settings.basic` scope +- Label create and management +- Accurate trash search (MCP search does not reliably isolate trash) +- All script-based operations in `scripts/` + +## Gmail MCP + +Pre-configured in `.mcp.json`. Provides read access to Gmail: +- `gmail_get_profile` — account info and total message count +- `gmail_search_messages` — search inbox with query strings +- `gmail_read_message` / `gmail_read_thread` — read email content +- `gmail_list_labels` — list all Gmail labels + +**Limitation**: The Gmail MCP is read-only and does not reliably isolate `in:trash` searches. Use Python API scripts for trash operations. + +## Python Gmail API (Tier 3 Setup) + +Requires: +1. A Google Cloud project with Gmail API enabled +2. An OAuth 2.0 client ID (download as `credentials.json`) +3. Running `scripts/oauth_setup.py` to generate a dual-scope token + +The token needs two scopes: +- `gmail.modify` — message operations (trash, label, archive, mark read) +- `gmail.settings.basic` — filter and label management + +See `scripts/oauth_setup.py` for the full setup flow. The standard local-server redirect does not work in this environment — the script uses a manual code-capture pattern via the browser URL bar. diff --git a/partner-built/gmail-inbox-cleaner/LICENSE b/partner-built/gmail-inbox-cleaner/LICENSE new file mode 100644 index 00000000..df26d19d --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Siddhant Kalra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/partner-built/gmail-inbox-cleaner/README.md b/partner-built/gmail-inbox-cleaner/README.md new file mode 100644 index 00000000..48a554ae --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/README.md @@ -0,0 +1,81 @@ +# Gmail Inbox Cleaner + +A systematic Gmail inbox overhaul plugin. Four stages — Preparation, Action, Future Proofing, Safety Review — designed for any inbox, any user. Claude does the reading and recommending; you make every final decision. + +## The Flow + +``` +PREPARATION → /gmail-prepare + Tool check + Full inbox audit (read ALL subject lines, then email bodies for ambiguous senders) + Unread audit (surface actionable emails, ask user how to manage unreads) + +ACTION → /gmail-cleanup + Bulk delete by Claude's content analysis (NOT by Gmail category) + Sender-by-sender review loop (one sender at a time, full picture before deciding) + +FUTURE PROOFING → /gmail-future-proof + Label + filter system (Claude suggests based on review; user decides everything) + Unsubscribe sweep (trashed marketing senders) + +SAFETY REVIEW → /gmail-bin-audit + Bin audit for false positives + Restore plan presented to user before execution + Final report + safe to empty trash +``` + +**Important**: Tell the user at the start — do not empty the trash until the Safety Review stage is complete. + +## Commands + +| Command | Stage | What it does | +|---------|-------|-------------| +| `/gmail-prepare` | Preparation | Tool check, full audit (subjects + bodies), unread review | +| `/gmail-cleanup` | Action | Bulk delete by content, then sender-by-sender review loop | +| `/gmail-future-proof` | Future Proofing | Label/filter system + unsubscribe sweep | +| `/gmail-bin-audit` | Safety Review | Trash audit, restore plan, final report | + +## Prerequisites + +1. **Gmail MCP connected** — for reading and searching emails +2. **Claude in Chrome enabled** — for bulk UI actions and button-click unsubscribes (Settings → Desktop app → Claude in Chrome) +3. **Python + Gmail API client** — for precision operations + ``` + pip install google-api-python-client google-auth-oauthlib + ``` +4. **Google Cloud credentials** — a `credentials.json` from Google Cloud Console with a configured OAuth 2.0 client +5. **OAuth token with dual scopes** — `gmail.modify` + `gmail.settings.basic` + +The plugin works without Python/API access but with reduced capability (no filter management, limited bulk operations). `/gmail-prepare` will tell you exactly what's available before starting. + +## OAuth Setup + +Two OAuth scopes are required — `gmail.modify` for message operations and `gmail.settings.basic` for filter management. The standard local-server OAuth flow doesn't work in this environment (sandbox localhost ≠ your browser's localhost). The plugin uses a manual code-capture flow instead: + +1. Claude generates the auth URL +2. You open it in your browser and click Allow +3. The browser shows "connection refused" — expected +4. Claude reads the auth code from your browser's URL bar automatically +5. Token is exchanged and saved with your refresh token + +Run `scripts/oauth_setup.py --credentials credentials.json --token token.json` and follow the prompts. + +## State Files + +The plugin maintains persistent state across sessions: + +| File | Contents | +|------|----------| +| `sender_index.json` | Full inbox grouped by sender, resume pointer (`next_sender_idx`) | +| `inbox_decisions.json` | Every sender decision — survives session resets | +| `filters_backup.json` | Filter snapshot before any bulk filter operation | +| `token.json` | OAuth token with refresh_token | + +## Key Design Principles + +- **Never delete by Gmail category** — important emails land in Promotions and Social. Decisions are based on reading actual content. +- **Sender-first, always** — processing emails in sequential order misses the full picture per sender. Every sender is reviewed as a complete unit. +- **User approves before every bulk action** — no autonomous mass deletions. Claude presents a plan; you approve. +- **Persist after every sender** — session timeouts are real. Decisions are saved immediately, not batched. +- **Back up before filter operations** — always snapshot filters before any bulk delete. +- **Bin audit before emptying trash** — the safety net for false positives from bulk delete. diff --git a/partner-built/gmail-inbox-cleaner/commands/bin-audit.md b/partner-built/gmail-inbox-cleaner/commands/bin-audit.md new file mode 100644 index 00000000..2871311b --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/commands/bin-audit.md @@ -0,0 +1,76 @@ +--- +description: Run Safety Review — bin audit before user clears trash +allowed-tools: Bash, Read, Write +--- + +Run the Safety Review (Bin Audit). This is the final stage. Remind the user: this is why they held off emptying trash. + +## Step 1 — Scan Trash + +**Always use the Python API for trash searches** — the Gmail MCP does not reliably isolate `in:trash` and may return inbox/sent/draft results. + +Run targeted searches: +```bash +# Financial +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/search_trash.py \ + --token token.json \ + --query "invoice OR receipt OR statement OR payment OR balance OR refund" \ + --output trash_financial.json + +# Government and legal +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/search_trash.py \ + --token token.json \ + --query "tax OR permit OR visa OR notice OR compliance OR deadline" \ + --output trash_legal.json + +# Personal senders (gmail.com, hotmail.com, outlook.com, yahoo.com) +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/search_trash.py \ + --token token.json \ + --query "from:gmail.com OR from:hotmail.com OR from:outlook.com OR from:yahoo.com" \ + --output trash_personal.json + +# Transactional +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/search_trash.py \ + --token token.json \ + --query "confirmation OR booking OR itinerary OR reservation OR ticket OR \"your order\"" \ + --output trash_transactional.json +``` + +Also check two-way relationships: for any flagged sender domain, run `gmail_search_messages` with `in:sent to:sender@domain.com` — if the user ever replied, flag it. + +## Step 2 — Cross-Check Decisions + +For each flagged item, check `inbox_decisions.json`: +- **Explicitly decided trash by the user** → keep trashed (informed decision) +- **Not in decisions** (caught by bulk delete before review) → surface as potential false positive + +## Step 3 — Present Restore Plan to User + +Do not restore anything without presenting the plan first: +- What was found (sender, subject samples, date range, why flagged) +- Whether it was explicitly decided or caught by bulk delete +- Where it will be restored (inbox, or a specific label) + +Get explicit approval, then execute: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action restore --ids-file restore_ids.json + +# To restore directly into a label: +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action restore --ids-file restore_ids.json --label-id Label_XXX +``` + +## Step 4 — Final Report + +Present full summary across all stages: +- **Before / After**: message counts +- **Trashed**: count by category +- **Archived**: count +- **Labeled**: count, by label +- **Unsubscribed**: count and list +- **Restored from bin**: count +- **Labels created**: list +- **Filters created**: list + +Then tell the user: "The bin audit is complete. It is now safe to empty your trash if you choose." Do not empty it on their behalf — that is a permanent deletion and must be a deliberate user action. diff --git a/partner-built/gmail-inbox-cleaner/commands/cleanup.md b/partner-built/gmail-inbox-cleaner/commands/cleanup.md new file mode 100644 index 00000000..07264c10 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/commands/cleanup.md @@ -0,0 +1,64 @@ +--- +description: Run Action Stage — bulk delete then sender review loop +allowed-tools: Bash, Read, Write +--- + +Run the Action Stage. Requires `/gmail-prepare` to have completed first (`sender_index.json` and `inbox_decisions.json` must exist). + +## Step 1 — Bulk Delete by Content Markers + +**This is NOT a category delete.** Do not mass-delete Gmail's Promotions, Social, or Forums tabs — important emails land there regularly. The bulk delete is based on Claude's reading of actual content from the Preparation Stage. + +Load the sender index and Claude's Round 1/2 classifications. Identify senders recommended for bulk delete: those where every email is clearly noise (all promotional subjects, bulk email infrastructure domains, completely inactive relationships, pure notification digests with no actionable content). + +Present the bulk delete plan grouped by type (job alerts, real estate alerts, social digests, re-engagement, marketing newsletters, etc.) with counts. Get explicit user approval before executing. + +Execute: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action trash --ids-file bulk_delete_ids.json +``` + +Report how many emails were trashed and the updated inbox count. + +## Step 2 — Sender-by-Sender Review Loop + +Every sender NOT covered by the bulk delete goes through this loop — one sender at a time, all emails from that sender reviewed before moving to the next. + +**Why sender-first**: Processing emails in time order catches the same sender across multiple batches, making decisions inconsistent and preventing you from seeing the complete relationship per sender. + +Load `sender_index.json`. Start from `next_sender_idx`. Skip senders already in `inbox_decisions.json`. + +**For each sender:** + +1. Show: name, domain, count, date range, ALL subject lines, Claude's recommended action + one-line reasoning +2. Offer to show email bodies before user decides (for ambiguous cases) +3. Present via AskUserQuestion: Trash / Archive / Label + Archive / Keep / Split / Skip +4. If Label: show existing labels, offer to create a new one +5. Execute immediately — do not queue decisions +6. Save to `inbox_decisions.json`: + ```bash + # Trash: + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action trash --ids-file sender_ids.json + + # Archive: + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action archive --ids-file sender_ids.json + + # Label + Archive: + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action label --label-id Label_XXX --ids-file sender_ids.json + ``` +7. Advance `next_sender_idx` in `sender_index.json` + +**Always flag before suggesting trash:** +- Email contains receipt, invoice, or confirmation number +- Email addresses user by personal name (not "Dear Customer") +- Sender domain is a financial institution, government body, or legal firm +- User has replied to this sender (check `in:sent to:sender@domain.com`) +- Subject contains: contract, agreement, tax, visa, permit, renewal, deadline + +**After every 10 senders:** report progress, ask to continue or pause. + +**Session resume**: `next_sender_idx` in `sender_index.json` is the resume pointer. Any new session picks up exactly where the last left off. diff --git a/partner-built/gmail-inbox-cleaner/commands/future-proof.md b/partner-built/gmail-inbox-cleaner/commands/future-proof.md new file mode 100644 index 00000000..5bc688b3 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/commands/future-proof.md @@ -0,0 +1,68 @@ +--- +description: Run Future Proofing stage — label/filter system and unsubscribe sweep +allowed-tools: Bash, Read, Write +--- + +Run the Future Proofing Stage. Run after the Action Stage is complete or substantially complete. + +## Step 1 — Label and Filter System + +The sender review loop gives Claude everything it needs to suggest intelligent filter categories. Do not build filters before the review — the review is the research. + +Analyze `inbox_decisions.json` for keep/label patterns. Propose label categories with: +- Suggested label name (user renames freely) +- What goes in it and why (derived from the review, not pre-defined) +- Sender domains/addresses to match +- Skip inbox? (ask per label) +- Mark as read on arrival? (**always ask explicitly — default is no**) + +Wait for user approval before creating each label. + +**Always back up existing filters first:** +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_filters.py \ + --token token.json backup --output filters_backup.json +``` + +**Create labels:** +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_labels.py \ + --token token.json create --name "Label Name" +``` + +Note the returned label ID for the filter step. + +**Create filters:** +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_filters.py --token token.json create \ + --from "sender1.com sender2@domain.com" --label-id Label_XXXXX [--skip-inbox] [--mark-read] +``` + +Filter notes: +- `--from` is space-separated — Gmail OR's the list automatically +- Never pass `--mark-read` unless user explicitly confirmed it +- Senders where emails sometimes need action should stay in inbox even if labeled + +After creating, list all active filters to confirm: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_filters.py --token token.json list +``` + +## Step 2 — Unsubscribe Sweep + +From `inbox_decisions.json` (trashed senders), identify marketing ones. Build the target list — exclude regulatory/legal senders and transactional-only senders (receipts, bookings). + +For each candidate, get the unsubscribe URL: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/get_unsubscribe_url.py \ + --token token.json --sender sender@domain.com --index sender_index.json +``` + +Present the full plan to the user before executing: sender, URL found (or "none found"), execution method (GET / Chrome click / form). Let user remove any senders they want to stay subscribed to. + +Execute based on method (see `references/unsubscribe-patterns.md`): +- Direct GET: `WebFetch` the URL, check response for confirmation text +- Button click: Claude in Chrome — navigate, find button, real click +- Form-based: Claude in Chrome — navigate, select reason, submit + +Log all results. Present the completed log to the user. diff --git a/partner-built/gmail-inbox-cleaner/commands/prepare.md b/partner-built/gmail-inbox-cleaner/commands/prepare.md new file mode 100644 index 00000000..31517e83 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/commands/prepare.md @@ -0,0 +1,83 @@ +--- +description: Run full preparation stage — tools, audit, and unread review +allowed-tools: Bash, Read, Write +--- + +Run the full Preparation Stage. This must complete before any action is taken on the inbox. + +**Tell the user immediately**: Do not empty your trash/bin until the Safety Review stage at the very end. Items deleted during cleanup are recoverable until that final stage. + +## Step 1 — Tool Check + +Verify in order: + +1. **Gmail MCP**: call `gmail_get_profile` — if it works, Tier 1 is available +2. **Claude in Chrome**: ask if enabled (Settings → Desktop app → Claude in Chrome) — Tier 2 +3. **Python API**: `python3 -c "import googleapiclient; print('OK')"` — if missing, install: + ```bash + pip install google-api-python-client google-auth-oauthlib --break-system-packages + ``` +4. **OAuth token**: check if `token.json` exists with both scopes. If not, run: + ```bash + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/oauth_setup.py \ + --credentials credentials.json --token token.json + ``` + +Report what's available and what each tier unlocks. Proceed even if some tools are missing. + +## Step 2 — Full Inbox Audit + +1. Use `gmail_get_profile` to get total message count and account address. +2. Build the sender index (Tier 3 preferred): + ```bash + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/build_sender_index.py \ + --token token.json --output sender_index.json + ``` + Warn the user this takes several minutes for large inboxes. Tier 1 fallback: use `gmail_search_messages` in a loop to pull sender/subject metadata in batches. + +3. Report: total messages, unique senders, top 20 by volume, distribution. + +**Reading Round 1 — All subject lines per sender:** + +Work sender-by-sender from highest volume. For each, read ALL subject lines (not a sample). Classify: +- **Clear noise** → recommend bulk delete +- **Clear signal** → recommend keep or label +- **Ambiguous** → flag for Round 2 + +**Reading Round 2 — Bodies for ambiguous senders:** + +For each Round 1 ambiguous sender, use `gmail_read_message` to read 2-3 full emails. Reclassify accordingly. + +After both rounds, present a summary: bulk-delete candidates by category, keep/label recommendations, senders needing user input. + +## Step 3 — Unread Audit + +Fetch all unread inbox messages. Classify: + +**Needs attention** (surface explicitly): +- Requires response, action, or signature +- Invoice, contract, deadline, outstanding balance +- Government or regulatory notice +- Account security alert +- Time-sensitive items + +**Informational unread** (can bulk-mark-read): +- Newsletters, digests the user already knows about +- Automated reports with no required action +- Notifications with no action required + +Present: "needs attention" list with enough detail to act, informational count, suggested default approach. Ask how the user wants to handle each category. **Do not mark anything read without explicit approval.** + +When approved, mark the confirmed set as read: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action mark-read --ids-file approved_unread_ids.json +``` + +## Output + +At end of preparation: +- Total messages and senders audited +- Bulk delete candidates: count and categories +- Unread status: handled / pending user decision +- Recommendation for how to proceed into Action Stage diff --git a/partner-built/gmail-inbox-cleaner/scripts/batch_action.py b/partner-built/gmail-inbox-cleaner/scripts/batch_action.py new file mode 100644 index 00000000..8b4bc369 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/batch_action.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Batch action on Gmail messages — trash, archive, label, restore, or mark read. + +Usage: + python3 batch_action.py --token token.json --action trash --ids-file ids.json + python3 batch_action.py --token token.json --action label --label-id Label_XXX --ids-file ids.json + python3 batch_action.py --token token.json --action mark-read --ids-file ids.json + python3 batch_action.py --token token.json --action restore --ids-file ids.json + +ids.json should be a JSON array of Gmail message ID strings. +""" +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + +CHUNK_SIZE = 1000 # Gmail batchModify limit + + +def chunk(lst, size): + for i in range(0, len(lst), size): + yield lst[i:i + size] + + +def trash(service, ids): + for c in chunk(ids, CHUNK_SIZE): + service.users().messages().batchModify( + userId="me", + body={"ids": c, "addLabelIds": ["TRASH"], "removeLabelIds": ["INBOX"]} + ).execute() + time.sleep(0.2) + print(f"Trashed {len(ids)} messages") + + +def archive(service, ids): + for c in chunk(ids, CHUNK_SIZE): + service.users().messages().batchModify( + userId="me", + body={"ids": c, "removeLabelIds": ["INBOX"]} + ).execute() + time.sleep(0.2) + print(f"Archived {len(ids)} messages") + + +def label(service, ids, label_id, skip_inbox=True): + action = {"addLabelIds": [label_id]} + if skip_inbox: + action["removeLabelIds"] = ["INBOX"] + for c in chunk(ids, CHUNK_SIZE): + service.users().messages().batchModify(userId="me", body={"ids": c, **action}).execute() + time.sleep(0.2) + print(f"Labeled {len(ids)} messages → {label_id}") + + +def mark_read(service, ids): + for c in chunk(ids, CHUNK_SIZE): + service.users().messages().batchModify( + userId="me", + body={"ids": c, "removeLabelIds": ["UNREAD"]} + ).execute() + time.sleep(0.2) + print(f"Marked {len(ids)} messages as read") + + +def restore(service, ids, label_id=None): + add = ["INBOX"] + ([label_id] if label_id else []) + for c in chunk(ids, CHUNK_SIZE): + service.users().messages().batchModify( + userId="me", + body={"ids": c, "removeLabelIds": ["TRASH"], "addLabelIds": add} + ).execute() + time.sleep(0.2) + print(f"Restored {len(ids)} messages to inbox") + + +def main(): + parser = argparse.ArgumentParser(description="Batch action on Gmail messages.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON") + parser.add_argument("--action", required=True, + choices=["trash", "archive", "label", "mark-read", "restore"], + help="Action to perform") + parser.add_argument("--ids-file", required=True, help="JSON file containing list of message IDs") + parser.add_argument("--label-id", help="Label ID (required for --action label, optional for restore)") + parser.add_argument("--keep-in-inbox", action="store_true", + help="For label action: keep in inbox (don't set skip-inbox)") + args = parser.parse_args() + + with open(args.ids_file) as f: + ids = json.load(f) + + if not ids: + print("No message IDs provided.") + return + + service = get_service(args.token) + print(f"Action: {args.action} on {len(ids)} messages") + + if args.action == "trash": + trash(service, ids) + elif args.action == "archive": + archive(service, ids) + elif args.action == "label": + if not args.label_id: + print("ERROR: --label-id required for label action") + sys.exit(1) + label(service, ids, args.label_id, skip_inbox=not args.keep_in_inbox) + elif args.action == "mark-read": + mark_read(service, ids) + elif args.action == "restore": + restore(service, ids, label_id=args.label_id) + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/scripts/build_sender_index.py b/partner-built/gmail-inbox-cleaner/scripts/build_sender_index.py new file mode 100644 index 00000000..e9996348 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/build_sender_index.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Build a sender index of the Gmail inbox. + +Groups every inbox message by sender (email address), sorted by volume. +Produces sender_index.json — the foundation for the entire cleanup process. +Includes a next_sender_idx pointer so sessions can resume exactly where they left off. + +Usage: + python3 build_sender_index.py --token /path/to/token.json --output sender_index.json +""" +import argparse +import json +import re +import sys +import time +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + + +def get_all_inbox_ids(service): + ids, pt = [], None + while True: + kw = {"userId": "me", "q": "in:inbox", "maxResults": 500} + if pt: + kw["pageToken"] = pt + r = service.users().messages().list(**kw).execute() + ids.extend(m["id"] for m in r.get("messages", [])) + pt = r.get("nextPageToken") + print(f"\r {len(ids)} message IDs collected...", end="", flush=True) + if not pt: + break + print() + return ids + + +def fetch_metadata_batch(service, ids): + results = {} + + def cb(request_id, response, exception): + if exception or not response: + return + hdrs = {h["name"].lower(): h["value"] + for h in response.get("payload", {}).get("headers", [])} + results[response["id"]] = { + "from": hdrs.get("from", ""), + "subject": hdrs.get("subject", "(no subject)"), + "date": hdrs.get("date", ""), + "ts": int(response.get("internalDate", "0")), + } + + batch = service.new_batch_http_request(callback=cb) + for mid in ids: + batch.add( + service.users().messages().get( + userId="me", id=mid, format="metadata", + metadataHeaders=["From", "Subject", "Date"], + ), + request_id=mid, + ) + batch.execute() + return results + + +def parse_sender(from_str): + m = re.search(r"<([^>]+)>", from_str) + if m: + email = m.group(1).lower().strip() + name = re.sub(r"\s*<[^>]+>", "", from_str).strip().strip("\"'") + return name or email, email + email = from_str.lower().strip() + return email, email + + +def main(): + parser = argparse.ArgumentParser(description="Build Gmail sender index.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON file") + parser.add_argument("--output", default="sender_index.json", help="Output file path") + args = parser.parse_args() + + service = get_service(args.token) + print("=== Building Sender Index ===\n") + + print("Collecting inbox message IDs...") + all_ids = get_all_inbox_ids(service) + print(f" {len(all_ids)} messages total\n") + + print("Fetching From/Subject/Date metadata (batch API)...") + all_meta = {} + n_batches = (len(all_ids) + 99) // 100 + for i in range(0, len(all_ids), 100): + batch_num = i // 100 + 1 + print(f"\r Batch {batch_num}/{n_batches} ({len(all_meta)} done)", end="", flush=True) + all_meta.update(fetch_metadata_batch(service, all_ids[i:i + 100])) + if batch_num % 20 == 0: + time.sleep(0.3) + print(f"\n {len(all_meta)} messages fetched\n") + + print("Grouping by sender...") + groups = defaultdict(list) + for mid, m in all_meta.items(): + name, email = parse_sender(m["from"]) + groups[email].append({ + "id": mid, "name": name, + "subject": m["subject"], "date": m["date"], "ts": m["ts"], + }) + + for email in groups: + groups[email].sort(key=lambda x: x["ts"], reverse=True) + + senders = sorted([ + {"email": email, "name": msgs[0]["name"], "count": len(msgs), "messages": msgs} + for email, msgs in groups.items() + ], key=lambda x: x["count"], reverse=True) + + index = { + "built": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "total_inbox_messages": len(all_meta), + "total_senders": len(senders), + "next_sender_idx": 0, + "senders": senders, + } + + Path(args.output).write_text(json.dumps(index, indent=2)) + print(f"Index saved to: {args.output}") + print(f"\nTop 15 senders by volume:") + for s in senders[:15]: + print(f" {s['count']:>5} {s['email']}") + print(f"\nTotal: {len(senders)} unique senders, {len(all_meta)} messages") + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/scripts/get_unsubscribe_url.py b/partner-built/gmail-inbox-cleaner/scripts/get_unsubscribe_url.py new file mode 100644 index 00000000..fca665d2 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/get_unsubscribe_url.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Extract unsubscribe URL from a Gmail message. + +Checks List-Unsubscribe header first (most reliable). +Falls back to extracting all hrefs from the raw HTML body. + +Usage: + python3 get_unsubscribe_url.py --token token.json --message-id MSG_ID + python3 get_unsubscribe_url.py --token token.json --sender "sender@domain.com" --index sender_index.json +""" +import argparse +import base64 +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + + +def get_unsubscribe_from_header(service, message_id): + """Extract HTTP unsubscribe URL from List-Unsubscribe header.""" + msg = service.users().messages().get( + userId="me", id=message_id, format="metadata", + metadataHeaders=["List-Unsubscribe", "List-Unsubscribe-Post"] + ).execute() + headers = {h["name"].lower(): h["value"] + for h in msg.get("payload", {}).get("headers", [])} + unsub = headers.get("list-unsubscribe", "") + post = headers.get("list-unsubscribe-post", "") + urls = re.findall(r"<(https?://[^>]+)>", unsub) + return urls[0] if urls else None, bool(post) + + +def get_hrefs_from_body(service, message_id): + """Extract all hrefs from the HTML body when List-Unsubscribe header is absent.""" + msg = service.users().messages().get(userId="me", id=message_id, format="full").execute() + + def extract_html(payload): + parts = [] + if payload.get("mimeType") == "text/html": + data = payload.get("body", {}).get("data", "") + if data: + parts.append(base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore")) + for part in payload.get("parts", []): + parts.extend(extract_html(part)) + return parts + + all_hrefs = [] + for html in extract_html(msg.get("payload", {})): + all_hrefs.extend(re.findall(r'href=["\']([^"\']+)["\']', html)) + return all_hrefs + + +def find_message_id_for_sender(sender_email, index_path): + with open(index_path) as f: + index = json.load(f) + for s in index["senders"]: + if s["email"].lower() == sender_email.lower(): + return s["messages"][0]["id"] if s["messages"] else None + return None + + +def main(): + parser = argparse.ArgumentParser(description="Get unsubscribe URL from a Gmail message.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--message-id", help="Gmail message ID") + group.add_argument("--sender", help="Sender email address (looks up most recent message from sender_index.json)") + parser.add_argument("--index", default="sender_index.json", help="Path to sender_index.json (used with --sender)") + args = parser.parse_args() + + service = get_service(args.token) + + if args.sender: + message_id = find_message_id_for_sender(args.sender, args.index) + if not message_id: + print(f"No messages found for sender: {args.sender}") + sys.exit(1) + print(f"Using most recent message from {args.sender}: {message_id}") + else: + message_id = args.message_id + + # Try header first + url, has_post = get_unsubscribe_from_header(service, message_id) + if url: + print(f"\nList-Unsubscribe URL found:") + print(f" {url}") + if has_post: + print(f" (one-click POST supported via List-Unsubscribe-Post header)") + print(f"\nMethod: {'POST' if has_post else 'GET'}") + else: + print("\nNo List-Unsubscribe header found. Extracting hrefs from HTML body...") + hrefs = get_hrefs_from_body(service, message_id) + unsub_candidates = [h for h in hrefs + if any(kw in h.lower() for kw in ["unsubscribe", "optout", "opt-out", "manage-preferences"])] + print(f"\nAll hrefs ({len(hrefs)} total):") + for h in hrefs[-10:]: # show last 10 — unsubscribe link is usually near the end + print(f" {h}") + if unsub_candidates: + print(f"\nLikely unsubscribe URLs:") + for h in unsub_candidates: + print(f" {h}") + print("\nMethod: Manual review required — navigate to URL in browser") + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/scripts/gmail_service.py b/partner-built/gmail-inbox-cleaner/scripts/gmail_service.py new file mode 100644 index 00000000..83742bf1 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/gmail_service.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" +Shared Gmail service builder. Import this in other scripts. + +Usage: + from gmail_service import get_service + service = get_service("/path/to/token.json") +""" +import json +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request +from googleapiclient.discovery import build + + +def get_service(token_path: str): + """Build and return an authenticated Gmail API service object. + Automatically refreshes the token if expired.""" + with open(token_path) as f: + data = json.load(f) + + creds = Credentials( + token=data.get("token") or data.get("access_token"), + refresh_token=data.get("refresh_token"), + token_uri=data.get("token_uri", "https://oauth2.googleapis.com/token"), + client_id=data["client_id"], + client_secret=data["client_secret"], + scopes=data.get("scopes"), + ) + + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + data["token"] = creds.token + with open(token_path, "w") as f: + json.dump(data, f, indent=2) + + return build("gmail", "v1", credentials=creds) diff --git a/partner-built/gmail-inbox-cleaner/scripts/manage_filters.py b/partner-built/gmail-inbox-cleaner/scripts/manage_filters.py new file mode 100644 index 00000000..2430be2b --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/manage_filters.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Create, list, backup, audit, and delete Gmail filters. +Requires gmail.settings.basic OAuth scope. + +Usage: + python3 manage_filters.py --token token.json list + python3 manage_filters.py --token token.json backup --output filters_backup.json + python3 manage_filters.py --token token.json audit + python3 manage_filters.py --token token.json create --from "sender1@example.com sender2.com" --label-id Label_XXX [--skip-inbox] [--mark-read] + python3 manage_filters.py --token token.json delete --filter-id ANe1BmjXXX + python3 manage_filters.py --token token.json delete-all # DESTRUCTIVE — always backup first +""" +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + +# Gmail system category label IDs — filters that only set these are "ghost" filters +SYSTEM_CATEGORY_LABELS = { + "CATEGORY_PROMOTIONS", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", + "CATEGORY_FORUMS", "CATEGORY_PERSONAL", +} +# Actions that do nothing meaningful on their own (no routing, no user label) +INERT_ACTIONS_ONLY = {"markImportant", "neverMarkSpam", "star", "markRead"} + + +def list_filters(service): + result = service.users().settings().filters().list(userId="me").execute() + filters = result.get("filter", []) + print(f"\n{len(filters)} filters:\n") + for i, f in enumerate(filters): + c = f.get("criteria", {}) + a = f.get("action", {}) + print(f" [{i:2d}] id={f['id']}") + print(f" criteria: {c}") + print(f" action: {a}") + return filters + + +def backup_filters(service, output_path): + result = service.users().settings().filters().list(userId="me").execute() + filters = result.get("filter", []) + Path(output_path).write_text(json.dumps(filters, indent=2)) + print(f"Backed up {len(filters)} filters to: {output_path}") + return filters + + +def is_ghost_filter(f): + """Return True if a filter has no meaningful routing action.""" + action = f.get("action", {}) + if not action: + return True + add = set(action.get("addLabelIds", [])) + remove = set(action.get("removeLabelIds", [])) + # Only system category labels — no user-created label routing + if add and add.issubset(SYSTEM_CATEGORY_LABELS) and not remove: + return True + # Only inert actions (mark important, never spam, star) with no label routing + all_action_keys = set(action.keys()) - {"addLabelIds", "removeLabelIds"} + if not add and not remove and all_action_keys.issubset(INERT_ACTIONS_ONLY): + return True + return False + + +def audit_filters(service): + result = service.users().settings().filters().list(userId="me").execute() + filters = result.get("filter", []) + ghosts = [f for f in filters if is_ghost_filter(f)] + real = [f for f in filters if not is_ghost_filter(f)] + + print(f"\nTotal filters: {len(filters)}") + print(f"Ghost filters (no meaningful action): {len(ghosts)}") + print(f"Real filters: {len(real)}") + + if ghosts: + print("\n--- Ghost filters ---") + for f in ghosts: + print(f" {f['id']} criteria={f.get('criteria', {})} action={f.get('action', {})}") + + if real: + print("\n--- Real filters ---") + for f in real: + print(f" {f['id']} criteria={f.get('criteria', {})} action={f.get('action', {})}") + + return ghosts, real + + +def create_filter(service, from_senders, label_id, skip_inbox=False, mark_read=False): + action = {"addLabelIds": [label_id]} + remove = [] + if skip_inbox: + remove.append("INBOX") + if mark_read: + remove.append("UNREAD") + if remove: + action["removeLabelIds"] = remove + + body = { + "criteria": {"from": from_senders}, # space-separated; Gmail OR's them + "action": action, + } + result = service.users().settings().filters().create(userId="me", body=body).execute() + print(f"Filter created: {result['id']}") + print(f" From: {from_senders}") + print(f" Action: {action}") + return result["id"] + + +def delete_filter(service, filter_id): + service.users().settings().filters().delete(userId="me", id=filter_id).execute() + print(f"Deleted filter: {filter_id}") + + +def delete_all_filters(service): + """DESTRUCTIVE. Always backup first.""" + result = service.users().settings().filters().list(userId="me").execute() + filters = result.get("filter", []) + print(f"Deleting {len(filters)} filters...") + for f in filters: + service.users().settings().filters().delete(userId="me", id=f["id"]).execute() + time.sleep(0.1) + print("All filters deleted.") + + +def main(): + parser = argparse.ArgumentParser(description="Manage Gmail filters.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON (needs gmail.settings.basic scope)") + parser.add_argument("command", choices=["list", "backup", "audit", "create", "delete", "delete-all"]) + parser.add_argument("--output", default="filters_backup.json", help="Output path for backup") + parser.add_argument("--from", dest="from_senders", help="Space-separated sender addresses/domains for filter") + parser.add_argument("--label-id", help="Label ID to apply") + parser.add_argument("--skip-inbox", action="store_true", help="Remove from inbox (skip inbox routing)") + parser.add_argument("--mark-read", action="store_true", help="Mark as read on arrival (default: off)") + parser.add_argument("--filter-id", help="Filter ID to delete") + args = parser.parse_args() + + service = get_service(args.token) + + if args.command == "list": + list_filters(service) + elif args.command == "backup": + backup_filters(service, args.output) + elif args.command == "audit": + audit_filters(service) + elif args.command == "create": + if not args.from_senders or not args.label_id: + print("ERROR: --from and --label-id required for create") + sys.exit(1) + create_filter(service, args.from_senders, args.label_id, + skip_inbox=args.skip_inbox, mark_read=args.mark_read) + elif args.command == "delete": + if not args.filter_id: + print("ERROR: --filter-id required for delete") + sys.exit(1) + delete_filter(service, args.filter_id) + elif args.command == "delete-all": + confirm = input("This will delete ALL filters. Type 'yes' to confirm: ") + if confirm.strip().lower() == "yes": + delete_all_filters(service) + else: + print("Aborted.") + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/scripts/manage_labels.py b/partner-built/gmail-inbox-cleaner/scripts/manage_labels.py new file mode 100644 index 00000000..577096fe --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/manage_labels.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Create, list, and look up Gmail labels. + +Usage: + python3 manage_labels.py --token token.json list + python3 manage_labels.py --token token.json create --name "Receipts" + python3 manage_labels.py --token token.json get-id --name "Finance" +""" +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + + +def list_labels(service): + result = service.users().labels().list(userId="me").execute() + user_labels = [l for l in result.get("labels", []) if l.get("type") == "user"] + system_labels = [l for l in result.get("labels", []) if l.get("type") == "system"] + + print(f"\nUser labels ({len(user_labels)}):") + for l in sorted(user_labels, key=lambda x: x["name"]): + print(f" {l['id']:45s} {l['name']}") + + print(f"\nSystem labels ({len(system_labels)}):") + for l in sorted(system_labels, key=lambda x: x["name"]): + print(f" {l['id']:45s} {l['name']}") + + return {l["name"]: l["id"] for l in result.get("labels", [])} + + +def create_label(service, name): + # Check if already exists + result = service.users().labels().list(userId="me").execute() + for l in result.get("labels", []): + if l["name"].lower() == name.lower(): + print(f"Label already exists: '{l['name']}' → {l['id']}") + return l["id"] + + label = service.users().labels().create( + userId="me", + body={ + "name": name, + "labelListVisibility": "labelShow", + "messageListVisibility": "show", + } + ).execute() + print(f"Created label: '{label['name']}' → {label['id']}") + return label["id"] + + +def get_label_id(service, name): + result = service.users().labels().list(userId="me").execute() + for l in result.get("labels", []): + if l["name"].lower() == name.lower(): + print(f"{l['id']}") + return l["id"] + print(f"Label not found: '{name}'") + return None + + +def main(): + parser = argparse.ArgumentParser(description="Manage Gmail labels.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON") + parser.add_argument("command", choices=["list", "create", "get-id"]) + parser.add_argument("--name", help="Label name (for create and get-id)") + args = parser.parse_args() + + service = get_service(args.token) + + if args.command == "list": + list_labels(service) + elif args.command == "create": + if not args.name: + print("ERROR: --name required for create") + sys.exit(1) + create_label(service, args.name) + elif args.command == "get-id": + if not args.name: + print("ERROR: --name required for get-id") + sys.exit(1) + get_label_id(service, args.name) + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/scripts/oauth_setup.py b/partner-built/gmail-inbox-cleaner/scripts/oauth_setup.py new file mode 100644 index 00000000..736dd7e6 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/oauth_setup.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +OAuth token setup — dual scope (gmail.modify + gmail.settings.basic). + +The sandbox localhost is unreachable from the user's browser, so the +standard local-server redirect flow doesn't work. This script generates +the auth URL. Claude in Chrome navigates to it, the user clicks Allow, +Chrome redirects to localhost (shows "connection refused" — expected), +and Claude reads the auth code from the tab URL via tabs_context_mcp. + +Usage: + python3 oauth_setup.py --credentials /path/to/credentials.json --token /path/to/token.json +""" +import argparse +import json +import secrets +import urllib.parse +import requests + +SCOPES = [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.settings.basic", +] +REDIRECT_URI = "http://localhost" +TOKEN_URI = "https://oauth2.googleapis.com/token" +AUTH_URI = "https://accounts.google.com/o/oauth2/auth" + + +def load_credentials(path): + with open(path) as f: + data = json.load(f) + installed = data.get("installed") or data.get("web", {}) + return installed["client_id"], installed["client_secret"] + + +def generate_auth_url(client_id): + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "scope": " ".join(SCOPES), + "state": secrets.token_urlsafe(16), + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "false", + } + return AUTH_URI + "?" + urllib.parse.urlencode(params) + + +def exchange_code(code, client_id, client_secret): + resp = requests.post(TOKEN_URI, data={ + "code": code, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": REDIRECT_URI, + "grant_type": "authorization_code", + }) + resp.raise_for_status() + return resp.json() + + +def save_token(token_data, client_id, client_secret, path): + token_data["client_id"] = client_id + token_data["client_secret"] = client_secret + token_data["token_uri"] = TOKEN_URI + token_data["scopes"] = SCOPES + with open(path, "w") as f: + json.dump(token_data, f, indent=2) + print(f"Token saved to: {path}") + print(f"Scopes: {SCOPES}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Set up Gmail OAuth token with dual scopes.") + parser.add_argument("--credentials", required=True, help="Path to credentials.json from Google Cloud Console") + parser.add_argument("--token", required=True, help="Output path for token.json") + parser.add_argument("--code", help="Auth code (if already captured from browser URL bar)") + args = parser.parse_args() + + client_id, client_secret = load_credentials(args.credentials) + + if args.code: + token_data = exchange_code(args.code, client_id, client_secret) + save_token(token_data, client_id, client_secret, args.token) + else: + url = generate_auth_url(client_id) + print("\nOpen this URL in your browser:") + print(url) + print("\nAfter clicking Allow, the browser will show 'connection refused' — that is expected.") + print("The auth code is in the URL bar. Run this script again with --code to complete setup.") diff --git a/partner-built/gmail-inbox-cleaner/scripts/search_trash.py b/partner-built/gmail-inbox-cleaner/scripts/search_trash.py new file mode 100644 index 00000000..41bbab5a --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/scripts/search_trash.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Search trash accurately using the Gmail API. + +NOTE: The Gmail MCP connector does not reliably isolate trash — it returns +inbox/sent/draft messages regardless of 'in:trash' in the query. This script +uses the API directly with labelIds=['TRASH'] for accurate results. + +Outputs a JSON file with matching message metadata for review. + +Usage: + python3 search_trash.py --token token.json --query "invoice OR receipt OR contract" --output trash_hits.json + python3 search_trash.py --token token.json --output trash_hits.json # all trash messages +""" +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from gmail_service import get_service + + +def search_trash(service, query_fragment=""): + q = f"in:trash {query_fragment}".strip() + ids, pt = [], None + while True: + kw = {"userId": "me", "q": q, "labelIds": ["TRASH"], "maxResults": 500} + if pt: + kw["pageToken"] = pt + r = service.users().messages().list(**kw).execute() + ids.extend(m["id"] for m in r.get("messages", [])) + pt = r.get("nextPageToken") + print(f"\r {len(ids)} matching messages...", end="", flush=True) + if not pt: + break + print() + return ids + + +def fetch_metadata_batch(service, ids): + results = {} + + def cb(request_id, response, exception): + if exception or not response: + return + hdrs = {h["name"].lower(): h["value"] + for h in response.get("payload", {}).get("headers", [])} + results[response["id"]] = { + "from": hdrs.get("from", ""), + "subject": hdrs.get("subject", "(no subject)"), + "date": hdrs.get("date", ""), + } + + batch = service.new_batch_http_request(callback=cb) + for mid in ids: + batch.add( + service.users().messages().get( + userId="me", id=mid, format="metadata", + metadataHeaders=["From", "Subject", "Date"], + ), + request_id=mid, + ) + batch.execute() + return results + + +def main(): + parser = argparse.ArgumentParser(description="Search Gmail trash accurately.") + parser.add_argument("--token", required=True, help="Path to OAuth token JSON") + parser.add_argument("--query", default="", help="Search query fragment (appended to 'in:trash')") + parser.add_argument("--output", default="trash_hits.json", help="Output JSON file path") + args = parser.parse_args() + + service = get_service(args.token) + print(f"Searching trash: '{('in:trash ' + args.query).strip()}'") + + ids = search_trash(service, args.query) + print(f"Found {len(ids)} messages. Fetching metadata...") + + all_meta = {} + for i in range(0, len(ids), 100): + all_meta.update(fetch_metadata_batch(service, ids[i:i + 100])) + if i % 2000 == 0 and i > 0: + time.sleep(0.3) + + # Group by sender + from collections import defaultdict + import re + groups = defaultdict(list) + for mid, m in all_meta.items(): + from_str = m["from"] + match = re.search(r"<([^>]+)>", from_str) + email = match.group(1).lower() if match else from_str.lower() + groups[email].append({"id": mid, "subject": m["subject"], "date": m["date"]}) + + results = sorted([ + {"email": email, "count": len(msgs), "messages": msgs} + for email, msgs in groups.items() + ], key=lambda x: x["count"], reverse=True) + + output = {"query": args.query, "total_hits": len(ids), "senders": results} + Path(args.output).write_text(json.dumps(output, indent=2)) + + print(f"\nTop senders in results:") + for s in results[:20]: + print(f" {s['count']:>5} {s['email']}") + print(f"\nFull results saved to: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/SKILL.md b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/SKILL.md new file mode 100644 index 00000000..8d3f0ee3 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/SKILL.md @@ -0,0 +1,56 @@ +--- +name: inbox-cleanup +description: > + Full Gmail inbox overhaul methodology. Use this skill when the user says + "clean my inbox", "audit my Gmail", "clear out my email", "help me + unsubscribe from everything", "set up Gmail filters", "rebuild my Gmail + filters", "go through my emails", or "help me get to inbox zero". This + skill encodes a four-stage methodology — Preparation, Action, Future + Proofing, Safety Review — designed to work for any inbox with Claude's + intelligence driving decisions and the user making final calls. +version: 0.3.0 +--- + +# Gmail Inbox Cleaner + +Four-stage systematic Gmail cleanup. Claude reads and recommends; the user decides everything. + +**Tell the user at the start**: Do not empty your trash until the Safety Review stage at the end. + +## Stages and Commands + +``` +PREPARATION → /gmail-prepare + Tool check → Full audit (all subject lines, then bodies for ambiguous senders) → Unread audit + +ACTION → /gmail-cleanup + Bulk delete by content markers (not by Gmail category) → Sender-by-sender review loop + +FUTURE PROOFING → /gmail-future-proof + Label + filter system (Claude suggests from review; user decides) → Unsubscribe sweep + +SAFETY REVIEW → /gmail-bin-audit + Trash scan for false positives → Restore plan with user approval → Final report +``` + +## Tool Tiers + +See `CONNECTORS.md` for what each tier enables. + +| Tier | Tools | Capability | +|------|-------|------------| +| 1 | Gmail MCP | Read-only audit, subject/body reading | +| 2 | + Claude in Chrome | Bulk delete UI, unsubscribe clicks, OAuth flow | +| 3 | + Python Gmail API | Sender index, batch operations, filter management, accurate trash search | + +Scripts are in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Full methodology detail is in `references/methodology.md`. Decision heuristics are in `references/decision-framework.md`. Unsubscribe patterns are in `references/unsubscribe-patterns.md`. + +## Key Rules + +- Never bulk-delete by Gmail category — read content first +- Sender-first always — see the full relationship before deciding +- Execute and persist after every individual sender — don't queue decisions +- Backup filters before any filter operation — use `manage_filters.py backup` +- Use `search_trash.py` for trash searches — MCP search does not reliably isolate trash +- Never mark emails as read without explicit user confirmation +- Never restore from trash without presenting the plan and getting approval first diff --git a/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/decision-framework.md b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/decision-framework.md new file mode 100644 index 00000000..9361fb37 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/decision-framework.md @@ -0,0 +1,170 @@ +# Decision Framework + +Heuristics for classifying senders during the audit and review loop. These are Claude's starting positions — the user overrides everything. + +The framework applies at two levels: sender-level (is this whole sender relationship noise or signal?) and email-level (within a sender, are some emails different in character?). + +--- + +## The Carrier vs. Cargo Test + +Before applying any heuristic, ask: **Is this email a carrier or cargo?** + +- **Carrier** — the sender wants something from the user: attention, clicks, re-engagement, a purchase. The email exists to serve the sender's interests. → Candidate for trash/unsubscribe. +- **Cargo** — the email is delivering something the user requested or agreed to receive: a statement, receipt, alert, notice, confirmation. The email serves the user's interests. → Keep or label. + +The same sender can be both. A bank that sends monthly statements (cargo) may also send promotional credit card offers (carrier). Distinguish by reading the actual subjects — never assume based on domain alone. + +--- + +## Strong Bulk-Delete Signals + +These signal the entire sender relationship is noise. Present as candidates for bulk delete without individual review. + +**Sender local-part patterns** (the part before @): +``` +noreply, no-reply, no_reply, donotreply, do-not-reply, +newsletter, newsletters, digest, weekly, daily, monthly, +marketing, promo, promotions, offers, deals, +notifications, notification, notify, alerts, alert, +updates, bounce, mailer, mailer-daemon, +automated, auto, robot, bot, system +``` + +**Sender domain infrastructure patterns** (bulk email providers with no personal relationship): +``` +*.sendgrid.net +*.mailchimp.com / *.list-manage.com +*.constantcontact.com +*.klaviyo.com +*.exacttarget.com +*.marketo.com +*.hubspot.com (marketing emails — different from transactional HubSpot) +*.greenhouse.io (job application systems) +*.lever.co (recruiting platforms) +*.breezy.hr +*.smartrecruiters.com +``` + +**Subject line patterns** (if ALL subjects from a sender match these, it's noise): +``` +% off / save X% / up to X% off +sale ends / limited time / ends tonight +exclusive offer / special offer / just for you +deal of the day / flash sale +promo code / discount code +weekly digest / monthly roundup / weekly picks +X new jobs matching / Y properties matching +someone liked / X people viewed / you have X new followers +we miss you / come back / it's been a while +``` + +**Category of senders that are almost always bulk-deletable:** +- Job board alerts (matching jobs, new postings from sites the user hasn't actively used) +- Real estate listing alerts (new listings, price drops from sites with no active search) +- Social notification digests (platform engagement summaries, "you have new followers") +- Inactive app re-engagement ("we haven't seen you in a while") +- Event recommendation emails (suggested events, things happening near you) +- Rewards program newsletters (points balance reminders, earning opportunities) + +--- + +## Always-Keep Signals + +Never auto-trash. Always show to user with a keep recommendation. + +**Domain categories:** +- Government domains (`.gov`, `.gc.ca`, `.gov.in`, `.nic.in`, etc.) +- Financial regulators and statutory bodies +- Legal correspondence (law firms, courts) +- Healthcare providers +- Educational institutions (personal correspondence, not newsletters) +- Immigration authorities + +**Sender relationship signals:** +- Any sender the user has replied to at any point (check sent mail for matching threads) +- Any sender the email address is a named individual at a company (e.g., `john.smith@company.com`) +- Any sender where subjects include personal names in the body/subject + +**Subject keywords that override any other signal:** +``` +invoice, receipt, confirmation, booking, itinerary, +contract, agreement, statement, notice, form, +deadline, renewal, expiry, action required, +tax, return, refund, payment due, outstanding balance, +password reset, security alert, login attempt, +visa, permit, application, approval, rejection +``` + +--- + +## Label-and-Archive Candidates + +Emails worth keeping but that should live outside the main inbox. Claude should suggest label categories based on what it finds in the review — do not impose specific label names. These are patterns, not prescriptions. + +**Common label categories that emerge across most inboxes:** + +| Pattern | Suggested label concept | Stay in inbox? | +|---------|------------------------|---------------| +| Order/delivery confirmations from regular services | Purchases or Receipts | Usually no | +| Bank statements, brokerage reports, fund notices | Financial | Usually no | +| Building/property management, landlord notices | Housing or Property | Depends — if actionable (maintenance, fees) → yes | +| Employer, HR, payroll correspondence | Work | Usually yes — may need action | +| Government, tax authorities | Government | Usually yes — needs attention | +| Publications/newsletters the user reads actively | Reading or Newsletters | No — they choose when to read | +| Healthcare, medical providers | Health | Usually yes | + +**Key question to ask the user for each proposed label:** +1. Should new emails from these senders skip your inbox, or stay visible? +2. Should they arrive unread (default) or be marked read on arrival? + +Never assume mark-as-read. Some users want labeled emails to remain unread as a reminder. Always ask. + +--- + +## Ambiguous Sender Types — Read Bodies, Not Just Subjects + +These warrant round 2 (reading actual email bodies) before recommending an action: + +**Financial services senders** — distinguish between: +- Statements and transaction alerts (cargo → keep) +- Promotional offers, cashback emails, credit card pitches (carrier → trash/unsub) + +**SaaS product senders** — distinguish between: +- Account activity, billing receipts, security notifications (cargo → keep) +- Product updates, feature announcements, newsletters (carrier → depends on user preference) +- Re-engagement campaigns (carrier → trash/unsub) + +**E-commerce platforms** — distinguish between: +- Receipts and order confirmations (cargo → label Purchases) +- Marketing ("items in your cart", "you might like") (carrier → trash/unsub) +- Shipping/delivery notifications (cargo → label Purchases or keep) + +**Social platforms** — distinguish between: +- Security alerts, login notifications, account changes (cargo → keep) +- Engagement summaries, notification digests (carrier → trash/unsub) +- Messages from other users forwarded by email (cargo → depends) + +--- + +## Mixed-Sender Handling + +When a single sender has some cargo and some carrier emails, do not treat them as one block. Propose split treatment to the user: + +> "This sender has 34 emails — 8 are transaction receipts from 2022-2024, 26 are promotional newsletters. I recommend keeping the receipts and trashing the newsletters. Want me to split them?" + +Split by: subject keyword match, date range, or specific subject patterns. Execute each batch separately. + +--- + +## Regulatory Senders — Cannot Unsubscribe + +Some senders are legally required to contact the user and have no unsubscribe mechanism. Attempting to unsubscribe will fail. Do not include them in the unsubscribe sweep: + +- Financial regulatory bodies (securities commissions, investment regulators) +- Fund registrars sending statutory investor notices +- Government tax authorities +- Immigration authorities +- Any sender whose emails reference a legal obligation or regulatory requirement + +Recommend keeping these. They cannot be filtered out. Best practice: label them and let them stay visible. diff --git a/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/methodology.md b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/methodology.md new file mode 100644 index 00000000..0e31af29 --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/methodology.md @@ -0,0 +1,227 @@ +# Methodology Reference + +Full 4-stage workflow detail. The SKILL.md is the trigger and overview — this is the operational guide. + +--- + +## STAGE 1: PREPARATION + +### 1.1 Tool Check + +Verify in order: +1. Gmail MCP: call `gmail_get_profile` — if it works, Tier 1 is available +2. Claude in Chrome: ask if enabled (Settings → Desktop app → Claude in Chrome) — Tier 2 +3. Python API: `python3 -c "import googleapiclient; print('OK')"` — install if missing: `pip install google-api-python-client google-auth-oauthlib --break-system-packages` +4. OAuth token: check if `token.json` exists with both scopes. If not, run `${CLAUDE_PLUGIN_ROOT}/scripts/oauth_setup.py` — Tier 3 + +Report what's available and what each tier unlocks. Continue even if some tools are missing. + +### 1.2 Full Inbox Audit + +**Tier 3 (preferred):** Run `build_sender_index.py` to group all inbox messages by sender. Produces `sender_index.json` — the foundation for all subsequent phases. Warn the user this takes several minutes for large inboxes. + +**Tier 1 fallback:** Use `gmail_search_messages` in a loop to pull sender/subject metadata in batches. Slower but works without the API. + +Report: total messages, unique senders, top 20 by volume, distribution. + +**Reading Round 1 — All subject lines:** +Work sender-by-sender from highest volume. For each, read ALL subject lines (not a sample). Classify: +- Clear noise → recommend bulk delete +- Clear signal → recommend keep or label +- Ambiguous → flag for Round 2 + +**Reading Round 2 — Bodies for ambiguous senders:** +Use `gmail_read_message` to read 2-3 full emails per ambiguous sender. Enough to resolve classification. Reclassify accordingly. + +After both rounds, present a summary: bulk-delete candidates by category, keep/label recommendations, senders needing user input. + +### 1.3 Unread Audit + +Fetch all unread inbox messages. Classify: + +**Needs attention** (surface explicitly): +- Requires response, action, or signature +- Invoice, contract, deadline, outstanding balance +- Government or regulatory notice +- Account security alert +- Time-sensitive items + +**Informational unread** (can bulk-mark-read): +- Newsletters, digests the user already knows about +- Automated reports with no required action +- Notifications the user is aware of + +Present: "needs attention" list with enough detail to act, informational count, suggested default approach. Ask how they want to handle each category. Do not mark anything read without explicit approval. + +Mark approved set as read: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action mark-read --ids-file approved_unread_ids.json +``` + +--- + +## STAGE 2: ACTION + +### 2.1 Bulk Delete by Content Markers + +Not a category delete. Decisions come from Claude's reading in Stage 1, not Gmail's category tabs. + +Target: senders where every single email is clearly noise. Reference `decision-framework.md` for signals. + +Present the plan grouped by type (job alerts, real estate alerts, social digests, re-engagement, marketing newsletters, etc.) with counts. Get explicit user approval before executing. + +Execute: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action trash --ids-file bulk_delete_ids.json +``` + +Or via Claude in Chrome for search-based bulk select when Python API is unavailable. + +### 2.2 Sender-by-Sender Review Loop + +Load `sender_index.json`. Start from `next_sender_idx`. Skip senders already in `inbox_decisions.json`. + +**Per sender:** +1. Show: name, domain, count, date range, ALL subject lines, Claude's recommended action + one-line reasoning +2. Offer to show email bodies before user decides (for ambiguous cases) +3. AskUserQuestion: Trash / Archive / Label + Archive / Keep / Split / Skip +4. If Label: show existing labels, offer to create a new one +5. Execute immediately — do not queue decisions +6. Save to `inbox_decisions.json` +7. Advance `next_sender_idx` in `sender_index.json` + +**Always flag before suggesting trash:** +- Email contains receipt, invoice, or confirmation number +- Email addresses user by personal name (not "Dear Customer") +- Sender domain is a financial institution, government body, or legal firm +- User has replied to this sender (check `in:sent to:sender@domain.com`) +- Subject contains: contract, agreement, tax, visa, permit, renewal, deadline + +After every 10 senders: report progress, ask to continue or pause. + +--- + +## STAGE 3: FUTURE PROOFING + +### 3.1 Label and Filter System + +Run after sender review is substantially complete — the review is the research. + +Analyze `inbox_decisions.json` for keep/label patterns. Propose label categories with: +- Suggested label name (user renames freely) +- What goes in it and why (derived from the review, not pre-defined) +- Sender domains/addresses to match +- Skip inbox? (ask per label) +- Mark as read on arrival? (always ask explicitly — default is no) + +Backup first: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_filters.py \ + --token token.json backup --output filters_backup.json +``` + +Create labels: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_labels.py \ + --token token.json create --name "Label Name" +``` + +Create filters: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/manage_filters.py --token token.json create \ + --from "sender1.com sender2@domain.com" --label-id Label_XXXXX [--skip-inbox] [--mark-read] +``` + +`criteria.from` is space-separated — Gmail OR's the list automatically. Never pass `--mark-read` unless user explicitly confirmed it. + +### 3.2 Unsubscribe Sweep + +From `inbox_decisions.json` (trashed senders), identify marketing ones. For each: + +1. Get unsubscribe URL: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/get_unsubscribe_url.py \ + --token token.json --sender sender@domain.com --index sender_index.json +``` + +2. Execute based on platform (see `unsubscribe-patterns.md`): + - Direct GET: `WebFetch` the URL, check response for confirmation text + - Button click required: Claude in Chrome — navigate, find button, real click + - Form-based: Claude in Chrome — navigate, select reason, submit + +3. Log result to `unsub_log.json` + +Present plan before executing. Skip: regulatory senders, transactional-only, dead links. + +--- + +## STAGE 4: SAFETY REVIEW — BIN AUDIT + +Remind user: this is why they held off emptying trash. + +### 4.1 Scan Trash + +Always use Python API — MCP search is unreliable for trash: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/search_trash.py \ + --token token.json \ + --query "invoice OR receipt OR contract OR statement OR booking OR visa" \ + --output trash_hits.json +``` + +Run multiple targeted searches: +- Personal domains: `gmail.com OR hotmail.com OR outlook.com OR yahoo.com` +- Financial: `invoice OR statement OR payment OR balance OR refund` +- Government/legal: `tax OR permit OR visa OR notice OR compliance` +- Two-way relationships: check `in:sent to:` for each flagged sender domain + +### 4.2 Cross-Check Decisions + +For each flagged item: check `inbox_decisions.json`. +- Explicitly decided trash by the user → keep trashed (informed decision) +- Not in decisions (caught by bulk delete before review) → surface as potential false positive + +### 4.3 Restore Plan + Approval + +Do not restore anything without presenting the plan first: +- What was found (sender, subject samples, date range, why flagged) +- Whether it was explicitly decided or caught by bulk delete +- Where it will be restored (inbox, or a specific label) + +Get explicit approval, then execute: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/batch_action.py \ + --token token.json --action restore --ids-file restore_ids.json [--label-id Label_XXX] +``` + +### 4.4 Final Report + +Present full summary across all stages. Tell the user it is safe to empty trash if they choose. Do not empty it on their behalf. + +--- + +## State Files + +| File | Contents | Survives session reset? | +|------|----------|------------------------| +| `sender_index.json` | Full inbox by sender, `next_sender_idx` pointer | Yes | +| `inbox_decisions.json` | `sender → action` map — append only | Yes | +| `filters_backup.json` | Filter snapshot before bulk operations | Yes | +| `unsub_log.json` | Unsubscribe outcomes | Yes | +| `token.json` | OAuth token with refresh_token | Yes | + +--- + +## Common Failures and Fixes + +| Failure | Cause | Fix | +|---------|-------|-----| +| 403 on filter operations | Token missing `gmail.settings.basic` | Re-run `oauth_setup.py` with both scopes | +| Gmail UI click does nothing | `isTrusted: false` blocks synthetic events | Use Claude in Chrome for real clicks | +| OAuth redirect unreachable | Sandbox localhost ≠ browser localhost | Navigate Chrome to auth URL; read code from tab URL | +| Trash search returns inbox/sent/draft | Gmail MCP ignores `in:trash` | Use `search_trash.py` with Python API | +| "Select all conversations" banner missing | Must be in category tab, not search view | Navigate to category tab URL directly | +| Session reset loses decisions | Decisions not persisted per sender | `inbox_decisions.json` is the fix — write after every sender | +| Filter system wiped | "Delete all" included real filters | Always `backup` before any filter bulk operation | diff --git a/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/unsubscribe-patterns.md b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/unsubscribe-patterns.md new file mode 100644 index 00000000..0fb8fe2f --- /dev/null +++ b/partner-built/gmail-inbox-cleaner/skills/inbox-cleanup/references/unsubscribe-patterns.md @@ -0,0 +1,128 @@ +# Unsubscribe Patterns + +Platform-by-platform methods for firing unsubscribes. Always try List-Unsubscribe header first — it's the most reliable and doesn't require HTML parsing or browser automation. + +--- + +## Source Priority + +1. **`List-Unsubscribe` header** — standard RFC 2369 header present in most bulk-sent marketing email. Parse for HTTP URLs (``). A GET to this URL is sufficient for most senders. + +2. **`List-Unsubscribe-Post` header** — indicates one-click POST is supported (RFC 8058). POST to the List-Unsubscribe URL with body `List-Unsubscribe=One-Click`. + +3. **HTML body hrefs** — when no List-Unsubscribe header exists, decode the raw HTML body (base64) and extract all `href` attributes. The unsubscribe link is typically: + - Located near the bottom of the email + - The last unique URL without tracking parameters (no `ext=`, `utm_`, `trk=`) + - Often contains the words `unsubscribe`, `optout`, `opt-out`, or `manage` in the path + +--- + +## Execution Methods + +### Direct GET (Most Senders) + +A simple HTTP GET to the URL is sufficient. Use `WebFetch` or Python `requests`: + +```python +import requests +response = requests.get(unsubscribe_url, timeout=15, allow_redirects=True) +# Check response.text for confirmation message +``` + +Confirm by checking the response page text for words like "unsubscribed", "removed", "opted out". + +### Button-Click Required (Loops and Similar) + +**Symptom**: Navigating to the unsubscribe URL loads a page showing individual lists as "unsubscribed," but a global opt-out button is still toggled off. The GET alone does not complete the opt-out. + +**Detection**: URLs matching `app.loops.so/unsubscribe`, `loops.so`, or similar. Page shows subscription status but no auto-redirect. + +**Fix**: Use Claude in Chrome to navigate to the URL and click the opt-out button (e.g., "Unsubscribe from all future email"). A real browser click is required — synthetic JS events are rejected (`isTrusted: false`). + +**Verification**: The button text changes to confirm opt-out (e.g., "Resubscribe" appears, indicating the opt-out is active). + +### Form-Based Unsubscribes + +**Symptom**: URL loads a form with a reason dropdown and a submit button. No auto-opt-out on page load. + +**Fix**: Use Claude in Chrome to navigate, select a reason from the dropdown, and click Submit. The page typically navigates away after a successful submission. + +**Common reason options** (select any valid one — the specific reason doesn't affect the opt-out): +- "I no longer want to receive these emails" +- "I didn't subscribe to this list" +- "Too many emails" + +### One-Click POST + +If `List-Unsubscribe-Post` header is present: + +```python +import requests +response = requests.post( + unsubscribe_url, + data="List-Unsubscribe=One-Click", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15 +) +``` + +--- + +## Bulk Email Platforms — Known Patterns + +| Platform | Detection | Method | +|----------|-----------|--------| +| Loops | `loops.so` in URL | Chrome button click | +| HubSpot | `hubspot.com`, `hs-email-open.hubspotemail.net` in URL | Direct GET | +| MoEngage | `moengage.com` in URL or response body | Direct GET | +| Wix | `wix.com/consent` or similar in URL | Direct GET | +| SendGrid | `sendgrid.net`, `em.` subdomain patterns | Direct GET or One-Click POST | +| Mailchimp | `list-manage.com`, `mailchi.mp` in URL | Direct GET | +| Klaviyo | `klaviyo.com` in URL | Direct GET | +| Constant Contact | `constantcontact.com` in URL | Direct GET | +| Custom forms | No matching pattern; URL loads form | Chrome form submit | + +--- + +## What Not to Unsubscribe From + +**Regulatory and legally mandated senders** — these notices are required by law and have no opt-out: +- Financial regulatory bodies and securities commissions +- Fund registrars sending statutory investor notices +- Government tax authorities +- Immigration authorities +- Any sender whose emails reference a legal obligation + +**Transactional-only senders** — not marketing lists; unsubscribe links either don't exist or would disable important account alerts: +- Receipt and order confirmation senders +- Booking and reservation confirmation systems +- Bank and payment transaction alerts +- Security and account activity notifications + +**Dead or erroring links** — if the URL returns 404, 5xx, or redirects to a maintenance page: +- Log as "retry later" with the URL +- Do not attempt workarounds (cache, alternative domains) + +--- + +## Unsubscribe Log Template + +Maintain a log file during the sweep: + +```json +{ + "completed": [ + {"sender": "sender@domain.com", "platform": "HubSpot", "method": "GET", "status": "confirmed", "confirmation": "You have been unsubscribed"}, + {"sender": "news@service.com", "platform": "Loops", "method": "Chrome click", "status": "confirmed", "confirmation": "Button changed to Resubscribe"} + ], + "skipped": [ + {"sender": "notices@regulator.gov", "reason": "Regulatory — cannot opt out"}, + {"sender": "receipts@shop.com", "reason": "Transactional only — no marketing list"} + ], + "retry": [ + {"sender": "email@someservice.com", "reason": "URL returned 503 — site unavailable", "url": "https://..."} + ] +} +``` + +Present the completed log to the user at the end of the sweep. From 198b248a3d08771ac88e6820bb5dfc141328deaa Mon Sep 17 00:00:00 2001 From: Siddhant Kalra Date: Sun, 29 Mar 2026 16:07:59 -0400 Subject: [PATCH 2/2] Update gmail-inbox-cleaner: restructured README, updated plugin.json metadata - Replaced placeholder README with full methodology documentation: the problem with category-based deletion, two-pass reading approach, sender-first architecture and why it matters, the flag system, filter system derived from review data, unsubscribe sweep logic, bin audit rationale, tool tier model - Updated plugin.json repo URL to correct self-hosted repo - Synced all files with latest version of plugin --- partner-built/gmail-inbox-cleaner/README.md | 227 ++++++++++++++------ 1 file changed, 167 insertions(+), 60 deletions(-) diff --git a/partner-built/gmail-inbox-cleaner/README.md b/partner-built/gmail-inbox-cleaner/README.md index 48a554ae..11374e4f 100644 --- a/partner-built/gmail-inbox-cleaner/README.md +++ b/partner-built/gmail-inbox-cleaner/README.md @@ -1,81 +1,188 @@ # Gmail Inbox Cleaner -A systematic Gmail inbox overhaul plugin. Four stages — Preparation, Action, Future Proofing, Safety Review — designed for any inbox, any user. Claude does the reading and recommending; you make every final decision. +A Claude plugin for systematically overhauling a Gmail inbox. Not a one-click cleaner. Not a bulk-delete tool. A structured four-stage process where Claude reads your actual email, builds a complete picture of every sender relationship in your inbox, and works through decisions with you one sender at a time — with persistent state, a safety net before permanent deletion, and a filter system built from what it learned about your inbox rather than generic categories. -## The Flow +--- -``` -PREPARATION → /gmail-prepare - Tool check - Full inbox audit (read ALL subject lines, then email bodies for ambiguous senders) - Unread audit (surface actionable emails, ask user how to manage unreads) - -ACTION → /gmail-cleanup - Bulk delete by Claude's content analysis (NOT by Gmail category) - Sender-by-sender review loop (one sender at a time, full picture before deciding) - -FUTURE PROOFING → /gmail-future-proof - Label + filter system (Claude suggests based on review; user decides everything) - Unsubscribe sweep (trashed marketing senders) - -SAFETY REVIEW → /gmail-bin-audit - Bin audit for false positives - Restore plan presented to user before execution - Final report + safe to empty trash -``` +## The Problem With How People Clean Inboxes -**Important**: Tell the user at the start — do not empty the trash until the Safety Review stage is complete. +The standard approaches all have the same failure mode: they don't read the mail. -## Commands +**Bulk delete by Gmail category** nukes Promotions, Social, Updates. Sounds reasonable until you remember that invoices from freelancers land in Promotions, that security alerts go to Updates, that the rental agreement your landlord sent shows up in Social. Gmail's category tabs are routing heuristics, not intent signals. Trusting them for permanent deletion means you're one missed invoice away from a problem. + +**Manual scrolling** works but takes hours, makes inconsistent decisions across sessions, and still misses the full picture — you'll decide to keep an email from a sender without realizing they've sent you 60 others you'd want to trash. + +**Unsubscribe-first approaches** solve the wrong problem. Reducing future email doesn't address the existing backlog, and you end up unsubscribing from things that weren't actually bothering you while the real noise stays. + +--- + +## What This Plugin Does Instead + +Claude reads every email before touching anything. + +The preparation stage works through your entire inbox — not a sample, not the first N messages, every sender — in two passes. First pass: all subject lines for every sender, classified into clear noise (uniform promotional/automated subjects), clear signal (financial, transactional, legal, personal), and ambiguous (mixed or unclear subjects). Second pass: for ambiguous senders, Claude opens 2-3 actual emails and reads the body to resolve classification. By the end, Claude has a recommended action for every sender relationship in your inbox, grounded in reading the actual content. + +Then it presents that to you and asks for approval before doing anything. + +--- + +## Why Sender-First, Not Time-First + +Most email tools process messages in chronological order — newest first, or batches of 1000. This creates a real problem: the same sender appears across multiple batches, so you make decisions without seeing the full relationship. You decide to keep a message from `noreply@company.com` without realizing that sender has also emailed you 40 times with promotional content you'd want to trash. + +This plugin groups every message in your inbox by sender before doing anything. When it comes time to act, you see the complete picture for each sender: how many emails, the full date range, all subject lines, Claude's recommended action and the reasoning behind it. You make one decision per sender relationship, not one decision per email. + +That decision is then persisted immediately to `inbox_decisions.json`. Not batched, not held in memory — written to disk after every single sender. LLM sessions time out. Context windows fill up. The sender index stores your resume pointer so any new session picks up exactly where the last one left off, with no repeated decisions. + +--- + +## The Action Stage Logic + +Once preparation is done, the action stage runs in two phases. + +**Phase 1: bulk delete by content markers.** Claude identifies senders where every single email is unambiguously noise — uniform promotional subjects, bulk email infrastructure domains (sendgrid, mailchimp, klaviyo in the From header), re-engagement campaigns, pure digest senders with no actionable content. These are grouped by type, shown to you with counts, and you approve the list before anything moves to trash. This typically handles 30-60% of inbox volume in one shot. + +**Phase 2: sender-by-sender review loop.** Every sender not caught by the bulk phase gets individual review. Claude shows the full sender picture and its recommendation, flags anything that warrants caution (receipts, confirmation numbers, government or legal domains, emails you've ever replied to), and asks what you want to do: trash, archive, label and archive, keep, split by date, or skip for now. You decide; Claude executes immediately and saves the decision. + +--- + +## The Flag System + +Before recommending trash for any sender, Claude checks: + +- Does any email from this sender contain a receipt number, invoice, confirmation code, or booking reference? +- Does any email address you by name rather than "Dear Customer"? +- Is the sender domain a financial institution, government body, or legal firm? +- Have you ever replied to this sender? (Checked against sent mail) +- Do any subject lines contain: contract, agreement, tax, visa, permit, renewal, deadline? + +If any of these are true, Claude raises it explicitly before suggesting action. The goal is to make sure you're making an informed decision, not rubber-stamping a recommendation. + +--- + +## The Filter System Is Built From Your Inbox, Not Templates + +After the action stage, you have a complete record in `inbox_decisions.json` of every sender and what you decided to do with them. The future-proofing stage uses that as its source of truth. + +Claude analyzes the senders you chose to keep or label, identifies patterns (what types of email do you actually want to receive and organize?), and proposes label names with the specific sender domains/addresses that should route to each. No pre-defined categories. No assumptions about what matters to you. The labels and filters are a direct output of reading your inbox and learning your preferences. + +For each proposed label, Claude asks two questions before creating anything: should emails from these senders skip your inbox, and should they arrive pre-marked as read? Both questions have defaults of no. The plugin never assumes routing behavior or marks emails as read without explicit confirmation. -| Command | Stage | What it does | -|---------|-------|-------------| -| `/gmail-prepare` | Preparation | Tool check, full audit (subjects + bodies), unread review | -| `/gmail-cleanup` | Action | Bulk delete by content, then sender-by-sender review loop | -| `/gmail-future-proof` | Future Proofing | Label/filter system + unsubscribe sweep | -| `/gmail-bin-audit` | Safety Review | Trash audit, restore plan, final report | +Existing filters are always backed up before any filter operation (`filters_backup.json`). The backup step is not optional. -## Prerequisites +--- -1. **Gmail MCP connected** — for reading and searching emails -2. **Claude in Chrome enabled** — for bulk UI actions and button-click unsubscribes (Settings → Desktop app → Claude in Chrome) -3. **Python + Gmail API client** — for precision operations - ``` - pip install google-api-python-client google-auth-oauthlib - ``` -4. **Google Cloud credentials** — a `credentials.json` from Google Cloud Console with a configured OAuth 2.0 client -5. **OAuth token with dual scopes** — `gmail.modify` + `gmail.settings.basic` +## The Unsubscribe Sweep -The plugin works without Python/API access but with reduced capability (no filter management, limited bulk operations). `/gmail-prepare` will tell you exactly what's available before starting. +After decisions are made, Claude builds an unsubscribe list from the senders marked for trash. It fetches the most recent email from each and extracts the unsubscribe mechanism — in priority order: `List-Unsubscribe` header (fastest, works via GET or POST), then HTML body links (the last unique non-tracking URL in the email body is usually unsubscribe). -## OAuth Setup +Platform-specific behavior matters here. Some platforms (Loops, HubSpot) route through redirect chains before hitting the actual unsubscribe endpoint. Some require a real browser button click because they block synthetic JavaScript events (`isTrusted: false`). Some use multi-step forms. The plugin handles each case with the right tool — direct GET for simple links, Claude in Chrome for real clicks and form submissions. -Two OAuth scopes are required — `gmail.modify` for message operations and `gmail.settings.basic` for filter management. The standard local-server OAuth flow doesn't work in this environment (sandbox localhost ≠ your browser's localhost). The plugin uses a manual code-capture flow instead: +The full list is shown to you before any request is sent. You remove anyone you want to stay subscribed to. Outcomes are logged. -1. Claude generates the auth URL -2. You open it in your browser and click Allow -3. The browser shows "connection refused" — expected -4. Claude reads the auth code from your browser's URL bar automatically +--- + +## The Bin Audit + +Before you empty your trash, this plugin runs a targeted scan to catch false positives from the bulk delete phase — emails that looked like noise by sender but contained something important. + +The scan runs targeted searches against the trash (using the Python API directly, because the Gmail MCP does not reliably isolate `in:trash`): financial keywords, government and legal keywords, personal sender domains, transactional keywords. For each hit, it cross-checks `inbox_decisions.json`. If you explicitly decided to trash that sender in the review loop, it stays trashed — you made an informed decision. If it was caught by the bulk delete before individual review, Claude surfaces it as a potential false positive with subject samples and a restore plan. + +The restore plan is presented to you before anything moves. You approve what gets restored and where it goes. Only after your approval does Claude execute restores. + +Then the final report: before/after counts, everything trashed, archived, labeled, unsubscribed, restored. At that point, it's safe to empty your trash. + +--- + +## Tool Tiers + +The plugin degrades gracefully depending on what tools you have connected: + +| Tier | Tools | What's available | +|------|-------|-----------------| +| 1 | Gmail MCP | Read-only audit, subject and body reading, search | +| 2 | + Claude in Chrome | Bulk UI operations, unsubscribe button clicks, OAuth code capture | +| 3 | + Python Gmail API | Sender index, batch operations, filter management, accurate trash search | + +`/gmail-prepare` checks what's available and reports before starting. You can run a meaningful cleanup with Tier 1 alone; Tier 3 unlocks everything. + +--- + +## Installation + +```bash +claude plugin marketplace add siddhantkalra/gmail-inbox-cleaner-plugin +claude plugin install gmail-inbox-cleaner@gmail-inbox-cleaner-plugin +``` + +### Prerequisites + +- **Gmail MCP** — connect from Cowork settings or add to `.mcp.json` +- **Claude in Chrome** — enable at Settings → Desktop app → Claude in Chrome (Tier 2) +- **Python + Gmail API client** for Tier 3: + ```bash + pip install google-api-python-client google-auth-oauthlib + ``` +- **Google Cloud credentials** — `credentials.json` from Google Cloud Console with an OAuth 2.0 client configured for Gmail + +### OAuth Setup + +Two scopes are required: `gmail.modify` (message operations) and `gmail.settings.basic` (filter management). Standard local OAuth redirect doesn't work here — sandbox localhost is a different network namespace from your browser. The plugin handles this with a manual code-capture flow: + +1. Claude generates the authorization URL +2. You open it and click Allow +3. Browser shows "connection refused" — that's expected +4. Claude reads the auth code from your browser's address bar 5. Token is exchanged and saved with your refresh token -Run `scripts/oauth_setup.py --credentials credentials.json --token token.json` and follow the prompts. +```bash +python3 scripts/oauth_setup.py --credentials credentials.json --token token.json +``` + +--- + +## Commands + +| Command | What it does | +|---------|--------------| +| `/gmail-prepare` | Tool check → full two-pass inbox audit → unread review | +| `/gmail-cleanup` | Bulk delete by content markers → sender-by-sender review loop | +| `/gmail-future-proof` | Label/filter system from review data → unsubscribe sweep | +| `/gmail-bin-audit` | Trash scan → restore plan → final report | + +--- + +## Scripts + +All Python scripts have a CLI interface with `argparse`: + +| Script | What it does | +|--------|--------------| +| `oauth_setup.py` | OAuth flow with dual-scope token, manual code-capture mode | +| `gmail_service.py` | Shared authenticated service, auto-refreshes expired tokens | +| `build_sender_index.py` | Groups all inbox messages by sender with batch metadata fetch | +| `batch_action.py` | Trash / archive / label / mark-read / restore via batchModify | +| `manage_labels.py` | Create, list, get label IDs | +| `manage_filters.py` | Create, backup, audit, list, delete filters; ghost filter detection | +| `search_trash.py` | Accurate trash search using `labelIds: ['TRASH']` parameter | +| `get_unsubscribe_url.py` | Extracts List-Unsubscribe header or HTML body hrefs | + +--- ## State Files -The plugin maintains persistent state across sessions: +| File | Contents | Persists across sessions | +|------|----------|--------------------------| +| `sender_index.json` | All inbox messages grouped by sender + `next_sender_idx` resume pointer | Yes | +| `inbox_decisions.json` | Every sender decision, append-only | Yes | +| `filters_backup.json` | Filter snapshot before any bulk operation | Yes | +| `unsub_log.json` | Unsubscribe outcomes | Yes | +| `token.json` | OAuth token with refresh_token | Yes | + +--- -| File | Contents | -|------|----------| -| `sender_index.json` | Full inbox grouped by sender, resume pointer (`next_sender_idx`) | -| `inbox_decisions.json` | Every sender decision — survives session resets | -| `filters_backup.json` | Filter snapshot before any bulk filter operation | -| `token.json` | OAuth token with refresh_token | +## License -## Key Design Principles +MIT — see [LICENSE](LICENSE) for details. -- **Never delete by Gmail category** — important emails land in Promotions and Social. Decisions are based on reading actual content. -- **Sender-first, always** — processing emails in sequential order misses the full picture per sender. Every sender is reviewed as a complete unit. -- **User approves before every bulk action** — no autonomous mass deletions. Claude presents a plan; you approve. -- **Persist after every sender** — session timeouts are real. Decisions are saved immediately, not batched. -- **Back up before filter operations** — always snapshot filters before any bulk delete. -- **Bin audit before emptying trash** — the safety net for false positives from bulk delete. +Built by [Siddhant Kalra](https://github.com/siddhantkalra).