mgraphctl 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.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""Outlook mail commands (spec §8.2)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from mgraphctl.cli import (
|
|
9
|
+
AllFlag,
|
|
10
|
+
DryRunFlag,
|
|
11
|
+
JsonFlag,
|
|
12
|
+
LimitOpt,
|
|
13
|
+
gate,
|
|
14
|
+
graph_command,
|
|
15
|
+
make_noun_app,
|
|
16
|
+
page_bounds,
|
|
17
|
+
read_body,
|
|
18
|
+
)
|
|
19
|
+
from mgraphctl.errors import MsgraphError, NotFoundError, UsageError
|
|
20
|
+
from mgraphctl.graph import mail
|
|
21
|
+
from mgraphctl.http import GraphClient
|
|
22
|
+
from mgraphctl.render import (
|
|
23
|
+
Column,
|
|
24
|
+
DryRunResult,
|
|
25
|
+
FileResult,
|
|
26
|
+
ListResult,
|
|
27
|
+
ObjectResult,
|
|
28
|
+
TextResult,
|
|
29
|
+
Window,
|
|
30
|
+
WriteResult,
|
|
31
|
+
fmt_dt,
|
|
32
|
+
fmt_person,
|
|
33
|
+
fmt_size,
|
|
34
|
+
note,
|
|
35
|
+
parse_dt,
|
|
36
|
+
truncate,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
app = make_noun_app("Outlook mail: list, read, send, reply, organise.")
|
|
40
|
+
drafts = make_noun_app("Draft messages.")
|
|
41
|
+
app.add_typer(drafts, name="drafts")
|
|
42
|
+
rules = make_noun_app("Inbox rules.")
|
|
43
|
+
app.add_typer(rules, name="rules")
|
|
44
|
+
|
|
45
|
+
BODY_LIMIT = 4000
|
|
46
|
+
|
|
47
|
+
FolderOpt = Annotated[str, typer.Option("--folder", help="Folder name, id, or 'all'.")]
|
|
48
|
+
ToOpt = Annotated[
|
|
49
|
+
list[str] | None, typer.Option("--to", help="Recipient address (repeatable, comma-separated).")
|
|
50
|
+
]
|
|
51
|
+
CcOpt = Annotated[list[str] | None, typer.Option("--cc", help="Copy recipient (repeatable).")]
|
|
52
|
+
BccOpt = Annotated[list[str] | None, typer.Option("--bcc", help="Blind copy (repeatable).")]
|
|
53
|
+
SubjectOpt = Annotated[str | None, typer.Option("--subject", help="Subject line.")]
|
|
54
|
+
BodyOpt = Annotated[str | None, typer.Option("--body", help="Message body.")]
|
|
55
|
+
BodyFileOpt = Annotated[str | None, typer.Option("--body-file", help="Body file, or - for stdin.")]
|
|
56
|
+
HtmlOpt = Annotated[bool, typer.Option("--html", help="The body is HTML, not plain text.")]
|
|
57
|
+
AttachOpt = Annotated[
|
|
58
|
+
list[Path] | None,
|
|
59
|
+
typer.Option("--attach", help="File to attach (repeatable).", exists=True, dir_okay=False),
|
|
60
|
+
]
|
|
61
|
+
ImportanceOpt = Annotated[str, typer.Option("--importance", help="low, normal or high.")]
|
|
62
|
+
MessageIdArg = Annotated[str, typer.Argument(metavar="ID", help="Message id.")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _split(values: list[str] | None) -> list[str]:
|
|
66
|
+
"""`--to a,b --to c` → `["a", "b", "c"]`."""
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
for value in values or []:
|
|
69
|
+
out += [part.strip() for part in value.split(",") if part.strip()]
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_fields(tz: str) -> list[tuple[str, object]]:
|
|
74
|
+
return [
|
|
75
|
+
("Subject", "subject"),
|
|
76
|
+
("From", lambda m: fmt_person(m.get("from"))),
|
|
77
|
+
("To", lambda m: mail.recipients_text(m, "toRecipients")),
|
|
78
|
+
("Cc", lambda m: mail.recipients_text(m, "ccRecipients")),
|
|
79
|
+
("Received", lambda m: fmt_dt(m.get("receivedDateTime"), tz)),
|
|
80
|
+
("Id", "id"),
|
|
81
|
+
("Web link", "webLink"),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _send_params(
|
|
86
|
+
*,
|
|
87
|
+
to: list[str] | None,
|
|
88
|
+
body: str | None,
|
|
89
|
+
body_file: str | None,
|
|
90
|
+
cc: list[str] | None = None,
|
|
91
|
+
bcc: list[str] | None = None,
|
|
92
|
+
subject: str | None = None,
|
|
93
|
+
html: bool = False,
|
|
94
|
+
attach: list[Path] | None = None,
|
|
95
|
+
importance: str = "normal",
|
|
96
|
+
save_to_sent: bool = True,
|
|
97
|
+
) -> mail.SendParams:
|
|
98
|
+
"""Turn the shared `send` / `drafts create` options into a validated `SendParams`."""
|
|
99
|
+
recipients = _split(to)
|
|
100
|
+
if not recipients:
|
|
101
|
+
raise UsageError("USAGE", "--to is required (one or more addresses)")
|
|
102
|
+
if importance not in mail.IMPORTANCE:
|
|
103
|
+
raise UsageError(
|
|
104
|
+
"USAGE",
|
|
105
|
+
f"--importance must be one of {', '.join(mail.IMPORTANCE)} (got {importance!r})",
|
|
106
|
+
)
|
|
107
|
+
return mail.SendParams(
|
|
108
|
+
to=recipients,
|
|
109
|
+
body=read_body(body, body_file),
|
|
110
|
+
cc=_split(cc),
|
|
111
|
+
bcc=_split(bcc),
|
|
112
|
+
subject=subject,
|
|
113
|
+
html=html,
|
|
114
|
+
attachments=list(attach or []),
|
|
115
|
+
importance=importance,
|
|
116
|
+
save_to_sent=save_to_sent,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _mark_patch(
|
|
121
|
+
*,
|
|
122
|
+
read: bool,
|
|
123
|
+
unread: bool,
|
|
124
|
+
flag: bool,
|
|
125
|
+
unflag: bool,
|
|
126
|
+
flag_complete: bool,
|
|
127
|
+
category: list[str] | None,
|
|
128
|
+
clear_categories: bool,
|
|
129
|
+
importance: str | None,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""The PATCH body `mail mark` sends; every contradictory pair is a usage error."""
|
|
132
|
+
if read and unread:
|
|
133
|
+
raise UsageError("USAGE", "--read and --unread are mutually exclusive")
|
|
134
|
+
if sum([flag, unflag, flag_complete]) > 1:
|
|
135
|
+
raise UsageError("USAGE", "give at most one of --flag, --unflag or --flag-complete")
|
|
136
|
+
categories = _split(category)
|
|
137
|
+
if categories and clear_categories:
|
|
138
|
+
raise UsageError("USAGE", "--category and --clear-categories are mutually exclusive")
|
|
139
|
+
patch: dict[str, object] = {}
|
|
140
|
+
if read or unread:
|
|
141
|
+
patch["isRead"] = read
|
|
142
|
+
for wanted, status in ((flag, "flagged"), (unflag, "notFlagged"), (flag_complete, "complete")):
|
|
143
|
+
if wanted:
|
|
144
|
+
patch["flag"] = {"flagStatus": status}
|
|
145
|
+
if categories or clear_categories:
|
|
146
|
+
patch["categories"] = categories
|
|
147
|
+
if importance is not None:
|
|
148
|
+
if importance not in mail.IMPORTANCE:
|
|
149
|
+
raise UsageError(
|
|
150
|
+
"USAGE",
|
|
151
|
+
f"--importance must be one of {', '.join(mail.IMPORTANCE)} (got {importance!r})",
|
|
152
|
+
)
|
|
153
|
+
patch["importance"] = importance
|
|
154
|
+
if not patch:
|
|
155
|
+
raise UsageError(
|
|
156
|
+
"USAGE",
|
|
157
|
+
"nothing to change; give --read/--unread, --flag/--unflag/--flag-complete, "
|
|
158
|
+
"--category/--clear-categories or --importance",
|
|
159
|
+
)
|
|
160
|
+
return patch
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _actions_summary(rule: dict) -> str:
|
|
164
|
+
return ", ".join(name for name, value in (rule.get("actions") or {}).items() if value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _headers_block(message: dict) -> str:
|
|
168
|
+
rows = message.get("internetMessageHeaders") or []
|
|
169
|
+
lines = [f" {row.get('name')}: {row.get('value')}" for row in rows]
|
|
170
|
+
return "\n".join(["Headers", *lines])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _tree_text(tree: list[dict]) -> str:
|
|
174
|
+
"""The folder tree: children indented by two spaces, ids in full (spec §8.2 `folders`)."""
|
|
175
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
176
|
+
|
|
177
|
+
def walk(folders: list[dict], level: int) -> None:
|
|
178
|
+
for folder in folders:
|
|
179
|
+
rows.append(
|
|
180
|
+
(
|
|
181
|
+
" " * level + truncate(folder.get("displayName")),
|
|
182
|
+
str(folder.get("unreadItemCount") or 0),
|
|
183
|
+
str(folder.get("totalItemCount") or 0),
|
|
184
|
+
str(folder.get("id") or ""),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
walk(folder.get("children") or [], level + 1)
|
|
188
|
+
|
|
189
|
+
walk(tree, 0)
|
|
190
|
+
if not rows:
|
|
191
|
+
return "No results."
|
|
192
|
+
rows.insert(0, ("name", "unread", "total", "id"))
|
|
193
|
+
widths = [max(len(row[i]) for row in rows) for i in range(3)]
|
|
194
|
+
return "\n".join(
|
|
195
|
+
f"{name:<{widths[0]}} {unread:>{widths[1]}} {total:>{widths[2]}} {folder_id}"
|
|
196
|
+
for name, unread, total, folder_id in rows
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------- read verbs
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command("list")
|
|
204
|
+
@graph_command(scopes=["Mail.Read"])
|
|
205
|
+
def list_(
|
|
206
|
+
client: GraphClient,
|
|
207
|
+
folder: FolderOpt = "inbox",
|
|
208
|
+
unread: Annotated[bool, typer.Option("--unread", help="Only unread messages.")] = False,
|
|
209
|
+
from_: Annotated[
|
|
210
|
+
list[str] | None, typer.Option("--from", help="Sender address (repeatable).")
|
|
211
|
+
] = None,
|
|
212
|
+
to: Annotated[
|
|
213
|
+
list[str] | None, typer.Option("--to", help="Recipient address (repeatable).")
|
|
214
|
+
] = None,
|
|
215
|
+
search: Annotated[str | None, typer.Option("--search", help="KQL query.")] = None,
|
|
216
|
+
after: Annotated[str | None, typer.Option("--after", help="Only messages after this.")] = None,
|
|
217
|
+
before: Annotated[
|
|
218
|
+
str | None, typer.Option("--before", help="Only messages before this.")
|
|
219
|
+
] = None,
|
|
220
|
+
select: Annotated[
|
|
221
|
+
str | None, typer.Option("--select", help="Comma-separated $select override.")
|
|
222
|
+
] = None,
|
|
223
|
+
limit: LimitOpt = None,
|
|
224
|
+
all_: AllFlag = False,
|
|
225
|
+
json_: JsonFlag = False,
|
|
226
|
+
):
|
|
227
|
+
"""List messages (default: Inbox, newest first)."""
|
|
228
|
+
limit, all_ = page_bounds(limit, all_, default=10)
|
|
229
|
+
tz = client.tz
|
|
230
|
+
after_dt = parse_dt(after, tz) if after else None
|
|
231
|
+
before_dt = parse_dt(before, tz, end_of_day=True) if before else None
|
|
232
|
+
page = mail.list_messages(
|
|
233
|
+
client,
|
|
234
|
+
folder_id=mail.resolve_folder(client, folder),
|
|
235
|
+
unread=unread,
|
|
236
|
+
search=search,
|
|
237
|
+
senders=_split(from_),
|
|
238
|
+
recipients=_split(to),
|
|
239
|
+
after=after_dt,
|
|
240
|
+
before=before_dt,
|
|
241
|
+
select=select,
|
|
242
|
+
limit=limit,
|
|
243
|
+
all_=all_,
|
|
244
|
+
tz=tz,
|
|
245
|
+
)
|
|
246
|
+
return ListResult(
|
|
247
|
+
items=page.items,
|
|
248
|
+
page=page,
|
|
249
|
+
window=Window(after=after_dt, before=before_dt),
|
|
250
|
+
hit_cap=mail.CAP_LIST if all_ else None,
|
|
251
|
+
columns=mail.message_columns(tz),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command("read")
|
|
256
|
+
@graph_command(scopes=["Mail.Read"])
|
|
257
|
+
def read(
|
|
258
|
+
client: GraphClient,
|
|
259
|
+
message_id: MessageIdArg,
|
|
260
|
+
html: Annotated[bool, typer.Option("--html", help="Show the HTML body verbatim.")] = False,
|
|
261
|
+
full: Annotated[bool, typer.Option("--full", help="Do not truncate the body.")] = False,
|
|
262
|
+
headers: Annotated[
|
|
263
|
+
bool, typer.Option("--headers", help="Also show the internet message headers.")
|
|
264
|
+
] = False,
|
|
265
|
+
output: Annotated[
|
|
266
|
+
Path | None, typer.Option("--output", help="Write the full body to this file.")
|
|
267
|
+
] = None,
|
|
268
|
+
save_attachments: Annotated[
|
|
269
|
+
Path | None, typer.Option("--save-attachments", help="Save file attachments here.")
|
|
270
|
+
] = None,
|
|
271
|
+
json_: JsonFlag = False,
|
|
272
|
+
):
|
|
273
|
+
"""Show one message."""
|
|
274
|
+
tz = client.tz
|
|
275
|
+
message = mail.get_message(client, message_id, headers=headers, html=html)
|
|
276
|
+
if save_attachments is not None:
|
|
277
|
+
for attachment in mail.list_attachments(client, message_id):
|
|
278
|
+
if not mail.is_file_attachment(attachment):
|
|
279
|
+
note(
|
|
280
|
+
f"skipped {attachment.get('name')}: "
|
|
281
|
+
f"{mail.attachment_type(attachment)} cannot be downloaded"
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
name = Path(str(attachment.get("name") or attachment["id"])).name
|
|
285
|
+
mail.download_attachment(client, message_id, attachment["id"], save_attachments / name)
|
|
286
|
+
body = mail.body_text(message, html=html)
|
|
287
|
+
if output is not None:
|
|
288
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
output.write_text(body)
|
|
290
|
+
return FileResult(
|
|
291
|
+
path=output,
|
|
292
|
+
bytes=len(body.encode()),
|
|
293
|
+
meta={"chars": len(body)},
|
|
294
|
+
message=f"Wrote {len(body)} chars to {output}",
|
|
295
|
+
)
|
|
296
|
+
shown = body
|
|
297
|
+
if not full and len(body) > BODY_LIMIT:
|
|
298
|
+
shown = body[:BODY_LIMIT]
|
|
299
|
+
note(f"(body truncated to {BODY_LIMIT} chars; use --full)")
|
|
300
|
+
if headers:
|
|
301
|
+
shown = _headers_block(message) + "\n\n" + shown
|
|
302
|
+
return ObjectResult(obj=message, fields=_read_fields(tz), body=shown)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command("attachments")
|
|
306
|
+
@graph_command(scopes=["Mail.Read"])
|
|
307
|
+
def attachments(
|
|
308
|
+
client: GraphClient,
|
|
309
|
+
message_id: MessageIdArg,
|
|
310
|
+
download: Annotated[
|
|
311
|
+
str | None, typer.Option("--download", help="Attachment id to download.")
|
|
312
|
+
] = None,
|
|
313
|
+
output: Annotated[
|
|
314
|
+
Path | None, typer.Option("--output", help="Where to write the --download file.")
|
|
315
|
+
] = None,
|
|
316
|
+
all_attachments: Annotated[
|
|
317
|
+
bool, typer.Option("--all-attachments", help="Download every file attachment.")
|
|
318
|
+
] = False,
|
|
319
|
+
output_dir: Annotated[
|
|
320
|
+
Path | None, typer.Option("--output-dir", help="Directory for --all-attachments.")
|
|
321
|
+
] = None,
|
|
322
|
+
json_: JsonFlag = False,
|
|
323
|
+
):
|
|
324
|
+
"""List a message's attachments, or download them."""
|
|
325
|
+
if download is not None and all_attachments:
|
|
326
|
+
raise UsageError("USAGE", "--download and --all-attachments are mutually exclusive")
|
|
327
|
+
if output is not None and download is None:
|
|
328
|
+
raise UsageError("USAGE", "--output names the file for --download; add --download")
|
|
329
|
+
if output_dir is not None and not all_attachments:
|
|
330
|
+
raise UsageError("USAGE", "--output-dir goes with --all-attachments")
|
|
331
|
+
items = mail.list_attachments(client, message_id)
|
|
332
|
+
if download is not None:
|
|
333
|
+
return _download_one(client, message_id, items, download, output)
|
|
334
|
+
if all_attachments:
|
|
335
|
+
return _download_all(client, message_id, items, output_dir or Path())
|
|
336
|
+
return ListResult(
|
|
337
|
+
items=items,
|
|
338
|
+
columns=[
|
|
339
|
+
Column("id", "id"),
|
|
340
|
+
Column("type", mail.attachment_kind),
|
|
341
|
+
Column("name", "name"),
|
|
342
|
+
Column("size", lambda a: fmt_size(a.get("size"))),
|
|
343
|
+
Column("inline", lambda a: "yes" if a.get("isInline") else "no"),
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _find_attachment(items: list[dict], attachment_id: str) -> dict:
|
|
349
|
+
for attachment in items:
|
|
350
|
+
if attachment.get("id") == attachment_id:
|
|
351
|
+
return attachment
|
|
352
|
+
raise NotFoundError("NOT_FOUND", f"attachment {attachment_id!r} not found on this message")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _download_one(
|
|
356
|
+
client: GraphClient,
|
|
357
|
+
message_id: str,
|
|
358
|
+
items: list[dict],
|
|
359
|
+
attachment_id: str,
|
|
360
|
+
output: Path | None,
|
|
361
|
+
) -> FileResult:
|
|
362
|
+
attachment = _find_attachment(items, attachment_id)
|
|
363
|
+
name = str(attachment.get("name") or attachment_id)
|
|
364
|
+
if not mail.is_file_attachment(attachment):
|
|
365
|
+
raise MsgraphError(
|
|
366
|
+
"ATTACHMENT_NOT_A_FILE",
|
|
367
|
+
f"{mail.attachment_type(attachment)} {name!r} cannot be downloaded",
|
|
368
|
+
hint="only fileAttachment items have bytes; open the message in Outlook instead",
|
|
369
|
+
)
|
|
370
|
+
got = mail.download_attachment(
|
|
371
|
+
client, message_id, attachment_id, output or Path(Path(name).name)
|
|
372
|
+
)
|
|
373
|
+
return FileResult(
|
|
374
|
+
path=got.path,
|
|
375
|
+
bytes=got.bytes,
|
|
376
|
+
meta={"id": attachment_id, "name": name, "contentType": got.content_type},
|
|
377
|
+
message=f"Downloaded {name} ({fmt_size(got.bytes)}) to {got.path}",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _download_all(
|
|
382
|
+
client: GraphClient, message_id: str, items: list[dict], output_dir: Path
|
|
383
|
+
) -> ListResult:
|
|
384
|
+
saved: list[dict] = []
|
|
385
|
+
for attachment in items:
|
|
386
|
+
name = str(attachment.get("name") or attachment["id"])
|
|
387
|
+
if not mail.is_file_attachment(attachment):
|
|
388
|
+
note(f"skipped {name}: {mail.attachment_type(attachment)} cannot be downloaded")
|
|
389
|
+
continue
|
|
390
|
+
got = mail.download_attachment(
|
|
391
|
+
client, message_id, attachment["id"], output_dir / Path(name).name
|
|
392
|
+
)
|
|
393
|
+
saved.append(
|
|
394
|
+
{"id": attachment["id"], "name": name, "bytes": got.bytes, "path": str(got.path)}
|
|
395
|
+
)
|
|
396
|
+
return ListResult(
|
|
397
|
+
items=saved,
|
|
398
|
+
empty_text="No file attachments.",
|
|
399
|
+
columns=[
|
|
400
|
+
Column("id", "id"),
|
|
401
|
+
Column("name", "name"),
|
|
402
|
+
Column("size", lambda a: fmt_size(a.get("bytes"))),
|
|
403
|
+
Column("path", "path"),
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.command("folders")
|
|
409
|
+
@graph_command(scopes=["Mail.Read"])
|
|
410
|
+
def folders(
|
|
411
|
+
client: GraphClient,
|
|
412
|
+
depth: Annotated[int, typer.Option("--depth", min=1, help="How many levels to show.")] = 2,
|
|
413
|
+
hidden: Annotated[bool, typer.Option("--hidden", help="Include hidden folders.")] = False,
|
|
414
|
+
json_: JsonFlag = False,
|
|
415
|
+
):
|
|
416
|
+
"""Show the mail folder tree."""
|
|
417
|
+
page = mail.list_folders(client, depth=depth, hidden=hidden)
|
|
418
|
+
if page.truncated and not json_:
|
|
419
|
+
note(f"(hit the {mail.CAP_FOLDERS}-item cap — narrow the query)")
|
|
420
|
+
return TextResult(
|
|
421
|
+
text=_tree_text(page.items),
|
|
422
|
+
json_obj={
|
|
423
|
+
"items": page.items,
|
|
424
|
+
"count": len(page.items),
|
|
425
|
+
"truncated": page.truncated,
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --------------------------------------------------------------------------- write verbs
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@app.command("send")
|
|
434
|
+
@graph_command(scopes=["Mail.Send"])
|
|
435
|
+
def send(
|
|
436
|
+
client: GraphClient,
|
|
437
|
+
to: ToOpt = None,
|
|
438
|
+
cc: CcOpt = None,
|
|
439
|
+
bcc: BccOpt = None,
|
|
440
|
+
subject: SubjectOpt = None,
|
|
441
|
+
body: BodyOpt = None,
|
|
442
|
+
body_file: BodyFileOpt = None,
|
|
443
|
+
html: HtmlOpt = False,
|
|
444
|
+
attach: AttachOpt = None,
|
|
445
|
+
importance: ImportanceOpt = "normal",
|
|
446
|
+
save_to_sent: Annotated[
|
|
447
|
+
bool,
|
|
448
|
+
typer.Option("--save-to-sent/--no-save-to-sent", help="Keep a copy in Sent Items."),
|
|
449
|
+
] = True,
|
|
450
|
+
dry_run: DryRunFlag = False,
|
|
451
|
+
json_: JsonFlag = False,
|
|
452
|
+
):
|
|
453
|
+
"""Send a message."""
|
|
454
|
+
params = _send_params(
|
|
455
|
+
to=to,
|
|
456
|
+
body=body,
|
|
457
|
+
body_file=body_file,
|
|
458
|
+
cc=cc,
|
|
459
|
+
bcc=bcc,
|
|
460
|
+
subject=subject,
|
|
461
|
+
html=html,
|
|
462
|
+
attach=attach,
|
|
463
|
+
importance=importance,
|
|
464
|
+
save_to_sent=save_to_sent,
|
|
465
|
+
)
|
|
466
|
+
if dry_run:
|
|
467
|
+
return DryRunResult(mail.plan_send(client, params, for_plan=True))
|
|
468
|
+
if mail.needs_draft_path(params):
|
|
469
|
+
gate(["Mail.ReadWrite"]) # the draft path writes before it sends (spec §4.4)
|
|
470
|
+
return WriteResult(obj=mail.run_send(client, params), message="Sent.")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@app.command("reply")
|
|
474
|
+
@graph_command(scopes=["Mail.Send"])
|
|
475
|
+
def reply(
|
|
476
|
+
client: GraphClient,
|
|
477
|
+
message_id: MessageIdArg,
|
|
478
|
+
body: BodyOpt = None,
|
|
479
|
+
body_file: BodyFileOpt = None,
|
|
480
|
+
html: HtmlOpt = False,
|
|
481
|
+
reply_all: Annotated[
|
|
482
|
+
bool, typer.Option("--reply-all", help="Reply to everyone on the message.")
|
|
483
|
+
] = False,
|
|
484
|
+
to: ToOpt = None,
|
|
485
|
+
dry_run: DryRunFlag = False,
|
|
486
|
+
json_: JsonFlag = False,
|
|
487
|
+
):
|
|
488
|
+
"""Reply to a message."""
|
|
489
|
+
plan = mail.plan_reply(
|
|
490
|
+
client,
|
|
491
|
+
message_id,
|
|
492
|
+
body=read_body(body, body_file),
|
|
493
|
+
html=html,
|
|
494
|
+
reply_all=reply_all,
|
|
495
|
+
extra_to=_split(to),
|
|
496
|
+
)
|
|
497
|
+
if dry_run:
|
|
498
|
+
return DryRunResult(plan)
|
|
499
|
+
mail.run_plan(client, plan)
|
|
500
|
+
return WriteResult(obj={"status": "sent"}, message="Sent.")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@app.command("forward")
|
|
504
|
+
@graph_command(scopes=["Mail.Send"])
|
|
505
|
+
def forward(
|
|
506
|
+
client: GraphClient,
|
|
507
|
+
message_id: MessageIdArg,
|
|
508
|
+
to: ToOpt = None,
|
|
509
|
+
body: BodyOpt = None,
|
|
510
|
+
html: HtmlOpt = False,
|
|
511
|
+
dry_run: DryRunFlag = False,
|
|
512
|
+
json_: JsonFlag = False,
|
|
513
|
+
):
|
|
514
|
+
"""Forward a message."""
|
|
515
|
+
recipients = _split(to)
|
|
516
|
+
if not recipients:
|
|
517
|
+
raise UsageError("USAGE", "--to is required (one or more addresses)")
|
|
518
|
+
plan = mail.plan_forward(client, message_id, to=recipients, body=body or "", html=html)
|
|
519
|
+
if dry_run:
|
|
520
|
+
return DryRunResult(plan)
|
|
521
|
+
mail.run_plan(client, plan)
|
|
522
|
+
return WriteResult(obj={"status": "sent"}, message="Sent.")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@app.command("mark")
|
|
526
|
+
@graph_command(scopes=["Mail.ReadWrite"])
|
|
527
|
+
def mark(
|
|
528
|
+
client: GraphClient,
|
|
529
|
+
message_ids: Annotated[list[str], typer.Argument(metavar="ID...", help="Message ids.")],
|
|
530
|
+
read: Annotated[bool, typer.Option("--read", help="Mark as read.")] = False,
|
|
531
|
+
unread: Annotated[bool, typer.Option("--unread", help="Mark as unread.")] = False,
|
|
532
|
+
flag: Annotated[bool, typer.Option("--flag", help="Flag for follow-up.")] = False,
|
|
533
|
+
unflag: Annotated[bool, typer.Option("--unflag", help="Remove the flag.")] = False,
|
|
534
|
+
flag_complete: Annotated[
|
|
535
|
+
bool, typer.Option("--flag-complete", help="Mark the flag complete.")
|
|
536
|
+
] = False,
|
|
537
|
+
category: Annotated[
|
|
538
|
+
list[str] | None, typer.Option("--category", help="Category to set (repeatable).")
|
|
539
|
+
] = None,
|
|
540
|
+
clear_categories: Annotated[
|
|
541
|
+
bool, typer.Option("--clear-categories", help="Remove every category.")
|
|
542
|
+
] = False,
|
|
543
|
+
importance: Annotated[
|
|
544
|
+
str | None, typer.Option("--importance", help="low, normal or high.")
|
|
545
|
+
] = None,
|
|
546
|
+
dry_run: DryRunFlag = False,
|
|
547
|
+
json_: JsonFlag = False,
|
|
548
|
+
):
|
|
549
|
+
"""Change read state, flag, categories or importance on one or more messages."""
|
|
550
|
+
patch = _mark_patch(
|
|
551
|
+
read=read,
|
|
552
|
+
unread=unread,
|
|
553
|
+
flag=flag,
|
|
554
|
+
unflag=unflag,
|
|
555
|
+
flag_complete=flag_complete,
|
|
556
|
+
category=category,
|
|
557
|
+
clear_categories=clear_categories,
|
|
558
|
+
importance=importance,
|
|
559
|
+
)
|
|
560
|
+
if dry_run:
|
|
561
|
+
return DryRunResult(mail.plan_mark(client, message_ids, patch))
|
|
562
|
+
return ListResult(
|
|
563
|
+
items=mail.run_mark(client, message_ids, patch),
|
|
564
|
+
columns=mail.message_columns(client.tz),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.command("move")
|
|
569
|
+
@graph_command(scopes=["Mail.ReadWrite"])
|
|
570
|
+
def move(
|
|
571
|
+
client: GraphClient,
|
|
572
|
+
message_id: MessageIdArg,
|
|
573
|
+
folder: Annotated[str, typer.Option("--folder", help="Destination folder name or id.")],
|
|
574
|
+
dry_run: DryRunFlag = False,
|
|
575
|
+
json_: JsonFlag = False,
|
|
576
|
+
):
|
|
577
|
+
"""Move a message to another folder. The message gets a new id."""
|
|
578
|
+
plan = mail.plan_move(client, message_id, folder)
|
|
579
|
+
if dry_run:
|
|
580
|
+
return DryRunResult(plan)
|
|
581
|
+
moved = mail.run_plan(client, plan)[0]
|
|
582
|
+
return WriteResult(obj=moved, message=f"Moved to {folder}; new id: {moved.get('id')}")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@app.command("delete")
|
|
586
|
+
@graph_command(scopes=["Mail.ReadWrite"])
|
|
587
|
+
def delete(
|
|
588
|
+
client: GraphClient,
|
|
589
|
+
message_id: MessageIdArg,
|
|
590
|
+
dry_run: DryRunFlag = False,
|
|
591
|
+
json_: JsonFlag = False,
|
|
592
|
+
):
|
|
593
|
+
"""Delete a message (Outlook moves it to Deleted Items)."""
|
|
594
|
+
plan = mail.plan_delete(client, message_id)
|
|
595
|
+
if dry_run:
|
|
596
|
+
return DryRunResult(plan)
|
|
597
|
+
mail.run_plan(client, plan)
|
|
598
|
+
return WriteResult(
|
|
599
|
+
obj={"status": "deleted", "id": message_id}, message=f"Deleted {message_id}."
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@rules.command("list")
|
|
604
|
+
@graph_command(scopes=["MailboxSettings.Read"])
|
|
605
|
+
def rules_list(client: GraphClient, json_: JsonFlag = False):
|
|
606
|
+
"""List the inbox rules."""
|
|
607
|
+
return ListResult(
|
|
608
|
+
items=mail.list_rules(client),
|
|
609
|
+
empty_text="No inbox rules.",
|
|
610
|
+
columns=[
|
|
611
|
+
Column("id", "id"),
|
|
612
|
+
Column("sequence", "sequence"),
|
|
613
|
+
Column("enabled", lambda r: "yes" if r.get("isEnabled") else "no"),
|
|
614
|
+
Column("name", "displayName"),
|
|
615
|
+
Column("actions", _actions_summary),
|
|
616
|
+
],
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@app.command("categories")
|
|
621
|
+
@graph_command(scopes=["MailboxSettings.Read"])
|
|
622
|
+
def categories(client: GraphClient, json_: JsonFlag = False):
|
|
623
|
+
"""List the mailbox's categories."""
|
|
624
|
+
return ListResult(
|
|
625
|
+
items=mail.list_categories(client),
|
|
626
|
+
empty_text="No categories.",
|
|
627
|
+
columns=[
|
|
628
|
+
Column("id", "id"),
|
|
629
|
+
Column("name", "displayName"),
|
|
630
|
+
Column("color", "color"),
|
|
631
|
+
],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@drafts.command("create")
|
|
636
|
+
@graph_command(scopes=["Mail.ReadWrite"])
|
|
637
|
+
def drafts_create(
|
|
638
|
+
client: GraphClient,
|
|
639
|
+
to: ToOpt = None,
|
|
640
|
+
cc: CcOpt = None,
|
|
641
|
+
bcc: BccOpt = None,
|
|
642
|
+
subject: SubjectOpt = None,
|
|
643
|
+
body: BodyOpt = None,
|
|
644
|
+
body_file: BodyFileOpt = None,
|
|
645
|
+
html: HtmlOpt = False,
|
|
646
|
+
attach: AttachOpt = None,
|
|
647
|
+
importance: ImportanceOpt = "normal",
|
|
648
|
+
dry_run: DryRunFlag = False,
|
|
649
|
+
json_: JsonFlag = False,
|
|
650
|
+
):
|
|
651
|
+
"""Create a draft message without sending it."""
|
|
652
|
+
params = _send_params(
|
|
653
|
+
to=to,
|
|
654
|
+
body=body,
|
|
655
|
+
body_file=body_file,
|
|
656
|
+
cc=cc,
|
|
657
|
+
bcc=bcc,
|
|
658
|
+
subject=subject,
|
|
659
|
+
html=html,
|
|
660
|
+
attach=attach,
|
|
661
|
+
importance=importance,
|
|
662
|
+
)
|
|
663
|
+
if dry_run:
|
|
664
|
+
return DryRunResult(mail.plan_create_draft(client, params, for_plan=True))
|
|
665
|
+
draft = mail.run_create_draft(client, params)
|
|
666
|
+
return WriteResult(obj=draft, message=f"Draft created: {draft.get('id')}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@drafts.command("send")
|
|
670
|
+
@graph_command(scopes=["Mail.ReadWrite", "Mail.Send"])
|
|
671
|
+
def drafts_send(
|
|
672
|
+
client: GraphClient,
|
|
673
|
+
message_id: MessageIdArg,
|
|
674
|
+
dry_run: DryRunFlag = False,
|
|
675
|
+
json_: JsonFlag = False,
|
|
676
|
+
):
|
|
677
|
+
"""Send an existing draft."""
|
|
678
|
+
plan = mail.plan_send_draft(client, message_id)
|
|
679
|
+
if dry_run:
|
|
680
|
+
return DryRunResult(plan)
|
|
681
|
+
mail.run_plan(client, plan)
|
|
682
|
+
return WriteResult(obj={"status": "sent"}, message="Sent.")
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@drafts.command("list")
|
|
686
|
+
@graph_command(scopes=["Mail.Read"])
|
|
687
|
+
def drafts_list(
|
|
688
|
+
client: GraphClient,
|
|
689
|
+
limit: LimitOpt = None,
|
|
690
|
+
all_: AllFlag = False,
|
|
691
|
+
json_: JsonFlag = False,
|
|
692
|
+
):
|
|
693
|
+
"""List draft messages, most recently changed first."""
|
|
694
|
+
limit, all_ = page_bounds(limit, all_, default=20)
|
|
695
|
+
page = mail.list_drafts(client, limit=limit, all_=all_)
|
|
696
|
+
return ListResult(
|
|
697
|
+
items=page.items,
|
|
698
|
+
page=page,
|
|
699
|
+
hit_cap=mail.CAP_LIST if all_ else None,
|
|
700
|
+
columns=[
|
|
701
|
+
Column("id", "id"),
|
|
702
|
+
Column("to", lambda m: truncate(mail.recipients_text(m))),
|
|
703
|
+
Column("subject", lambda m: truncate(m.get("subject"))),
|
|
704
|
+
],
|
|
705
|
+
)
|