gmail-agent 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gmail_agent/cli.py ADDED
@@ -0,0 +1,775 @@
1
+ """Command line interface. Human-readable output by default, `--json` for scripts and agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import __version__, auth, config
12
+ from .errors import GmailAgentError, SetupError, describe_os_error
13
+ from .gmail import DEFAULT_BODY_CHARS, DEFAULT_TEXT_CHARS, MARK_ACTIONS, Gmail
14
+ from .guard import ALLOWED_DIRS_ENV
15
+
16
+ EPILOG = """\
17
+ examples:
18
+ gmail-agent search "from:billing@example.com newer_than:30d" -n 5
19
+ gmail-agent download 18c2f0a1b2c3d4e5 -o ~/Downloads/invoices --name invoice.pdf
20
+ gmail-agent fetch "subject:invoice after:2026/01/01" -o ./invoices --match "*.pdf"
21
+ gmail-agent send --to alice@example.com --subject Hi --body "Hello" --attach report.pdf
22
+ gmail-agent reply 18c2f0a1b2c3d4e5 --all --body "Thanks!" --dry-run
23
+ gmail-agent message 18c2f0a1b2c3d4e5 --json
24
+
25
+ Every write command takes --dry-run (show the exact request, change nothing).
26
+ Queries use Gmail search syntax: https://support.google.com/mail/answer/7190
27
+ """
28
+
29
+ LEVEL_HELP = {
30
+ "readonly": "read and download only",
31
+ "compose": "read, send, drafts",
32
+ "modify": "read, send, drafts, labels, archive, trash (default)",
33
+ "full": "everything, including permanent delete",
34
+ }
35
+
36
+
37
+ # Output -----------------------------------------------------------------------------------
38
+
39
+
40
+ def _size(n: float) -> str:
41
+ for unit in ("B", "KB", "MB"):
42
+ if n < 1024 or unit == "MB":
43
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
44
+ n /= 1024
45
+ return f"{n} B"
46
+
47
+
48
+ def _attachment_lines(atts: list[dict], indent: str = " ") -> list[str]:
49
+ lines = []
50
+ for a in atts:
51
+ meta = f"{a['mime_type']}, {_size(a['size'])}" + (", inline" if a.get("inline") else "")
52
+ lines.append(f"{indent}[{a['part_id']}] {a['filename']} ({meta})")
53
+ return lines
54
+
55
+
56
+ def _print_summary(m: dict) -> None:
57
+ prefix = f"draft {m['draft_id']} " if m.get("draft_id") else ""
58
+ print(f"{prefix}{m['id']} {m['date']} {m['from'] or m['to']}")
59
+ print(f" {m['subject'] or '(no subject)'}")
60
+ for line in _attachment_lines(m["attachments"]):
61
+ print(line)
62
+
63
+
64
+ def _print_detail(m: dict) -> None:
65
+ if m.get("draft_id"):
66
+ print(f"{'Draft:':<10} {m['draft_id']}")
67
+ for key in ("from", "to", "cc", "bcc", "reply_to", "subject"):
68
+ if m.get(key):
69
+ print(f"{key.replace('_', '-').title() + ':':<10} {m[key]}")
70
+ print(f"{'Date:':<10} {m['date']}")
71
+ print(f"{'Id:':<10} {m['id']} (thread {m['thread_id']})")
72
+ if m["labels"]:
73
+ print(f"{'Labels:':<10} {', '.join(m['labels'])}")
74
+ if m["attachments"]:
75
+ print("Attachments:")
76
+ for line in _attachment_lines(m["attachments"], " "):
77
+ print(line)
78
+ print()
79
+ print(m["body"] or "(no text body)")
80
+ if m["body_truncated"]:
81
+ print("\n[body truncated; use --max-chars 0 for all of it]")
82
+
83
+
84
+ def _print_saved(saved: list[dict]) -> None:
85
+ for s in saved:
86
+ print(s["path"])
87
+ if not saved:
88
+ print("No attachments saved.", file=sys.stderr)
89
+
90
+
91
+ def _print_write(res: dict) -> None:
92
+ if res.get("dry_run"):
93
+ print("Dry run, nothing was changed.")
94
+ if res.get("scope_ok") is False:
95
+ print("Note: the current login lacks the scope for this; the real call would fail.")
96
+ for req in res["requests"]:
97
+ print(json.dumps(req, indent=2, ensure_ascii=False))
98
+ if "mime" in res:
99
+ print("\n--- MIME message ---")
100
+ print(res["mime"])
101
+ elif res.get("sent"):
102
+ print(f"Sent message {res['id']} (thread {res['thread_id']}).")
103
+ elif "draft_id" in res and "message_id" in res:
104
+ print(f"Draft {res['draft_id']} saved (message {res['message_id']}). It was not sent.")
105
+ elif res.get("deleted") and "draft_id" in res:
106
+ print(f"Deleted draft {res['draft_id']}.")
107
+ elif "action" in res:
108
+ print(f"{res['action']}: {len(res['ids'])} {res['target']}")
109
+ elif "added" in res:
110
+ print(
111
+ f"Labels on {len(res['ids'])} {res['target']}: "
112
+ f"+{','.join(res['added']) or '-'} -{','.join(res['removed']) or '-'}"
113
+ )
114
+ else:
115
+ print(json.dumps(res, ensure_ascii=False))
116
+
117
+
118
+ # Helpers ----------------------------------------------------------------------------------
119
+
120
+
121
+ def _read_arg(value: str | None, file: str | None) -> str | None:
122
+ if file:
123
+ return sys.stdin.read() if file == "-" else Path(file).read_text(encoding="utf-8")
124
+ return value
125
+
126
+
127
+ def _bodies(args) -> tuple[str | None, str | None]:
128
+ return _read_arg(args.body, args.body_file), _read_arg(args.html, args.html_file)
129
+
130
+
131
+ def _limit(n: int) -> int | None:
132
+ return None if n <= 0 else n
133
+
134
+
135
+ # Commands ---------------------------------------------------------------------------------
136
+
137
+
138
+ def cmd_login(args) -> Any:
139
+ creds = auth.login(level=args.scope, open_browser=not args.no_browser)
140
+ granted = auth.granted_scopes(creds)
141
+ email = Gmail(auth.build_service(creds), granted).profile()["email"]
142
+ level = config.level_of(granted)
143
+ result = {
144
+ "logged_in": True,
145
+ "email": email,
146
+ "level": level,
147
+ "scopes": granted,
148
+ "token": str(config.token_path()),
149
+ }
150
+ if not args.json:
151
+ print(f"Logged in as {email} with {level} access. Token saved to {config.token_path()}")
152
+ if level != args.scope:
153
+ print(
154
+ f"Warning: you asked for {args.scope} but Google granted {level}. Some boxes on the "
155
+ "consent screen were probably unticked. Run login again to fix it.",
156
+ file=sys.stderr,
157
+ )
158
+ return result
159
+
160
+
161
+ def cmd_setup(args) -> Any:
162
+ from .setup import Setup
163
+
164
+ result = Setup(
165
+ project=args.project,
166
+ client_file=args.client_file,
167
+ use_gcloud=not args.no_gcloud,
168
+ open_browser=not args.no_browser,
169
+ level=args.scope,
170
+ ).run()
171
+ if not args.json:
172
+ print(f"Set up: logged in as {result['email']} with {result['level']} access.")
173
+ print("Check it any time with: gmail-agent status")
174
+ return result
175
+
176
+
177
+ def cmd_logout(args) -> Any:
178
+ removed = auth.logout()
179
+ if not args.json:
180
+ print(f"Removed {config.token_path()}" if removed else "Not logged in.")
181
+ print("To revoke access entirely: https://myaccount.google.com/permissions")
182
+ return {"removed": removed, "token": str(config.token_path())}
183
+
184
+
185
+ class _StatusFailed(Exception):
186
+ def __init__(self, status: dict) -> None:
187
+ self.status = status
188
+
189
+
190
+ def cmd_status(args) -> Any:
191
+ status: dict[str, Any] = {
192
+ "version": __version__,
193
+ "config_dir": str(config.config_dir()),
194
+ "credentials_file": config.credentials_path().exists(),
195
+ "token_file": config.token_path().exists(),
196
+ "logged_in": False,
197
+ "email": None,
198
+ "level": None,
199
+ "scopes": [],
200
+ "problem": None,
201
+ }
202
+ try:
203
+ creds = auth.load_credentials()
204
+ status["scopes"] = list(creds.scopes or [])
205
+ status["level"] = config.level_of(status["scopes"])
206
+ status["email"] = Gmail(auth.build_service(creds), status["scopes"]).profile()["email"]
207
+ status["logged_in"] = True
208
+ except GmailAgentError as e:
209
+ status["problem"] = str(e)
210
+ if not args.json:
211
+ yes = {True: "yes", False: "no"}
212
+ print(f"gmail-agent {status['version']}")
213
+ print(f"config dir: {status['config_dir']}")
214
+ print(f"OAuth client: {yes[status['credentials_file']]} ({config.credentials_path()})")
215
+ print(f"token: {yes[status['token_file']]} ({config.token_path()})")
216
+ print(f"logged in: {status['email'] or 'no'}")
217
+ if status["level"]:
218
+ print(f"access: {status['level']} ({LEVEL_HELP.get(status['level'], '')})")
219
+ if status["problem"]:
220
+ print(f"\nproblem: {status['problem']}")
221
+ if not status["logged_in"]:
222
+ # Exit non-zero so `gmail-agent status` works as a setup check in scripts.
223
+ raise _StatusFailed(status)
224
+ return status
225
+
226
+
227
+ def cmd_search(args) -> Any:
228
+ res = Gmail.connect().search(
229
+ args.query, args.max_results, args.page_token, args.include_spam_trash
230
+ )
231
+ if not args.json:
232
+ for m in res["messages"]:
233
+ _print_summary(m)
234
+ if not res["messages"]:
235
+ print("No messages found.", file=sys.stderr)
236
+ if res["next_page_token"]:
237
+ print(f"\nMore results: --page-token {res['next_page_token']}")
238
+ return res
239
+
240
+
241
+ def cmd_message(args) -> Any:
242
+ m = Gmail.connect().get_message(args.message_id, _limit(args.max_chars))
243
+ if not args.json:
244
+ _print_detail(m)
245
+ return m
246
+
247
+
248
+ def cmd_thread(args) -> Any:
249
+ t = Gmail.connect().get_thread(args.thread_id, _limit(args.max_chars))
250
+ if not args.json:
251
+ for i, m in enumerate(t["messages"]):
252
+ if i:
253
+ print("\n" + "-" * 72 + "\n")
254
+ _print_detail(m)
255
+ return t
256
+
257
+
258
+ def cmd_attachments(args) -> Any:
259
+ atts = Gmail.connect().attachments(args.message_id)
260
+ if not args.json:
261
+ for line in _attachment_lines(atts, ""):
262
+ print(line)
263
+ if not atts:
264
+ print("No attachments.", file=sys.stderr)
265
+ return {"message_id": args.message_id, "attachments": atts}
266
+
267
+
268
+ def cmd_download(args) -> Any:
269
+ saved = Gmail.connect().download(args.message_id, args.out_dir, args.name, args.skip_inline)
270
+ if not args.json:
271
+ _print_saved(saved)
272
+ return {"saved": saved}
273
+
274
+
275
+ def cmd_fetch(args) -> Any:
276
+ res = Gmail.connect().download_matching(
277
+ args.query, args.out_dir, args.match, args.max_messages, args.skip_inline
278
+ )
279
+ if not args.json:
280
+ _print_saved(res["saved"])
281
+ print(f"Scanned {res['messages_scanned']} messages.", file=sys.stderr)
282
+ if res["more_messages"]:
283
+ print(
284
+ f"More messages match; raise --max-messages (now {args.max_messages}).",
285
+ file=sys.stderr,
286
+ )
287
+ return res
288
+
289
+
290
+ def cmd_read(args) -> Any:
291
+ res = Gmail.connect().read_attachment_text(
292
+ args.message_id, args.attachment, _limit(args.max_chars)
293
+ )
294
+ if not args.json:
295
+ print(res["text"])
296
+ if res["truncated"]:
297
+ print("\n[text truncated; use --max-chars 0 for all of it]", file=sys.stderr)
298
+ return res
299
+
300
+
301
+ def _written(args, res: dict) -> dict:
302
+ if not args.json:
303
+ _print_write(res)
304
+ return res
305
+
306
+
307
+ def cmd_send(args) -> Any:
308
+ text, html = _bodies(args)
309
+ res = Gmail.connect().send(
310
+ args.to,
311
+ args.subject or "",
312
+ text,
313
+ html,
314
+ args.cc,
315
+ args.bcc,
316
+ args.attach,
317
+ as_draft=args.draft,
318
+ dry_run=args.dry_run,
319
+ )
320
+ return _written(args, res)
321
+
322
+
323
+ def cmd_reply(args) -> Any:
324
+ text, html = _bodies(args)
325
+ res = Gmail.connect().reply(
326
+ args.message_id,
327
+ text,
328
+ html,
329
+ reply_all=args.all,
330
+ cc=args.cc,
331
+ bcc=args.bcc,
332
+ attachments=args.attach,
333
+ quote=not args.no_quote,
334
+ as_draft=args.draft,
335
+ dry_run=args.dry_run,
336
+ )
337
+ return _written(args, res)
338
+
339
+
340
+ def cmd_forward(args) -> Any:
341
+ text, html = _bodies(args)
342
+ res = Gmail.connect().forward(
343
+ args.message_id,
344
+ args.to,
345
+ text,
346
+ html,
347
+ args.cc,
348
+ args.bcc,
349
+ args.attach,
350
+ include_attachments=not args.no_attachments,
351
+ as_draft=args.draft,
352
+ dry_run=args.dry_run,
353
+ )
354
+ return _written(args, res)
355
+
356
+
357
+ def cmd_draft_list(args) -> Any:
358
+ res = Gmail.connect().list_drafts(args.max_results, args.page_token, args.query)
359
+ if not args.json:
360
+ for d in res["drafts"]:
361
+ _print_summary(d)
362
+ if not res["drafts"]:
363
+ print("No drafts.", file=sys.stderr)
364
+ return res
365
+
366
+
367
+ def cmd_draft_get(args) -> Any:
368
+ d = Gmail.connect().get_draft(args.draft_id, _limit(args.max_chars))
369
+ if not args.json:
370
+ _print_detail(d)
371
+ return d
372
+
373
+
374
+ def cmd_draft_create(args) -> Any:
375
+ args.draft = True
376
+ return cmd_send(args)
377
+
378
+
379
+ def cmd_draft_update(args) -> Any:
380
+ text, html = _bodies(args)
381
+ res = Gmail.connect().update_draft(
382
+ args.draft_id,
383
+ args.to,
384
+ args.subject,
385
+ text,
386
+ html,
387
+ args.cc,
388
+ args.bcc,
389
+ args.attach,
390
+ keep_attachments=not args.drop_attachments,
391
+ dry_run=args.dry_run,
392
+ )
393
+ return _written(args, res)
394
+
395
+
396
+ def cmd_draft_send(args) -> Any:
397
+ return _written(args, Gmail.connect().send_draft(args.draft_id, dry_run=args.dry_run))
398
+
399
+
400
+ def cmd_draft_delete(args) -> Any:
401
+ return _written(args, Gmail.connect().delete_draft(args.draft_id, dry_run=args.dry_run))
402
+
403
+
404
+ def cmd_labels(args) -> Any:
405
+ labels = Gmail.connect().labels()
406
+ if not args.json:
407
+ for lb in labels:
408
+ print(f"{lb['name']:<40} {lb['id']} ({lb['type']})")
409
+ return {"labels": labels}
410
+
411
+
412
+ def cmd_label_create(args) -> Any:
413
+ res = Gmail.connect().create_label(args.name, dry_run=args.dry_run)
414
+ if not args.json and not res.get("dry_run"):
415
+ print(f"Created label {res['name']} ({res['id']}).")
416
+ return res
417
+ return _written(args, res)
418
+
419
+
420
+ def cmd_label_rename(args) -> Any:
421
+ res = Gmail.connect().rename_label(args.label, args.new_name, dry_run=args.dry_run)
422
+ if not args.json and not res.get("dry_run"):
423
+ print(f"Renamed {res['old_name']} to {res['name']}.")
424
+ return res
425
+ return _written(args, res)
426
+
427
+
428
+ def cmd_label_delete(args) -> Any:
429
+ res = Gmail.connect().delete_label(args.label, dry_run=args.dry_run)
430
+ if not args.json and not res.get("dry_run"):
431
+ print(f"Deleted label {res['name']}. Messages keep their other labels.")
432
+ return res
433
+ return _written(args, res)
434
+
435
+
436
+ def cmd_label_add(args) -> Any:
437
+ res = Gmail.connect().modify_labels(
438
+ args.ids, add=[args.label], threads=args.thread, dry_run=args.dry_run
439
+ )
440
+ return _written(args, res)
441
+
442
+
443
+ def cmd_label_remove(args) -> Any:
444
+ res = Gmail.connect().modify_labels(
445
+ args.ids, remove=[args.label], threads=args.thread, dry_run=args.dry_run
446
+ )
447
+ return _written(args, res)
448
+
449
+
450
+ def cmd_mark(args) -> Any:
451
+ res = Gmail.connect().mark(args.ids, args.action, threads=args.thread, dry_run=args.dry_run)
452
+ return _written(args, res)
453
+
454
+
455
+ def cmd_trash(args) -> Any:
456
+ res = Gmail.connect().trash(args.ids, threads=args.thread, undo=False, dry_run=args.dry_run)
457
+ return _written(args, res)
458
+
459
+
460
+ def cmd_untrash(args) -> Any:
461
+ res = Gmail.connect().trash(args.ids, threads=args.thread, undo=True, dry_run=args.dry_run)
462
+ return _written(args, res)
463
+
464
+
465
+ def cmd_delete(args) -> Any:
466
+ if not args.permanent and not args.dry_run:
467
+ raise GmailAgentError(
468
+ "delete skips the Trash and cannot be undone. Add --permanent to confirm, or use "
469
+ "`gmail-agent trash` instead."
470
+ )
471
+ res = Gmail.connect().delete_permanently(args.ids, threads=args.thread, dry_run=args.dry_run)
472
+ return _written(args, res)
473
+
474
+
475
+ def cmd_mcp(args) -> Any:
476
+ from .server import build_server
477
+
478
+ server = build_server(
479
+ read_only=args.read_only,
480
+ allow_delete=args.allow_delete,
481
+ scopes=auth.saved_scopes(),
482
+ allowed_dirs=args.allow_dir,
483
+ )
484
+ server.run()
485
+ return None
486
+
487
+
488
+ # Parser -----------------------------------------------------------------------------------
489
+
490
+
491
+ def build_parser() -> argparse.ArgumentParser:
492
+ json_help = "print JSON (for scripts and agents)"
493
+ common = argparse.ArgumentParser(add_help=False)
494
+ # SUPPRESS keeps a subcommand's unset --json from overwriting one given before it.
495
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=json_help)
496
+ dry = argparse.ArgumentParser(add_help=False)
497
+ dry.add_argument(
498
+ "--dry-run", action="store_true", help="show the exact request (and MIME), change nothing"
499
+ )
500
+
501
+ p = argparse.ArgumentParser(
502
+ prog="gmail-agent",
503
+ description="Gmail for humans and AI agents: search, read, download attachments, "
504
+ "send, reply, forward, drafts, labels, trash.",
505
+ epilog=EPILOG,
506
+ formatter_class=argparse.RawDescriptionHelpFormatter,
507
+ )
508
+ p.add_argument("--version", action="version", version=f"gmail-agent {__version__}")
509
+ p.add_argument("--json", action="store_true", help=f"{json_help}; also accepted after COMMAND")
510
+ sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
511
+
512
+ def add(parent, name: str, func, help: str, write: bool = False, **kw):
513
+ parents = [common, dry] if write else [common]
514
+ sp = parent.add_parser(name, parents=parents, help=help, description=help, **kw)
515
+ sp.set_defaults(func=func)
516
+ return sp
517
+
518
+ def ids_arg(sp, what="message"):
519
+ sp.add_argument("ids", nargs="+", metavar="ID", help=f"{what} id(s)")
520
+ sp.add_argument("--thread", action="store_true", help="the ids are thread ids")
521
+
522
+ def body_args(sp):
523
+ g = sp.add_mutually_exclusive_group()
524
+ g.add_argument("--body", help="plain-text body")
525
+ g.add_argument("--body-file", metavar="PATH", help="read the plain body from a file, or -")
526
+ h = sp.add_mutually_exclusive_group()
527
+ h.add_argument("--html", help="HTML body (sent with a plain-text alternative)")
528
+ h.add_argument("--html-file", metavar="PATH", help="read the HTML body from a file, or -")
529
+
530
+ def recipients(sp, to_required: bool = False):
531
+ sp.add_argument("--to", action="append", required=to_required, help="repeatable")
532
+ sp.add_argument("--cc", action="append", help="repeatable")
533
+ sp.add_argument("--bcc", action="append", help="repeatable")
534
+
535
+ def attach(sp):
536
+ sp.add_argument("--attach", action="append", metavar="PATH", help="file; repeatable")
537
+
538
+ def as_draft(sp):
539
+ sp.add_argument("--draft", action="store_true", help="save as a draft instead of sending")
540
+
541
+ # Setup
542
+ sp = add(
543
+ sub,
544
+ "setup",
545
+ cmd_setup,
546
+ "first-time setup: Google Cloud project, OAuth client, login (guided)",
547
+ )
548
+ sp.add_argument("--project", metavar="ID", help="use this Google Cloud project")
549
+ sp.add_argument(
550
+ "--client-file", metavar="PATH", help="the Desktop client JSON you already downloaded"
551
+ )
552
+ sp.add_argument("--no-gcloud", action="store_true", help="do not use gcloud even if present")
553
+ sp.add_argument("--no-browser", action="store_true", help="print links instead of opening them")
554
+ sp.add_argument(
555
+ "--scope",
556
+ choices=list(config.LEVELS),
557
+ help="access level to log in with (default: keep a working login, else modify)",
558
+ )
559
+ sp = add(sub, "login", cmd_login, "log in with your browser and choose the access level")
560
+ sp.add_argument(
561
+ "--scope",
562
+ "--scopes",
563
+ choices=list(config.LEVELS),
564
+ default=config.DEFAULT_LEVEL,
565
+ help="; ".join(f"{k}: {v}" for k, v in LEVEL_HELP.items()),
566
+ )
567
+ sp.add_argument("--no-browser", action="store_true", help="print the login URL instead")
568
+ add(sub, "logout", cmd_logout, "delete the saved token")
569
+ add(sub, "status", cmd_status, "check setup and show account and access (exit 1 if not ready)")
570
+
571
+ # Reading
572
+ sp = add(sub, "search", cmd_search, "search messages with a Gmail query")
573
+ sp.add_argument("query", help='Gmail query, e.g. "from:alice@example.com has:attachment"')
574
+ sp.add_argument("-n", "--max-results", type=int, default=20, help="page size (default 20)")
575
+ sp.add_argument("--page-token", help="token from a previous page")
576
+ sp.add_argument("--include-spam-trash", action="store_true")
577
+
578
+ sp = add(sub, "message", cmd_message, "show one message: headers, text body, attachments")
579
+ sp.add_argument("message_id")
580
+ sp.add_argument("--max-chars", type=int, default=0, help="truncate the body (0 = no limit)")
581
+
582
+ sp = add(sub, "thread", cmd_thread, "show every message in a thread")
583
+ sp.add_argument("thread_id")
584
+ sp.add_argument(
585
+ "--max-chars",
586
+ type=int,
587
+ default=DEFAULT_BODY_CHARS,
588
+ help=f"truncate each body (default {DEFAULT_BODY_CHARS}, 0 = no limit)",
589
+ )
590
+
591
+ # Attachments
592
+ sp = add(sub, "attachments", cmd_attachments, "list a message's attachments")
593
+ sp.add_argument("message_id")
594
+
595
+ skip_help = "skip images embedded in the HTML body (logos, signatures)"
596
+ sp = add(sub, "download", cmd_download, "save a message's attachments to a folder")
597
+ sp.add_argument("message_id")
598
+ sp.add_argument("-o", "--out-dir", default=".", help="target folder (default: current)")
599
+ sp.add_argument(
600
+ "--name",
601
+ action="append",
602
+ metavar="FILENAME|PART_ID",
603
+ help="only this attachment; repeat for more (default: all)",
604
+ )
605
+ sp.add_argument("--skip-inline", action="store_true", help=skip_help)
606
+
607
+ sp = add(sub, "fetch", cmd_fetch, "save all attachments from messages matching a query")
608
+ sp.add_argument("query")
609
+ sp.add_argument("-o", "--out-dir", default=".", help="target folder (default: current)")
610
+ sp.add_argument("--match", metavar="GLOB", help='only filenames matching, e.g. "*.pdf"')
611
+ sp.add_argument("--max-messages", type=int, default=50, help="default 50")
612
+ sp.add_argument("--skip-inline", action="store_true", help=skip_help)
613
+
614
+ sp = add(sub, "read", cmd_read, "print the text of a PDF or text attachment")
615
+ sp.add_argument("message_id")
616
+ sp.add_argument("attachment", help="filename or part id")
617
+ sp.add_argument(
618
+ "--max-chars",
619
+ type=int,
620
+ default=DEFAULT_TEXT_CHARS,
621
+ help=f"default {DEFAULT_TEXT_CHARS}, 0 = no limit",
622
+ )
623
+
624
+ # Sending
625
+ sp = add(sub, "send", cmd_send, "send a new message", write=True)
626
+ recipients(sp)
627
+ sp.add_argument("--subject")
628
+ body_args(sp)
629
+ attach(sp)
630
+ as_draft(sp)
631
+
632
+ sp = add(sub, "reply", cmd_reply, "reply in the thread (quotes the original)", write=True)
633
+ sp.add_argument("message_id")
634
+ sp.add_argument("--all", action="store_true", help="reply to all recipients")
635
+ body_args(sp)
636
+ sp.add_argument("--cc", action="append", help="extra Cc; repeatable")
637
+ sp.add_argument("--bcc", action="append", help="repeatable")
638
+ attach(sp)
639
+ sp.add_argument("--no-quote", action="store_true", help="do not quote the original")
640
+ as_draft(sp)
641
+
642
+ sp = add(sub, "forward", cmd_forward, "forward a message with its attachments", write=True)
643
+ sp.add_argument("message_id")
644
+ recipients(sp, to_required=False)
645
+ body_args(sp)
646
+ attach(sp)
647
+ sp.add_argument("--no-attachments", action="store_true", help="leave out the attachments")
648
+ as_draft(sp)
649
+
650
+ # Drafts
651
+ dp = sub.add_parser("draft", help="list, show, create, update, send and delete drafts")
652
+ dsub = dp.add_subparsers(dest="draft_command", required=True, metavar="ACTION")
653
+ sp = add(dsub, "list", cmd_draft_list, "list drafts")
654
+ sp.add_argument("-n", "--max-results", type=int, default=20)
655
+ sp.add_argument("--page-token")
656
+ sp.add_argument("--query", help="Gmail query to filter drafts")
657
+ sp = add(dsub, "get", cmd_draft_get, "show a draft")
658
+ sp.add_argument("draft_id")
659
+ sp.add_argument("--max-chars", type=int, default=0, help="truncate the body (0 = no limit)")
660
+ sp = add(dsub, "create", cmd_draft_create, "create a draft", write=True)
661
+ recipients(sp)
662
+ sp.add_argument("--subject")
663
+ body_args(sp)
664
+ attach(sp)
665
+ sp = add(dsub, "update", cmd_draft_update, "change a draft; unset fields stay", write=True)
666
+ sp.add_argument("draft_id")
667
+ recipients(sp)
668
+ sp.add_argument("--subject")
669
+ body_args(sp)
670
+ attach(sp)
671
+ sp.add_argument(
672
+ "--drop-attachments", action="store_true", help="remove the draft's current attachments"
673
+ )
674
+ sp = add(dsub, "send", cmd_draft_send, "send a draft", write=True)
675
+ sp.add_argument("draft_id")
676
+ sp = add(dsub, "delete", cmd_draft_delete, "delete a draft", write=True)
677
+ sp.add_argument("draft_id")
678
+
679
+ # Labels
680
+ add(sub, "labels", cmd_labels, "list labels (same as `label list`)")
681
+ lp = sub.add_parser("label", help="list, create, rename, delete, add and remove labels")
682
+ lsub = lp.add_subparsers(dest="label_command", required=True, metavar="ACTION")
683
+ add(lsub, "list", cmd_labels, "list labels")
684
+ sp = add(lsub, "create", cmd_label_create, "create a label", write=True)
685
+ sp.add_argument("name", help='use "/" for nesting, e.g. "Receipts/2026"')
686
+ sp = add(lsub, "rename", cmd_label_rename, "rename a label", write=True)
687
+ sp.add_argument("label", help="name or id")
688
+ sp.add_argument("new_name")
689
+ sp = add(lsub, "delete", cmd_label_delete, "delete a label (messages are kept)", write=True)
690
+ sp.add_argument("label", help="name or id")
691
+ sp = add(lsub, "add", cmd_label_add, "add a label to messages or threads", write=True)
692
+ sp.add_argument("label", help="name or id")
693
+ ids_arg(sp)
694
+ sp = add(lsub, "remove", cmd_label_remove, "remove a label", write=True)
695
+ sp.add_argument("label", help="name or id")
696
+ ids_arg(sp)
697
+
698
+ # Message state
699
+ mark_help = {
700
+ "read": "mark messages or threads as read",
701
+ "unread": "mark messages or threads as unread",
702
+ "star": "star messages or threads",
703
+ "unstar": "remove the star",
704
+ "archive": "archive (remove from Inbox, keep in All Mail)",
705
+ "unarchive": "move back to the Inbox",
706
+ }
707
+ for action in MARK_ACTIONS:
708
+ name = f"mark-{action}" if action in ("read", "unread") else action
709
+ sp = add(sub, name, cmd_mark, mark_help[action], write=True)
710
+ sp.set_defaults(action=action)
711
+ ids_arg(sp)
712
+
713
+ sp = add(sub, "trash", cmd_trash, "move to Trash (emptied by Gmail after 30 days)", write=True)
714
+ ids_arg(sp)
715
+ sp = add(sub, "untrash", cmd_untrash, "restore from Trash", write=True)
716
+ ids_arg(sp)
717
+ sp = add(
718
+ sub,
719
+ "delete",
720
+ cmd_delete,
721
+ "delete permanently, skipping Trash (needs --permanent and the full scope)",
722
+ write=True,
723
+ )
724
+ ids_arg(sp)
725
+ sp.add_argument("--permanent", action="store_true", help="confirm: this cannot be undone")
726
+
727
+ # MCP
728
+ sp = add(sub, "mcp", cmd_mcp, "run the MCP server over stdio")
729
+ sp.add_argument("--read-only", action="store_true", help="expose only the reading tools")
730
+ sp.add_argument(
731
+ "--allow-delete",
732
+ action="store_true",
733
+ help="also expose delete_permanently (needs `login --scope full`)",
734
+ )
735
+ sp.add_argument(
736
+ "--allow-dir",
737
+ action="append",
738
+ metavar="PATH",
739
+ help="a folder the tools may save attachments to and attach files from, besides "
740
+ f"~/Downloads; repeatable (also ${ALLOWED_DIRS_ENV})",
741
+ )
742
+ return p
743
+
744
+
745
+ def main(argv: list[str] | None = None) -> int:
746
+ args = build_parser().parse_args(argv)
747
+ json_out = getattr(args, "json", False)
748
+ try:
749
+ result = args.func(args)
750
+ except _StatusFailed as e:
751
+ if json_out:
752
+ print(json.dumps(e.status, indent=2, ensure_ascii=False))
753
+ return 1
754
+ except GmailAgentError as e:
755
+ if json_out:
756
+ print(json.dumps({"error": str(e), "code": e.code}, ensure_ascii=False))
757
+ else:
758
+ print(f"error: {e}", file=sys.stderr)
759
+ return 2 if isinstance(e, SetupError) else 1
760
+ except OSError as e: # e.g. --body-file that does not exist
761
+ msg = describe_os_error(e)
762
+ if json_out:
763
+ print(json.dumps({"error": msg, "code": "error"}, ensure_ascii=False))
764
+ else:
765
+ print(f"error: {msg}", file=sys.stderr)
766
+ return 1
767
+ except KeyboardInterrupt:
768
+ return 130
769
+ if json_out and result is not None:
770
+ print(json.dumps(result, indent=2, ensure_ascii=False))
771
+ return 0
772
+
773
+
774
+ if __name__ == "__main__":
775
+ sys.exit(main())