Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ uv run gmail_cli.py reply <message-id> --body "Reply text"
uv run gmail_cli.py reply <message-id> --body "Reply text" --draft
uv run gmail_cli.py reply <message-id> --body "Reply text" --cc "cc@example.com"
uv run gmail_cli.py reply <message-id> --body "Reply text" --bcc "hidden@example.com"

# Archive emails (removes the INBOX label)
uv run gmail_cli.py archive <message-id> [<message-id> ...]
```

## Setup
Expand All @@ -52,3 +55,7 @@ uv run gmail_cli.py reply <message-id> --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.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
32 changes: 32 additions & 0 deletions auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion gmail_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down