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