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.
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}"