From 01a6fddaf3c462f2219232290ccc8d726c9f57fe Mon Sep 17 00:00:00 2001 From: Nitsan Avni Date: Fri, 27 Feb 2026 12:36:16 +0100 Subject: [PATCH 1/5] Add archive command to remove emails from inbox Adds gmail archive ... which removes the INBOX label, matching Gmail archive behavior. Adds gmail.modify scope to support this. Co-Authored-By: Claude Opus 4.6 --- auth.py | 1 + commands.py | 17 ++++++++++++++++- gmail_cli.py | 7 ++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/auth.py b/auth.py index 29018d0..d7b1ed8 100644 --- a/auth.py +++ b/auth.py @@ -14,6 +14,7 @@ SCOPES = [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.compose', + 'https://www.googleapis.com/auth/gmail.modify', ] BASE_DIR = Path(__file__).parent diff --git a/commands.py b/commands.py index 56dffaf..29d0c3e 100644 --- a/commands.py +++ b/commands.py @@ -7,7 +7,7 @@ from pathlib import Path from auth import authenticate -from html_to_markdown import convert_to_markdown +from markdownify import markdownify as convert_to_markdown def format_date(timestamp_ms: str) -> str: @@ -213,6 +213,21 @@ def cmd_send(args: argparse.Namespace) -> int: return 0 +def cmd_archive(args: argparse.Namespace) -> int: + """Archive emails by removing the INBOX label.""" + service = authenticate(args.account) + + for msg_id in args.ids: + service.users().messages().modify( + userId='me', + id=msg_id, + body={'removeLabelIds': ['INBOX']} + ).execute() + print(f'Archived: {msg_id}') + + return 0 + + def cmd_reply(args: argparse.Namespace) -> int: """Reply to an existing email or create a draft reply.""" body = get_body_content(args) diff --git a/gmail_cli.py b/gmail_cli.py index 246f99e..7042d0c 100644 --- a/gmail_cli.py +++ b/gmail_cli.py @@ -10,7 +10,7 @@ cmd_accounts_list, cmd_accounts_remove, ) -from commands import cmd_list, cmd_read, cmd_reply, cmd_send +from commands import cmd_archive, cmd_list, cmd_read, cmd_reply, cmd_send def add_compose_args(parser: argparse.ArgumentParser) -> None: @@ -64,6 +64,11 @@ def main() -> int: add_compose_args(reply_parser) reply_parser.set_defaults(func=cmd_reply) + # archive command + archive_parser = subparsers.add_parser('archive', help='Archive emails') + archive_parser.add_argument('ids', nargs='+', help='Message IDs to archive') + archive_parser.set_defaults(func=cmd_archive) + # accounts command accounts_parser = subparsers.add_parser('accounts', help='Manage Gmail accounts') accounts_parser.set_defaults(func=cmd_accounts) From 57e934d86b631f17798bb187aebcf5ca51822692 Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:23:01 -0400 Subject: [PATCH 2/5] fix: restore html_to_markdown import The archive branch swapped the HTML-to-markdown dependency from html_to_markdown to markdownify. markdownify is declared in neither pyproject.toml nor uv.lock, so `gmail read` raised ModuleNotFoundError on any HTML email. The swap is also unrelated to the archive feature. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands.py b/commands.py index 29d0c3e..313853b 100644 --- a/commands.py +++ b/commands.py @@ -7,7 +7,7 @@ from pathlib import Path from auth import authenticate -from markdownify import markdownify as convert_to_markdown +from html_to_markdown import convert_to_markdown def format_date(timestamp_ms: str) -> str: From 8e09151db02ce3f2d8e9ab301dfd96097b0ad1ca Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:24:35 -0400 Subject: [PATCH 3/5] fix: keep archiving remaining IDs when one message fails An invalid message ID raised HttpError out of the loop, so IDs before it were already archived while IDs after it silently never ran. Report the failure, continue the batch, and exit non-zero. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- commands.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/commands.py b/commands.py index 313853b..26f97cf 100644 --- a/commands.py +++ b/commands.py @@ -2,11 +2,13 @@ import argparse import base64 +import sys from datetime import datetime from email.mime.text import MIMEText from pathlib import Path from auth import authenticate +from googleapiclient.errors import HttpError from html_to_markdown import convert_to_markdown @@ -217,15 +219,22 @@ def cmd_archive(args: argparse.Namespace) -> int: """Archive emails by removing the INBOX label.""" service = authenticate(args.account) + failed = 0 for msg_id in args.ids: - service.users().messages().modify( - userId='me', - id=msg_id, - body={'removeLabelIds': ['INBOX']} - ).execute() + try: + service.users().messages().modify( + userId='me', + id=msg_id, + body={'removeLabelIds': ['INBOX']} + ).execute() + except HttpError as exc: + # Keep going so one bad ID doesn't strand the rest of the batch. + print(f'Error archiving {msg_id}: {exc.reason}', file=sys.stderr) + failed += 1 + continue print(f'Archived: {msg_id}') - return 0 + return 1 if failed else 0 def cmd_reply(args: argparse.Namespace) -> int: From 368f827e17010857a749dffdc0c9332f97754cda Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:24:35 -0400 Subject: [PATCH 4/5] fix: force re-auth when a stored token lacks a required scope Adding gmail.modify invalidates every existing token, but the old token still refreshes cleanly, so the API rejected archive with an opaque 403. Credentials.from_authorized_user_info() overrides a token's scopes with the ones passed in, so creds.scopes cannot detect this - read the granted scopes from the token file and re-consent when any required scope is absent. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- auth.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/auth.py b/auth.py index d7b1ed8..23cc245 100644 --- a/auth.py +++ b/auth.py @@ -103,6 +103,28 @@ def load_token(email: str) -> Credentials | None: return Credentials.from_authorized_user_info(token_data, SCOPES) +def stored_scopes(email: str) -> set[str]: + """Read the scopes actually granted to an account's stored token. + + Credentials.from_authorized_user_info() overrides the token's own scopes + with the ones we pass it, so creds.scopes reports what we asked for rather + than what was granted. Read the file directly to see the truth. + """ + token_path = get_token_path(email) + if not token_path.exists(): + return set() + + scopes = json.loads(token_path.read_text()).get('scopes') or [] + if isinstance(scopes, str): + scopes = scopes.split(' ') + return set(scopes) + + +def missing_scopes(email: str) -> list[str]: + """Return required scopes the stored token does not grant.""" + return sorted(set(SCOPES) - stored_scopes(email)) + + def save_token(email: str, creds: Credentials) -> None: """Save credentials to token file for specified email.""" token_path = get_token_path(email) @@ -159,6 +181,15 @@ def authenticate(account: str | None = None) -> Any: # Load existing credentials creds = load_token(email) + # A token minted before a scope was added still refreshes cleanly, but the + # API then rejects the new operation with an opaque 403. Force re-consent. + if creds and (missing := missing_scopes(email)): + print(f'Token for {email} is missing required scope(s):', file=sys.stderr) + for scope in missing: + print(f' - {scope}', file=sys.stderr) + print('Re-authorizing...', file=sys.stderr) + creds = None + if not (creds and creds.valid): refreshed = creds and refresh_credentials(email, creds) if not refreshed: From cf6a1f2ed362434347395749be3763d9b639d9dd Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:25:09 -0400 Subject: [PATCH 5/5] docs: document archive command and gmail.modify scope Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- CLAUDE.md | 7 +++++++ README.md | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 077fb1c..82b3b2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,9 @@ uv run gmail_cli.py reply --body "Reply text" uv run gmail_cli.py reply --body "Reply text" --draft uv run gmail_cli.py reply --body "Reply text" --cc "cc@example.com" uv run gmail_cli.py reply --body "Reply text" --bcc "hidden@example.com" + +# Archive emails (removes the INBOX label) +uv run gmail_cli.py archive [ ...] ``` ## Setup @@ -52,3 +55,7 @@ uv run gmail_cli.py reply --body "Reply text" --bcc "hidden@example - `gmail.readonly` - list/read - `gmail.compose` - send/reply/drafts +- `gmail.modify` - archive (label changes) + +Adding a scope invalidates existing tokens. The CLI detects a token that +predates a scope and re-runs the OAuth flow instead of failing with a 403. diff --git a/README.md b/README.md index fdf4c04..1d1d3e5 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,21 @@ uv run gmail_cli.py reply abc123def --body "Thanks!" --draft uv run gmail_cli.py reply abc123def --body "Thanks!" --bcc "hidden@example.com" ``` +### Archive emails + +Removes the `INBOX` label, matching Gmail's native archive behavior. + +```bash +# Archive one message +uv run gmail_cli.py archive abc123def + +# Archive several at once +uv run gmail_cli.py archive abc123 def456 ghi789 +``` + +Archiving needs the `gmail.modify` scope. The first archive run after +upgrading re-opens the browser to grant it. + ## Gmail Query Syntax Use [Gmail search operators](https://support.google.com/mail/answer/7190):