blob: bce1a433339718462257732725213c2dcbe9abe7 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Bulk-modify Gmail threads via the OAuth refresh-token flow.
Companion to ``create_draft``: same credentials file, same auth
flow, same broad ``https://mail.google.com/`` scope. Default behaviour
is *mark threads matching ``--query`` as read* (i.e. remove the
system ``UNREAD`` label). Add or remove arbitrary labels via
``--add-label`` / ``--remove-label``.
Why this exists: the claude.ai Gmail MCP connector's modify-side
tools (``unlabel_thread``, ``label_thread``) ride on an OAuth scope
set that excludes ``gmail.modify``, so bulk mark-as-read fails with
*"Request had insufficient authentication scopes"*. The
``oauth_curl`` backend's setup
(see ``tools/gmail/oauth-draft/README.md``) already grants the broader
scope, which covers modify, so this command just rides on the
credentials file the user already has.
The token returned by the refresh-token flow lives ~1 hour. The
script does not refresh mid-run; if you ever drive this against a
list of more than a few thousand threads, refactor to refresh on
401 / when ``time.monotonic() > token_acquired_at + 3000``.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from oauth_draft.credentials import (
GMAIL_API,
Credentials,
locate_credentials,
refresh_access_token,
)
def list_thread_ids(access_token: str, query: str) -> list[str]:
"""Paginate ``threads.list?q=<query>`` and return every matching thread ID."""
ids: list[str] = []
page_token: str | None = None
while True:
params = {"q": query, "maxResults": "500"}
if page_token:
params["pageToken"] = page_token
url = f"{GMAIL_API}/threads?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
data = json.loads(r.read())
except urllib.error.HTTPError as e:
raise SystemExit(
f"Gmail threads.list failed ({e.code}): {e.read().decode(errors='replace')}"
) from e
ids.extend(t["id"] for t in data.get("threads", []))
page_token = data.get("nextPageToken")
if not page_token:
break
return ids
def modify_thread(
access_token: str,
thread_id: str,
add_labels: list[str],
remove_labels: list[str],
) -> None:
payload: dict = {}
if add_labels:
payload["addLabelIds"] = add_labels
if remove_labels:
payload["removeLabelIds"] = remove_labels
req = urllib.request.Request(
f"{GMAIL_API}/threads/{thread_id}/modify",
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as r:
r.read()
except urllib.error.HTTPError as e:
raise SystemExit(
f"Gmail threads.modify failed ({e.code}) for {thread_id}: {e.read().decode(errors='replace')}"
) from e
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
ap = argparse.ArgumentParser(
description="Bulk-modify Gmail threads matching a search query.",
)
ap.add_argument(
"--query",
required=True,
help='Gmail search query, e.g. "label:apache-security in:spam is:unread".',
)
ap.add_argument(
"--add-label",
action="append",
default=[],
help="System or user label ID to add. Repeatable. Default: none.",
)
ap.add_argument(
"--remove-label",
action="append",
default=[],
help=(
"System or user label ID to remove. Repeatable. "
'Default when neither --add-label nor --remove-label is given: ["UNREAD"] '
"(i.e. mark threads as read)."
),
)
ap.add_argument(
"--execute",
action="store_true",
help=(
"Actually modify matching threads. The default is a list-only dry-run "
"(prints matches and the count, makes no Gmail writes). Required for "
"any mutation; the two-step shape is the safety gate against typo-induced "
"broad-query mailbox damage."
),
)
ap.add_argument(
"--max",
type=int,
default=None,
help="Cap the number of threads listed/modified. Useful for smoke tests.",
)
ap.add_argument(
"--credentials",
help=(
"Override the credentials file path. "
"Default: $GMAIL_OAUTH_CREDENTIALS or "
"~/.config/apache-magpie/gmail-oauth.json."
),
)
args = ap.parse_args(argv)
if not args.add_label and not args.remove_label:
args.remove_label = ["UNREAD"]
return args
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
creds_path = locate_credentials(args.credentials)
creds = Credentials.load(creds_path, require_from_address=False)
access_token = refresh_access_token(creds)
print(f"Listing threads matching: {args.query!r}", file=sys.stderr)
ids = list_thread_ids(access_token, args.query)
print(f"Found {len(ids)} matching thread(s).", file=sys.stderr)
if args.max is not None and len(ids) > args.max:
print(f"--max={args.max}: trimming to first {args.max}.", file=sys.stderr)
ids = ids[: args.max]
if not args.execute:
for tid in ids:
print(tid)
print(
f"Dry-run: {len(ids)} thread(s) would be modified "
f"(add: {args.add_label or '[]'}, remove: {args.remove_label or '[]'}). "
f"Re-run with --execute to apply.",
file=sys.stderr,
)
return 0
print(
f"Modifying {len(ids)} thread(s) "
f"(add: {args.add_label or '[]'}, remove: {args.remove_label or '[]'})...",
file=sys.stderr,
)
failed = 0
for i, tid in enumerate(ids, 1):
try:
modify_thread(access_token, tid, args.add_label, args.remove_label)
except SystemExit as e:
print(f"[{i}/{len(ids)}] FAILED {tid}: {e}", file=sys.stderr)
failed += 1
continue
if i % 25 == 0 or i == len(ids):
print(f"[{i}/{len(ids)}] done", file=sys.stderr)
if failed:
print(f"FAILED: {failed} thread(s) did not get modified.", file=sys.stderr)
return 1
print(f"OK: {len(ids)} thread(s) modified.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())