mxctl 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.
@@ -0,0 +1,390 @@
1
+ """Mail analytics commands: stats, top-senders, digest, show-flagged."""
2
+
3
+ from collections import Counter, defaultdict
4
+ from datetime import datetime, timedelta
5
+
6
+ from mxctl.config import (
7
+ APPLESCRIPT_TIMEOUT_LONG,
8
+ DEFAULT_DIGEST_LIMIT,
9
+ DEFAULT_MAILBOX,
10
+ DEFAULT_MESSAGE_LIMIT,
11
+ DEFAULT_TOP_SENDERS_LIMIT,
12
+ FIELD_SEPARATOR,
13
+ MAX_MESSAGES_BATCH,
14
+ resolve_account,
15
+ save_message_aliases,
16
+ validate_limit,
17
+ )
18
+ from mxctl.util.applescript import escape, run
19
+ from mxctl.util.dates import to_applescript_date
20
+ from mxctl.util.formatting import die, format_output, truncate
21
+ from mxctl.util.mail_helpers import extract_email, parse_message_line
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # top-senders
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def cmd_top_senders(args) -> None:
28
+ """Show most frequent email senders over a time period."""
29
+ days = getattr(args, "days", 30)
30
+ limit = getattr(args, "limit", DEFAULT_TOP_SENDERS_LIMIT)
31
+
32
+ since_dt = datetime.now() - timedelta(days=days)
33
+ since_as = to_applescript_date(since_dt)
34
+
35
+ script = f"""
36
+ tell application "Mail"
37
+ set output to ""
38
+ repeat with acct in (every account)
39
+ if enabled of acct then
40
+ repeat with mbox in (mailboxes of acct)
41
+ if name of mbox is "INBOX" then
42
+ try
43
+ set msgs to (every message of mbox whose date received >= date "{since_as}")
44
+ set msgCount to count of msgs
45
+ set cap to {MAX_MESSAGES_BATCH}
46
+ if msgCount < cap then set cap to msgCount
47
+ repeat with i from 1 to cap
48
+ set m to item i of msgs
49
+ set output to output & (sender of m) & linefeed
50
+ end repeat
51
+ end try
52
+ exit repeat
53
+ end if
54
+ end repeat
55
+ end if
56
+ end repeat
57
+ return output
58
+ end tell
59
+ """
60
+
61
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
62
+ if not result.strip():
63
+ format_output(args, f"No messages found in the last {days} days.",
64
+ json_data={"days": days, "senders": []})
65
+ return
66
+
67
+ counter = Counter(line.strip() for line in result.strip().split("\n") if line.strip())
68
+ top = counter.most_common(limit)
69
+
70
+ text = f"Top {limit} senders (last {days} days):"
71
+ for i, (sender, count) in enumerate(top, 1):
72
+ text += f"\n {i}. {truncate(sender, 50)} — {count} messages"
73
+ format_output(args, text, json_data=[{"sender": s, "count": c} for s, c in top])
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # digest — grouped unread summary
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def cmd_digest(args) -> None:
81
+ """Show unread messages grouped by sender domain."""
82
+ script = f"""
83
+ tell application "Mail"
84
+ set output to ""
85
+ repeat with acct in (every account)
86
+ if enabled of acct then
87
+ repeat with mbox in (mailboxes of acct)
88
+ if name of mbox is "INBOX" then
89
+ try
90
+ set unreadMsgs to (every message of mbox whose read status is false)
91
+ set acctName to name of acct
92
+ set cap to {DEFAULT_DIGEST_LIMIT}
93
+ if (count of unreadMsgs) < cap then set cap to (count of unreadMsgs)
94
+ repeat with j from 1 to cap
95
+ set m to item j of unreadMsgs
96
+ set output to output & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed
97
+ end repeat
98
+ end try
99
+ exit repeat
100
+ end if
101
+ end repeat
102
+ end if
103
+ end repeat
104
+ return output
105
+ end tell
106
+ """
107
+
108
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
109
+ if not result.strip():
110
+ format_output(args, "No unread messages. Inbox zero!")
111
+ return
112
+
113
+ # Group by sender domain
114
+ groups = defaultdict(list)
115
+ for line in result.strip().split("\n"):
116
+ if not line.strip():
117
+ continue
118
+ msg = parse_message_line(line, ["account", "id", "subject", "sender", "date"], FIELD_SEPARATOR)
119
+ if msg is None:
120
+ continue
121
+ # Extract domain from sender
122
+ email = extract_email(msg["sender"])
123
+ if "@" in email:
124
+ domain = email.split("@")[1].lower()
125
+ else:
126
+ domain = "other"
127
+ groups[domain].append(msg)
128
+
129
+ # Collect all messages into a flat list for sequential aliases
130
+ all_messages = []
131
+ for _domain, msgs in sorted(groups.items(), key=lambda x: -len(x[1])):
132
+ all_messages.extend(msgs)
133
+ save_message_aliases([m["id"] for m in all_messages])
134
+ for i, m in enumerate(all_messages, 1):
135
+ m["alias"] = i
136
+
137
+ total = sum(len(msgs) for msgs in groups.values())
138
+ text = f"Unread Digest ({total} messages, {len(groups)} groups):"
139
+ for domain, msgs in sorted(groups.items(), key=lambda x: -len(x[1])):
140
+ text += f"\n\n {domain} ({len(msgs)}):"
141
+ for m in msgs[:5]:
142
+ text += f"\n [{m['alias']}] {truncate(m['subject'], 45)}"
143
+ text += f"\n From: {truncate(m['sender'], 40)}"
144
+ if len(msgs) > 5:
145
+ text += f"\n ... and {len(msgs) - 5} more"
146
+ format_output(args, text, json_data=dict(groups))
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # stats
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def cmd_stats(args) -> None:
154
+ """Show message count and unread count for a mailbox, or account-wide stats with --all."""
155
+ show_all = getattr(args, "all", False)
156
+ # For --all, we need to know if the user *explicitly* passed -a, not just the resolved default.
157
+ # resolve_account() falls back to the configured default (e.g. iCloud), which would cause
158
+ # --all without -a to incorrectly use the single-account branch.
159
+ explicit_account = getattr(args, "account", None)
160
+ account = resolve_account(explicit_account)
161
+
162
+ if show_all:
163
+ # Only use the account branch when the user explicitly specified -a.
164
+ if explicit_account:
165
+ # --all -a ACCOUNT: stats for every mailbox in one account
166
+ acct_escaped = escape(account)
167
+ script = f"""
168
+ tell application "Mail"
169
+ set acct to account "{acct_escaped}"
170
+ set acctName to name of acct
171
+ set output to ""
172
+ set grandTotal to 0
173
+ set grandUnread to 0
174
+ repeat with mb in (every mailbox of acct)
175
+ set mbName to name of mb
176
+ set totalCount to count of messages of mb
177
+ set unreadCount to unread count of mb
178
+ set grandTotal to grandTotal + totalCount
179
+ set grandUnread to grandUnread + unreadCount
180
+ set output to output & acctName & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & (totalCount as text) & "{FIELD_SEPARATOR}" & (unreadCount as text) & linefeed
181
+ end repeat
182
+ return (grandTotal as text) & "{FIELD_SEPARATOR}" & (grandUnread as text) & linefeed & output
183
+ end tell
184
+ """
185
+ else:
186
+ # --all (no -a): stats for every mailbox across ALL accounts
187
+ script = f"""
188
+ tell application "Mail"
189
+ set output to ""
190
+ set grandTotal to 0
191
+ set grandUnread to 0
192
+ repeat with acct in (every account)
193
+ if enabled of acct then
194
+ set acctName to name of acct
195
+ repeat with mb in (every mailbox of acct)
196
+ set mbName to name of mb
197
+ set totalCount to count of messages of mb
198
+ set unreadCount to unread count of mb
199
+ set grandTotal to grandTotal + totalCount
200
+ set grandUnread to grandUnread + unreadCount
201
+ set output to output & acctName & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & (totalCount as text) & "{FIELD_SEPARATOR}" & (unreadCount as text) & linefeed
202
+ end repeat
203
+ end if
204
+ end repeat
205
+ return (grandTotal as text) & "{FIELD_SEPARATOR}" & (grandUnread as text) & linefeed & output
206
+ end tell
207
+ """
208
+
209
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
210
+ lines = result.strip().split("\n")
211
+ if not lines:
212
+ scope = f"account '{account}'" if explicit_account else "any account"
213
+ format_output(args, f"No mailboxes found in {scope}.",
214
+ json_data={"mailboxes": []})
215
+ return
216
+
217
+ # First line has grand totals
218
+ totals_parts = lines[0].split(FIELD_SEPARATOR)
219
+ grand_total = int(totals_parts[0]) if len(totals_parts) >= 1 and totals_parts[0].isdigit() else 0
220
+ grand_unread = int(totals_parts[1]) if len(totals_parts) >= 2 and totals_parts[1].isdigit() else 0
221
+
222
+ # Subsequent lines: acctName|mbName|total|unread
223
+ mailboxes = []
224
+ for line in lines[1:]:
225
+ if not line.strip():
226
+ continue
227
+ parts = line.split(FIELD_SEPARATOR)
228
+ if len(parts) >= 4:
229
+ mailboxes.append({
230
+ "account": parts[0],
231
+ "name": parts[1],
232
+ "total": int(parts[2]) if parts[2].isdigit() else 0,
233
+ "unread": int(parts[3]) if parts[3].isdigit() else 0,
234
+ })
235
+
236
+ # Build text output — use explicit_account so resolved defaults don't bleed in.
237
+ scope_label = f"Account: {account}" if explicit_account else "All Accounts"
238
+ text = f"{scope_label}\n"
239
+ text += f"Total: {grand_total} messages, {grand_unread} unread\n"
240
+ text += f"\nMailboxes ({len(mailboxes)}):"
241
+ for mb in mailboxes:
242
+ acct_prefix = "" if explicit_account else f"[{mb['account']}] "
243
+ text += f"\n {acct_prefix}{mb['name']}: {mb['total']} messages, {mb['unread']} unread"
244
+
245
+ format_output(args, text, json_data={
246
+ "scope": account or "all",
247
+ "total_messages": grand_total,
248
+ "total_unread": grand_unread,
249
+ "mailboxes": mailboxes,
250
+ })
251
+ else:
252
+ # Single mailbox stats (existing behavior)
253
+ if not account:
254
+ die("Account required. Use -a ACCOUNT.")
255
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
256
+ acct_escaped = escape(account)
257
+ mb_escaped = escape(mailbox)
258
+
259
+ script = f"""
260
+ tell application "Mail"
261
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
262
+ set totalCount to count of messages of mb
263
+ set unreadCount to unread count of mb
264
+ return (totalCount as text) & "{FIELD_SEPARATOR}" & (unreadCount as text)
265
+ end tell
266
+ """
267
+
268
+ result = run(script)
269
+ parts = result.split(FIELD_SEPARATOR)
270
+ total = int(parts[0]) if len(parts) >= 1 and parts[0].isdigit() else 0
271
+ unread = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0
272
+
273
+ format_output(args, f"{mailbox} [{account}]: {total} messages, {unread} unread",
274
+ json_data={"mailbox": mailbox, "account": account, "total": total, "unread": unread})
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # show-flagged — list all flagged messages
279
+ # ---------------------------------------------------------------------------
280
+
281
+ def cmd_show_flagged(args) -> None:
282
+ """List all flagged messages."""
283
+ account = resolve_account(getattr(args, "account", None))
284
+ limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT))
285
+
286
+ if account:
287
+ # Search in specific account only
288
+ acct_escaped = escape(account)
289
+ script = f"""
290
+ tell application "Mail"
291
+ set acct to account "{acct_escaped}"
292
+ set output to ""
293
+ set totalFound to 0
294
+ repeat with mb in (every mailbox of acct)
295
+ if totalFound >= {limit} then exit repeat
296
+ set mbName to name of mb
297
+ set flaggedMsgs to (every message of mb whose flagged status is true)
298
+ repeat with m in flaggedMsgs
299
+ if totalFound >= {limit} then exit repeat
300
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & "{acct_escaped}" & linefeed
301
+ set totalFound to totalFound + 1
302
+ end repeat
303
+ end repeat
304
+ return output
305
+ end tell
306
+ """
307
+ else:
308
+ # Search across all accounts
309
+ script = f"""
310
+ tell application "Mail"
311
+ set output to ""
312
+ set totalFound to 0
313
+ repeat with acct in (every account)
314
+ if totalFound >= {limit} then exit repeat
315
+ set acctName to name of acct
316
+ repeat with mb in (every mailbox of acct)
317
+ if totalFound >= {limit} then exit repeat
318
+ set mbName to name of mb
319
+ set flaggedMsgs to (every message of mb whose flagged status is true)
320
+ repeat with m in flaggedMsgs
321
+ if totalFound >= {limit} then exit repeat
322
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & acctName & linefeed
323
+ set totalFound to totalFound + 1
324
+ end repeat
325
+ end repeat
326
+ end repeat
327
+ return output
328
+ end tell
329
+ """
330
+
331
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
332
+
333
+ if not result.strip():
334
+ scope = f" in account '{account}'" if account else " across all accounts"
335
+ format_output(args, f"No flagged messages found{scope}.",
336
+ json_data={"flagged_messages": []})
337
+ return
338
+
339
+ # Build JSON data
340
+ messages = []
341
+ for line in result.strip().split("\n"):
342
+ if not line.strip():
343
+ continue
344
+ msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR)
345
+ if msg is not None:
346
+ messages.append(msg)
347
+
348
+ save_message_aliases([m["id"] for m in messages])
349
+ for i, m in enumerate(messages, 1):
350
+ m["alias"] = i
351
+
352
+ # Build text output
353
+ scope = f" in account '{account}'" if account else " across all accounts"
354
+ text = f"Flagged messages{scope} (showing up to {limit}):"
355
+ for m in messages:
356
+ text += f"\n- [{m['alias']}] {truncate(m['subject'], 60)}"
357
+ text += f"\n From: {m['sender']}"
358
+ text += f"\n Date: {m['date']}"
359
+ text += f"\n Location: {m['mailbox']} [{m['account']}]"
360
+ format_output(args, text, json_data=messages)
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Registration
365
+ # ---------------------------------------------------------------------------
366
+
367
+ def register(subparsers) -> None:
368
+ """Register analytics mail subcommands."""
369
+ p = subparsers.add_parser("top-senders", help="Most frequent senders")
370
+ p.add_argument("--days", type=int, default=30, help="Look back N days (default: 30)")
371
+ p.add_argument("--limit", type=int, default=10, help="Number of senders to show")
372
+ p.add_argument("--json", action="store_true", help="Output as JSON")
373
+ p.set_defaults(func=cmd_top_senders)
374
+
375
+ p = subparsers.add_parser("digest", help="Grouped unread summary")
376
+ p.add_argument("--json", action="store_true", help="Output as JSON")
377
+ p.set_defaults(func=cmd_digest)
378
+
379
+ p = subparsers.add_parser("stats", help="Message count and unread count for a mailbox")
380
+ p.add_argument("mailbox", nargs="?", default=None, help="Mailbox name (default: INBOX)")
381
+ p.add_argument("-a", "--account", help="Mail account name")
382
+ p.add_argument("--all", action="store_true", help="Show account-wide stats across all mailboxes")
383
+ p.add_argument("--json", action="store_true", help="Output as JSON")
384
+ p.set_defaults(func=cmd_stats)
385
+
386
+ p = subparsers.add_parser("show-flagged", help="List all flagged messages")
387
+ p.add_argument("-a", "--account", help="Filter by account name")
388
+ p.add_argument("--limit", type=int, default=DEFAULT_MESSAGE_LIMIT, help="Maximum messages to show")
389
+ p.add_argument("--json", action="store_true", help="Output as JSON")
390
+ p.set_defaults(func=cmd_show_flagged)
@@ -0,0 +1,156 @@
1
+ """Attachment commands: attachments (list), save-attachment."""
2
+
3
+ import os
4
+
5
+ from mxctl.util.applescript import escape, run, sanitize_path, validate_msg_id
6
+ from mxctl.util.applescript_templates import list_attachments
7
+ from mxctl.util.formatting import die, format_output, truncate
8
+ from mxctl.util.mail_helpers import resolve_message_context
9
+
10
+
11
+ def cmd_attachments(args) -> None:
12
+ """List attachments on a message."""
13
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
14
+ message_id = validate_msg_id(args.id)
15
+
16
+ script = list_attachments(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id)
17
+
18
+ result = run(script)
19
+ lines = result.strip().split("\n")
20
+
21
+ if len(lines) <= 1:
22
+ subject = lines[0] if lines else "Unknown"
23
+ format_output(
24
+ args,
25
+ f"No attachments in message '{truncate(subject, 50)}'.",
26
+ json_data={"subject": subject, "attachments": []}
27
+ )
28
+ return
29
+
30
+ subject = lines[0]
31
+ att_list = [a for a in lines[1:] if a.strip()]
32
+
33
+ text = f"Attachments in '{truncate(subject, 50)}':"
34
+ for i, att in enumerate(att_list, 1):
35
+ text += f"\n {i}. {att}"
36
+ format_output(args, text, json_data={"subject": subject, "attachments": att_list})
37
+
38
+
39
+ def cmd_save_attachment(args) -> None:
40
+ """Save an attachment from a message to disk."""
41
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
42
+ message_id = validate_msg_id(args.id)
43
+ attachment = args.attachment
44
+ output_dir = sanitize_path(getattr(args, "output_dir", "~/Downloads"))
45
+
46
+ # Ensure output directory exists
47
+ if not os.path.isdir(output_dir):
48
+ die(f"Output directory does not exist: {output_dir}")
49
+
50
+ # First, get the list of attachments to resolve index vs name
51
+ list_script = list_attachments(f'"{acct_escaped}"', f'"{mb_escaped}"', message_id)
52
+
53
+ result = run(list_script)
54
+ lines = result.strip().split("\n")
55
+
56
+ if len(lines) <= 1:
57
+ subject = lines[0] if lines else "Unknown"
58
+ die(f"No attachments found in message '{truncate(subject, 50)}'.")
59
+
60
+ subject = lines[0]
61
+ att_list = [a for a in lines[1:] if a.strip()]
62
+
63
+ # Resolve attachment name (could be index or name)
64
+ att_name = None
65
+ if attachment.isdigit():
66
+ # Index-based (1-indexed)
67
+ idx = int(attachment) - 1
68
+ if 0 <= idx < len(att_list):
69
+ att_name = att_list[idx]
70
+ else:
71
+ die(f"Attachment index {attachment} out of range (1-{len(att_list)}).")
72
+ else:
73
+ # Name-based (exact match or prefix match)
74
+ exact_matches = [a for a in att_list if a == attachment]
75
+ if exact_matches:
76
+ att_name = exact_matches[0]
77
+ else:
78
+ # Try prefix match
79
+ prefix_matches = [a for a in att_list if a.startswith(attachment)]
80
+ if len(prefix_matches) == 1:
81
+ att_name = prefix_matches[0]
82
+ elif len(prefix_matches) > 1:
83
+ die(f"Ambiguous attachment name '{attachment}'. Matches: {', '.join(prefix_matches)}")
84
+ else:
85
+ die(f"Attachment '{attachment}' not found. Available: {', '.join(att_list)}")
86
+
87
+ # Build save path
88
+ save_path = os.path.join(output_dir, att_name)
89
+
90
+ # Guard against path traversal (e.g. att_name = "../../.ssh/authorized_keys")
91
+ real_save = os.path.realpath(os.path.abspath(save_path))
92
+ real_base = os.path.realpath(os.path.abspath(output_dir))
93
+ if not real_save.startswith(real_base + os.sep) and real_save != real_base:
94
+ die("Unsafe attachment filename: path traversal detected.")
95
+
96
+ save_path_posix = save_path # Already absolute from sanitize_path + join
97
+
98
+ # Escape for AppleScript
99
+ att_name_escaped = escape(att_name)
100
+ save_path_posix_escaped = escape(save_path_posix)
101
+
102
+ # AppleScript to save the attachment
103
+ save_script = f"""
104
+ tell application "Mail"
105
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
106
+ set theMsg to first message of mb whose id is {message_id}
107
+ repeat with att in (mail attachments of theMsg)
108
+ if name of att is "{att_name_escaped}" then
109
+ save att in POSIX file "{save_path_posix_escaped}"
110
+ return "saved"
111
+ end if
112
+ end repeat
113
+ error "Attachment not found"
114
+ end tell
115
+ """
116
+
117
+ try:
118
+ run(save_script)
119
+ except SystemExit:
120
+ die(f"Failed to save attachment '{att_name}'.")
121
+
122
+ # Verify file was created
123
+ if not os.path.isfile(save_path):
124
+ die(f"Attachment save reported success but file not found: {save_path}")
125
+
126
+ format_output(
127
+ args,
128
+ f"Saved attachment '{att_name}' from message '{truncate(subject, 50)}' to:\n {save_path}",
129
+ json_data={
130
+ "message_id": message_id,
131
+ "subject": subject,
132
+ "attachment": att_name,
133
+ "saved_to": save_path,
134
+ }
135
+ )
136
+
137
+
138
+ def register(subparsers) -> None:
139
+ """Register attachment subcommands."""
140
+ # attachments (list)
141
+ p = subparsers.add_parser("attachments", help="List attachments on a message")
142
+ p.add_argument("id", type=int, help="Message ID")
143
+ p.add_argument("-a", "--account", help="Mail account name")
144
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
145
+ p.add_argument("--json", action="store_true", help="Output as JSON")
146
+ p.set_defaults(func=cmd_attachments)
147
+
148
+ # save-attachment
149
+ p = subparsers.add_parser("save-attachment", help="Save an attachment from a message")
150
+ p.add_argument("id", type=int, help="Message ID")
151
+ p.add_argument("attachment", help="Attachment name or index (1-based)")
152
+ p.add_argument("-a", "--account", help="Mail account name")
153
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
154
+ p.add_argument("--output-dir", help="Output directory (default: ~/Downloads)")
155
+ p.add_argument("--json", action="store_true", help="Output as JSON")
156
+ p.set_defaults(func=cmd_save_attachment)