mxctl 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)