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,430 @@
1
+ """Composite mail commands built on core: export, thread, reply, forward."""
2
+
3
+ import os
4
+ import re
5
+ from email.utils import parseaddr
6
+
7
+ from mxctl.config import (
8
+ APPLESCRIPT_TIMEOUT_BATCH,
9
+ APPLESCRIPT_TIMEOUT_LONG,
10
+ DEFAULT_MAILBOX,
11
+ FIELD_SEPARATOR,
12
+ MAX_EXPORT_BULK_LIMIT,
13
+ RECORD_SEPARATOR,
14
+ resolve_account,
15
+ save_message_aliases,
16
+ )
17
+ from mxctl.util.applescript import escape, run, validate_msg_id
18
+ from mxctl.util.formatting import die, format_output, truncate
19
+ from mxctl.util.mail_helpers import extract_email, normalize_subject, parse_message_line
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # export — save message(s) as markdown
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def cmd_export(args) -> None:
26
+ """Export message(s) as markdown files."""
27
+ account = resolve_account(getattr(args, "account", None))
28
+ if not account:
29
+ die("Account required. Use -a ACCOUNT.")
30
+
31
+ target = args.target # could be a message ID or mailbox name
32
+ dest = args.to
33
+ after = getattr(args, "after", None)
34
+
35
+ # If target is numeric, it's a single message export
36
+ if target.isdigit():
37
+ _export_single(args, int(target), account, getattr(args, "mailbox", None) or DEFAULT_MAILBOX, dest)
38
+ else:
39
+ _export_bulk(args, target, account, dest, after)
40
+
41
+
42
+ def _export_single(args, msg_id: int, account: str, mailbox: str, dest: str) -> None:
43
+ acct_escaped = escape(account)
44
+ mb_escaped = escape(mailbox)
45
+
46
+ script = f"""
47
+ tell application "Mail"
48
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
49
+ set theMsg to first message of mb whose id is {msg_id}
50
+ set msgSubject to subject of theMsg
51
+ set msgSender to sender of theMsg
52
+ set msgDate to date received of theMsg
53
+ set msgContent to content of theMsg
54
+
55
+ set toList to ""
56
+ repeat with r in (to recipients of theMsg)
57
+ set toList to toList & (address of r) & ", "
58
+ end repeat
59
+
60
+ return msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & msgDate & "{FIELD_SEPARATOR}" & toList & "{FIELD_SEPARATOR}" & msgContent
61
+ end tell
62
+ """
63
+
64
+ result = run(script)
65
+ parts = result.split(FIELD_SEPARATOR)
66
+ if len(parts) < 5:
67
+ die("Failed to read message.")
68
+
69
+ subject, sender, date, to_list, content = parts[:5]
70
+
71
+ # Build markdown
72
+ safe_subject = re.sub(r'[^\w\s-]', '', subject).strip().replace(' ', '-')[:60]
73
+ filename = f"{safe_subject}.md" if safe_subject else f"message-{msg_id}.md"
74
+
75
+ md = f"# {subject}\n\n"
76
+ md += f"**From:** {sender} \n"
77
+ md += f"**To:** {to_list.rstrip(', ')} \n"
78
+ md += f"**Date:** {date} \n\n"
79
+ md += "---\n\n"
80
+ md += content
81
+
82
+ dest_path = os.path.expanduser(dest)
83
+ if os.path.isdir(dest_path):
84
+ filepath = os.path.join(dest_path, filename)
85
+ # Guard against path traversal in the generated filename
86
+ real_filepath = os.path.realpath(os.path.abspath(filepath))
87
+ real_dest = os.path.realpath(os.path.abspath(dest_path))
88
+ if not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest:
89
+ die("Unsafe export filename: path traversal detected.")
90
+ else:
91
+ filepath = dest_path
92
+
93
+ os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
94
+ with open(filepath, "w", encoding="utf-8") as f:
95
+ f.write(md)
96
+
97
+ format_output(args, f"Exported to: {filepath}", json_data={"path": filepath, "subject": subject})
98
+
99
+
100
+ def _export_bulk(args, mailbox: str, account: str, dest: str, after: str | None) -> None:
101
+ acct_escaped = escape(account)
102
+ mb_escaped = escape(mailbox)
103
+
104
+ whose = ""
105
+ if after:
106
+ from mxctl.util.dates import parse_date, to_applescript_date
107
+ dt = parse_date(after)
108
+ whose = f'whose date received >= date "{to_applescript_date(dt)}"'
109
+
110
+ script = f"""
111
+ tell application "Mail"
112
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
113
+ set msgs to (every message of mb {whose})
114
+ set ct to count of msgs
115
+ set cap to {MAX_EXPORT_BULK_LIMIT}
116
+ if ct < cap then set cap to ct
117
+ set output to ""
118
+ repeat with i from 1 to cap
119
+ set m to item i of msgs
120
+ 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}" & linefeed
121
+ end repeat
122
+ return output
123
+ end tell
124
+ """
125
+
126
+ result = run(script, timeout=APPLESCRIPT_TIMEOUT_BATCH)
127
+ dest_dir = os.path.expanduser(dest)
128
+ os.makedirs(dest_dir, exist_ok=True)
129
+
130
+ entries = result.split(RECORD_SEPARATOR)
131
+ exported = 0
132
+ for entry in entries:
133
+ entry = entry.strip()
134
+ if not entry:
135
+ continue
136
+ parts = entry.split(FIELD_SEPARATOR)
137
+ if len(parts) < 5:
138
+ continue
139
+ msg_id, subject, sender, date, content = parts[0], parts[1], parts[2], parts[3], FIELD_SEPARATOR.join(parts[4:])
140
+
141
+ safe_subject = re.sub(r'[^\w\s-]', '', subject).strip().replace(' ', '-')[:50]
142
+ filename = f"{safe_subject}-{msg_id}.md" if safe_subject else f"message-{msg_id}.md"
143
+
144
+ filepath = os.path.join(dest_dir, filename)
145
+ real_filepath = os.path.realpath(os.path.abspath(filepath))
146
+ real_dest = os.path.realpath(os.path.abspath(dest_dir))
147
+ if not real_filepath.startswith(real_dest + os.sep) and real_filepath != real_dest:
148
+ continue
149
+
150
+ md = f"# {subject}\n\n**From:** {sender} \n**Date:** {date}\n\n---\n\n{content}"
151
+ with open(filepath, "w", encoding="utf-8") as f:
152
+ f.write(md)
153
+ exported += 1
154
+
155
+ format_output(args, f"Exported {exported} messages to {dest_dir}",
156
+ json_data={"directory": dest_dir, "exported": exported})
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # thread — show conversation thread
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def cmd_thread(args) -> None:
164
+ """Show full conversation thread for a message."""
165
+ account = resolve_account(getattr(args, "account", None))
166
+ if not account:
167
+ die("Account required. Use -a ACCOUNT.")
168
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
169
+ message_id = validate_msg_id(args.id)
170
+ limit = getattr(args, "limit", 100)
171
+ all_accounts = getattr(args, "all_accounts", False)
172
+
173
+ acct_escaped = escape(account)
174
+ mb_escaped = escape(mailbox)
175
+
176
+ # Get the subject to find related messages
177
+ script = f"""
178
+ tell application "Mail"
179
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
180
+ set theMsg to first message of mb whose id is {message_id}
181
+ return subject of theMsg
182
+ end tell
183
+ """
184
+ subject = run(script)
185
+
186
+ # Strip Re:/Fwd: prefixes to get the thread subject
187
+ thread_subject = normalize_subject(subject)
188
+ thread_escaped = escape(thread_subject)
189
+
190
+ # Search for messages with this subject (default: current account only)
191
+ if all_accounts:
192
+ acct_loop = 'repeat with acct in (every account)\nset acctName to name of acct'
193
+ acct_loop_end = 'end repeat'
194
+ else:
195
+ acct_loop = f'set acct to account "{acct_escaped}"\nset acctName to name of acct'
196
+ acct_loop_end = ''
197
+
198
+ script2 = f"""
199
+ tell application "Mail"
200
+ set output to ""
201
+ set totalFound to 0
202
+ {acct_loop}
203
+ repeat with mbox in (mailboxes of acct)
204
+ if totalFound >= {limit} then exit repeat
205
+ set mbName to name of mbox
206
+ set msgs to (every message of mbox whose subject contains "{thread_escaped}")
207
+ repeat with m in msgs
208
+ if totalFound >= {limit} then exit repeat
209
+ 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
210
+ set totalFound to totalFound + 1
211
+ end repeat
212
+ end repeat
213
+ {acct_loop_end}
214
+ return output
215
+ end tell
216
+ """
217
+
218
+ result = run(script2, timeout=APPLESCRIPT_TIMEOUT_LONG)
219
+ if not result.strip():
220
+ format_output(args, f"No thread found for '{subject}'.",
221
+ json_data={"thread_subject": thread_subject, "messages": []})
222
+ return
223
+
224
+ messages = []
225
+ for line in result.strip().split("\n"):
226
+ if not line.strip():
227
+ continue
228
+ msg = parse_message_line(line, ["id", "subject", "sender", "date", "mailbox", "account"], FIELD_SEPARATOR)
229
+ if msg is not None:
230
+ messages.append(msg)
231
+
232
+ save_message_aliases([m["id"] for m in messages])
233
+ for i, m in enumerate(messages, 1):
234
+ m["alias"] = i
235
+
236
+ text = f"Thread: {thread_subject} ({len(messages)} messages):"
237
+ for m in messages:
238
+ text += f"\n [{m['alias']}] {truncate(m['subject'], 50)}"
239
+ text += f"\n From: {m['sender']} Date: {m['date']}"
240
+ format_output(args, text, json_data=messages)
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # reply — create a reply draft
245
+ # ---------------------------------------------------------------------------
246
+
247
+ def cmd_reply(args) -> None:
248
+ """Create a reply draft for a message."""
249
+ account = resolve_account(getattr(args, "account", None))
250
+ if not account:
251
+ die("Account required. Use -a ACCOUNT.")
252
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
253
+ message_id = validate_msg_id(args.id)
254
+ body = args.body
255
+
256
+ acct_escaped = escape(account)
257
+ mb_escaped = escape(mailbox)
258
+
259
+ # Get original message details
260
+ script = f"""
261
+ tell application "Mail"
262
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
263
+ set theMsg to first message of mb whose id is {message_id}
264
+ set msgSubject to subject of theMsg
265
+ set msgSender to sender of theMsg
266
+ set msgDate to date received of theMsg
267
+ set msgContent to content of theMsg
268
+ return msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & msgDate & "{FIELD_SEPARATOR}" & msgContent
269
+ end tell
270
+ """
271
+
272
+ result = run(script)
273
+ parts = result.split(FIELD_SEPARATOR)
274
+ if len(parts) < 4:
275
+ die("Failed to read original message.")
276
+
277
+ orig_subject, orig_sender, orig_date, orig_content = parts[0], parts[1], parts[2], FIELD_SEPARATOR.join(parts[3:])
278
+
279
+ reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
280
+ # Extract email from sender
281
+ reply_to = extract_email(orig_sender)
282
+ if not reply_to or "@" not in reply_to:
283
+ die(f"Cannot determine reply address from sender: '{orig_sender}'")
284
+
285
+ # Build reply body with quote
286
+ quoted = "\n".join(f"> {line}" for line in orig_content.split("\n")[:20])
287
+ full_body = f"{body}\n\nOn {orig_date}, {orig_sender} wrote:\n{quoted}"
288
+
289
+ body_escaped = escape(full_body)
290
+ subject_escaped = escape(reply_subject)
291
+ reply_to_escaped = escape(reply_to)
292
+
293
+ draft_script = f"""
294
+ tell application "Mail"
295
+ set emailAddrs to get (email addresses of account "{acct_escaped}")
296
+ if class of emailAddrs is list then
297
+ set senderEmail to item 1 of emailAddrs
298
+ else
299
+ set senderEmail to emailAddrs
300
+ end if
301
+ set newMsg to make new outgoing message with properties {{subject:"{subject_escaped}", content:"{body_escaped}", visible:true}}
302
+ tell newMsg
303
+ set sender to senderEmail
304
+ make new to recipient at end of to recipients with properties {{address:"{reply_to_escaped}"}}
305
+ end tell
306
+ return "draft created"
307
+ end tell
308
+ """
309
+
310
+ run(draft_script)
311
+
312
+ format_output(args,
313
+ f"Reply draft created.\nTo: {reply_to}\nSubject: {reply_subject}\n\nOpen in Mail.app to review and send.",
314
+ json_data={"status": "reply_draft_created", "to": reply_to, "subject": reply_subject})
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # forward — create a forward draft
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def cmd_forward(args) -> None:
322
+ """Create a forward draft for a message."""
323
+ account = resolve_account(getattr(args, "account", None))
324
+ if not account:
325
+ die("Account required. Use -a ACCOUNT.")
326
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
327
+ message_id = validate_msg_id(args.id)
328
+ to_addr = args.to
329
+
330
+ acct_escaped = escape(account)
331
+ mb_escaped = escape(mailbox)
332
+
333
+ script = f"""
334
+ tell application "Mail"
335
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
336
+ set theMsg to first message of mb whose id is {message_id}
337
+ set msgSubject to subject of theMsg
338
+ set msgSender to sender of theMsg
339
+ set msgDate to date received of theMsg
340
+ set msgContent to content of theMsg
341
+ return msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & msgDate & "{FIELD_SEPARATOR}" & msgContent
342
+ end tell
343
+ """
344
+
345
+ result = run(script)
346
+ parts = result.split(FIELD_SEPARATOR)
347
+ if len(parts) < 4:
348
+ die("Failed to read original message.")
349
+
350
+ orig_subject, orig_sender, orig_date, orig_content = parts[0], parts[1], parts[2], FIELD_SEPARATOR.join(parts[3:])
351
+
352
+ fwd_subject = f"Fwd: {orig_subject}" if not orig_subject.lower().startswith("fwd:") else orig_subject
353
+ fwd_body = f"---------- Forwarded message ----------\nFrom: {orig_sender}\nDate: {orig_date}\nSubject: {orig_subject}\n\n{orig_content}"
354
+
355
+ # Extract email from to_addr (handles both bare and formatted addresses)
356
+ _, to_email = parseaddr(to_addr)
357
+ if not to_email or "@" not in to_email:
358
+ die(f"Cannot determine forward address from: '{to_addr}'")
359
+
360
+ body_escaped = escape(fwd_body)
361
+ subject_escaped = escape(fwd_subject)
362
+ to_escaped = escape(to_email)
363
+
364
+ draft_script = f"""
365
+ tell application "Mail"
366
+ set emailAddrs to get (email addresses of account "{acct_escaped}")
367
+ if class of emailAddrs is list then
368
+ set senderEmail to item 1 of emailAddrs
369
+ else
370
+ set senderEmail to emailAddrs
371
+ end if
372
+ set newMsg to make new outgoing message with properties {{subject:"{subject_escaped}", content:"{body_escaped}", visible:true}}
373
+ tell newMsg
374
+ set sender to senderEmail
375
+ make new to recipient at end of to recipients with properties {{address:"{to_escaped}"}}
376
+ end tell
377
+ return "draft created"
378
+ end tell
379
+ """
380
+
381
+ run(draft_script)
382
+
383
+ format_output(args,
384
+ f"Forward draft created.\nTo: {to_addr}\nSubject: {fwd_subject}\n\nOpen in Mail.app to review and send.",
385
+ json_data={"status": "forward_draft_created", "to": to_addr, "subject": fwd_subject})
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Registration
390
+ # ---------------------------------------------------------------------------
391
+
392
+ def register(subparsers) -> None:
393
+ """Register composite mail subcommands."""
394
+ # export
395
+ p = subparsers.add_parser("export", help="Export message(s) as markdown")
396
+ p.add_argument("target", help="Message ID (single) or mailbox name (bulk)")
397
+ p.add_argument("--to", required=True, help="Destination path or directory")
398
+ p.add_argument("-a", "--account", help="Mail account name")
399
+ p.add_argument("-m", "--mailbox", help="Mailbox for single message export (default: INBOX)")
400
+ p.add_argument("--after", help="For bulk export: only messages after date (YYYY-MM-DD)")
401
+ p.add_argument("--json", action="store_true", help="Output as JSON")
402
+ p.set_defaults(func=cmd_export)
403
+
404
+ # thread
405
+ p = subparsers.add_parser("thread", help="Show full conversation thread")
406
+ p.add_argument("id", type=int, help="Message ID")
407
+ p.add_argument("-a", "--account", help="Mail account name")
408
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
409
+ p.add_argument("--limit", type=int, default=100, help="Max thread messages (default: 100)")
410
+ p.add_argument("--all-accounts", action="store_true", help="Search all accounts (default: current only)")
411
+ p.add_argument("--json", action="store_true", help="Output as JSON")
412
+ p.set_defaults(func=cmd_thread)
413
+
414
+ # reply
415
+ p = subparsers.add_parser("reply", help="Create a reply draft")
416
+ p.add_argument("id", type=int, help="Message ID to reply to")
417
+ p.add_argument("--body", required=True, help="Reply text")
418
+ p.add_argument("-a", "--account", help="Mail account name")
419
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
420
+ p.add_argument("--json", action="store_true", help="Output as JSON")
421
+ p.set_defaults(func=cmd_reply)
422
+
423
+ # forward
424
+ p = subparsers.add_parser("forward", help="Create a forward draft")
425
+ p.add_argument("id", type=int, help="Message ID to forward")
426
+ p.add_argument("--to", required=True, help="Recipient email")
427
+ p.add_argument("-a", "--account", help="Mail account name")
428
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
429
+ p.add_argument("--json", action="store_true", help="Output as JSON")
430
+ p.set_defaults(func=cmd_forward)