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/__init__.py +8 -0
- gmail_agent/__main__.py +5 -0
- gmail_agent/auth.py +231 -0
- gmail_agent/cli.py +775 -0
- gmail_agent/compose.py +218 -0
- gmail_agent/config.py +65 -0
- gmail_agent/errors.py +31 -0
- gmail_agent/files.py +62 -0
- gmail_agent/gmail.py +863 -0
- gmail_agent/guard.py +187 -0
- gmail_agent/mime.py +256 -0
- gmail_agent/server.py +455 -0
- gmail_agent/setup.py +313 -0
- gmail_agent-0.3.0.dist-info/METADATA +451 -0
- gmail_agent-0.3.0.dist-info/RECORD +18 -0
- gmail_agent-0.3.0.dist-info/WHEEL +4 -0
- gmail_agent-0.3.0.dist-info/entry_points.txt +2 -0
- gmail_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
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())
|