Cleaning Up 7,700 Emails From the Terminal With MCP, Gmail API and Microsoft Graph

58,000 unread emails on Outlook. 338 spams on Gmail. Newsletters from 2019, BitGo notifications for a wallet I closed years ago, Acadomia reminders for tutoring sessions I never gave, and somewhere in the middle, probably some important stuff. The plan was "I'll sort through it this weekend." That weekend was in 2023.

The idea: plug MCP (Model Context Protocol) into Gmail and Outlook so Claude Code can do the sorting from the terminal. No clicking through web UIs, no manual selection. Server-side filters, automated unsubscribes, and an encrypted repo to reinstall the whole setup on any machine. Result after 4 hours: ~7,700 emails processed, ~50 filters created, ~28 automated unsubscribes.

The MCP setup: two servers, two worlds

The Model Context Protocol lets Claude Code call external tools — here, the Gmail and Outlook APIs — through MCP servers that expose standardized "tools." Two servers, two very different experiences.

Gmail: the native claude.ai MCP (the one built into the web interface) is deliberately limited by Anthropic: no Spam folder access, no filter creation. Fine for reading emails, useless for automation. The community MCP @gongrzhe/server-gmail-autoauth-mcp exposes everything: create_filter, batch_modify_emails, batch_delete_emails, list_filters. Setup: a Google Cloud project with desktop OAuth, an npx command that launches the auth flow in the browser, and a refresh_token saved to ~/.gmail-mcp/credentials.json.

Outlook: the ryaker/outlook-mcp server is a community project wrapping Microsoft Graph. Functional, but with two bugs that need patching before it's usable for real work.

Azure AD: the OAuth trap that costs you 45 minutes

First instinct for the Azure app registration: "Public client/native" platform, as every tutorial suggests for CLI apps. Immediate error on token exchange:

AADSTS90023: Public clients can't send a client secret

The Outlook MCP sends a client_secret in confidential mode, but Azure treats the "Public client" platform as PKCE-only. The two are incompatible.

Fix: remove the "Public client" platform, add "Web" with the same redirect URI, and disable "Allow public client flows" in the app's Settings. If you forget that last checkbox, Azure keeps treating the app as public even with a Web platform configured.

Second trap, sneakier: for a personal Microsoft account (MSA, like @hotmail.com or @outlook.com), the tenant in the OAuth URL must be consumers:

https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize

Not the directory ID that the Azure portal shows by default in the app overview. Otherwise: AADSTS50020 — "user account from identity provider does not exist in tenant." The error message doesn't mention the tenant, of course.

Patching the Outlook MCP: scopes and env vars

Once OAuth was working, two problems in the Outlook MCP code itself:

Bug 1 — Scopes too narrow. The auth server hardcodes Mail.Read, Calendars.Read, Contacts.Read. To create automatic sorting rules on Outlook, you need Mail.ReadWrite and MailboxSettings.ReadWrite. Without the latter, the Graph API returns a silent 403 on POST /me/mailFolders/inbox/messageRules. One-line patch in outlook-auth-server.js.

Bug 2 — Inconsistent env var names. The auth server expects MS_CLIENT_ID / MS_CLIENT_SECRET. The runtime expects OUTLOOK_CLIENT_ID / OUTLOOK_CLIENT_SECRET. The .env needs both pairs, or you have to pass the variables via claude mcp add -e KEY=value at the user scope.

Two bugs, two trivial patches — but without them, nothing works and the error messages don't point to the cause.

RFC 8058: the instant unsubscribe nobody uses

This was the most interesting discovery of the entire session. RFC 8058 (2017) defines a one-shot unsubscribe mechanism via a single HTTP POST. The header List-Unsubscribe-Post: List-Unsubscribe=One-Click is present in the email, and all you need is to POST to the URL in List-Unsubscribe with the body List-Unsubscribe=One-Click.

No preference page. No "confirm your unsubscribe." No CAPTCHA. One POST, a 200/202/204, done.

