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,593 @@
1
+ """Message action commands: mark-read, mark-unread, flag, unflag, move, delete, unsubscribe."""
2
+
3
+ import ipaddress
4
+ import re
5
+ import socket
6
+ import ssl
7
+ import subprocess
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ from mxctl.config import APPLESCRIPT_TIMEOUT_SHORT, FIELD_SEPARATOR, resolve_account
13
+ from mxctl.util.applescript import escape, run, validate_msg_id
14
+ from mxctl.util.applescript_templates import set_message_property
15
+ from mxctl.util.formatting import die, format_output, truncate
16
+ from mxctl.util.mail_helpers import parse_email_headers, resolve_mailbox, resolve_message_context
17
+
18
+
19
+ def _mark_read_status(args, read_status: bool) -> None:
20
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
21
+ message_id = validate_msg_id(args.id)
22
+ read_val = "true" if read_status else "false"
23
+
24
+ script = set_message_property(
25
+ f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
26
+ 'read status', read_val
27
+ )
28
+
29
+ subject = run(script)
30
+ status_word = "read" if read_status else "unread"
31
+ format_output(args, f"Message '{truncate(subject, 50)}' marked as {status_word}.",
32
+ json_data={"id": message_id, "subject": subject, "status": status_word})
33
+
34
+
35
+ def _flag_status(args, flagged: bool) -> None:
36
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
37
+ message_id = validate_msg_id(args.id)
38
+ flagged_val = "true" if flagged else "false"
39
+
40
+ script = set_message_property(
41
+ f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
42
+ 'flagged status', flagged_val
43
+ )
44
+
45
+ subject = run(script)
46
+ status_word = "flagged" if flagged else "unflagged"
47
+ format_output(args, f"Message '{truncate(subject, 50)}' {status_word}.",
48
+ json_data={"id": message_id, "subject": subject, "status": status_word})
49
+
50
+
51
+ def cmd_mark_read(args) -> None:
52
+ """Mark a message as read."""
53
+ _mark_read_status(args, True)
54
+
55
+
56
+ def cmd_mark_unread(args) -> None:
57
+ """Mark a message as unread."""
58
+ _mark_read_status(args, False)
59
+
60
+
61
+ def cmd_flag(args) -> None:
62
+ """Flag a message."""
63
+ _flag_status(args, True)
64
+
65
+
66
+ def cmd_unflag(args) -> None:
67
+ """Unflag a message."""
68
+ _flag_status(args, False)
69
+
70
+
71
+ def cmd_move(args) -> None:
72
+ """Move a message to a different mailbox."""
73
+ account = resolve_account(getattr(args, "account", None))
74
+ if not account:
75
+ die("Account required. Use -a ACCOUNT.")
76
+ source = getattr(args, "from_mailbox", None)
77
+ dest = getattr(args, "to_mailbox", None)
78
+ if not source or not dest:
79
+ die("Both --from and --to mailboxes are required.")
80
+ message_id = validate_msg_id(args.id)
81
+
82
+ acct_escaped = escape(account)
83
+ source = resolve_mailbox(account, source)
84
+ dest = resolve_mailbox(account, dest)
85
+ src_escaped = escape(source)
86
+ dest_escaped = escape(dest)
87
+
88
+ script = f"""
89
+ tell application "Mail"
90
+ set srcMb to mailbox "{src_escaped}" of account "{acct_escaped}"
91
+ set destMb to mailbox "{dest_escaped}" of account "{acct_escaped}"
92
+ set theMsg to first message of srcMb whose id is {message_id}
93
+ set msgSubject to subject of theMsg
94
+ move theMsg to destMb
95
+ return msgSubject
96
+ end tell
97
+ """
98
+
99
+ subject = run(script)
100
+ format_output(args, f"Message '{truncate(subject, 50)}' moved from '{source}' to '{dest}'.",
101
+ json_data={"id": message_id, "subject": subject, "from": source, "to": dest})
102
+
103
+
104
+ def cmd_delete(args) -> None:
105
+ """Delete a message by moving it to Trash."""
106
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
107
+ message_id = validate_msg_id(args.id)
108
+
109
+ script = f"""
110
+ tell application "Mail"
111
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
112
+ set theMsg to first message of mb whose id is {message_id}
113
+ set msgSubject to subject of theMsg
114
+ delete theMsg
115
+ return msgSubject
116
+ end tell
117
+ """
118
+
119
+ subject = run(script)
120
+ format_output(args, f"Message '{truncate(subject, 50)}' moved to Trash.",
121
+ json_data={"id": message_id, "subject": subject, "status": "deleted"})
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # unsubscribe — extract List-Unsubscribe and act on it
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+
130
+ _PRIVATE_NETWORKS = [
131
+ ipaddress.ip_network("10.0.0.0/8"),
132
+ ipaddress.ip_network("172.16.0.0/12"),
133
+ ipaddress.ip_network("192.168.0.0/16"),
134
+ ipaddress.ip_network("127.0.0.0/8"),
135
+ ipaddress.ip_network("169.254.0.0/16"),
136
+ ipaddress.ip_network("::1/128"),
137
+ ipaddress.ip_network("fc00::/7"),
138
+ ]
139
+
140
+
141
+ def _is_private_url(url: str) -> bool:
142
+ """Return True if the URL resolves to a private or loopback address."""
143
+ try:
144
+ parsed = urllib.parse.urlparse(url)
145
+ hostname = parsed.hostname
146
+ if not hostname:
147
+ return True
148
+ addr = ipaddress.ip_address(socket.gethostbyname(hostname))
149
+ return any(addr in net for net in _PRIVATE_NETWORKS)
150
+ except (OSError, ValueError):
151
+ # DNS failure or invalid address — block to be safe
152
+ return True
153
+
154
+
155
+ def _extract_urls(header_value: str) -> tuple[list[str], list[str]]:
156
+ """Extract https and mailto URLs from a List-Unsubscribe header value.
157
+
158
+ Returns (https_urls, mailto_urls).
159
+ """
160
+ https_urls = re.findall(r"<(https?://[^>]+)>", header_value)
161
+ mailto_urls = re.findall(r"<(mailto:[^>]+)>", header_value)
162
+ return https_urls, mailto_urls
163
+
164
+
165
+ def cmd_unsubscribe(args) -> None:
166
+ """Unsubscribe from a mailing list via List-Unsubscribe header."""
167
+ dry_run = getattr(args, "dry_run", False)
168
+ force_open = getattr(args, "open", False)
169
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
170
+ message_id = validate_msg_id(args.id)
171
+
172
+ # Fetch headers + subject
173
+ script = f"""
174
+ tell application "Mail"
175
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
176
+ set theMsg to first message of mb whose id is {message_id}
177
+ set subj to subject of theMsg
178
+ set hdrs to all headers of theMsg
179
+ return subj & "{FIELD_SEPARATOR}" & "HEADER_SPLIT" & "{FIELD_SEPARATOR}" & hdrs
180
+ end tell
181
+ """
182
+
183
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_SHORT)
184
+ parts = result.split(FIELD_SEPARATOR + "HEADER_SPLIT" + FIELD_SEPARATOR, 1)
185
+ subject = parts[0] if len(parts) >= 1 else "Unknown"
186
+ raw_headers = parts[1] if len(parts) >= 2 else ""
187
+
188
+ headers = parse_email_headers(raw_headers)
189
+
190
+ unsub_header = headers.get("List-Unsubscribe", "")
191
+ if isinstance(unsub_header, list):
192
+ unsub_header = ", ".join(unsub_header)
193
+
194
+ unsub_post = headers.get("List-Unsubscribe-Post", "")
195
+ if isinstance(unsub_post, list):
196
+ unsub_post = " ".join(unsub_post)
197
+
198
+ if not unsub_header:
199
+ format_output(args,
200
+ f"No unsubscribe option found for '{truncate(subject, 50)}'.\n"
201
+ "This email doesn't include a List-Unsubscribe header.",
202
+ json_data={"id": message_id, "subject": subject, "unsubscribe": False,
203
+ "reason": "No List-Unsubscribe header found"})
204
+ return
205
+
206
+ https_urls, mailto_urls = _extract_urls(unsub_header)
207
+ one_click = bool(unsub_post and "One-Click" in unsub_post and https_urls)
208
+
209
+ # Dry-run: just show what we found
210
+ if dry_run:
211
+ text = f"Unsubscribe info for '{truncate(subject, 50)}':"
212
+ text += f"\n One-click supported: {'Yes' if one_click else 'No'}"
213
+ if https_urls:
214
+ text += "\n HTTPS URLs:"
215
+ for u in https_urls:
216
+ text += f"\n {truncate(u, 100)}"
217
+ if mailto_urls:
218
+ text += "\n Mailto:"
219
+ for u in mailto_urls:
220
+ text += f"\n {u}"
221
+ format_output(args, text, json_data={
222
+ "id": message_id, "subject": subject, "one_click_supported": one_click,
223
+ "https_urls": https_urls, "mailto_urls": mailto_urls})
224
+ return
225
+
226
+ # Attempt one-click unsubscribe (RFC 8058)
227
+ if one_click and not force_open:
228
+ url = https_urls[0]
229
+ if _is_private_url(url):
230
+ die(f"Refused to POST to private/internal address: {url}")
231
+ try:
232
+ ctx = ssl.create_default_context(cafile="/etc/ssl/cert.pem")
233
+ req = urllib.request.Request(
234
+ url,
235
+ data=b"List-Unsubscribe=One-Click",
236
+ headers={
237
+ "Content-Type": "application/x-www-form-urlencoded",
238
+ },
239
+ method="POST",
240
+ )
241
+ resp = urllib.request.urlopen(req, timeout=APPLESCRIPT_TIMEOUT_SHORT, context=ctx)
242
+ status = resp.status
243
+ format_output(args,
244
+ f"Unsubscribed from '{truncate(subject, 50)}' via one-click (HTTP {status}).",
245
+ json_data={"id": message_id, "subject": subject, "unsubscribed": True,
246
+ "method": "one-click", "status_code": status})
247
+ return
248
+ except (urllib.error.URLError, OSError) as e:
249
+ # One-click failed, fall through to browser
250
+ err_msg = str(e)
251
+ if not getattr(args, "json", False):
252
+ print(f"One-click failed ({err_msg}), opening in browser instead...")
253
+
254
+ # Fall back to opening HTTPS URL in browser
255
+ if https_urls:
256
+ url = https_urls[0]
257
+ subprocess.run(["open", url], check=False)
258
+ format_output(args,
259
+ f"Opened unsubscribe page for '{truncate(subject, 50)}' in browser.",
260
+ json_data={"id": message_id, "subject": subject, "unsubscribed": "pending",
261
+ "method": "browser", "url": url})
262
+ return
263
+
264
+ # Only mailto available
265
+ if mailto_urls:
266
+ addr = mailto_urls[0].replace("mailto:", "")
267
+ format_output(args,
268
+ f"No HTTPS unsubscribe link. Mailto only:\n {addr}\n"
269
+ "Send an email to that address to unsubscribe.",
270
+ json_data={"id": message_id, "subject": subject, "unsubscribed": False,
271
+ "method": "mailto_only", "mailto": addr})
272
+ return
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # junk / not-junk
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def cmd_junk(args) -> None:
280
+ """Mark a message as junk or spam."""
281
+ import sys
282
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
283
+ message_id = validate_msg_id(args.id)
284
+
285
+ script = set_message_property(
286
+ f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
287
+ 'junk mail status', 'true'
288
+ )
289
+
290
+ # Run the AppleScript; if message not found, give a cross-account hint
291
+ try:
292
+ subject = run(script)
293
+ except SystemExit:
294
+ # run() already printed the error; add an actionable hint and re-raise
295
+ explicit_account = getattr(args, "account", None)
296
+ if not explicit_account:
297
+ print(
298
+ "Hint: If this message belongs to another account, use -a ACCOUNT.\n"
299
+ " Run `mxctl accounts` to see account names.",
300
+ file=sys.stderr,
301
+ )
302
+ raise
303
+
304
+ format_output(
305
+ args,
306
+ f"Message '{truncate(subject, 50)}' marked as junk.",
307
+ json_data={"id": message_id, "subject": subject, "status": "junk"}
308
+ )
309
+
310
+
311
+ def _try_not_junk_in_mailbox(acct_escaped: str, junk_escaped: str, inbox_escaped: str, message_id: int, subject: str = "", sender: str = "") -> str | None:
312
+ """Try to mark a message as not-junk from a specific mailbox.
313
+
314
+ Uses subprocess directly so that individual mailbox attempts can fail silently
315
+ (returning None) without calling sys.exit. The module-level run() always exits
316
+ on error, which would prevent trying fallback mailboxes.
317
+
318
+ When subject and sender are provided, searches by subject+sender in the junk
319
+ mailbox (because AppleScript message IDs are mailbox-specific and become invalid
320
+ after a cross-mailbox move). Falls back to ID-based lookup only when no
321
+ subject/sender context is available.
322
+
323
+ Returns the message subject string on success, None if the message or mailbox
324
+ was not found.
325
+ """
326
+ import subprocess as _subprocess
327
+
328
+ if subject and sender:
329
+ # Search by subject + sender — avoids stale-ID problem after cross-mailbox moves
330
+ subj_esc = escape(subject)
331
+ sender_esc = escape(sender)
332
+ script = f"""
333
+ tell application "Mail"
334
+ set acct to account "{acct_escaped}"
335
+ set junkMb to mailbox "{junk_escaped}" of acct
336
+ set inboxMb to mailbox "{inbox_escaped}" of acct
337
+ set theMsg to first message of junkMb whose (subject is "{subj_esc}" and sender is "{sender_esc}")
338
+ set msgSubject to subject of theMsg
339
+ set junk mail status of theMsg to false
340
+ move theMsg to inboxMb
341
+ return msgSubject
342
+ end tell
343
+ """
344
+ else:
345
+ # Fallback: look up by numeric ID (works if the message hasn't moved mailboxes)
346
+ script = f"""
347
+ tell application "Mail"
348
+ set acct to account "{acct_escaped}"
349
+ set junkMb to mailbox "{junk_escaped}" of acct
350
+ set inboxMb to mailbox "{inbox_escaped}" of acct
351
+ set theMsg to first message of junkMb whose id is {message_id}
352
+ set msgSubject to subject of theMsg
353
+ set junk mail status of theMsg to false
354
+ move theMsg to inboxMb
355
+ return msgSubject
356
+ end tell
357
+ """
358
+ result = _subprocess.run(
359
+ ["osascript", "-e", script],
360
+ capture_output=True,
361
+ text=True,
362
+ timeout=30,
363
+ )
364
+ if result.returncode == 0:
365
+ return result.stdout.strip()
366
+ err_lower = result.stderr.strip().lower()
367
+ if (
368
+ "can't get message" in err_lower
369
+ or "can't get mailbox" in err_lower
370
+ or "no messages matched" in err_lower
371
+ ):
372
+ return None
373
+ # Unexpected error — return None silently (don't leak internal AppleScript errors to user)
374
+ return None
375
+
376
+
377
+ def cmd_not_junk(args) -> None:
378
+ """Mark a message as not junk and move it back to INBOX.
379
+
380
+ Searches the Junk mailbox (and Gmail [Gmail]/Spam for Gmail accounts) because
381
+ AppleScript message IDs become invalid in the original mailbox once a message
382
+ is moved to Junk.
383
+
384
+ Uses subject+sender search (not ID) to find the message in the junk folder,
385
+ since IDs are mailbox-specific and become stale after a cross-mailbox move.
386
+ If a custom -m MAILBOX is given, only that mailbox is tried.
387
+ """
388
+ import sys
389
+ account = resolve_account(getattr(args, "account", None))
390
+ if not account:
391
+ die("Account required. Use -a ACCOUNT.")
392
+ message_id = validate_msg_id(args.id)
393
+
394
+ acct_escaped = escape(account)
395
+ inbox_mailbox = resolve_mailbox(account, "INBOX")
396
+ inbox_escaped = escape(inbox_mailbox)
397
+
398
+ # Try to fetch the original message details (subject + sender) so we can
399
+ # search by content in the junk folder. This succeeds when called immediately
400
+ # after cmd_junk (the original INBOX message is still accessible by ID before
401
+ # the AppleScript cache expires), and fails gracefully after a restart.
402
+ orig_subject = ""
403
+ orig_sender = ""
404
+ try:
405
+ import subprocess as _subprocess
406
+ fetch_script = f"""
407
+ tell application "Mail"
408
+ set acct to account "{acct_escaped}"
409
+ set inboxMb to mailbox "{inbox_escaped}" of acct
410
+ set theMsg to first message of inboxMb whose id is {message_id}
411
+ return (subject of theMsg) & "{FIELD_SEPARATOR}" & (sender of theMsg)
412
+ end tell
413
+ """
414
+ fetch_result = _subprocess.run(
415
+ ["osascript", "-e", fetch_script],
416
+ capture_output=True,
417
+ text=True,
418
+ timeout=15,
419
+ )
420
+ if fetch_result.returncode == 0:
421
+ parts = fetch_result.stdout.strip().split(FIELD_SEPARATOR, 1)
422
+ if len(parts) == 2:
423
+ orig_subject, orig_sender = parts[0], parts[1]
424
+ except Exception:
425
+ pass # Non-fatal — fall back to ID-based lookup below
426
+
427
+ custom_mailbox = getattr(args, "mailbox", None)
428
+ if custom_mailbox:
429
+ # User explicitly specified where to look — trust them, single attempt
430
+ candidates = [resolve_mailbox(account, custom_mailbox)]
431
+ else:
432
+ # Build a prioritized list of junk folder candidates
433
+ junk_primary = resolve_mailbox(account, "Junk")
434
+ candidates = [junk_primary]
435
+ from mxctl.config import get_gmail_accounts
436
+ if account in get_gmail_accounts():
437
+ if "[Gmail]/Spam" not in candidates:
438
+ candidates.append("[Gmail]/Spam")
439
+ # Gmail's label architecture means messages may only be findable via All Mail
440
+ if "[Gmail]/All Mail" not in candidates:
441
+ candidates.append("[Gmail]/All Mail")
442
+ else:
443
+ # For non-Gmail accounts also try "Spam" as an alias
444
+ if "Spam" not in candidates:
445
+ candidates.append("Spam")
446
+
447
+ # Try each candidate mailbox until the message is found
448
+ for junk_mailbox in candidates:
449
+ junk_escaped = escape(junk_mailbox)
450
+ subject = _try_not_junk_in_mailbox(
451
+ acct_escaped, junk_escaped, inbox_escaped, message_id,
452
+ subject=orig_subject, sender=orig_sender,
453
+ )
454
+ if subject is not None:
455
+ format_output(
456
+ args,
457
+ f"Message '{truncate(subject, 50)}' marked as not junk and moved to INBOX.",
458
+ json_data={"id": message_id, "subject": subject, "status": "not_junk", "moved_to": "INBOX"}
459
+ )
460
+ return
461
+
462
+ # All candidates failed — message not found in any junk folder
463
+ tried = ", ".join(f'"{m}"' for m in candidates)
464
+ print(
465
+ f"Error: Message {message_id} not found in junk folder(s) ({tried}).\n"
466
+ "The message may have already been moved, or use -m MAILBOX to specify its location.",
467
+ file=sys.stderr,
468
+ )
469
+ sys.exit(1)
470
+
471
+
472
+ def cmd_open(args) -> None:
473
+ """Open a message in Mail.app."""
474
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
475
+ message_id = validate_msg_id(args.id)
476
+
477
+ script = f"""
478
+ tell application "Mail"
479
+ set theMb to mailbox "{mb_escaped}" of account "{acct_escaped}"
480
+ set theMsg to (first message of theMb whose id is {message_id})
481
+ set msgSubject to subject of theMsg
482
+ if (count of message viewers) is 0 then
483
+ make new message viewer
484
+ end if
485
+ set selected mailboxes of first message viewer to {{theMb}}
486
+ set selected messages of first message viewer to {{theMsg}}
487
+ activate
488
+ return msgSubject
489
+ end tell
490
+ """
491
+
492
+ subject = run(script)
493
+ format_output(
494
+ args,
495
+ f"Opened message {message_id} in Mail.app",
496
+ json_data={
497
+ "opened": True,
498
+ "message_id": message_id,
499
+ "account": account,
500
+ "mailbox": mailbox,
501
+ "subject": subject,
502
+ },
503
+ )
504
+
505
+
506
+ # ---------------------------------------------------------------------------
507
+ # Registration
508
+ # ---------------------------------------------------------------------------
509
+
510
+ def register(subparsers) -> None:
511
+ """Register message action subcommands."""
512
+ # mark-read
513
+ p = subparsers.add_parser("mark-read", help="Mark message as read")
514
+ p.add_argument("id", type=int, help="Message ID")
515
+ p.add_argument("-a", "--account", help="Mail account name")
516
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
517
+ p.add_argument("--json", action="store_true", help="Output as JSON")
518
+ p.set_defaults(func=cmd_mark_read)
519
+
520
+ # mark-unread
521
+ p = subparsers.add_parser("mark-unread", help="Mark message as unread")
522
+ p.add_argument("id", type=int, help="Message ID")
523
+ p.add_argument("-a", "--account", help="Mail account name")
524
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
525
+ p.add_argument("--json", action="store_true", help="Output as JSON")
526
+ p.set_defaults(func=cmd_mark_unread)
527
+
528
+ # flag
529
+ p = subparsers.add_parser("flag", help="Flag a message")
530
+ p.add_argument("id", type=int, help="Message ID")
531
+ p.add_argument("-a", "--account", help="Mail account name")
532
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
533
+ p.add_argument("--json", action="store_true", help="Output as JSON")
534
+ p.set_defaults(func=cmd_flag)
535
+
536
+ # unflag
537
+ p = subparsers.add_parser("unflag", help="Unflag a message")
538
+ p.add_argument("id", type=int, help="Message ID")
539
+ p.add_argument("-a", "--account", help="Mail account name")
540
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
541
+ p.add_argument("--json", action="store_true", help="Output as JSON")
542
+ p.set_defaults(func=cmd_unflag)
543
+
544
+ # move
545
+ p = subparsers.add_parser("move", help="Move message to different mailbox")
546
+ p.add_argument("id", type=int, help="Message ID")
547
+ p.add_argument("-a", "--account", help="Mail account name")
548
+ p.add_argument("--from", dest="from_mailbox", required=True, help="Source mailbox")
549
+ p.add_argument("--to", dest="to_mailbox", required=True, help="Destination mailbox")
550
+ p.add_argument("--json", action="store_true", help="Output as JSON")
551
+ p.set_defaults(func=cmd_move)
552
+
553
+ # delete
554
+ p = subparsers.add_parser("delete", help="Delete message (move to Trash)")
555
+ p.add_argument("id", type=int, help="Message ID")
556
+ p.add_argument("-a", "--account", help="Mail account name")
557
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
558
+ p.add_argument("--json", action="store_true", help="Output as JSON")
559
+ p.set_defaults(func=cmd_delete)
560
+
561
+ # unsubscribe
562
+ p = subparsers.add_parser("unsubscribe", help="Unsubscribe from a mailing list")
563
+ p.add_argument("id", type=int, help="Message ID")
564
+ p.add_argument("-a", "--account", help="Mail account name")
565
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
566
+ p.add_argument("--dry-run", action="store_true", help="Show unsubscribe links without acting")
567
+ p.add_argument("--open", action="store_true", help="Force open in browser (skip one-click)")
568
+ p.add_argument("--json", action="store_true", help="Output as JSON")
569
+ p.set_defaults(func=cmd_unsubscribe)
570
+
571
+ # junk
572
+ p = subparsers.add_parser("junk", help="Mark message as junk/spam")
573
+ p.add_argument("id", type=int, help="Message ID")
574
+ p.add_argument("-a", "--account", help="Mail account name")
575
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
576
+ p.add_argument("--json", action="store_true", help="Output as JSON")
577
+ p.set_defaults(func=cmd_junk)
578
+
579
+ # not-junk
580
+ p = subparsers.add_parser("not-junk", help="Mark message as not junk and move to INBOX")
581
+ p.add_argument("id", type=int, help="Message ID")
582
+ p.add_argument("-a", "--account", help="Mail account name")
583
+ p.add_argument("-m", "--mailbox", help="Source mailbox (default: Junk)")
584
+ p.add_argument("--json", action="store_true", help="Output as JSON")
585
+ p.set_defaults(func=cmd_not_junk)
586
+
587
+ # open
588
+ p = subparsers.add_parser("open", help="Open message in Mail.app")
589
+ p.add_argument("id", type=int, help="Message ID")
590
+ p.add_argument("-a", "--account", help="Mail account name")
591
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
592
+ p.add_argument("--json", action="store_true", help="Output as JSON")
593
+ p.set_defaults(func=cmd_open)