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.
- mxctl/__init__.py +3 -0
- mxctl/__main__.py +5 -0
- mxctl/commands/__init__.py +0 -0
- mxctl/commands/mail/__init__.py +1 -0
- mxctl/commands/mail/accounts.py +347 -0
- mxctl/commands/mail/actions.py +593 -0
- mxctl/commands/mail/ai.py +355 -0
- mxctl/commands/mail/analytics.py +390 -0
- mxctl/commands/mail/attachments.py +156 -0
- mxctl/commands/mail/batch.py +402 -0
- mxctl/commands/mail/compose.py +133 -0
- mxctl/commands/mail/composite.py +430 -0
- mxctl/commands/mail/inbox_tools.py +502 -0
- mxctl/commands/mail/manage.py +185 -0
- mxctl/commands/mail/messages.py +363 -0
- mxctl/commands/mail/setup.py +362 -0
- mxctl/commands/mail/system.py +202 -0
- mxctl/commands/mail/templates.py +145 -0
- mxctl/commands/mail/todoist_integration.py +145 -0
- mxctl/commands/mail/undo.py +323 -0
- mxctl/config.py +227 -0
- mxctl/main.py +89 -0
- mxctl/util/__init__.py +0 -0
- mxctl/util/applescript.py +139 -0
- mxctl/util/applescript_templates.py +185 -0
- mxctl/util/dates.py +54 -0
- mxctl/util/formatting.py +52 -0
- mxctl/util/mail_helpers.py +192 -0
- mxctl-0.3.0.dist-info/METADATA +439 -0
- mxctl-0.3.0.dist-info/RECORD +33 -0
- mxctl-0.3.0.dist-info/WHEEL +4 -0
- mxctl-0.3.0.dist-info/entry_points.txt +2 -0
- mxctl-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|