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,402 @@
1
+ """Batch mail operations: batch-read, batch-flag, batch-move, batch-delete."""
2
+
3
+ import sys
4
+ from datetime import datetime, timedelta
5
+
6
+ from mxctl.commands.mail.undo import log_batch_operation, log_fence_operation
7
+ from mxctl.config import (
8
+ APPLESCRIPT_TIMEOUT_LONG,
9
+ DEFAULT_MAILBOX,
10
+ resolve_account,
11
+ )
12
+ from mxctl.util.applescript import escape, run
13
+ from mxctl.util.dates import to_applescript_date
14
+ from mxctl.util.formatting import die, format_output
15
+ from mxctl.util.mail_helpers import resolve_mailbox
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # batch-read — mark all as read in a mailbox
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def cmd_batch_read(args) -> None:
22
+ """Mark all messages as read in a mailbox."""
23
+ account = resolve_account(getattr(args, "account", None))
24
+ if not account:
25
+ die("Account required. Use -a ACCOUNT.")
26
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
27
+ limit = getattr(args, "limit", None) or 25
28
+
29
+ acct_escaped = escape(account)
30
+ mb_escaped = escape(mailbox)
31
+
32
+ script = f"""
33
+ tell application "Mail"
34
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
35
+ set unreadMsgs to (every message of mb whose read status is false)
36
+ set ct to count of unreadMsgs
37
+ set cap to {limit}
38
+ if ct < cap then set cap to ct
39
+ repeat with i from 1 to cap
40
+ set m to item i of unreadMsgs
41
+ set read status of m to true
42
+ end repeat
43
+ return cap
44
+ end tell
45
+ """
46
+
47
+ result = run(script)
48
+ count = int(result) if result.isdigit() else 0
49
+ log_fence_operation("batch-read")
50
+ format_output(args, f"Marked {count} messages as read in {mailbox} [{account}] (limit: {limit}).",
51
+ json_data={"mailbox": mailbox, "account": account, "marked_read": count, "limit": limit})
52
+ print(f"Note: batch-read operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", file=sys.stderr)
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # batch-flag — flag all from a sender
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def cmd_batch_flag(args) -> None:
60
+ """Flag all messages from a specific sender."""
61
+ account = resolve_account(getattr(args, "account", None))
62
+ if not account:
63
+ die("Account required. Use -a ACCOUNT.")
64
+ sender = getattr(args, "from_sender", None)
65
+ if not sender:
66
+ die("--from-sender is required.")
67
+ limit = getattr(args, "limit", None) or 25
68
+
69
+ acct_escaped = escape(account)
70
+ sender_escaped = escape(sender)
71
+
72
+ script = f"""
73
+ tell application "Mail"
74
+ set output to 0
75
+ repeat with mbox in (mailboxes of account "{acct_escaped}")
76
+ if output >= {limit} then exit repeat
77
+ set msgs to (every message of mbox whose sender contains "{sender_escaped}")
78
+ repeat with m in msgs
79
+ if output >= {limit} then exit repeat
80
+ set flagged status of m to true
81
+ set output to output + 1
82
+ end repeat
83
+ end repeat
84
+ return output
85
+ end tell
86
+ """
87
+
88
+ result = run(script)
89
+ count = int(result) if result.isdigit() else 0
90
+ log_fence_operation("batch-flag")
91
+ format_output(args, f"Flagged {count} messages from '{sender}' in account '{account}' (limit: {limit}).",
92
+ json_data={"sender": sender, "account": account, "flagged": count, "limit": limit})
93
+ print(f"Note: batch-flag operations are not tracked in undo history. Use --limit N to cap scope (current: {limit} messages).", file=sys.stderr)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # batch-move — move all messages from a sender to a folder
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def cmd_batch_move(args) -> None:
101
+ """Move all messages from a sender to a mailbox."""
102
+ account = resolve_account(getattr(args, "account", None))
103
+ if not account:
104
+ die("Account required. Use -a ACCOUNT.")
105
+ sender = getattr(args, "from_sender", None)
106
+ if not sender:
107
+ die("--from-sender is required.")
108
+ dest_mailbox = getattr(args, "to_mailbox", None)
109
+ if not dest_mailbox:
110
+ die("--to-mailbox is required.")
111
+ dest_mailbox = resolve_mailbox(account, dest_mailbox)
112
+
113
+ dry_run = getattr(args, "dry_run", False)
114
+ limit = getattr(args, "limit", None)
115
+
116
+ acct_escaped = escape(account)
117
+ sender_escaped = escape(sender)
118
+ dest_escaped = escape(dest_mailbox)
119
+
120
+ # First, count matching messages
121
+ count_script = f"""
122
+ tell application "Mail"
123
+ set output to 0
124
+ repeat with mbox in (mailboxes of account "{acct_escaped}")
125
+ set msgs to (every message of mbox whose sender contains "{sender_escaped}")
126
+ set output to output + (count of msgs)
127
+ end repeat
128
+ return output
129
+ end tell
130
+ """
131
+
132
+ count_result = run(count_script)
133
+ total_count = int(count_result) if count_result.isdigit() else 0
134
+
135
+ if total_count == 0:
136
+ format_output(args, f"No messages found from sender '{sender}'.",
137
+ json_data={"sender": sender, "account": account, "moved": 0})
138
+ return
139
+
140
+ if dry_run:
141
+ effective_count = min(total_count, limit) if limit else total_count
142
+ format_output(args, f"Dry run: Would move {effective_count} messages from '{sender}' to '{dest_mailbox}'.",
143
+ json_data={"sender": sender, "to_mailbox": dest_mailbox, "account": account, "would_move": effective_count, "total_matching": total_count, "dry_run": True})
144
+ return
145
+
146
+ # Actually move the messages and collect their IDs for undo logging
147
+ limit_clause = f"if moveCount >= {limit} then exit repeat" if limit else ""
148
+
149
+ move_script = f"""
150
+ tell application "Mail"
151
+ set destMb to mailbox "{dest_escaped}" of account "{acct_escaped}"
152
+ set moveCount to 0
153
+ set movedIds to {{}}
154
+ repeat with mbox in (mailboxes of account "{acct_escaped}")
155
+ {limit_clause}
156
+ set sourceMbName to name of mbox
157
+ set msgs to (every message of mbox whose sender contains "{sender_escaped}")
158
+ repeat with m in msgs
159
+ {limit_clause}
160
+ try
161
+ set msgId to id of m
162
+ move m to destMb
163
+ set end of movedIds to msgId
164
+ set moveCount to moveCount + 1
165
+ end try
166
+ end repeat
167
+ end repeat
168
+ set output to (moveCount as text)
169
+ repeat with msgId in movedIds
170
+ set output to output & linefeed & (msgId as text)
171
+ end repeat
172
+ return output
173
+ end tell
174
+ """
175
+
176
+ result = run(move_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
177
+ lines = result.strip().split("\n")
178
+ moved = int(lines[0]) if lines and lines[0].isdigit() else 0
179
+ message_ids = [int(line) for line in lines[1:] if line.isdigit()]
180
+
181
+ # Log the operation for undo
182
+ if moved > 0:
183
+ # We don't track source mailbox per-message, so we'll use None
184
+ # The undo will move from dest back to the original location
185
+ log_batch_operation(
186
+ operation_type="batch-move",
187
+ account=account,
188
+ message_ids=message_ids,
189
+ source_mailbox=None, # Multiple source mailboxes possible
190
+ dest_mailbox=dest_mailbox,
191
+ sender=sender,
192
+ )
193
+
194
+ format_output(args, f"Moved {moved} messages from '{sender}' to '{dest_mailbox}'.",
195
+ json_data={"sender": sender, "to_mailbox": dest_mailbox, "account": account, "moved": moved})
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # batch-delete — delete messages by sender and/or age from a mailbox
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def cmd_batch_delete(args) -> None:
203
+ """Delete messages matching sender and/or age filters."""
204
+ account = resolve_account(getattr(args, "account", None))
205
+ if not account:
206
+ die("Account required. Use -a ACCOUNT.")
207
+
208
+ mailbox = getattr(args, "mailbox", None)
209
+ older_than_days = getattr(args, "older_than", None)
210
+ sender = getattr(args, "from_sender", None)
211
+ dry_run = getattr(args, "dry_run", False)
212
+ force = getattr(args, "force", False)
213
+ limit = getattr(args, "limit", None)
214
+
215
+ if older_than_days is None and sender is None:
216
+ die("Specify --older-than <days>, --from-sender <email>, or both.")
217
+
218
+ # --older-than without --from-sender still requires --mailbox for safety
219
+ if older_than_days is not None and sender is None and not mailbox:
220
+ die("--mailbox is required when using --older-than without --from-sender.")
221
+
222
+ if mailbox:
223
+ mailbox = resolve_mailbox(account, mailbox)
224
+
225
+ acct_escaped = escape(account)
226
+
227
+ # Build AppleScript whose-clause
228
+ where_parts = []
229
+ if older_than_days is not None:
230
+ cutoff_dt = datetime.now() - timedelta(days=older_than_days)
231
+ cutoff_applescript = to_applescript_date(cutoff_dt)
232
+ where_parts.append(f'date received < date "{cutoff_applescript}"')
233
+ if sender:
234
+ sender_escaped = escape(sender)
235
+ where_parts.append(f'sender contains "{sender_escaped}"')
236
+ where_clause = " and ".join(where_parts)
237
+
238
+ # Human-readable descriptions for output
239
+ scope_desc = f"'{mailbox}'" if mailbox else "all mailboxes"
240
+ filter_parts = []
241
+ if sender:
242
+ filter_parts.append(f"from '{sender}'")
243
+ if older_than_days is not None:
244
+ filter_parts.append(f"older than {older_than_days} days")
245
+ filter_desc = " and ".join(filter_parts)
246
+
247
+ # Count matching messages
248
+ if mailbox:
249
+ mb_escaped = escape(mailbox)
250
+ count_script = f"""
251
+ tell application "Mail"
252
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
253
+ set targetMsgs to (every message of mb whose {where_clause})
254
+ return count of targetMsgs
255
+ end tell
256
+ """
257
+ else:
258
+ count_script = f"""
259
+ tell application "Mail"
260
+ set total to 0
261
+ repeat with mbox in (mailboxes of account "{acct_escaped}")
262
+ set targetMsgs to (every message of mbox whose {where_clause})
263
+ set total to total + (count of targetMsgs)
264
+ end repeat
265
+ return total
266
+ end tell
267
+ """
268
+
269
+ count_result = run(count_script)
270
+ total_count = int(count_result) if count_result.isdigit() else 0
271
+
272
+ if total_count == 0:
273
+ format_output(args, f"No messages found {filter_desc} in {scope_desc}.",
274
+ json_data={"account": account, "mailbox": mailbox, "sender": sender,
275
+ "older_than_days": older_than_days, "deleted": 0})
276
+ return
277
+
278
+ if dry_run:
279
+ effective_count = min(total_count, limit) if limit else total_count
280
+ format_output(args, f"Dry run: Would delete {effective_count} messages {filter_desc} from {scope_desc}.",
281
+ json_data={"account": account, "mailbox": mailbox, "sender": sender,
282
+ "older_than_days": older_than_days, "would_delete": effective_count, "total_matching": total_count, "dry_run": True})
283
+ return
284
+
285
+ if not force:
286
+ die(f"This will delete {total_count} messages {filter_desc} from {scope_desc}. Use --force to confirm.")
287
+
288
+ # Build delete script
289
+ # Use "repeat with m in list" (not indexed) so deletions don't shift remaining references.
290
+ # Wrap each delete in try/end try so a single failure (e.g. Gmail All Mail quirk) doesn't
291
+ # abort the whole batch — failures are silently skipped and the count reflects actual deletes.
292
+ limit_check = f"if deleteCount >= {limit} then exit repeat" if limit else ""
293
+ if mailbox:
294
+ mb_escaped = escape(mailbox)
295
+ delete_script = f"""
296
+ tell application "Mail"
297
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
298
+ set targetMsgs to (every message of mb whose {where_clause})
299
+ set deleteCount to 0
300
+ set deletedIds to {{}}
301
+ repeat with m in targetMsgs
302
+ {limit_check}
303
+ try
304
+ set msgId to id of m
305
+ delete m
306
+ set end of deletedIds to msgId
307
+ set deleteCount to deleteCount + 1
308
+ end try
309
+ end repeat
310
+ set output to (deleteCount as text)
311
+ repeat with msgId in deletedIds
312
+ set output to output & linefeed & (msgId as text)
313
+ end repeat
314
+ return output
315
+ end tell
316
+ """
317
+ else:
318
+ delete_script = f"""
319
+ tell application "Mail"
320
+ set deleteCount to 0
321
+ set deletedIds to {{}}
322
+ repeat with mbox in (mailboxes of account "{acct_escaped}")
323
+ {limit_check}
324
+ set targetMsgs to (every message of mbox whose {where_clause})
325
+ repeat with m in targetMsgs
326
+ {limit_check}
327
+ try
328
+ set msgId to id of m
329
+ delete m
330
+ set end of deletedIds to msgId
331
+ set deleteCount to deleteCount + 1
332
+ end try
333
+ end repeat
334
+ end repeat
335
+ set output to (deleteCount as text)
336
+ repeat with msgId in deletedIds
337
+ set output to output & linefeed & (msgId as text)
338
+ end repeat
339
+ return output
340
+ end tell
341
+ """
342
+
343
+ result = run(delete_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
344
+ lines = result.strip().split("\n")
345
+ deleted = int(lines[0]) if lines and lines[0].isdigit() else 0
346
+ message_ids = [int(line) for line in lines[1:] if line.isdigit()]
347
+
348
+ if deleted > 0:
349
+ log_batch_operation(
350
+ operation_type="batch-delete",
351
+ account=account,
352
+ message_ids=message_ids,
353
+ source_mailbox=mailbox,
354
+ dest_mailbox=None,
355
+ sender=sender,
356
+ older_than_days=older_than_days,
357
+ )
358
+
359
+ format_output(args, f"Deleted {deleted} messages {filter_desc} from {scope_desc}.",
360
+ json_data={"account": account, "mailbox": mailbox, "sender": sender,
361
+ "older_than_days": older_than_days, "deleted": deleted})
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # Registration
366
+ # ---------------------------------------------------------------------------
367
+
368
+ def register(subparsers) -> None:
369
+ """Register batch mail subcommands."""
370
+ p = subparsers.add_parser("batch-read", help="Mark messages as read in a mailbox")
371
+ p.add_argument("-a", "--account", help="Mail account name")
372
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
373
+ p.add_argument("--limit", type=int, default=25, help="Maximum number of messages to mark read (default: 25)")
374
+ p.add_argument("--json", action="store_true", help="Output as JSON")
375
+ p.set_defaults(func=cmd_batch_read)
376
+
377
+ p = subparsers.add_parser("batch-flag", help="Flag messages from a sender")
378
+ p.add_argument("--from-sender", required=True, help="Sender email to match")
379
+ p.add_argument("-a", "--account", help="Mail account name")
380
+ p.add_argument("--limit", type=int, default=25, help="Maximum number of messages to flag (default: 25)")
381
+ p.add_argument("--json", action="store_true", help="Output as JSON")
382
+ p.set_defaults(func=cmd_batch_flag)
383
+
384
+ p = subparsers.add_parser("batch-move", help="Move all messages from a sender to a mailbox")
385
+ p.add_argument("--from-sender", required=True, help="Sender email to match")
386
+ p.add_argument("--to-mailbox", required=True, help="Destination mailbox")
387
+ p.add_argument("-a", "--account", help="Mail account name")
388
+ p.add_argument("--dry-run", action="store_true", help="Show what would be moved without moving")
389
+ p.add_argument("--limit", type=int, help="Maximum number of messages to move")
390
+ p.add_argument("--json", action="store_true", help="Output as JSON")
391
+ p.set_defaults(func=cmd_batch_move)
392
+
393
+ p = subparsers.add_parser("batch-delete", help="Delete messages by sender and/or age")
394
+ p.add_argument("--from-sender", help="Delete messages from this sender (across all mailboxes, or use -m to scope)")
395
+ p.add_argument("--older-than", type=int, help="Delete messages older than N days (requires -m when used alone)")
396
+ p.add_argument("-m", "--mailbox", help="Scope to a specific mailbox (required when using --older-than alone)")
397
+ p.add_argument("-a", "--account", help="Mail account name")
398
+ p.add_argument("--dry-run", action="store_true", help="Show what would be deleted without deleting")
399
+ p.add_argument("--limit", type=int, help="Maximum number of messages to delete")
400
+ p.add_argument("--force", action="store_true", help="Skip confirmation prompt")
401
+ p.add_argument("--json", action="store_true", help="Output as JSON")
402
+ p.set_defaults(func=cmd_batch_delete)
@@ -0,0 +1,133 @@
1
+ """Draft creation command."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from mxctl.config import TEMPLATES_FILE, file_lock, resolve_account
7
+ from mxctl.util.applescript import escape, run
8
+ from mxctl.util.formatting import die, format_output
9
+
10
+
11
+ def cmd_draft(args) -> None:
12
+ """Create a draft email for manual review and sending."""
13
+ account = resolve_account(getattr(args, "account", None))
14
+ if not account:
15
+ die("Account required. Use -a ACCOUNT.")
16
+
17
+ to_addr = args.to
18
+ subject = args.subject
19
+ body = args.body
20
+ cc = getattr(args, "cc", None)
21
+ bcc = getattr(args, "bcc", None)
22
+
23
+ # Handle template loading
24
+ template_name = getattr(args, "template", None)
25
+ if template_name:
26
+ if os.path.isfile(TEMPLATES_FILE):
27
+ with file_lock(TEMPLATES_FILE), open(TEMPLATES_FILE) as f:
28
+ try:
29
+ templates = json.load(f)
30
+ except (json.JSONDecodeError, OSError):
31
+ die("Templates file is corrupt. Run 'mxctl templates list' to diagnose.")
32
+ if template_name not in templates:
33
+ die(f"Template '{template_name}' not found. Use 'mxctl templates list' to see available templates.")
34
+ template = templates[template_name]
35
+ # Apply template, allowing flag overrides
36
+ if not subject:
37
+ subject = template.get("subject", "")
38
+ if not body:
39
+ body = template.get("body", "")
40
+ else:
41
+ die("No templates file found. Create templates with 'mxctl templates create'.")
42
+
43
+ # Validate that we have subject and body
44
+ if not subject:
45
+ die("Subject required. Use --subject or --template.")
46
+ if not body:
47
+ die("Body required. Use --body or --template.")
48
+
49
+ acct_escaped = escape(account)
50
+ subject_escaped = escape(subject)
51
+ body_escaped = escape(body)
52
+
53
+ to_commands = []
54
+ for addr in to_addr.split(","):
55
+ addr = addr.strip()
56
+ if addr:
57
+ to_commands.append(
58
+ f'make new to recipient at end of to recipients with properties {{address:"{escape(addr)}"}}'
59
+ )
60
+
61
+ cc_commands = []
62
+ if cc:
63
+ for addr in cc.split(","):
64
+ addr = addr.strip()
65
+ if addr:
66
+ cc_commands.append(
67
+ f'make new cc recipient at end of cc recipients with properties {{address:"{escape(addr)}"}}'
68
+ )
69
+
70
+ bcc_commands = []
71
+ if bcc:
72
+ for addr in bcc.split(","):
73
+ addr = addr.strip()
74
+ if addr:
75
+ bcc_commands.append(
76
+ f'make new bcc recipient at end of bcc recipients with properties {{address:"{escape(addr)}"}}'
77
+ )
78
+
79
+ all_recipient_commands = "\n ".join(to_commands + cc_commands + bcc_commands)
80
+
81
+ script = f"""
82
+ tell application "Mail"
83
+ set emailAddrs to get (email addresses of account "{acct_escaped}")
84
+ if class of emailAddrs is list then
85
+ set senderEmail to item 1 of emailAddrs
86
+ else
87
+ set senderEmail to emailAddrs
88
+ end if
89
+ set newMsg to make new outgoing message with properties {{subject:"{subject_escaped}", content:"{body_escaped}", visible:true}}
90
+ tell newMsg
91
+ set sender to senderEmail
92
+ {all_recipient_commands}
93
+ end tell
94
+ return "draft created"
95
+ end tell
96
+ """
97
+
98
+ run(script)
99
+
100
+ data = {
101
+ "status": "draft_created",
102
+ "to": to_addr,
103
+ "subject": subject,
104
+ "account": account,
105
+ }
106
+ if cc:
107
+ data["cc"] = cc
108
+ if bcc:
109
+ data["bcc"] = bcc
110
+
111
+ text = f"Draft created successfully!\n\nTo: {to_addr}"
112
+ if cc:
113
+ text += f"\nCC: {cc}"
114
+ if bcc:
115
+ text += f"\nBCC: {bcc}"
116
+ text += f"\nSubject: {subject}"
117
+ text += "\n\nThe draft is open in Mail.app for review. You must manually click Send."
118
+
119
+ format_output(args, text, json_data=data)
120
+
121
+
122
+ def register(subparsers) -> None:
123
+ """Register email composition subcommands."""
124
+ p = subparsers.add_parser("draft", help="Create a draft email (does NOT send)")
125
+ p.add_argument("--to", required=True, help="Recipient email(s), comma-separated")
126
+ p.add_argument("--subject", help="Email subject (or use --template)")
127
+ p.add_argument("--body", help="Email body (plain text, or use --template)")
128
+ p.add_argument("--template", help="Load subject/body from template (flags override template values)")
129
+ p.add_argument("--cc", help="CC recipient(s), comma-separated")
130
+ p.add_argument("--bcc", help="BCC recipient(s), comma-separated")
131
+ p.add_argument("-a", "--account", help="Mail account to send from")
132
+ p.add_argument("--json", action="store_true", help="Output as JSON")
133
+ p.set_defaults(func=cmd_draft)