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,502 @@
1
+ """Inbox management tools: process-inbox, clean-newsletters, weekly-review."""
2
+
3
+ from collections import defaultdict
4
+ from datetime import datetime, timedelta
5
+
6
+ from mxctl.config import (
7
+ APPLESCRIPT_TIMEOUT_LONG,
8
+ DEFAULT_MAILBOX,
9
+ FIELD_SEPARATOR,
10
+ MAX_MESSAGES_BATCH,
11
+ NOREPLY_PATTERNS,
12
+ resolve_account,
13
+ save_message_aliases,
14
+ validate_limit,
15
+ )
16
+ from mxctl.util.applescript import escape, run
17
+ from mxctl.util.applescript_templates import mailbox_iterator
18
+ from mxctl.util.dates import to_applescript_date
19
+ from mxctl.util.formatting import format_output, truncate
20
+ from mxctl.util.mail_helpers import extract_display_name, extract_email, parse_message_line
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Private helpers — AppleScript builders
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _build_process_inbox_script(account: str | None, limit: int) -> str:
27
+ """Return an AppleScript that scans INBOX(es) for unread messages.
28
+
29
+ When *account* is given, only that account's INBOX is scanned.
30
+ Otherwise all enabled accounts are scanned up to *limit* total messages.
31
+ Output rows: acctName|id|subject|sender|date|flagged
32
+ """
33
+ msg_row = (
34
+ f'set output to output & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}"'
35
+ f' & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}"'
36
+ f' & (date received of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & linefeed'
37
+ )
38
+ if account:
39
+ acct_escaped = escape(account)
40
+ return f"""
41
+ tell application "Mail"
42
+ set output to ""
43
+ set totalFound to 0
44
+ set acct to account "{acct_escaped}"
45
+ set acctName to name of acct
46
+ if enabled of acct then
47
+ repeat with mbox in (mailboxes of acct)
48
+ if name of mbox is "INBOX" then
49
+ try
50
+ set unreadMsgs to (every message of mbox whose read status is false)
51
+ set cap to {limit}
52
+ if (count of unreadMsgs) < cap then set cap to (count of unreadMsgs)
53
+ repeat with j from 1 to cap
54
+ set m to item j of unreadMsgs
55
+ {msg_row}
56
+ set totalFound to totalFound + 1
57
+ end repeat
58
+ end try
59
+ exit repeat
60
+ end if
61
+ end repeat
62
+ end if
63
+ return output
64
+ end tell
65
+ """
66
+ # All enabled accounts — honour the global limit across accounts
67
+ return f"""
68
+ tell application "Mail"
69
+ set output to ""
70
+ set totalFound to 0
71
+ repeat with acct in (every account)
72
+ if totalFound >= {limit} then exit repeat
73
+ if enabled of acct then
74
+ set acctName to name of acct
75
+ repeat with mbox in (mailboxes of acct)
76
+ if totalFound >= {limit} then exit repeat
77
+ if name of mbox is "INBOX" then
78
+ try
79
+ set unreadMsgs to (every message of mbox whose read status is false)
80
+ set cap to {limit} - totalFound
81
+ if (count of unreadMsgs) < cap then set cap to (count of unreadMsgs)
82
+ repeat with j from 1 to cap
83
+ set m to item j of unreadMsgs
84
+ {msg_row}
85
+ set totalFound to totalFound + 1
86
+ end repeat
87
+ end try
88
+ exit repeat
89
+ end if
90
+ end repeat
91
+ end if
92
+ end repeat
93
+ return output
94
+ end tell
95
+ """
96
+
97
+
98
+ def _build_newsletters_script(account: str | None, mailbox: str, limit: int) -> str:
99
+ """Return an AppleScript that collects sender/read-status rows from a mailbox.
100
+
101
+ When *account* is given, only that account's named mailbox is scanned.
102
+ Otherwise all enabled accounts are scanned up to *limit* total messages.
103
+ Output rows: sender|read_status
104
+ """
105
+ msg_row = (
106
+ f'set output to output & (sender of m) & "{FIELD_SEPARATOR}" & (read status of m) & linefeed'
107
+ )
108
+ if account:
109
+ acct_escaped = escape(account)
110
+ mb_escaped = escape(mailbox)
111
+ return f"""
112
+ tell application "Mail"
113
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
114
+ set allMsgs to (every message of mb)
115
+ set msgCount to count of allMsgs
116
+ set cap to {limit}
117
+ if msgCount < cap then set cap to msgCount
118
+ set output to ""
119
+ repeat with i from 1 to cap
120
+ set m to item i of allMsgs
121
+ {msg_row}
122
+ end repeat
123
+ return output
124
+ end tell
125
+ """
126
+ # All enabled accounts — honour the global limit across accounts
127
+ return f"""
128
+ tell application "Mail"
129
+ set output to ""
130
+ set totalFound to 0
131
+ repeat with acct in (every account)
132
+ if enabled of acct then
133
+ repeat with mbox in (mailboxes of acct)
134
+ if name of mbox is "{mailbox}" then
135
+ try
136
+ set allMsgs to (every message of mbox)
137
+ set msgCount to count of allMsgs
138
+ set cap to {limit}
139
+ if msgCount < cap then set cap to msgCount
140
+ repeat with i from 1 to cap
141
+ set m to item i of allMsgs
142
+ {msg_row}
143
+ set totalFound to totalFound + 1
144
+ if totalFound >= {limit} then exit repeat
145
+ end repeat
146
+ end try
147
+ exit repeat
148
+ end if
149
+ end repeat
150
+ if totalFound >= {limit} then exit repeat
151
+ end if
152
+ end repeat
153
+ return output
154
+ end tell
155
+ """
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # process-inbox — categorize unread messages and suggest actions
160
+ # ---------------------------------------------------------------------------
161
+
162
+ def cmd_process_inbox(args) -> None:
163
+ """Read-only diagnostic: categorize unread messages and output action plan."""
164
+ # Use only the explicitly-passed -a flag, not the config default.
165
+ # resolve_account() would return the default account (e.g. iCloud) when no
166
+ # flag is given, causing process-inbox to show only one account instead of all.
167
+ account = getattr(args, "account", None)
168
+ limit = validate_limit(getattr(args, "limit", 50))
169
+
170
+ script = _build_process_inbox_script(account, limit)
171
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
172
+ if not result.strip():
173
+ format_output(args, "No unread messages found.")
174
+ return
175
+
176
+ # Parse and categorize messages
177
+ flagged = []
178
+ people = []
179
+ notifications = []
180
+
181
+ for line in result.strip().split("\n"):
182
+ if not line.strip():
183
+ continue
184
+ msg = parse_message_line(line, ["account", "id", "subject", "sender", "date", "flagged"], FIELD_SEPARATOR)
185
+ if msg is None:
186
+ continue
187
+
188
+ if msg["flagged"]:
189
+ flagged.append(msg)
190
+ elif any(p in extract_email(msg["sender"]).lower() for p in NOREPLY_PATTERNS):
191
+ notifications.append(msg)
192
+ else:
193
+ people.append(msg)
194
+
195
+ # Assign sequential aliases across all categories
196
+ all_messages = flagged + people + notifications
197
+ save_message_aliases([m["id"] for m in all_messages])
198
+ for i, m in enumerate(all_messages, 1):
199
+ m["alias"] = i
200
+
201
+ total = len(flagged) + len(people) + len(notifications)
202
+ text = f"Inbox Processing Plan ({total} unread):"
203
+
204
+ # Suggest actions for each category
205
+ if flagged:
206
+ text += f"\n\nFLAGGED ({len(flagged)}) — High priority:"
207
+ for m in flagged[:5]:
208
+ sender = extract_display_name(m["sender"])
209
+ text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
210
+ if len(flagged) > 5:
211
+ text += f"\n ... and {len(flagged) - 5} more"
212
+ text += "\n\nSuggested commands:"
213
+ text += f"\n mxctl read <ID> -a \"{flagged[0]['account']}\""
214
+ text += f"\n mxctl to-todoist <ID> -a \"{flagged[0]['account']}\" --priority 4"
215
+
216
+ if people:
217
+ text += f"\n\nPEOPLE ({len(people)}) — Requires attention:"
218
+ for m in people[:5]:
219
+ sender = extract_display_name(m["sender"])
220
+ text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
221
+ if len(people) > 5:
222
+ text += f"\n ... and {len(people) - 5} more"
223
+ text += "\n\nSuggested commands:"
224
+ text += f"\n mxctl read <ID> -a \"{people[0]['account']}\""
225
+ text += f"\n mxctl mark-read <ID> -a \"{people[0]['account']}\""
226
+
227
+ if notifications:
228
+ text += f"\n\nNOTIFICATIONS ({len(notifications)}) — Bulk actions:"
229
+ for m in notifications[:5]:
230
+ sender = extract_display_name(m["sender"])
231
+ text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
232
+ if len(notifications) > 5:
233
+ text += f"\n ... and {len(notifications) - 5} more"
234
+ text += "\n\nSuggested commands:"
235
+ text += f"\n mxctl batch-read -a \"{notifications[0]['account']}\""
236
+ text += f"\n mxctl unsubscribe <ID> -a \"{notifications[0]['account']}\""
237
+
238
+ json_data = {
239
+ "total": total,
240
+ "flagged": flagged,
241
+ "people": people,
242
+ "notifications": notifications,
243
+ }
244
+ format_output(args, text, json_data=json_data)
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # clean-newsletters — identify bulk senders + suggest cleanup
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def cmd_clean_newsletters(args) -> None:
252
+ """Identify likely newsletter senders and suggest batch-move commands."""
253
+ account = resolve_account(getattr(args, "account", None))
254
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
255
+ limit = max(1, min(getattr(args, "limit", 200), MAX_MESSAGES_BATCH))
256
+
257
+ script = _build_newsletters_script(account, mailbox, limit)
258
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
259
+ if not result.strip():
260
+ scope = f"in {mailbox} [{account}]" if account else "in INBOX across all accounts"
261
+ format_output(args, f"No messages found {scope}.", json_data={"newsletters": []})
262
+ return
263
+
264
+ # Group by sender email
265
+ sender_stats = defaultdict(lambda: {"total": 0, "unread": 0})
266
+ for line in result.strip().split("\n"):
267
+ if not line.strip():
268
+ continue
269
+ parts = line.split(FIELD_SEPARATOR)
270
+ if len(parts) >= 2:
271
+ sender_raw = parts[0]
272
+ is_read = parts[1].lower() == "true"
273
+ email = extract_email(sender_raw)
274
+ sender_stats[email]["total"] += 1
275
+ if not is_read:
276
+ sender_stats[email]["unread"] += 1
277
+
278
+ # Identify likely newsletters
279
+ newsletters = []
280
+ for email, stats in sender_stats.items():
281
+ is_likely_newsletter = (
282
+ stats["total"] >= 3 or
283
+ any(pattern in email.lower() for pattern in NOREPLY_PATTERNS)
284
+ )
285
+ if is_likely_newsletter:
286
+ newsletters.append({
287
+ "sender": email,
288
+ "total_messages": stats["total"],
289
+ "unread_messages": stats["unread"],
290
+ })
291
+
292
+ # Sort by message count descending
293
+ newsletters.sort(key=lambda x: x["total_messages"], reverse=True)
294
+
295
+ if not newsletters:
296
+ format_output(args, "No newsletter senders identified.", json_data={"newsletters": []})
297
+ return
298
+
299
+ # Build output
300
+ scope = f" in {mailbox} [{account}]" if account else " across all accounts"
301
+ text = f"Identified {len(newsletters)} newsletter senders{scope} (from {limit} recent messages):"
302
+
303
+ for nl in newsletters:
304
+ text += f"\n\n {nl['sender']}"
305
+ text += f"\n Total: {nl['total_messages']} messages ({nl['unread_messages']} unread)"
306
+
307
+ # Suggest cleanup command
308
+ acct_flag = f"-a \"{account}\"" if account else ""
309
+ cleanup_cmd = f"mxctl batch-move --from-sender \"{nl['sender']}\" --to-mailbox \"Newsletters\" {acct_flag}"
310
+ text += f"\n Cleanup: {cleanup_cmd}"
311
+
312
+ format_output(args, text, json_data={"newsletters": newsletters})
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # weekly-review — flagged + unreplied + attachment report
317
+ # ---------------------------------------------------------------------------
318
+
319
+ def cmd_weekly_review(args) -> None:
320
+ """Generate weekly review: flagged, messages with attachments, unreplied from people."""
321
+ account = resolve_account(getattr(args, "account", None))
322
+ days = getattr(args, "days", 7)
323
+
324
+ # Calculate date threshold
325
+ since_dt = datetime.now() - timedelta(days=days)
326
+ since_as = to_applescript_date(since_dt)
327
+
328
+ # Pass the already-escaped account name to mailbox_iterator (or None for all accounts).
329
+ acct_escaped = escape(account) if account else None
330
+
331
+ # Category 1: Flagged messages (all mailboxes, not date-filtered)
332
+ flagged_inner = (
333
+ f'set flaggedMsgs to (every message of mb whose flagged status is true)\n'
334
+ f' repeat with m in flaggedMsgs\n'
335
+ f' set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed\n'
336
+ f' end repeat'
337
+ )
338
+ flagged_script = mailbox_iterator(flagged_inner, account=acct_escaped)
339
+
340
+ # Category 2: Messages with attachments from last N days
341
+ attachments_inner = (
342
+ f'set msgs to (every message of mb whose date received >= date "{since_as}")\n'
343
+ f' set msgCount to count of msgs\n'
344
+ f' set cap to {MAX_MESSAGES_BATCH}\n'
345
+ f' if msgCount < cap then set cap to msgCount\n'
346
+ f' repeat with i from 1 to cap\n'
347
+ f' set m to item i of msgs\n'
348
+ f' if (count of mail attachments of m) > 0 then\n'
349
+ f' 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}" & (count of mail attachments of m) & linefeed\n'
350
+ f' end if\n'
351
+ f' end repeat'
352
+ )
353
+ attachments_script = mailbox_iterator(attachments_inner, account=acct_escaped)
354
+
355
+ # Category 3: Unreplied messages from people (last N days, not yet replied to)
356
+ unreplied_inner = (
357
+ f'set msgs to (every message of mb whose date received >= date "{since_as}" and was replied to is false)\n'
358
+ f' set msgCount to count of msgs\n'
359
+ f' set cap to {MAX_MESSAGES_BATCH}\n'
360
+ f' if msgCount < cap then set cap to msgCount\n'
361
+ f' repeat with i from 1 to cap\n'
362
+ f' set m to item i of msgs\n'
363
+ f' set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed\n'
364
+ f' end repeat'
365
+ )
366
+ unreplied_script = mailbox_iterator(unreplied_inner, account=acct_escaped)
367
+
368
+ # Execute all three queries
369
+ flagged_result = run(flagged_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
370
+ attachments_result = run(attachments_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
371
+ unreplied_result = run(unreplied_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
372
+
373
+ # Parse flagged messages
374
+ flagged_messages = []
375
+ if flagged_result.strip():
376
+ for line in flagged_result.strip().split("\n"):
377
+ if not line.strip():
378
+ continue
379
+ msg = parse_message_line(line, ["id", "subject", "sender", "date"], FIELD_SEPARATOR)
380
+ if msg is not None:
381
+ flagged_messages.append(msg)
382
+
383
+ # Parse messages with attachments
384
+ attachment_messages = []
385
+ if attachments_result.strip():
386
+ for line in attachments_result.strip().split("\n"):
387
+ if not line.strip():
388
+ continue
389
+ msg = parse_message_line(line, ["id", "subject", "sender", "date", "attachment_count"], FIELD_SEPARATOR)
390
+ if msg is not None:
391
+ msg["attachment_count"] = int(msg["attachment_count"]) if str(msg["attachment_count"]).isdigit() else 0
392
+ attachment_messages.append(msg)
393
+
394
+ # Parse unreplied messages (filter out noreply senders)
395
+ unreplied_messages = []
396
+ if unreplied_result.strip():
397
+ for line in unreplied_result.strip().split("\n"):
398
+ if not line.strip():
399
+ continue
400
+ msg = parse_message_line(line, ["id", "subject", "sender", "date"], FIELD_SEPARATOR)
401
+ if msg is None:
402
+ continue
403
+ sender_email = extract_email(msg["sender"])
404
+ # Skip if sender matches noreply patterns
405
+ if not any(pattern in sender_email.lower() for pattern in NOREPLY_PATTERNS):
406
+ unreplied_messages.append(msg)
407
+
408
+ # Assign sequential aliases across all sections
409
+ all_messages = flagged_messages + attachment_messages + unreplied_messages
410
+ save_message_aliases([m["id"] for m in all_messages])
411
+ for i, m in enumerate(all_messages, 1):
412
+ m["alias"] = i
413
+
414
+ # Build report
415
+ scope = f" for account '{account}'" if account else " across all accounts"
416
+ text = f"Weekly Review{scope} (last {days} days):"
417
+
418
+ # Section 1: Flagged messages
419
+ text += f"\n\nFlagged Messages ({len(flagged_messages)}):"
420
+ if flagged_messages:
421
+ for msg in flagged_messages[:10]: # Show up to 10
422
+ text += f"\n [{msg['alias']}] {truncate(msg['subject'], 60)}"
423
+ text += f"\n From: {truncate(msg['sender'], 50)}"
424
+ if len(flagged_messages) > 10:
425
+ text += f"\n ... and {len(flagged_messages) - 10} more"
426
+ else:
427
+ text += "\n None"
428
+
429
+ # Section 2: Messages with attachments
430
+ text += f"\n\nMessages with Attachments ({len(attachment_messages)}):"
431
+ if attachment_messages:
432
+ for msg in attachment_messages[:10]:
433
+ text += f"\n [{msg['alias']}] {truncate(msg['subject'], 60)} ({msg['attachment_count']} attachments)"
434
+ text += f"\n From: {truncate(msg['sender'], 50)}"
435
+ if len(attachment_messages) > 10:
436
+ text += f"\n ... and {len(attachment_messages) - 10} more"
437
+ else:
438
+ text += "\n None"
439
+
440
+ # Section 3: Unreplied from people
441
+ text += f"\n\nUnreplied from People ({len(unreplied_messages)}):"
442
+ if unreplied_messages:
443
+ for msg in unreplied_messages[:10]:
444
+ text += f"\n [{msg['alias']}] {truncate(msg['subject'], 60)}"
445
+ text += f"\n From: {truncate(msg['sender'], 50)}"
446
+ if len(unreplied_messages) > 10:
447
+ text += f"\n ... and {len(unreplied_messages) - 10} more"
448
+ else:
449
+ text += "\n None"
450
+
451
+ # Add suggested actions
452
+ text += "\n\nSuggested Actions:"
453
+ if flagged_messages:
454
+ text += "\n • Review flagged messages and unflag when done: mxctl unflag <id>"
455
+ if unreplied_messages:
456
+ text += "\n • Reply to pending messages from real people"
457
+ if attachment_messages:
458
+ text += "\n • Review and save important attachments: mxctl save-attachment <id> <filename> <path>"
459
+ if not flagged_messages and not unreplied_messages and not attachment_messages:
460
+ text += "\n • Great job! Your inbox is clean."
461
+
462
+ # Build JSON response
463
+ json_data = {
464
+ "days": days,
465
+ "account": account,
466
+ "flagged_count": len(flagged_messages),
467
+ "attachment_count": len(attachment_messages),
468
+ "unreplied_count": len(unreplied_messages),
469
+ "flagged_messages": flagged_messages,
470
+ "attachment_messages": attachment_messages,
471
+ "unreplied_messages": unreplied_messages,
472
+ }
473
+
474
+ format_output(args, text, json_data=json_data)
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # Registration
479
+ # ---------------------------------------------------------------------------
480
+
481
+ def register(subparsers) -> None:
482
+ # process-inbox
483
+ p = subparsers.add_parser("process-inbox", help="Categorize unread messages and suggest actions")
484
+ p.add_argument("-a", "--account", help="Limit to specific account (default: all)")
485
+ p.add_argument("--limit", type=int, default=50, help="Max messages to scan (default: 50)")
486
+ p.add_argument("--json", action="store_true", help="Output as JSON")
487
+ p.set_defaults(func=cmd_process_inbox)
488
+
489
+ # clean-newsletters
490
+ p = subparsers.add_parser("clean-newsletters", help="Identify bulk senders and suggest cleanup")
491
+ p.add_argument("-a", "--account", help="Mail account name")
492
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
493
+ p.add_argument("--limit", type=int, default=200, help="Number of recent messages to analyze (default: 200)")
494
+ p.add_argument("--json", action="store_true", help="Output as JSON")
495
+ p.set_defaults(func=cmd_clean_newsletters)
496
+
497
+ # weekly-review
498
+ p = subparsers.add_parser("weekly-review", help="Flagged + unreplied + attachment report")
499
+ p.add_argument("-a", "--account", help="Filter by account name (default: all accounts)")
500
+ p.add_argument("--days", type=int, default=7, help="Look back N days (default: 7)")
501
+ p.add_argument("--json", action="store_true", help="Output as JSON")
502
+ p.set_defaults(func=cmd_weekly_review)
@@ -0,0 +1,185 @@
1
+ """Mailbox management commands: create-mailbox, delete-mailbox, empty-trash."""
2
+
3
+ import subprocess
4
+
5
+ from mxctl.config import resolve_account
6
+ from mxctl.util.applescript import escape, run
7
+ from mxctl.util.formatting import die, format_output
8
+ from mxctl.util.mail_helpers import resolve_mailbox
9
+
10
+
11
+ def cmd_create_mailbox(args) -> None:
12
+ """Create a new mailbox."""
13
+ account = resolve_account(getattr(args, "account", None))
14
+ if not account:
15
+ die("Account required. Use -a ACCOUNT.")
16
+ name = args.name
17
+
18
+ acct_escaped = escape(account)
19
+ mb_escaped = escape(name)
20
+
21
+ script = f"""
22
+ tell application "Mail"
23
+ set acct to account "{acct_escaped}"
24
+ make new mailbox with properties {{name:"{mb_escaped}"}} at acct
25
+ return "created"
26
+ end tell
27
+ """
28
+
29
+ run(script)
30
+ format_output(
31
+ args,
32
+ f"Mailbox '{name}' created in account '{account}'.",
33
+ json_data={"mailbox": name, "account": account, "status": "created"}
34
+ )
35
+
36
+
37
+ def cmd_delete_mailbox(args) -> None:
38
+ """Delete a mailbox and all its messages."""
39
+ account = resolve_account(getattr(args, "account", None))
40
+ if not account:
41
+ die("Account required. Use -a ACCOUNT.")
42
+ name = args.name
43
+
44
+ if not getattr(args, "force", False):
45
+ die(f"Deleting mailbox '{name}' is permanent and cannot be undone. Re-run with --force to confirm.")
46
+
47
+ acct_escaped = escape(account)
48
+ mb_escaped = escape(name)
49
+
50
+ # Check message count
51
+ count_script = f"""
52
+ tell application "Mail"
53
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
54
+ return count of messages of mb
55
+ end tell
56
+ """
57
+ try:
58
+ count_result = run(count_script)
59
+ msg_count = int(count_result) if count_result.isdigit() else 0
60
+ except SystemExit:
61
+ msg_count = 0
62
+
63
+ delete_script = f"""
64
+ tell application "Mail"
65
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
66
+ delete mb
67
+ return "deleted"
68
+ end tell
69
+ """
70
+
71
+ run(delete_script)
72
+ warning = f" ({msg_count} messages were deleted)" if msg_count > 0 else ""
73
+ format_output(
74
+ args,
75
+ f"Mailbox '{name}' deleted from account '{account}'.{warning}",
76
+ json_data={"mailbox": name, "account": account, "status": "deleted", "messages_deleted": msg_count}
77
+ )
78
+
79
+
80
+ def cmd_empty_trash(args) -> None:
81
+ """Empty the Trash via Mail.app's Erase Deleted Items menu.
82
+
83
+ Uses System Events to trigger the menu command, which opens a
84
+ confirmation dialog for the user to approve manually.
85
+ """
86
+ account = resolve_account(getattr(args, "account", None))
87
+ all_accounts = getattr(args, "all", False)
88
+
89
+ if not account and not all_accounts:
90
+ die("Account required. Use -a ACCOUNT or --all.")
91
+
92
+ # Build the menu item name — Mail.app appends \u2026 (ellipsis) to each entry
93
+ if all_accounts:
94
+ menu_item = "In All Accounts\u2026"
95
+ label = "all accounts"
96
+ else:
97
+ menu_item = f"{account}\u2026"
98
+ label = account
99
+
100
+ # Count messages before erase so we can report it
101
+ if not all_accounts:
102
+ acct_escaped = escape(account)
103
+ trash_mb = resolve_mailbox(account, "Trash")
104
+ trash_mb_escaped = escape(trash_mb)
105
+ count_script = f"""
106
+ tell application "Mail"
107
+ set acct to account "{acct_escaped}"
108
+ set trashMb to mailbox "{trash_mb_escaped}" of acct
109
+ return count of messages of trashMb
110
+ end tell
111
+ """
112
+ try:
113
+ count_result = run(count_script)
114
+ msg_count = int(count_result) if count_result.isdigit() else 0
115
+ except SystemExit:
116
+ msg_count = 0
117
+
118
+ if msg_count == 0:
119
+ format_output(
120
+ args,
121
+ f"Trash is already empty for '{account}'.",
122
+ json_data={"account": label, "status": "already_empty",
123
+ "messages": 0},
124
+ )
125
+ return
126
+ else:
127
+ msg_count = None # unknown when erasing all accounts
128
+
129
+ # Use System Events to click the menu — this triggers a native
130
+ # confirmation dialog that the user must approve.
131
+ menu_escaped = escape(menu_item)
132
+ ui_script = f"""
133
+ tell application "Mail" to activate
134
+ delay 0.5
135
+ tell application "System Events"
136
+ tell process "Mail"
137
+ click menu item "{menu_escaped}" of menu 1 of menu item ¬
138
+ "Erase Deleted Items" of menu "Mailbox" of menu bar 1
139
+ end tell
140
+ end tell
141
+ return "dialog_opened"
142
+ """
143
+
144
+ try:
145
+ result = subprocess.run(
146
+ ["osascript", "-e", ui_script],
147
+ capture_output=True, text=True, timeout=15,
148
+ )
149
+ if result.returncode != 0:
150
+ err = result.stderr.strip()
151
+ if "Can't get menu item" in err:
152
+ die(f"Menu item '{menu_item}' not found. Check account name.")
153
+ die(f"Failed to open erase dialog: {err}")
154
+ except subprocess.TimeoutExpired:
155
+ die("Timed out waiting for Mail.app menu.")
156
+
157
+ count_msg = f" ({msg_count} messages)" if msg_count is not None else ""
158
+ format_output(
159
+ args,
160
+ f"Erase dialog opened for {label}{count_msg}. Confirm in Mail.app to permanently delete.",
161
+ json_data={"account": label, "status": "confirmation_pending",
162
+ "messages": msg_count},
163
+ )
164
+
165
+
166
+ def register(subparsers) -> None:
167
+ """Register mailbox management subcommands."""
168
+ p = subparsers.add_parser("create-mailbox", help="Create a new mailbox")
169
+ p.add_argument("name", help="Mailbox name")
170
+ p.add_argument("-a", "--account", help="Mail account name")
171
+ p.add_argument("--json", action="store_true", help="Output as JSON")
172
+ p.set_defaults(func=cmd_create_mailbox)
173
+
174
+ p = subparsers.add_parser("delete-mailbox", help="Delete a mailbox (and all messages)")
175
+ p.add_argument("name", help="Mailbox name")
176
+ p.add_argument("-a", "--account", help="Mail account name")
177
+ p.add_argument("--force", action="store_true", help="Confirm permanent deletion (required)")
178
+ p.add_argument("--json", action="store_true", help="Output as JSON")
179
+ p.set_defaults(func=cmd_delete_mailbox)
180
+
181
+ p = subparsers.add_parser("empty-trash", help="Empty Trash (opens confirmation dialog)")
182
+ p.add_argument("-a", "--account", help="Mail account name")
183
+ p.add_argument("--all", action="store_true", help="Erase deleted items in all accounts")
184
+ p.add_argument("--json", action="store_true", help="Output as JSON")
185
+ p.set_defaults(func=cmd_empty_trash)