google-workspace-cli 0.1.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.
- google_workspace_cli-0.1.0.dist-info/METADATA +270 -0
- google_workspace_cli-0.1.0.dist-info/RECORD +13 -0
- google_workspace_cli-0.1.0.dist-info/WHEEL +4 -0
- google_workspace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- google_workspace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- gw/__init__.py +1 -0
- gw/auth.py +199 -0
- gw/calendar.py +239 -0
- gw/cli.py +119 -0
- gw/config.py +110 -0
- gw/drive.py +189 -0
- gw/gmail.py +366 -0
- gw/output.py +35 -0
gw/gmail.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gw.auth import build_service
|
|
10
|
+
from gw.config import DEFAULT_CONFIG_DIR, GwConfig
|
|
11
|
+
from gw.output import format_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_header(headers: list[dict], name: str) -> str:
|
|
15
|
+
for h in headers:
|
|
16
|
+
if h["name"].lower() == name.lower():
|
|
17
|
+
return h["value"]
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_parts(payload: dict):
|
|
22
|
+
"""Yield every MIME part in the payload tree, including nested multiparts."""
|
|
23
|
+
for part in payload.get("parts", []):
|
|
24
|
+
yield part
|
|
25
|
+
yield from _iter_parts(part)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_gmail_after(since: str) -> str:
|
|
29
|
+
"""Convert an ISO datetime/date to a token Gmail's after: operator accepts.
|
|
30
|
+
|
|
31
|
+
Gmail only understands YYYY/MM/DD or Unix-epoch seconds, not ISO datetimes.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
dt = datetime.fromisoformat(since)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return since # assume the caller already passed a Gmail-acceptable token
|
|
37
|
+
return str(int(dt.timestamp()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _decode_body(payload: dict) -> str:
|
|
41
|
+
"""Extract plain text body from message payload."""
|
|
42
|
+
if payload.get("mimeType") == "text/plain" and payload.get("body", {}).get("data"):
|
|
43
|
+
return base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace")
|
|
44
|
+
for part in payload.get("parts", []):
|
|
45
|
+
text = _decode_body(part)
|
|
46
|
+
if text:
|
|
47
|
+
return text
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group()
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def mail(ctx: click.Context) -> None:
|
|
54
|
+
"""Gmail operations."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mail.command("list")
|
|
59
|
+
@click.option("--query", default=None, help="Gmail search query")
|
|
60
|
+
@click.option("--limit", default=None, type=int, help="Max messages to show")
|
|
61
|
+
@click.option("--since", default=None, help="Only show messages after this datetime (ISO format)")
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def list_messages(ctx: click.Context, query: str | None, limit: int | None, since: str | None) -> None:
|
|
64
|
+
"""List messages."""
|
|
65
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
66
|
+
cfg = GwConfig(config_dir)
|
|
67
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
68
|
+
output_json = ctx.obj.get("json", False)
|
|
69
|
+
|
|
70
|
+
if limit is None:
|
|
71
|
+
default_limit = cfg.get_default("mail", "limit")
|
|
72
|
+
limit = default_limit if default_limit is not None else 20
|
|
73
|
+
|
|
74
|
+
search_query = query or cfg.get_default("mail", "check_query") or ""
|
|
75
|
+
if since:
|
|
76
|
+
search_query += f" after:{_to_gmail_after(since)}"
|
|
77
|
+
|
|
78
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
79
|
+
result = service.users().messages().list(userId="me", q=search_query, maxResults=limit).execute()
|
|
80
|
+
|
|
81
|
+
messages = []
|
|
82
|
+
for msg_meta in result.get("messages", []):
|
|
83
|
+
msg = (
|
|
84
|
+
service.users()
|
|
85
|
+
.messages()
|
|
86
|
+
.get(
|
|
87
|
+
userId="me",
|
|
88
|
+
id=msg_meta["id"],
|
|
89
|
+
format="metadata",
|
|
90
|
+
metadataHeaders=["Subject", "From", "Date"],
|
|
91
|
+
)
|
|
92
|
+
.execute()
|
|
93
|
+
)
|
|
94
|
+
headers = msg.get("payload", {}).get("headers", [])
|
|
95
|
+
messages.append(
|
|
96
|
+
{
|
|
97
|
+
"id": msg["id"],
|
|
98
|
+
"subject": _get_header(headers, "Subject"),
|
|
99
|
+
"from": _get_header(headers, "From"),
|
|
100
|
+
"date": _get_header(headers, "Date"),
|
|
101
|
+
"unread": "UNREAD" in msg.get("labelIds", []),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
click.echo(format_output(messages, output_json=output_json, account=account))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mail.command()
|
|
109
|
+
@click.argument("message_id")
|
|
110
|
+
@click.pass_context
|
|
111
|
+
def read(ctx: click.Context, message_id: str) -> None:
|
|
112
|
+
"""Read a message (does NOT mark as read)."""
|
|
113
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
114
|
+
cfg = GwConfig(config_dir)
|
|
115
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
116
|
+
output_json = ctx.obj.get("json", False)
|
|
117
|
+
|
|
118
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
119
|
+
msg = service.users().messages().get(userId="me", id=message_id, format="full").execute()
|
|
120
|
+
headers = msg.get("payload", {}).get("headers", [])
|
|
121
|
+
body = _decode_body(msg.get("payload", {}))
|
|
122
|
+
|
|
123
|
+
data = {
|
|
124
|
+
"id": msg["id"],
|
|
125
|
+
"subject": _get_header(headers, "Subject"),
|
|
126
|
+
"from": _get_header(headers, "From"),
|
|
127
|
+
"date": _get_header(headers, "Date"),
|
|
128
|
+
"body": body,
|
|
129
|
+
}
|
|
130
|
+
click.echo(format_output(data, output_json=output_json, account=account))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@mail.command()
|
|
134
|
+
@click.option("--to", required=True, help="Recipient email")
|
|
135
|
+
@click.option("--subject", required=True)
|
|
136
|
+
@click.option("--body", required=True)
|
|
137
|
+
@click.pass_context
|
|
138
|
+
def send(ctx: click.Context, to: str, subject: str, body: str) -> None:
|
|
139
|
+
"""Send a message."""
|
|
140
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
141
|
+
cfg = GwConfig(config_dir)
|
|
142
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
143
|
+
output_json = ctx.obj.get("json", False)
|
|
144
|
+
|
|
145
|
+
message = MIMEText(body)
|
|
146
|
+
message["to"] = to
|
|
147
|
+
message["subject"] = subject
|
|
148
|
+
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
|
149
|
+
|
|
150
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
151
|
+
result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
152
|
+
click.echo(format_output(f"Sent message (id: {result['id']})", output_json=output_json, account=account))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@mail.command()
|
|
156
|
+
@click.argument("message_id")
|
|
157
|
+
@click.option("--body", required=True)
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def reply(ctx: click.Context, message_id: str, body: str) -> None:
|
|
160
|
+
"""Reply to a message."""
|
|
161
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
162
|
+
cfg = GwConfig(config_dir)
|
|
163
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
164
|
+
output_json = ctx.obj.get("json", False)
|
|
165
|
+
|
|
166
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
167
|
+
original = (
|
|
168
|
+
service.users()
|
|
169
|
+
.messages()
|
|
170
|
+
.get(userId="me", id=message_id, format="metadata", metadataHeaders=["Subject", "From", "Message-ID"])
|
|
171
|
+
.execute()
|
|
172
|
+
)
|
|
173
|
+
headers = original.get("payload", {}).get("headers", [])
|
|
174
|
+
subject = _get_header(headers, "Subject")
|
|
175
|
+
sender = _get_header(headers, "From")
|
|
176
|
+
msg_id_header = _get_header(headers, "Message-ID")
|
|
177
|
+
|
|
178
|
+
if not subject.lower().startswith("re:"):
|
|
179
|
+
subject = f"Re: {subject}"
|
|
180
|
+
|
|
181
|
+
message = MIMEText(body)
|
|
182
|
+
message["to"] = sender
|
|
183
|
+
message["subject"] = subject
|
|
184
|
+
message["In-Reply-To"] = msg_id_header
|
|
185
|
+
message["References"] = msg_id_header
|
|
186
|
+
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
|
187
|
+
|
|
188
|
+
result = (
|
|
189
|
+
service.users()
|
|
190
|
+
.messages()
|
|
191
|
+
.send(
|
|
192
|
+
userId="me",
|
|
193
|
+
body={"raw": raw, "threadId": original["threadId"]},
|
|
194
|
+
)
|
|
195
|
+
.execute()
|
|
196
|
+
)
|
|
197
|
+
click.echo(format_output(f"Replied (id: {result['id']})", output_json=output_json, account=account))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@mail.command("labels")
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def list_labels(ctx: click.Context) -> None:
|
|
203
|
+
"""List Gmail labels."""
|
|
204
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
205
|
+
cfg = GwConfig(config_dir)
|
|
206
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
207
|
+
output_json = ctx.obj.get("json", False)
|
|
208
|
+
|
|
209
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
210
|
+
result = service.users().labels().list(userId="me").execute()
|
|
211
|
+
labels = [{"id": lbl["id"], "name": lbl["name"]} for lbl in result.get("labels", [])]
|
|
212
|
+
click.echo(format_output(labels, output_json=output_json, account=account))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@mail.command("label")
|
|
216
|
+
@click.argument("message_id")
|
|
217
|
+
@click.option("--add", "add_labels", multiple=True, help="Labels to add")
|
|
218
|
+
@click.option("--remove", "remove_labels", multiple=True, help="Labels to remove")
|
|
219
|
+
@click.pass_context
|
|
220
|
+
def modify_labels(ctx: click.Context, message_id: str, add_labels: tuple, remove_labels: tuple) -> None:
|
|
221
|
+
"""Add or remove labels from a message."""
|
|
222
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
223
|
+
cfg = GwConfig(config_dir)
|
|
224
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
225
|
+
output_json = ctx.obj.get("json", False)
|
|
226
|
+
|
|
227
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
228
|
+
service.users().messages().modify(
|
|
229
|
+
userId="me",
|
|
230
|
+
id=message_id,
|
|
231
|
+
body={"addLabelIds": list(add_labels), "removeLabelIds": list(remove_labels)},
|
|
232
|
+
).execute()
|
|
233
|
+
click.echo(format_output("Labels updated.", output_json=output_json, account=account))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mail.command()
|
|
237
|
+
@click.argument("message_id")
|
|
238
|
+
@click.option("--read", "mark_read", is_flag=True, help="Mark as read")
|
|
239
|
+
@click.option("--unread", "mark_unread", is_flag=True, help="Mark as unread")
|
|
240
|
+
@click.pass_context
|
|
241
|
+
def mark(ctx: click.Context, message_id: str, mark_read: bool, mark_unread: bool) -> None:
|
|
242
|
+
"""Mark a message as read or unread."""
|
|
243
|
+
if mark_read == mark_unread:
|
|
244
|
+
raise click.UsageError("Specify exactly one of --read or --unread.")
|
|
245
|
+
|
|
246
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
247
|
+
cfg = GwConfig(config_dir)
|
|
248
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
249
|
+
output_json = ctx.obj.get("json", False)
|
|
250
|
+
|
|
251
|
+
body: dict = {"addLabelIds": [], "removeLabelIds": []}
|
|
252
|
+
if mark_read:
|
|
253
|
+
body["removeLabelIds"].append("UNREAD")
|
|
254
|
+
if mark_unread:
|
|
255
|
+
body["addLabelIds"].append("UNREAD")
|
|
256
|
+
|
|
257
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
258
|
+
service.users().messages().modify(userId="me", id=message_id, body=body).execute()
|
|
259
|
+
|
|
260
|
+
status = "read" if mark_read else "unread"
|
|
261
|
+
click.echo(format_output(f"Marked as {status}.", output_json=output_json, account=account))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@mail.command()
|
|
265
|
+
@click.argument("message_id")
|
|
266
|
+
@click.pass_context
|
|
267
|
+
def attachments(ctx: click.Context, message_id: str) -> None:
|
|
268
|
+
"""List attachments of a message."""
|
|
269
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
270
|
+
cfg = GwConfig(config_dir)
|
|
271
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
272
|
+
output_json = ctx.obj.get("json", False)
|
|
273
|
+
|
|
274
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
275
|
+
msg = service.users().messages().get(userId="me", id=message_id, format="full").execute()
|
|
276
|
+
|
|
277
|
+
atts = []
|
|
278
|
+
for part in _iter_parts(msg.get("payload", {})):
|
|
279
|
+
if part.get("filename"):
|
|
280
|
+
atts.append(
|
|
281
|
+
{
|
|
282
|
+
"attachment_id": part["body"].get("attachmentId", ""),
|
|
283
|
+
"filename": part["filename"],
|
|
284
|
+
"mime_type": part.get("mimeType", ""),
|
|
285
|
+
"size": part["body"].get("size", 0),
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
click.echo(format_output(atts, output_json=output_json, account=account))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@mail.command()
|
|
293
|
+
@click.argument("message_id")
|
|
294
|
+
@click.option("--attachment-id", required=True)
|
|
295
|
+
@click.option("--out", required=True, type=click.Path(), help="Output file path")
|
|
296
|
+
@click.pass_context
|
|
297
|
+
def download(ctx: click.Context, message_id: str, attachment_id: str, out: str) -> None:
|
|
298
|
+
"""Download an attachment."""
|
|
299
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
300
|
+
cfg = GwConfig(config_dir)
|
|
301
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
302
|
+
output_json = ctx.obj.get("json", False)
|
|
303
|
+
|
|
304
|
+
service = build_service(cfg, account, "gmail", "v1")
|
|
305
|
+
att = service.users().messages().attachments().get(userId="me", messageId=message_id, id=attachment_id).execute()
|
|
306
|
+
|
|
307
|
+
data = base64.urlsafe_b64decode(att["data"])
|
|
308
|
+
from pathlib import Path
|
|
309
|
+
|
|
310
|
+
out_path = Path(out).resolve()
|
|
311
|
+
out_path.write_bytes(data)
|
|
312
|
+
click.echo(format_output(f"Downloaded to {out_path}", output_json=output_json, account=account))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@mail.command("to-drive")
|
|
316
|
+
@click.argument("message_id")
|
|
317
|
+
@click.option("--attachment-id", required=True)
|
|
318
|
+
@click.option("--folder", default=None, help="Drive folder ID")
|
|
319
|
+
@click.pass_context
|
|
320
|
+
def to_drive(ctx: click.Context, message_id: str, attachment_id: str, folder: str | None) -> None:
|
|
321
|
+
"""Save an attachment to Google Drive."""
|
|
322
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
323
|
+
cfg = GwConfig(config_dir)
|
|
324
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
325
|
+
output_json = ctx.obj.get("json", False)
|
|
326
|
+
|
|
327
|
+
gmail_service = build_service(cfg, account, "gmail", "v1")
|
|
328
|
+
msg = gmail_service.users().messages().get(userId="me", id=message_id, format="full").execute()
|
|
329
|
+
filename = ""
|
|
330
|
+
mime_type = "application/octet-stream"
|
|
331
|
+
for part in _iter_parts(msg.get("payload", {})):
|
|
332
|
+
if part.get("body", {}).get("attachmentId") == attachment_id:
|
|
333
|
+
filename = part.get("filename") or "attachment"
|
|
334
|
+
mime_type = part.get("mimeType", mime_type)
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
att = (
|
|
338
|
+
gmail_service.users()
|
|
339
|
+
.messages()
|
|
340
|
+
.attachments()
|
|
341
|
+
.get(userId="me", messageId=message_id, id=attachment_id)
|
|
342
|
+
.execute()
|
|
343
|
+
)
|
|
344
|
+
data = base64.urlsafe_b64decode(att["data"])
|
|
345
|
+
|
|
346
|
+
from googleapiclient.http import MediaInMemoryUpload
|
|
347
|
+
|
|
348
|
+
drive_service = build_service(cfg, account, "drive", "v3")
|
|
349
|
+
file_metadata: dict = {"name": filename}
|
|
350
|
+
if folder:
|
|
351
|
+
file_metadata["parents"] = [folder]
|
|
352
|
+
|
|
353
|
+
media = MediaInMemoryUpload(data, mimetype=mime_type)
|
|
354
|
+
result = drive_service.files().create(body=file_metadata, media_body=media, fields="id,name,webViewLink").execute()
|
|
355
|
+
|
|
356
|
+
click.echo(
|
|
357
|
+
format_output(
|
|
358
|
+
{
|
|
359
|
+
"id": result["id"],
|
|
360
|
+
"name": result["name"],
|
|
361
|
+
"link": result.get("webViewLink", ""),
|
|
362
|
+
},
|
|
363
|
+
output_json=output_json,
|
|
364
|
+
account=account,
|
|
365
|
+
)
|
|
366
|
+
)
|
gw/output.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_output(
|
|
7
|
+
data: list | dict | str,
|
|
8
|
+
*,
|
|
9
|
+
output_json: bool,
|
|
10
|
+
account: str,
|
|
11
|
+
) -> str:
|
|
12
|
+
if output_json:
|
|
13
|
+
return json.dumps(data, indent=2, ensure_ascii=False, default=str)
|
|
14
|
+
|
|
15
|
+
prefix = f"[{account}]"
|
|
16
|
+
|
|
17
|
+
if isinstance(data, str):
|
|
18
|
+
return f"{prefix} {data}"
|
|
19
|
+
|
|
20
|
+
if isinstance(data, list):
|
|
21
|
+
if not data:
|
|
22
|
+
return f"{prefix} No results."
|
|
23
|
+
lines = [prefix]
|
|
24
|
+
for item in data:
|
|
25
|
+
parts = [str(v) for v in item.values()]
|
|
26
|
+
lines.append(" " + " | ".join(parts))
|
|
27
|
+
return "\n".join(lines)
|
|
28
|
+
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
lines = [prefix]
|
|
31
|
+
for key, value in data.items():
|
|
32
|
+
lines.append(f" {key}: {value}")
|
|
33
|
+
return "\n".join(lines)
|
|
34
|
+
|
|
35
|
+
return f"{prefix} {data}"
|