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,355 @@
|
|
|
1
|
+
"""AI-optimized mail commands designed for Claude Code: summary, triage, context, find-related."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from mxctl.config import (
|
|
6
|
+
APPLESCRIPT_TIMEOUT_LONG,
|
|
7
|
+
DEFAULT_DIGEST_LIMIT,
|
|
8
|
+
DEFAULT_MAILBOX,
|
|
9
|
+
FIELD_SEPARATOR,
|
|
10
|
+
MAX_MESSAGES_BATCH,
|
|
11
|
+
NOREPLY_PATTERNS,
|
|
12
|
+
RECORD_SEPARATOR,
|
|
13
|
+
resolve_account,
|
|
14
|
+
save_message_aliases,
|
|
15
|
+
)
|
|
16
|
+
from mxctl.util.applescript import escape, run, validate_msg_id
|
|
17
|
+
from mxctl.util.applescript_templates import inbox_iterator_all_accounts
|
|
18
|
+
from mxctl.util.formatting import die, format_output, truncate
|
|
19
|
+
from mxctl.util.mail_helpers import extract_display_name, extract_email, normalize_subject, parse_message_line
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# summary — ultra-concise one-liner per unread
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def cmd_summary(args) -> None:
|
|
26
|
+
"""Generate an ultra-concise one-liner per unread message."""
|
|
27
|
+
inner_ops = f'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'
|
|
28
|
+
script = inbox_iterator_all_accounts(inner_ops, cap=20)
|
|
29
|
+
|
|
30
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
31
|
+
if not result.strip():
|
|
32
|
+
format_output(args, "No unread messages.")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
messages = []
|
|
36
|
+
for line in result.strip().split("\n"):
|
|
37
|
+
if not line.strip():
|
|
38
|
+
continue
|
|
39
|
+
msg = parse_message_line(line, ["account", "id", "subject", "sender", "date"], FIELD_SEPARATOR)
|
|
40
|
+
if msg is not None:
|
|
41
|
+
messages.append(msg)
|
|
42
|
+
|
|
43
|
+
save_message_aliases([m["id"] for m in messages])
|
|
44
|
+
for i, m in enumerate(messages, 1):
|
|
45
|
+
m["alias"] = i
|
|
46
|
+
|
|
47
|
+
# Ultra-concise format for AI consumption
|
|
48
|
+
text = f"{len(messages)} unread:"
|
|
49
|
+
for m in messages:
|
|
50
|
+
sender = extract_display_name(m["sender"])
|
|
51
|
+
text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 55)}"
|
|
52
|
+
format_output(args, text, json_data=messages)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# triage — unread grouped by urgency/category
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def cmd_triage(args) -> None:
|
|
60
|
+
"""Group unread messages by urgency and category."""
|
|
61
|
+
account = resolve_account(getattr(args, "account", None))
|
|
62
|
+
inner_ops = f'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) & "{FIELD_SEPARATOR}" & (flagged status of m) & linefeed'
|
|
63
|
+
script = inbox_iterator_all_accounts(inner_ops, cap=30, account=account)
|
|
64
|
+
|
|
65
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
66
|
+
if not result.strip():
|
|
67
|
+
format_output(args, "No unread messages. Inbox zero!")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
flagged = []
|
|
71
|
+
people = []
|
|
72
|
+
notifications = []
|
|
73
|
+
|
|
74
|
+
# Simple heuristic categorization
|
|
75
|
+
for line in result.strip().split("\n"):
|
|
76
|
+
if not line.strip():
|
|
77
|
+
continue
|
|
78
|
+
msg = parse_message_line(line, ["account", "id", "subject", "sender", "date", "flagged"], FIELD_SEPARATOR)
|
|
79
|
+
if msg is None:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if msg["flagged"]:
|
|
83
|
+
flagged.append(msg)
|
|
84
|
+
elif any(p in extract_email(msg["sender"]).lower() for p in NOREPLY_PATTERNS):
|
|
85
|
+
notifications.append(msg)
|
|
86
|
+
else:
|
|
87
|
+
people.append(msg)
|
|
88
|
+
|
|
89
|
+
# Assign sequential aliases across all categories
|
|
90
|
+
all_messages = flagged + people + notifications
|
|
91
|
+
save_message_aliases([m["id"] for m in all_messages])
|
|
92
|
+
for i, m in enumerate(all_messages, 1):
|
|
93
|
+
m["alias"] = i
|
|
94
|
+
|
|
95
|
+
total = len(flagged) + len(people) + len(notifications)
|
|
96
|
+
text = f"Triage ({total} unread):"
|
|
97
|
+
|
|
98
|
+
if flagged:
|
|
99
|
+
text += f"\n\nFLAGGED ({len(flagged)}):"
|
|
100
|
+
for m in flagged:
|
|
101
|
+
sender = extract_display_name(m["sender"])
|
|
102
|
+
text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
|
|
103
|
+
|
|
104
|
+
if people:
|
|
105
|
+
text += f"\n\nPEOPLE ({len(people)}):"
|
|
106
|
+
for m in people:
|
|
107
|
+
sender = extract_display_name(m["sender"])
|
|
108
|
+
text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
|
|
109
|
+
|
|
110
|
+
if notifications:
|
|
111
|
+
text += f"\n\nNOTIFICATIONS ({len(notifications)}):"
|
|
112
|
+
for m in notifications:
|
|
113
|
+
sender = extract_display_name(m["sender"])
|
|
114
|
+
text += f"\n [{m['alias']}] {truncate(sender, 20)}: {truncate(m['subject'], 50)}"
|
|
115
|
+
|
|
116
|
+
format_output(args, text, json_data={"flagged": flagged, "people": people, "notifications": notifications})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# context — message + full thread history
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def cmd_context(args) -> None:
|
|
124
|
+
"""Show a message with full thread history."""
|
|
125
|
+
account = resolve_account(getattr(args, "account", None))
|
|
126
|
+
if not account:
|
|
127
|
+
die("Account required. Use -a ACCOUNT.")
|
|
128
|
+
mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
|
|
129
|
+
message_id = validate_msg_id(args.id)
|
|
130
|
+
limit = max(1, min(getattr(args, "limit", 50), MAX_MESSAGES_BATCH))
|
|
131
|
+
all_accounts = getattr(args, "all_accounts", False)
|
|
132
|
+
|
|
133
|
+
acct_escaped = escape(account)
|
|
134
|
+
mb_escaped = escape(mailbox)
|
|
135
|
+
|
|
136
|
+
# Get the full message + thread subject
|
|
137
|
+
script = f"""
|
|
138
|
+
tell application "Mail"
|
|
139
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
140
|
+
set theMsg to first message of mb whose id is {message_id}
|
|
141
|
+
set msgSubject to subject of theMsg
|
|
142
|
+
set msgSender to sender of theMsg
|
|
143
|
+
set msgDate to date received of theMsg
|
|
144
|
+
set msgContent to content of theMsg
|
|
145
|
+
|
|
146
|
+
set toList to ""
|
|
147
|
+
repeat with r in (to recipients of theMsg)
|
|
148
|
+
set toList to toList & (address of r) & ", "
|
|
149
|
+
end repeat
|
|
150
|
+
|
|
151
|
+
return msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & msgDate & "{FIELD_SEPARATOR}" & toList & "{FIELD_SEPARATOR}" & msgContent
|
|
152
|
+
end tell
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
result = run(script)
|
|
156
|
+
parts = result.split(FIELD_SEPARATOR)
|
|
157
|
+
if len(parts) < 5:
|
|
158
|
+
die("Failed to read message.")
|
|
159
|
+
|
|
160
|
+
subject, sender, date, to_list, content = parts[0], parts[1], parts[2], parts[3], FIELD_SEPARATOR.join(parts[4:])
|
|
161
|
+
|
|
162
|
+
# Find thread
|
|
163
|
+
thread_subject = normalize_subject(subject)
|
|
164
|
+
thread_escaped = escape(thread_subject)
|
|
165
|
+
|
|
166
|
+
# Search for thread messages (current account or all accounts based on flag)
|
|
167
|
+
if all_accounts:
|
|
168
|
+
acct_loop = 'repeat with acct in (every account)\nset acctName to name of acct'
|
|
169
|
+
acct_loop_end = 'end repeat'
|
|
170
|
+
else:
|
|
171
|
+
acct_loop = f'set acct to account "{acct_escaped}"\nset acctName to name of acct'
|
|
172
|
+
acct_loop_end = ''
|
|
173
|
+
|
|
174
|
+
thread_script = f"""
|
|
175
|
+
tell application "Mail"
|
|
176
|
+
set output to ""
|
|
177
|
+
set totalFound to 0
|
|
178
|
+
{acct_loop}
|
|
179
|
+
repeat with mbox in (mailboxes of acct)
|
|
180
|
+
if totalFound >= {limit} then exit repeat
|
|
181
|
+
try
|
|
182
|
+
set msgs to (every message of mbox whose subject contains "{thread_escaped}")
|
|
183
|
+
repeat with m in msgs
|
|
184
|
+
if totalFound >= {limit} then exit repeat
|
|
185
|
+
if (id of m) is not {message_id} then
|
|
186
|
+
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}" & (content of m) & "{RECORD_SEPARATOR}"
|
|
187
|
+
set totalFound to totalFound + 1
|
|
188
|
+
end if
|
|
189
|
+
end repeat
|
|
190
|
+
end try
|
|
191
|
+
end repeat
|
|
192
|
+
{acct_loop_end}
|
|
193
|
+
return output
|
|
194
|
+
end tell
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
thread_result = run(thread_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
198
|
+
|
|
199
|
+
# Build thread data
|
|
200
|
+
thread_entries = []
|
|
201
|
+
if thread_result.strip():
|
|
202
|
+
for entry in thread_result.split(RECORD_SEPARATOR):
|
|
203
|
+
entry = entry.strip()
|
|
204
|
+
if not entry:
|
|
205
|
+
continue
|
|
206
|
+
p = entry.split(FIELD_SEPARATOR)
|
|
207
|
+
if len(p) >= 5:
|
|
208
|
+
thread_entries.append({
|
|
209
|
+
"id": int(p[0]) if p[0].isdigit() else p[0],
|
|
210
|
+
"subject": p[1],
|
|
211
|
+
"from": p[2],
|
|
212
|
+
"date": p[3],
|
|
213
|
+
"body": FIELD_SEPARATOR.join(p[4:]),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
data = {
|
|
217
|
+
"message": {
|
|
218
|
+
"id": message_id,
|
|
219
|
+
"subject": subject,
|
|
220
|
+
"from": sender,
|
|
221
|
+
"to": to_list.rstrip(", "),
|
|
222
|
+
"date": date,
|
|
223
|
+
"body": content,
|
|
224
|
+
},
|
|
225
|
+
"thread": thread_entries,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
text = f"=== Message ===\nFrom: {sender}\nTo: {to_list.rstrip(', ')}\nDate: {date}\nSubject: {subject}\n\n{content}"
|
|
229
|
+
if thread_entries:
|
|
230
|
+
text += "\n\n=== Thread History ==="
|
|
231
|
+
for t in thread_entries:
|
|
232
|
+
text += f"\n\n--- [{t['id']}] {t['subject']} ---\nFrom: {t['from']} Date: {t['date']}\n{t['body']}"
|
|
233
|
+
|
|
234
|
+
format_output(args, text, json_data=data)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# find-related — search + group by conversation
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def cmd_find_related(args) -> None:
|
|
242
|
+
"""Search for messages and group results by conversation."""
|
|
243
|
+
query = args.query
|
|
244
|
+
|
|
245
|
+
# If query is a numeric message ID, look up the message first
|
|
246
|
+
if query.isdigit():
|
|
247
|
+
message_id = int(query)
|
|
248
|
+
lookup_script = f"""
|
|
249
|
+
tell application "Mail"
|
|
250
|
+
repeat with acct in (every account)
|
|
251
|
+
repeat with mbox in (mailboxes of acct)
|
|
252
|
+
try
|
|
253
|
+
set theMsg to first message of mbox whose id is {message_id}
|
|
254
|
+
return (subject of theMsg) & "{FIELD_SEPARATOR}" & (sender of theMsg)
|
|
255
|
+
end try
|
|
256
|
+
end repeat
|
|
257
|
+
end repeat
|
|
258
|
+
return ""
|
|
259
|
+
end tell
|
|
260
|
+
"""
|
|
261
|
+
lookup_result = run(lookup_script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
262
|
+
if not lookup_result.strip():
|
|
263
|
+
format_output(args, f"Message {message_id} not found.")
|
|
264
|
+
return
|
|
265
|
+
parts = lookup_result.strip().split(FIELD_SEPARATOR)
|
|
266
|
+
query = normalize_subject(parts[0])
|
|
267
|
+
|
|
268
|
+
query_escaped = escape(query)
|
|
269
|
+
|
|
270
|
+
script = f"""
|
|
271
|
+
tell application "Mail"
|
|
272
|
+
set output to ""
|
|
273
|
+
set totalFound to 0
|
|
274
|
+
repeat with acct in (every account)
|
|
275
|
+
if totalFound >= {DEFAULT_DIGEST_LIMIT} then exit repeat
|
|
276
|
+
set acctName to name of acct
|
|
277
|
+
repeat with mbox in (mailboxes of acct)
|
|
278
|
+
if totalFound >= {DEFAULT_DIGEST_LIMIT} then exit repeat
|
|
279
|
+
set mbName to name of mbox
|
|
280
|
+
set searchResults to (every message of mbox whose subject contains "{query_escaped}")
|
|
281
|
+
repeat with m in searchResults
|
|
282
|
+
if totalFound >= {DEFAULT_DIGEST_LIMIT} then exit repeat
|
|
283
|
+
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
|
|
284
|
+
set totalFound to totalFound + 1
|
|
285
|
+
end repeat
|
|
286
|
+
end repeat
|
|
287
|
+
end repeat
|
|
288
|
+
return output
|
|
289
|
+
end tell
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
293
|
+
if not result.strip():
|
|
294
|
+
format_output(args, f"No messages found matching '{query}'.")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Group by normalized subject (thread)
|
|
298
|
+
threads = defaultdict(list)
|
|
299
|
+
for line in result.strip().split("\n"):
|
|
300
|
+
if not line.strip():
|
|
301
|
+
continue
|
|
302
|
+
msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR)
|
|
303
|
+
if msg is None:
|
|
304
|
+
continue
|
|
305
|
+
# Normalize subject for grouping
|
|
306
|
+
normalized = normalize_subject(msg["subject"]).lower()
|
|
307
|
+
threads[normalized].append(msg)
|
|
308
|
+
|
|
309
|
+
# Assign sequential aliases across all threads
|
|
310
|
+
all_msgs_flat = []
|
|
311
|
+
for _, msgs_list in sorted(threads.items(), key=lambda x: -len(x[1])):
|
|
312
|
+
all_msgs_flat.extend(msgs_list)
|
|
313
|
+
save_message_aliases([m["id"] for m in all_msgs_flat])
|
|
314
|
+
for i, m in enumerate(all_msgs_flat, 1):
|
|
315
|
+
m["alias"] = i
|
|
316
|
+
|
|
317
|
+
text = f"Related messages for '{query}' ({len(threads)} conversations):"
|
|
318
|
+
for thread_subject, msgs in sorted(threads.items(), key=lambda x: -len(x[1])):
|
|
319
|
+
text += f"\n\n {thread_subject} ({len(msgs)} messages):"
|
|
320
|
+
for m in msgs[:5]:
|
|
321
|
+
sender = extract_display_name(m["sender"])
|
|
322
|
+
text += f"\n [{m['alias']}] {truncate(sender, 20)} — {m['date']}"
|
|
323
|
+
if len(msgs) > 5:
|
|
324
|
+
text += f"\n ... and {len(msgs) - 5} more"
|
|
325
|
+
format_output(args, text, json_data=dict(threads))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Registration
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def register(subparsers) -> None:
|
|
333
|
+
"""Register AI-optimized mail subcommands."""
|
|
334
|
+
p = subparsers.add_parser("summary", help="Ultra-concise one-liner per unread (AI-optimized)")
|
|
335
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
336
|
+
p.set_defaults(func=cmd_summary)
|
|
337
|
+
|
|
338
|
+
p = subparsers.add_parser("triage", help="Unread grouped by urgency/category")
|
|
339
|
+
p.add_argument("-a", "--account", help="Filter to a specific account")
|
|
340
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
341
|
+
p.set_defaults(func=cmd_triage)
|
|
342
|
+
|
|
343
|
+
p = subparsers.add_parser("context", help="Message + full thread history")
|
|
344
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
345
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
346
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
347
|
+
p.add_argument("--limit", type=int, default=50, help="Max thread messages (default: 50)")
|
|
348
|
+
p.add_argument("--all-accounts", action="store_true", help="Search all accounts (default: current only)")
|
|
349
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
350
|
+
p.set_defaults(func=cmd_context)
|
|
351
|
+
|
|
352
|
+
p = subparsers.add_parser("find-related", help="Search + group results by conversation")
|
|
353
|
+
p.add_argument("query", help="Search term")
|
|
354
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
355
|
+
p.set_defaults(func=cmd_find_related)
|