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,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)
|