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,593 @@
|
|
|
1
|
+
"""Message action commands: mark-read, mark-unread, flag, unflag, move, delete, unsubscribe."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import ssl
|
|
7
|
+
import subprocess
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from mxctl.config import APPLESCRIPT_TIMEOUT_SHORT, FIELD_SEPARATOR, resolve_account
|
|
13
|
+
from mxctl.util.applescript import escape, run, validate_msg_id
|
|
14
|
+
from mxctl.util.applescript_templates import set_message_property
|
|
15
|
+
from mxctl.util.formatting import die, format_output, truncate
|
|
16
|
+
from mxctl.util.mail_helpers import parse_email_headers, resolve_mailbox, resolve_message_context
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _mark_read_status(args, read_status: bool) -> None:
|
|
20
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
21
|
+
message_id = validate_msg_id(args.id)
|
|
22
|
+
read_val = "true" if read_status else "false"
|
|
23
|
+
|
|
24
|
+
script = set_message_property(
|
|
25
|
+
f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
|
|
26
|
+
'read status', read_val
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
subject = run(script)
|
|
30
|
+
status_word = "read" if read_status else "unread"
|
|
31
|
+
format_output(args, f"Message '{truncate(subject, 50)}' marked as {status_word}.",
|
|
32
|
+
json_data={"id": message_id, "subject": subject, "status": status_word})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _flag_status(args, flagged: bool) -> None:
|
|
36
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
37
|
+
message_id = validate_msg_id(args.id)
|
|
38
|
+
flagged_val = "true" if flagged else "false"
|
|
39
|
+
|
|
40
|
+
script = set_message_property(
|
|
41
|
+
f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
|
|
42
|
+
'flagged status', flagged_val
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
subject = run(script)
|
|
46
|
+
status_word = "flagged" if flagged else "unflagged"
|
|
47
|
+
format_output(args, f"Message '{truncate(subject, 50)}' {status_word}.",
|
|
48
|
+
json_data={"id": message_id, "subject": subject, "status": status_word})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_mark_read(args) -> None:
|
|
52
|
+
"""Mark a message as read."""
|
|
53
|
+
_mark_read_status(args, True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_mark_unread(args) -> None:
|
|
57
|
+
"""Mark a message as unread."""
|
|
58
|
+
_mark_read_status(args, False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_flag(args) -> None:
|
|
62
|
+
"""Flag a message."""
|
|
63
|
+
_flag_status(args, True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_unflag(args) -> None:
|
|
67
|
+
"""Unflag a message."""
|
|
68
|
+
_flag_status(args, False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_move(args) -> None:
|
|
72
|
+
"""Move a message to a different mailbox."""
|
|
73
|
+
account = resolve_account(getattr(args, "account", None))
|
|
74
|
+
if not account:
|
|
75
|
+
die("Account required. Use -a ACCOUNT.")
|
|
76
|
+
source = getattr(args, "from_mailbox", None)
|
|
77
|
+
dest = getattr(args, "to_mailbox", None)
|
|
78
|
+
if not source or not dest:
|
|
79
|
+
die("Both --from and --to mailboxes are required.")
|
|
80
|
+
message_id = validate_msg_id(args.id)
|
|
81
|
+
|
|
82
|
+
acct_escaped = escape(account)
|
|
83
|
+
source = resolve_mailbox(account, source)
|
|
84
|
+
dest = resolve_mailbox(account, dest)
|
|
85
|
+
src_escaped = escape(source)
|
|
86
|
+
dest_escaped = escape(dest)
|
|
87
|
+
|
|
88
|
+
script = f"""
|
|
89
|
+
tell application "Mail"
|
|
90
|
+
set srcMb to mailbox "{src_escaped}" of account "{acct_escaped}"
|
|
91
|
+
set destMb to mailbox "{dest_escaped}" of account "{acct_escaped}"
|
|
92
|
+
set theMsg to first message of srcMb whose id is {message_id}
|
|
93
|
+
set msgSubject to subject of theMsg
|
|
94
|
+
move theMsg to destMb
|
|
95
|
+
return msgSubject
|
|
96
|
+
end tell
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
subject = run(script)
|
|
100
|
+
format_output(args, f"Message '{truncate(subject, 50)}' moved from '{source}' to '{dest}'.",
|
|
101
|
+
json_data={"id": message_id, "subject": subject, "from": source, "to": dest})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_delete(args) -> None:
|
|
105
|
+
"""Delete a message by moving it to Trash."""
|
|
106
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
107
|
+
message_id = validate_msg_id(args.id)
|
|
108
|
+
|
|
109
|
+
script = f"""
|
|
110
|
+
tell application "Mail"
|
|
111
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
112
|
+
set theMsg to first message of mb whose id is {message_id}
|
|
113
|
+
set msgSubject to subject of theMsg
|
|
114
|
+
delete theMsg
|
|
115
|
+
return msgSubject
|
|
116
|
+
end tell
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
subject = run(script)
|
|
120
|
+
format_output(args, f"Message '{truncate(subject, 50)}' moved to Trash.",
|
|
121
|
+
json_data={"id": message_id, "subject": subject, "status": "deleted"})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# unsubscribe — extract List-Unsubscribe and act on it
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_PRIVATE_NETWORKS = [
|
|
131
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
132
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
133
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
134
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
135
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
136
|
+
ipaddress.ip_network("::1/128"),
|
|
137
|
+
ipaddress.ip_network("fc00::/7"),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_private_url(url: str) -> bool:
|
|
142
|
+
"""Return True if the URL resolves to a private or loopback address."""
|
|
143
|
+
try:
|
|
144
|
+
parsed = urllib.parse.urlparse(url)
|
|
145
|
+
hostname = parsed.hostname
|
|
146
|
+
if not hostname:
|
|
147
|
+
return True
|
|
148
|
+
addr = ipaddress.ip_address(socket.gethostbyname(hostname))
|
|
149
|
+
return any(addr in net for net in _PRIVATE_NETWORKS)
|
|
150
|
+
except (OSError, ValueError):
|
|
151
|
+
# DNS failure or invalid address — block to be safe
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _extract_urls(header_value: str) -> tuple[list[str], list[str]]:
|
|
156
|
+
"""Extract https and mailto URLs from a List-Unsubscribe header value.
|
|
157
|
+
|
|
158
|
+
Returns (https_urls, mailto_urls).
|
|
159
|
+
"""
|
|
160
|
+
https_urls = re.findall(r"<(https?://[^>]+)>", header_value)
|
|
161
|
+
mailto_urls = re.findall(r"<(mailto:[^>]+)>", header_value)
|
|
162
|
+
return https_urls, mailto_urls
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_unsubscribe(args) -> None:
|
|
166
|
+
"""Unsubscribe from a mailing list via List-Unsubscribe header."""
|
|
167
|
+
dry_run = getattr(args, "dry_run", False)
|
|
168
|
+
force_open = getattr(args, "open", False)
|
|
169
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
170
|
+
message_id = validate_msg_id(args.id)
|
|
171
|
+
|
|
172
|
+
# Fetch headers + subject
|
|
173
|
+
script = f"""
|
|
174
|
+
tell application "Mail"
|
|
175
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
176
|
+
set theMsg to first message of mb whose id is {message_id}
|
|
177
|
+
set subj to subject of theMsg
|
|
178
|
+
set hdrs to all headers of theMsg
|
|
179
|
+
return subj & "{FIELD_SEPARATOR}" & "HEADER_SPLIT" & "{FIELD_SEPARATOR}" & hdrs
|
|
180
|
+
end tell
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_SHORT)
|
|
184
|
+
parts = result.split(FIELD_SEPARATOR + "HEADER_SPLIT" + FIELD_SEPARATOR, 1)
|
|
185
|
+
subject = parts[0] if len(parts) >= 1 else "Unknown"
|
|
186
|
+
raw_headers = parts[1] if len(parts) >= 2 else ""
|
|
187
|
+
|
|
188
|
+
headers = parse_email_headers(raw_headers)
|
|
189
|
+
|
|
190
|
+
unsub_header = headers.get("List-Unsubscribe", "")
|
|
191
|
+
if isinstance(unsub_header, list):
|
|
192
|
+
unsub_header = ", ".join(unsub_header)
|
|
193
|
+
|
|
194
|
+
unsub_post = headers.get("List-Unsubscribe-Post", "")
|
|
195
|
+
if isinstance(unsub_post, list):
|
|
196
|
+
unsub_post = " ".join(unsub_post)
|
|
197
|
+
|
|
198
|
+
if not unsub_header:
|
|
199
|
+
format_output(args,
|
|
200
|
+
f"No unsubscribe option found for '{truncate(subject, 50)}'.\n"
|
|
201
|
+
"This email doesn't include a List-Unsubscribe header.",
|
|
202
|
+
json_data={"id": message_id, "subject": subject, "unsubscribe": False,
|
|
203
|
+
"reason": "No List-Unsubscribe header found"})
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
https_urls, mailto_urls = _extract_urls(unsub_header)
|
|
207
|
+
one_click = bool(unsub_post and "One-Click" in unsub_post and https_urls)
|
|
208
|
+
|
|
209
|
+
# Dry-run: just show what we found
|
|
210
|
+
if dry_run:
|
|
211
|
+
text = f"Unsubscribe info for '{truncate(subject, 50)}':"
|
|
212
|
+
text += f"\n One-click supported: {'Yes' if one_click else 'No'}"
|
|
213
|
+
if https_urls:
|
|
214
|
+
text += "\n HTTPS URLs:"
|
|
215
|
+
for u in https_urls:
|
|
216
|
+
text += f"\n {truncate(u, 100)}"
|
|
217
|
+
if mailto_urls:
|
|
218
|
+
text += "\n Mailto:"
|
|
219
|
+
for u in mailto_urls:
|
|
220
|
+
text += f"\n {u}"
|
|
221
|
+
format_output(args, text, json_data={
|
|
222
|
+
"id": message_id, "subject": subject, "one_click_supported": one_click,
|
|
223
|
+
"https_urls": https_urls, "mailto_urls": mailto_urls})
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Attempt one-click unsubscribe (RFC 8058)
|
|
227
|
+
if one_click and not force_open:
|
|
228
|
+
url = https_urls[0]
|
|
229
|
+
if _is_private_url(url):
|
|
230
|
+
die(f"Refused to POST to private/internal address: {url}")
|
|
231
|
+
try:
|
|
232
|
+
ctx = ssl.create_default_context(cafile="/etc/ssl/cert.pem")
|
|
233
|
+
req = urllib.request.Request(
|
|
234
|
+
url,
|
|
235
|
+
data=b"List-Unsubscribe=One-Click",
|
|
236
|
+
headers={
|
|
237
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
238
|
+
},
|
|
239
|
+
method="POST",
|
|
240
|
+
)
|
|
241
|
+
resp = urllib.request.urlopen(req, timeout=APPLESCRIPT_TIMEOUT_SHORT, context=ctx)
|
|
242
|
+
status = resp.status
|
|
243
|
+
format_output(args,
|
|
244
|
+
f"Unsubscribed from '{truncate(subject, 50)}' via one-click (HTTP {status}).",
|
|
245
|
+
json_data={"id": message_id, "subject": subject, "unsubscribed": True,
|
|
246
|
+
"method": "one-click", "status_code": status})
|
|
247
|
+
return
|
|
248
|
+
except (urllib.error.URLError, OSError) as e:
|
|
249
|
+
# One-click failed, fall through to browser
|
|
250
|
+
err_msg = str(e)
|
|
251
|
+
if not getattr(args, "json", False):
|
|
252
|
+
print(f"One-click failed ({err_msg}), opening in browser instead...")
|
|
253
|
+
|
|
254
|
+
# Fall back to opening HTTPS URL in browser
|
|
255
|
+
if https_urls:
|
|
256
|
+
url = https_urls[0]
|
|
257
|
+
subprocess.run(["open", url], check=False)
|
|
258
|
+
format_output(args,
|
|
259
|
+
f"Opened unsubscribe page for '{truncate(subject, 50)}' in browser.",
|
|
260
|
+
json_data={"id": message_id, "subject": subject, "unsubscribed": "pending",
|
|
261
|
+
"method": "browser", "url": url})
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Only mailto available
|
|
265
|
+
if mailto_urls:
|
|
266
|
+
addr = mailto_urls[0].replace("mailto:", "")
|
|
267
|
+
format_output(args,
|
|
268
|
+
f"No HTTPS unsubscribe link. Mailto only:\n {addr}\n"
|
|
269
|
+
"Send an email to that address to unsubscribe.",
|
|
270
|
+
json_data={"id": message_id, "subject": subject, "unsubscribed": False,
|
|
271
|
+
"method": "mailto_only", "mailto": addr})
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# junk / not-junk
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def cmd_junk(args) -> None:
|
|
280
|
+
"""Mark a message as junk or spam."""
|
|
281
|
+
import sys
|
|
282
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
283
|
+
message_id = validate_msg_id(args.id)
|
|
284
|
+
|
|
285
|
+
script = set_message_property(
|
|
286
|
+
f'"{acct_escaped}"', f'"{mb_escaped}"', message_id,
|
|
287
|
+
'junk mail status', 'true'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Run the AppleScript; if message not found, give a cross-account hint
|
|
291
|
+
try:
|
|
292
|
+
subject = run(script)
|
|
293
|
+
except SystemExit:
|
|
294
|
+
# run() already printed the error; add an actionable hint and re-raise
|
|
295
|
+
explicit_account = getattr(args, "account", None)
|
|
296
|
+
if not explicit_account:
|
|
297
|
+
print(
|
|
298
|
+
"Hint: If this message belongs to another account, use -a ACCOUNT.\n"
|
|
299
|
+
" Run `mxctl accounts` to see account names.",
|
|
300
|
+
file=sys.stderr,
|
|
301
|
+
)
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
format_output(
|
|
305
|
+
args,
|
|
306
|
+
f"Message '{truncate(subject, 50)}' marked as junk.",
|
|
307
|
+
json_data={"id": message_id, "subject": subject, "status": "junk"}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _try_not_junk_in_mailbox(acct_escaped: str, junk_escaped: str, inbox_escaped: str, message_id: int, subject: str = "", sender: str = "") -> str | None:
|
|
312
|
+
"""Try to mark a message as not-junk from a specific mailbox.
|
|
313
|
+
|
|
314
|
+
Uses subprocess directly so that individual mailbox attempts can fail silently
|
|
315
|
+
(returning None) without calling sys.exit. The module-level run() always exits
|
|
316
|
+
on error, which would prevent trying fallback mailboxes.
|
|
317
|
+
|
|
318
|
+
When subject and sender are provided, searches by subject+sender in the junk
|
|
319
|
+
mailbox (because AppleScript message IDs are mailbox-specific and become invalid
|
|
320
|
+
after a cross-mailbox move). Falls back to ID-based lookup only when no
|
|
321
|
+
subject/sender context is available.
|
|
322
|
+
|
|
323
|
+
Returns the message subject string on success, None if the message or mailbox
|
|
324
|
+
was not found.
|
|
325
|
+
"""
|
|
326
|
+
import subprocess as _subprocess
|
|
327
|
+
|
|
328
|
+
if subject and sender:
|
|
329
|
+
# Search by subject + sender — avoids stale-ID problem after cross-mailbox moves
|
|
330
|
+
subj_esc = escape(subject)
|
|
331
|
+
sender_esc = escape(sender)
|
|
332
|
+
script = f"""
|
|
333
|
+
tell application "Mail"
|
|
334
|
+
set acct to account "{acct_escaped}"
|
|
335
|
+
set junkMb to mailbox "{junk_escaped}" of acct
|
|
336
|
+
set inboxMb to mailbox "{inbox_escaped}" of acct
|
|
337
|
+
set theMsg to first message of junkMb whose (subject is "{subj_esc}" and sender is "{sender_esc}")
|
|
338
|
+
set msgSubject to subject of theMsg
|
|
339
|
+
set junk mail status of theMsg to false
|
|
340
|
+
move theMsg to inboxMb
|
|
341
|
+
return msgSubject
|
|
342
|
+
end tell
|
|
343
|
+
"""
|
|
344
|
+
else:
|
|
345
|
+
# Fallback: look up by numeric ID (works if the message hasn't moved mailboxes)
|
|
346
|
+
script = f"""
|
|
347
|
+
tell application "Mail"
|
|
348
|
+
set acct to account "{acct_escaped}"
|
|
349
|
+
set junkMb to mailbox "{junk_escaped}" of acct
|
|
350
|
+
set inboxMb to mailbox "{inbox_escaped}" of acct
|
|
351
|
+
set theMsg to first message of junkMb whose id is {message_id}
|
|
352
|
+
set msgSubject to subject of theMsg
|
|
353
|
+
set junk mail status of theMsg to false
|
|
354
|
+
move theMsg to inboxMb
|
|
355
|
+
return msgSubject
|
|
356
|
+
end tell
|
|
357
|
+
"""
|
|
358
|
+
result = _subprocess.run(
|
|
359
|
+
["osascript", "-e", script],
|
|
360
|
+
capture_output=True,
|
|
361
|
+
text=True,
|
|
362
|
+
timeout=30,
|
|
363
|
+
)
|
|
364
|
+
if result.returncode == 0:
|
|
365
|
+
return result.stdout.strip()
|
|
366
|
+
err_lower = result.stderr.strip().lower()
|
|
367
|
+
if (
|
|
368
|
+
"can't get message" in err_lower
|
|
369
|
+
or "can't get mailbox" in err_lower
|
|
370
|
+
or "no messages matched" in err_lower
|
|
371
|
+
):
|
|
372
|
+
return None
|
|
373
|
+
# Unexpected error — return None silently (don't leak internal AppleScript errors to user)
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_not_junk(args) -> None:
|
|
378
|
+
"""Mark a message as not junk and move it back to INBOX.
|
|
379
|
+
|
|
380
|
+
Searches the Junk mailbox (and Gmail [Gmail]/Spam for Gmail accounts) because
|
|
381
|
+
AppleScript message IDs become invalid in the original mailbox once a message
|
|
382
|
+
is moved to Junk.
|
|
383
|
+
|
|
384
|
+
Uses subject+sender search (not ID) to find the message in the junk folder,
|
|
385
|
+
since IDs are mailbox-specific and become stale after a cross-mailbox move.
|
|
386
|
+
If a custom -m MAILBOX is given, only that mailbox is tried.
|
|
387
|
+
"""
|
|
388
|
+
import sys
|
|
389
|
+
account = resolve_account(getattr(args, "account", None))
|
|
390
|
+
if not account:
|
|
391
|
+
die("Account required. Use -a ACCOUNT.")
|
|
392
|
+
message_id = validate_msg_id(args.id)
|
|
393
|
+
|
|
394
|
+
acct_escaped = escape(account)
|
|
395
|
+
inbox_mailbox = resolve_mailbox(account, "INBOX")
|
|
396
|
+
inbox_escaped = escape(inbox_mailbox)
|
|
397
|
+
|
|
398
|
+
# Try to fetch the original message details (subject + sender) so we can
|
|
399
|
+
# search by content in the junk folder. This succeeds when called immediately
|
|
400
|
+
# after cmd_junk (the original INBOX message is still accessible by ID before
|
|
401
|
+
# the AppleScript cache expires), and fails gracefully after a restart.
|
|
402
|
+
orig_subject = ""
|
|
403
|
+
orig_sender = ""
|
|
404
|
+
try:
|
|
405
|
+
import subprocess as _subprocess
|
|
406
|
+
fetch_script = f"""
|
|
407
|
+
tell application "Mail"
|
|
408
|
+
set acct to account "{acct_escaped}"
|
|
409
|
+
set inboxMb to mailbox "{inbox_escaped}" of acct
|
|
410
|
+
set theMsg to first message of inboxMb whose id is {message_id}
|
|
411
|
+
return (subject of theMsg) & "{FIELD_SEPARATOR}" & (sender of theMsg)
|
|
412
|
+
end tell
|
|
413
|
+
"""
|
|
414
|
+
fetch_result = _subprocess.run(
|
|
415
|
+
["osascript", "-e", fetch_script],
|
|
416
|
+
capture_output=True,
|
|
417
|
+
text=True,
|
|
418
|
+
timeout=15,
|
|
419
|
+
)
|
|
420
|
+
if fetch_result.returncode == 0:
|
|
421
|
+
parts = fetch_result.stdout.strip().split(FIELD_SEPARATOR, 1)
|
|
422
|
+
if len(parts) == 2:
|
|
423
|
+
orig_subject, orig_sender = parts[0], parts[1]
|
|
424
|
+
except Exception:
|
|
425
|
+
pass # Non-fatal — fall back to ID-based lookup below
|
|
426
|
+
|
|
427
|
+
custom_mailbox = getattr(args, "mailbox", None)
|
|
428
|
+
if custom_mailbox:
|
|
429
|
+
# User explicitly specified where to look — trust them, single attempt
|
|
430
|
+
candidates = [resolve_mailbox(account, custom_mailbox)]
|
|
431
|
+
else:
|
|
432
|
+
# Build a prioritized list of junk folder candidates
|
|
433
|
+
junk_primary = resolve_mailbox(account, "Junk")
|
|
434
|
+
candidates = [junk_primary]
|
|
435
|
+
from mxctl.config import get_gmail_accounts
|
|
436
|
+
if account in get_gmail_accounts():
|
|
437
|
+
if "[Gmail]/Spam" not in candidates:
|
|
438
|
+
candidates.append("[Gmail]/Spam")
|
|
439
|
+
# Gmail's label architecture means messages may only be findable via All Mail
|
|
440
|
+
if "[Gmail]/All Mail" not in candidates:
|
|
441
|
+
candidates.append("[Gmail]/All Mail")
|
|
442
|
+
else:
|
|
443
|
+
# For non-Gmail accounts also try "Spam" as an alias
|
|
444
|
+
if "Spam" not in candidates:
|
|
445
|
+
candidates.append("Spam")
|
|
446
|
+
|
|
447
|
+
# Try each candidate mailbox until the message is found
|
|
448
|
+
for junk_mailbox in candidates:
|
|
449
|
+
junk_escaped = escape(junk_mailbox)
|
|
450
|
+
subject = _try_not_junk_in_mailbox(
|
|
451
|
+
acct_escaped, junk_escaped, inbox_escaped, message_id,
|
|
452
|
+
subject=orig_subject, sender=orig_sender,
|
|
453
|
+
)
|
|
454
|
+
if subject is not None:
|
|
455
|
+
format_output(
|
|
456
|
+
args,
|
|
457
|
+
f"Message '{truncate(subject, 50)}' marked as not junk and moved to INBOX.",
|
|
458
|
+
json_data={"id": message_id, "subject": subject, "status": "not_junk", "moved_to": "INBOX"}
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# All candidates failed — message not found in any junk folder
|
|
463
|
+
tried = ", ".join(f'"{m}"' for m in candidates)
|
|
464
|
+
print(
|
|
465
|
+
f"Error: Message {message_id} not found in junk folder(s) ({tried}).\n"
|
|
466
|
+
"The message may have already been moved, or use -m MAILBOX to specify its location.",
|
|
467
|
+
file=sys.stderr,
|
|
468
|
+
)
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def cmd_open(args) -> None:
|
|
473
|
+
"""Open a message in Mail.app."""
|
|
474
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
475
|
+
message_id = validate_msg_id(args.id)
|
|
476
|
+
|
|
477
|
+
script = f"""
|
|
478
|
+
tell application "Mail"
|
|
479
|
+
set theMb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
480
|
+
set theMsg to (first message of theMb whose id is {message_id})
|
|
481
|
+
set msgSubject to subject of theMsg
|
|
482
|
+
if (count of message viewers) is 0 then
|
|
483
|
+
make new message viewer
|
|
484
|
+
end if
|
|
485
|
+
set selected mailboxes of first message viewer to {{theMb}}
|
|
486
|
+
set selected messages of first message viewer to {{theMsg}}
|
|
487
|
+
activate
|
|
488
|
+
return msgSubject
|
|
489
|
+
end tell
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
subject = run(script)
|
|
493
|
+
format_output(
|
|
494
|
+
args,
|
|
495
|
+
f"Opened message {message_id} in Mail.app",
|
|
496
|
+
json_data={
|
|
497
|
+
"opened": True,
|
|
498
|
+
"message_id": message_id,
|
|
499
|
+
"account": account,
|
|
500
|
+
"mailbox": mailbox,
|
|
501
|
+
"subject": subject,
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
# Registration
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
def register(subparsers) -> None:
|
|
511
|
+
"""Register message action subcommands."""
|
|
512
|
+
# mark-read
|
|
513
|
+
p = subparsers.add_parser("mark-read", help="Mark message as read")
|
|
514
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
515
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
516
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
517
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
518
|
+
p.set_defaults(func=cmd_mark_read)
|
|
519
|
+
|
|
520
|
+
# mark-unread
|
|
521
|
+
p = subparsers.add_parser("mark-unread", help="Mark message as unread")
|
|
522
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
523
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
524
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
525
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
526
|
+
p.set_defaults(func=cmd_mark_unread)
|
|
527
|
+
|
|
528
|
+
# flag
|
|
529
|
+
p = subparsers.add_parser("flag", help="Flag a message")
|
|
530
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
531
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
532
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
533
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
534
|
+
p.set_defaults(func=cmd_flag)
|
|
535
|
+
|
|
536
|
+
# unflag
|
|
537
|
+
p = subparsers.add_parser("unflag", help="Unflag a message")
|
|
538
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
539
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
540
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
541
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
542
|
+
p.set_defaults(func=cmd_unflag)
|
|
543
|
+
|
|
544
|
+
# move
|
|
545
|
+
p = subparsers.add_parser("move", help="Move message to different mailbox")
|
|
546
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
547
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
548
|
+
p.add_argument("--from", dest="from_mailbox", required=True, help="Source mailbox")
|
|
549
|
+
p.add_argument("--to", dest="to_mailbox", required=True, help="Destination mailbox")
|
|
550
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
551
|
+
p.set_defaults(func=cmd_move)
|
|
552
|
+
|
|
553
|
+
# delete
|
|
554
|
+
p = subparsers.add_parser("delete", help="Delete message (move to Trash)")
|
|
555
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
556
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
557
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
558
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
559
|
+
p.set_defaults(func=cmd_delete)
|
|
560
|
+
|
|
561
|
+
# unsubscribe
|
|
562
|
+
p = subparsers.add_parser("unsubscribe", help="Unsubscribe from a mailing list")
|
|
563
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
564
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
565
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
566
|
+
p.add_argument("--dry-run", action="store_true", help="Show unsubscribe links without acting")
|
|
567
|
+
p.add_argument("--open", action="store_true", help="Force open in browser (skip one-click)")
|
|
568
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
569
|
+
p.set_defaults(func=cmd_unsubscribe)
|
|
570
|
+
|
|
571
|
+
# junk
|
|
572
|
+
p = subparsers.add_parser("junk", help="Mark message as junk/spam")
|
|
573
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
574
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
575
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
576
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
577
|
+
p.set_defaults(func=cmd_junk)
|
|
578
|
+
|
|
579
|
+
# not-junk
|
|
580
|
+
p = subparsers.add_parser("not-junk", help="Mark message as not junk and move to INBOX")
|
|
581
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
582
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
583
|
+
p.add_argument("-m", "--mailbox", help="Source mailbox (default: Junk)")
|
|
584
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
585
|
+
p.set_defaults(func=cmd_not_junk)
|
|
586
|
+
|
|
587
|
+
# open
|
|
588
|
+
p = subparsers.add_parser("open", help="Open message in Mail.app")
|
|
589
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
590
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
591
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
592
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
593
|
+
p.set_defaults(func=cmd_open)
|