import json, urllib.request, urllib.parse, re

TOKEN = "..."  # Gmail OAuth token
mid = "18f..."  # message ID

# Fetch List-Unsubscribe headers
url = (f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{mid}"
       "?format=metadata&metadataHeaders=List-Unsubscribe"
       "&metadataHeaders=List-Unsubscribe-Post")
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"})
data = json.loads(urllib.request.urlopen(req).read())
headers = {h['name'].lower(): h['value']
           for h in data['payload']['headers']}

# If sender supports RFC 8058
if 'One-Click' in headers.get('list-unsubscribe-post', ''):
    target = re.search(r'<(https?://[^>]+)>',
                       headers['list-unsubscribe']).group(1)
    urllib.request.urlopen(urllib.request.Request(
        target,
        data=b"List-Unsubscribe=One-Click",
        method='POST',
        headers={'Content-Type': 'application/x-www-form-urlencoded'}
    ))
    print(f"Unsubscribed from {target}")

70% of serious senders implement it: LinkedIn, BitGo, Coursera, AWS, Hellowork, Meteojob, NVIDIA, Indeed... But no mainstream UI exposes it. Everyone clicks "unsubscribe" in the email body and goes through 3 pages of preference center. The RFC is 9 years old and it's invisible.

Session results: 28 successful unsubscribes out of ~30 candidates, in milliseconds per sender. The 2 failures: malformed URLs in the header (one mailto: with no HTTP alternative, and one broken link).

For senders without RFC 8058, the fallback is the link in the email body. That means parsing HTML, finding the href containing "unsubscribe" or "preferences," and often submitting a Marketo or Eloqua form. Much more fragile, much slower. RFC 8058 is a different world.

When MCP fails: direct Graph API calls

The search-emails tool in the Outlook MCP has a sneaky problem: it silently ignores the date filter in the query string. received<2025-05-12 returns 2026 emails. For serious sweeps across thousands of messages, you need to bypass the MCP and hit Microsoft Graph directly.

# Grab the token from the MCP's token file
TOKEN=$(jq -r .access_token ~/.outlook-mcp-tokens.json)

# Graph query with actual date filtering
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://graph.microsoft.com/v1.0/me/messages?\$filter=receivedDateTime+lt+2025-05-12T00:00:00Z+and+hasAttachments+eq+false&\$top=999"

For bulk moves, the Graph $batch endpoint accepts 20 requests in one call. Throughput becomes reasonable: 5,000 emails moved in a few minutes.

import json, urllib.request

TOKEN = "..."

def move_batch(message_ids, destination="deleteditems"):
    """Move up to 20 emails per batch via Graph $batch"""
    body = {"requests": [
        {"id": str(i), "method": "POST",
         "url": f"/me/messages/{mid}/move",
         "headers": {"Content-Type": "application/json"},
         "body": {"destinationId": destination}}
        for i, mid in enumerate(message_ids[:20])
    ]}
    req = urllib.request.Request(
        "https://graph.microsoft.com/v1.0/$batch",
        data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {TOKEN}",
                 "Content-Type": "application/json"}
    )
    return json.loads(urllib.request.urlopen(req).read())

Gmail has its equivalent: POST /gmail/v1/users/me/messages/batchModify with up to 1,000 IDs in a single request. Even more efficient — Google clearly optimized for batch operations.

The token that expires mid-sweep

The Outlook token lives for one hour. On a 5,000-email sweep with pauses between batches, it expires in the middle of processing. First time it happens, it's a surprise — the first 200 emails go through, then 403 Unauthorized in a loop.

import json, urllib.request, urllib.parse

MS_CLIENT_ID = "..."
MS_CLIENT_SECRET = "..."

def refresh_token(creds):
    """Refresh the Outlook token via OAuth2"""
    data = urllib.parse.urlencode({
        'client_id': MS_CLIENT_ID,
        'client_secret': MS_CLIENT_SECRET,
        'refresh_token': creds['refresh_token'],
        'grant_type': 'refresh_token',
        'scope': 'offline_access Mail.ReadWrite MailboxSettings.ReadWrite',
    }).encode()
    r = json.loads(urllib.request.urlopen(
        urllib.request.Request(
            "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
            data=data
        )
    ).read())
    creds['access_token'] = r['access_token']
    return creds

