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