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): diff --git a/auth.py b/auth.py index 29018d0..23cc245 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 @@ -102,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) @@ -158,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: diff --git a/commands.py b/commands.py index 56dffaf..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 @@ -213,6 +215,28 @@ 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) + + failed = 0 + for msg_id in args.ids: + 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 1 if failed else 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)