Standard stuff, but part of the experience. The final script integrates auto-refresh before each batch, and the backup repo's bootstrap.sh handles it automatically.

Outlook folders vs Gmail labels

For automated sorting, Outlook and Gmail have opposite philosophies.

Outlook: hierarchical folders + rules with moveToFolder. I created 7 thematic folders (Finance, Server, Orders, FTX Recovery, Farnell, Acadomia, Lauréat) + 30 sorting rules. Result: the main inbox is reset to zero for transactional emails. Every new matching email goes straight to the right folder.

Gmail: labels, and that's it. An email can have N labels and still sits in the inbox. To achieve the same result as an Outlook folder, you need to combine a label + "Skip the Inbox" in the filter. Doable, but less visually clean — the inbox counter doesn't behave the same way.

19 Gmail filters created during the session: mainly to auto-archive GitHub notifications, server alerts, and newsletters I want to keep but not see in the inbox.

What Graph can't do

The blocked senders list (Junk Email blocked senders) isn't exposed by Microsoft Graph for personal MSA accounts. You have to go through the Outlook web UI or EWS legacy — an API that Microsoft has been deprecating since 2022 but which remains the only way to access certain features.

Workaround: create Graph rules that match scam domains and move emails to Deleted Items. Same effect, but less clean than the real Junk blocklist which prevents delivery altogether.

Microsoft has been promising to add this API "soon" for 2 years. It's in their public backlog. Still not there.

The encrypted backup repo

To reinstall the whole setup on a new machine in 2 minutes, I created a private GitHub repo with:

  • OAuth secrets encrypted with openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -salt
  • A bootstrap.sh that asks for the passphrase, decrypts, places files at the right paths, clones the Outlook MCP, patches the scopes, runs npm install, and registers both MCPs in Claude Code at user scope
  • Passphrase stored in 1Password

A fun detail: GitHub has "push protection" that detects Google client secret patterns (GOCSPX-*) and blocks the push — even on a private repo. AES encryption solves this as a side effect, since the pushed file is opaque binary.

Claude Code's auto-mode classifier

Several times during the session, Claude Code's classifier blocked actions: "move emails from Spam to Inbox," "delete a filter you didn't create in this session." Explicit validation required, even after a general go-ahead.

It's not a bug — it's deliberate protection against agents taking actions not explicitly authorized on sensitive data. In the context of email cleanup, it's sometimes frustrating (you'd want a --yes-i-know-what-im-doing flag), but it's probably the right approach for a tool with access to your mailbox.

Final numbers

Metric Volume
MCPs installed and configured2 (Gmail + Outlook)
MCP bugs patched2 (scopes + env vars Outlook)
Gmail filters created19
Outlook rules created30+
Outlook folders organized7
RFC 8058 unsubscribes28
Gmail emails processed~2,470
Outlook emails processed~5,250
Ad-hoc Python lines~400
Bash lines~200
Total session time~4h

Conclusion

The setup took longer than expected — patching the Outlook MCP, debugging Azure OAuth, handling expiring tokens. But once both MCP servers were functional, the execution speed was in a different league. Where I would have spent days clicking through web UIs, Claude Code chains filters, batch moves and RFC 8058 unsubscribes like a script — because that's what it is.

The real discovery is RFC 8058. A 9-year-old standard, implemented by most serious senders, and completely invisible in consumer interfaces. No email client exposes it with one click. You have to fetch the header yourself. That's exactly the kind of plumbing MCP makes accessible: not AI magic, just programmatic access to APIs nobody bothers to call manually.

Useful links to reproduce the setup: Gmail API, Microsoft Graph, RFC 8058, Gmail MCP Server, Outlook MCP.

Comments (0)