From 01a6fddaf3c462f2219232290ccc8d726c9f57fe Mon Sep 17 00:00:00 2001 From: Nitsan Avni Date: Fri, 27 Feb 2026 12:36:16 +0100 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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): From 70778d97016b81769f8388bdebe3fb30e458f01d Mon Sep 17 00:00:00 2001 From: Nitsan Avni Date: Fri, 27 Feb 2026 21:29:54 +0100 Subject: [PATCH 06/12] Add attachments command to download email attachments Adds gmail attachments [-o output-dir] to save attachments from an email to disk. Co-Authored-By: Claude Opus 4.6 --- commands.py | 34 ++++++++++++++++++++++++++++++++++ gmail_cli.py | 8 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/commands.py b/commands.py index 26f97cf..ca62e5d 100644 --- a/commands.py +++ b/commands.py @@ -237,6 +237,40 @@ def cmd_archive(args: argparse.Namespace) -> int: return 1 if failed else 0 +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('.') + + msg = service.users().messages().get( + userId='me', id=args.id, format='full' + ).execute() + + parts = msg.get('payload', {}).get('parts', []) + found = 0 + for part in parts: + filename = part.get('filename') + if not filename: + continue + attachment_id = part.get('body', {}).get('attachmentId') + if not attachment_id: + continue + + attachment = service.users().messages().attachments().get( + userId='me', messageId=args.id, id=attachment_id + ).execute() + + data = base64.urlsafe_b64decode(attachment['data']) + filepath = output_dir / filename + filepath.write_bytes(data) + print(f'Saved: {filepath}') + found += 1 + + if not found: + print('No attachments found.') + 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 7042d0c..7f5b8a1 100644 --- a/gmail_cli.py +++ b/gmail_cli.py @@ -10,7 +10,7 @@ cmd_accounts_list, cmd_accounts_remove, ) -from commands import cmd_archive, cmd_list, cmd_read, cmd_reply, cmd_send +from commands import cmd_archive, cmd_attachments, cmd_list, cmd_read, cmd_reply, cmd_send def add_compose_args(parser: argparse.ArgumentParser) -> None: @@ -69,6 +69,12 @@ def main() -> int: 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) From 46b6397058e47c1ee00cb370819f9af7c0759838 Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:27:32 -0400 Subject: [PATCH 07/12] refactor: extract attachments command into attachment_commands.py Adding cmd_attachments pushed commands.py to 304 lines, over the project's 300-line limit. Move it to its own module, matching account_commands.py. Pure move - behavior is unchanged. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- attachment_commands.py | 41 +++++++++++++++++++++++++++++++++++++++++ commands.py | 34 ---------------------------------- gmail_cli.py | 3 ++- 3 files changed, 43 insertions(+), 35 deletions(-) create mode 100644 attachment_commands.py diff --git a/attachment_commands.py b/attachment_commands.py new file mode 100644 index 0000000..5935aa5 --- /dev/null +++ b/attachment_commands.py @@ -0,0 +1,41 @@ +"""Attachment download command handler for Gmail CLI.""" + +import argparse +import base64 +from pathlib import Path + +from auth import authenticate + + +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('.') + + msg = service.users().messages().get( + userId='me', id=args.id, format='full' + ).execute() + + parts = msg.get('payload', {}).get('parts', []) + found = 0 + for part in parts: + filename = part.get('filename') + if not filename: + continue + attachment_id = part.get('body', {}).get('attachmentId') + if not attachment_id: + continue + + attachment = service.users().messages().attachments().get( + userId='me', messageId=args.id, id=attachment_id + ).execute() + + data = base64.urlsafe_b64decode(attachment['data']) + filepath = output_dir / filename + filepath.write_bytes(data) + print(f'Saved: {filepath}') + found += 1 + + if not found: + print('No attachments found.') + return 0 diff --git a/commands.py b/commands.py index ca62e5d..26f97cf 100644 --- a/commands.py +++ b/commands.py @@ -237,40 +237,6 @@ def cmd_archive(args: argparse.Namespace) -> int: return 1 if failed else 0 -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('.') - - msg = service.users().messages().get( - userId='me', id=args.id, format='full' - ).execute() - - parts = msg.get('payload', {}).get('parts', []) - found = 0 - for part in parts: - filename = part.get('filename') - if not filename: - continue - attachment_id = part.get('body', {}).get('attachmentId') - if not attachment_id: - continue - - attachment = service.users().messages().attachments().get( - userId='me', messageId=args.id, id=attachment_id - ).execute() - - data = base64.urlsafe_b64decode(attachment['data']) - filepath = output_dir / filename - filepath.write_bytes(data) - print(f'Saved: {filepath}') - found += 1 - - if not found: - print('No attachments found.') - 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 7f5b8a1..10d2468 100644 --- a/gmail_cli.py +++ b/gmail_cli.py @@ -10,7 +10,8 @@ cmd_accounts_list, cmd_accounts_remove, ) -from commands import cmd_archive, cmd_attachments, 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: From e11035e3c05fe9bd53c878bbc96822512f486972 Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:27:51 -0400 Subject: [PATCH 08/12] fix(security): contain attachment downloads to the output directory Attachment filenames come from the email, so the sender controls them. 'output_dir / filename' honored both traversal names ('../../.ssh/authorized_keys') and absolute names ('/etc/cron.d/pwn') - pathlib discards the left operand entirely when the right one is absolute. Anyone who could email the user could write a file anywhere the user could write. Keep only the final path component, and skip names that sanitize to nothing. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- attachment_commands.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/attachment_commands.py b/attachment_commands.py index 5935aa5..2b2e37d 100644 --- a/attachment_commands.py +++ b/attachment_commands.py @@ -2,11 +2,29 @@ import argparse import base64 +import sys 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 cmd_attachments(args: argparse.Namespace) -> int: """Download attachments from an email.""" service = authenticate(args.account) @@ -19,19 +37,26 @@ def cmd_attachments(args: argparse.Namespace) -> int: parts = msg.get('payload', {}).get('parts', []) found = 0 for part in parts: - filename = part.get('filename') - if not filename: + raw_name = part.get('filename') + if not raw_name: continue attachment_id = part.get('body', {}).get('attachmentId') if not attachment_id: continue + 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 = base64.urlsafe_b64decode(attachment['data']) - filepath = output_dir / filename + filepath = output_dir / name filepath.write_bytes(data) print(f'Saved: {filepath}') found += 1 From a41416c72b7baa0a223febe68f2d28e354a44dec Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:28:08 -0400 Subject: [PATCH 09/12] fix: find attachments nested below the top-level parts The loop only scanned payload['parts'], so attachments inside a nested multipart/related or multipart/alternative - which is how most mail clients structure messages - reported "No attachments found." get_body() in commands.py already recurses for the same reason; mirror it. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- attachment_commands.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/attachment_commands.py b/attachment_commands.py index 2b2e37d..2e460f9 100644 --- a/attachment_commands.py +++ b/attachment_commands.py @@ -3,6 +3,7 @@ import argparse import base64 import sys +from collections.abc import Iterator from pathlib import Path from auth import authenticate @@ -25,6 +26,20 @@ def safe_filename(filename: str) -> str | 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 cmd_attachments(args: argparse.Namespace) -> int: """Download attachments from an email.""" service = authenticate(args.account) @@ -34,15 +49,10 @@ def cmd_attachments(args: argparse.Namespace) -> int: userId='me', id=args.id, format='full' ).execute() - parts = msg.get('payload', {}).get('parts', []) found = 0 - for part in parts: - raw_name = part.get('filename') - if not raw_name: - continue - attachment_id = part.get('body', {}).get('attachmentId') - if not attachment_id: - continue + 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: From 3e7490ab94247045fb4756beeb0d95d1f21622cf Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:28:31 -0400 Subject: [PATCH 10/12] fix: create the output dir and stop attachments overwriting each other Three write-path problems: '-o some/new/dir' raised FileNotFoundError because the directory was never created; two attachments sharing a filename silently clobbered each other; and a missing 'data' field raised KeyError. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- attachment_commands.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/attachment_commands.py b/attachment_commands.py index 2e460f9..33a6eb9 100644 --- a/attachment_commands.py +++ b/attachment_commands.py @@ -2,6 +2,7 @@ import argparse import base64 +import itertools import sys from collections.abc import Iterator from pathlib import Path @@ -40,10 +41,27 @@ def iter_attachment_parts(part: dict) -> Iterator[dict]: 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' @@ -65,9 +83,13 @@ def cmd_attachments(args: argparse.Namespace) -> int: userId='me', messageId=args.id, id=attachment_id ).execute() - data = base64.urlsafe_b64decode(attachment['data']) - filepath = output_dir / name - filepath.write_bytes(data) + 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 From bff2b7ff0e086e22fe925bdffd11122630bd6cb1 Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:28:38 -0400 Subject: [PATCH 11/12] test: add regression tests for attachment download safety Covers the path-traversal containment, nested-part discovery, filename collisions, and output-dir creation. Verified these fail against the pre-fix behavior. Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- pyproject.toml | 4 +- tests/test_attachment_commands.py | 106 ++++++++++++++++++++++ uv.lock | 145 +++++++++++++++++++++++++++++- 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 tests/test_attachment_commands.py diff --git a/pyproject.toml b/pyproject.toml index 7270394..5e1c250 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,4 +14,6 @@ dependencies = [ gmail-cli = "gmail_cli:main" [dependency-groups] -dev = [] +dev = [ + "pytest>=8.0.0", +] diff --git a/tests/test_attachment_commands.py b/tests/test_attachment_commands.py new file mode 100644 index 0000000..734e27d --- /dev/null +++ b/tests/test_attachment_commands.py @@ -0,0 +1,106 @@ +"""Regression tests for attachment download safety.""" + +import argparse +import base64 +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import attachment_commands as ac + + +def make_part(filename='', attachment_id=None, mime='application/octet-stream', parts=None): + part = {'filename': filename, 'mimeType': mime, 'body': {}} + if attachment_id: + part['body']['attachmentId'] = attachment_id + if parts: + part['parts'] = parts + return part + + +def fake_service(payload, data): + service = MagicMock() + messages = service.users.return_value.messages.return_value + messages.get.return_value.execute.return_value = {'payload': payload} + messages.attachments.return_value.get.side_effect = ( + lambda userId, messageId, id: MagicMock( + execute=MagicMock(return_value={'data': base64.urlsafe_b64encode(data[id]).decode()}) + ) + ) + return service + + +def run(monkeypatch, payload, data, output): + monkeypatch.setattr(ac, 'authenticate', lambda account: fake_service(payload, data)) + args = argparse.Namespace(account=None, id='m1', output=str(output)) + return ac.cmd_attachments(args) + + +@pytest.mark.parametrize('hostile,expected', [ + ('../../etc/passwd', 'passwd'), + ('/etc/cron.d/pwn', 'pwn'), + ('a/b/c.txt', 'c.txt'), + ('ok.pdf', 'ok.pdf'), + ('..', None), + ('.', None), + ('', None), +]) +def test_safe_filename_strips_paths(hostile, expected): + assert ac.safe_filename(hostile) == expected + + +def test_attachment_cannot_escape_output_dir(tmp_path, monkeypatch): + """A sender-chosen filename must never write outside the output directory.""" + outside = tmp_path / 'outside.txt' + payload = make_part(mime='multipart/mixed', parts=[ + make_part(f'../../{outside.name}', 'a1'), + make_part(str(outside), 'a2'), + ]) + outdir = tmp_path / 'downloads' + + run(monkeypatch, payload, {'a1': b'one', 'a2': b'two'}, outdir) + + assert not outside.exists(), 'attachment escaped the output directory' + assert {p.name for p in outdir.iterdir()} == {'outside.txt', 'outside-1.txt'} + + +def test_finds_attachments_nested_in_subparts(tmp_path, monkeypatch): + """Attachments below the top level must still be found.""" + payload = make_part(mime='multipart/mixed', parts=[ + make_part(mime='multipart/related', parts=[ + make_part(mime='multipart/alternative', parts=[ + make_part('deep.pdf', 'a1'), + ]), + ]), + ]) + outdir = tmp_path / 'out' + + run(monkeypatch, payload, {'a1': b'deep'}, outdir) + + assert (outdir / 'deep.pdf').read_bytes() == b'deep' + + +def test_duplicate_filenames_do_not_clobber(tmp_path, monkeypatch): + payload = make_part(mime='multipart/mixed', parts=[ + make_part('report.pdf', 'a1'), + make_part('report.pdf', 'a2'), + ]) + outdir = tmp_path / 'out' + + run(monkeypatch, payload, {'a1': b'first', 'a2': b'second'}, outdir) + + assert (outdir / 'report.pdf').read_bytes() == b'first' + assert (outdir / 'report-1.pdf').read_bytes() == b'second' + + +def test_creates_missing_output_dir(tmp_path, monkeypatch): + payload = make_part(mime='multipart/mixed', parts=[make_part('a.txt', 'a1')]) + outdir = tmp_path / 'does' / 'not' / 'exist' + + run(monkeypatch, payload, {'a1': b'x'}, outdir) + + assert (outdir / 'a.txt').exists() diff --git a/uv.lock b/uv.lock index 58d200f..0ea2f13 100644 --- a/uv.lock +++ b/uv.lock @@ -104,6 +104,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "gmail-cli" version = "0.1.0" @@ -115,6 +136,11 @@ dependencies = [ { name = "html-to-markdown" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "google-api-python-client", specifier = ">=2.100.0" }, @@ -124,7 +150,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] [[package]] name = "google-api-core" @@ -241,6 +267,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -250,6 +285,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "proto-plus" version = "1.27.0" @@ -298,6 +351,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyparsing" version = "3.3.1" @@ -307,6 +369,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -347,6 +427,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0" From 315d707df55c8d4ba9db3423e573acbd42b9ba15 Mon Sep 17 00:00:00 2001 From: Saadiq Rodgers-King Date: Mon, 13 Jul 2026 23:29:03 -0400 Subject: [PATCH 12/12] docs: document attachments command and filename sanitizing Claude-Session: https://claude.ai/code/session_01XN4T96G5R4UczQpgA2nqES --- CLAUDE.md | 10 ++++++++++ README.md | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 82b3b2a..06d85bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,16 @@ uv run gmail_cli.py reply --body "Reply text" --bcc "hidden@example # Archive emails (removes the INBOX label) uv run gmail_cli.py archive [ ...] + +# Download attachments (sender-supplied names are stripped to a bare filename) +uv run gmail_cli.py attachments +uv run gmail_cli.py attachments --output ./downloads +``` + +## Tests + +```bash +uv run --dev pytest tests/ ``` ## Setup diff --git a/README.md b/README.md index 1d1d3e5..89ae5fa 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,21 @@ 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):