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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ 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> ...]

# Download attachments (sender-supplied names are stripped to a bare filename)
uv run gmail_cli.py attachments <message-id>
uv run gmail_cli.py attachments <message-id> --output ./downloads
```

## Tests

```bash
uv run --dev pytest tests/
```

## Setup
Expand All @@ -52,3 +65,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.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ 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.

### Download attachments

```bash
# Save to the current directory
uv run gmail_cli.py attachments abc123def

# Save to a specific directory (created if missing)
uv run gmail_cli.py attachments abc123def --output ./downloads
```

Attachment filenames are chosen by the sender, so they are stripped to a bare
filename before saving — a download can never be written outside the output
directory. Colliding names are suffixed (`report.pdf`, `report-1.pdf`) rather
than overwritten.

## Gmail Query Syntax

Use [Gmail search operators](https://support.google.com/mail/answer/7190):
Expand Down
98 changes: 98 additions & 0 deletions attachment_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Attachment download command handler for Gmail CLI."""

import argparse
import base64
import itertools
import sys
from collections.abc import Iterator
from pathlib import Path

from auth import authenticate


def safe_filename(filename: str) -> str | None:
"""Reduce a sender-controlled attachment name to a bare filename.

Attachment names arrive from the email, so a hostile sender picks them.
Both '../../.ssh/authorized_keys' and '/etc/cron.d/pwn' are legal MIME
filenames, and pathlib honors both: an absolute right-hand operand makes
`output_dir / filename` discard output_dir entirely. Keep only the final
path component so a download can never escape the output directory.

Returns None when nothing usable survives sanitizing.
"""
name = Path(filename).name
if name in ('', '.', '..'):
return None
return name


def iter_attachment_parts(part: dict) -> Iterator[dict]:
"""Yield every part carrying an attachment, at any nesting depth.

Real messages nest: multipart/mixed wrapping multipart/alternative, inline
images under multipart/related, forwarded message/rfc822. Scanning only the
top-level parts misses attachments that are plainly visible in Gmail.
"""
if part.get('filename') and part.get('body', {}).get('attachmentId'):
yield part

for subpart in part.get('parts', []):
yield from iter_attachment_parts(subpart)


def unique_path(directory: Path, name: str) -> Path:
"""Return a path in directory that does not overwrite an existing file."""
candidate = directory / name
if not candidate.exists():
return candidate

stem, suffix = Path(name).stem, Path(name).suffix
for n in itertools.count(1):
candidate = directory / f'{stem}-{n}{suffix}'
if not candidate.exists():
return candidate

raise AssertionError('unreachable') # pragma: no cover


def cmd_attachments(args: argparse.Namespace) -> int:
"""Download attachments from an email."""
service = authenticate(args.account)

output_dir = Path(args.output) if args.output else Path('.')
output_dir.mkdir(parents=True, exist_ok=True)

msg = service.users().messages().get(
userId='me', id=args.id, format='full'
).execute()

found = 0
for part in iter_attachment_parts(msg.get('payload', {})):
raw_name = part['filename']
attachment_id = part['body']['attachmentId']

name = safe_filename(raw_name)
if name is None:
print(f'Skipped unsafe attachment name: {raw_name!r}', file=sys.stderr)
continue
if name != raw_name:
print(f'Sanitized attachment name {raw_name!r} -> {name!r}', file=sys.stderr)

attachment = service.users().messages().attachments().get(
userId='me', messageId=args.id, id=attachment_id
).execute()

data = attachment.get('data')
if not data:
print(f'No data returned for attachment: {name}', file=sys.stderr)
continue

filepath = unique_path(output_dir, name)
filepath.write_bytes(base64.urlsafe_b64decode(data))
print(f'Saved: {filepath}')
found += 1

if not found:
print('No attachments found.')
return 0
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
14 changes: 13 additions & 1 deletion gmail_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
cmd_accounts_list,
cmd_accounts_remove,
)
from commands import cmd_list, cmd_read, cmd_reply, cmd_send
from attachment_commands import cmd_attachments
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 +65,17 @@ 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)

# attachments command
attach_parser = subparsers.add_parser('attachments', help='Download attachments')
attach_parser.add_argument('id', help='Message ID')
attach_parser.add_argument('--output', '-o', help='Output directory (default: current)')
attach_parser.set_defaults(func=cmd_attachments)

# accounts command
accounts_parser = subparsers.add_parser('accounts', help='Manage Gmail accounts')
accounts_parser.set_defaults(func=cmd_accounts)
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ dependencies = [
gmail-cli = "gmail_cli:main"

[dependency-groups]
dev = []
dev = [
"pytest>=8.0.0",
]
Loading