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,362 @@
|
|
|
1
|
+
"""Setup wizard for first-time configuration: init."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import termios
|
|
7
|
+
import tty
|
|
8
|
+
|
|
9
|
+
from mxctl import __version__
|
|
10
|
+
from mxctl.config import ( # noqa: F401 — CONFIG_DIR imported for test monkeypatching
|
|
11
|
+
CONFIG_DIR,
|
|
12
|
+
CONFIG_FILE,
|
|
13
|
+
FIELD_SEPARATOR,
|
|
14
|
+
_save_json,
|
|
15
|
+
get_config,
|
|
16
|
+
)
|
|
17
|
+
from mxctl.util.applescript import run
|
|
18
|
+
from mxctl.util.formatting import format_output
|
|
19
|
+
|
|
20
|
+
# ANSI helpers
|
|
21
|
+
_G = "\x1b[1;32m" # bold green
|
|
22
|
+
_C = "\x1b[1;36m" # bold cyan
|
|
23
|
+
_D = "\x1b[90m" # dim gray
|
|
24
|
+
_B = "\x1b[1m" # bold
|
|
25
|
+
_R = "\x1b[0m" # reset
|
|
26
|
+
_W = "\x1b[1;37m" # bold white
|
|
27
|
+
|
|
28
|
+
_BANNER = f"""{_C}
|
|
29
|
+
_ _
|
|
30
|
+
| | | |
|
|
31
|
+
_ __ _____ ___| |_| |
|
|
32
|
+
| '_ ` _ \\ \\/ / __| __| |
|
|
33
|
+
| | | | | |> < (__| |_| |
|
|
34
|
+
|_| |_| |_/_/\\_\\___|\\__|_|{_R}
|
|
35
|
+
|
|
36
|
+
{_B}Apple Mail from your terminal{_R} {_D}— v{__version__}{_R}
|
|
37
|
+
{_D}─────────────────────────────────────────{_R}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Styled key hints for the hint bar
|
|
41
|
+
_K_ARROWS = f"{_D}↑/↓{_R}"
|
|
42
|
+
_K_SPACE = f"{_G}[Space]{_R}"
|
|
43
|
+
_K_ENTER = f"{_W}[Enter]{_R}"
|
|
44
|
+
_K_CANCEL = f"{_D}Ctrl+C cancel{_R}"
|
|
45
|
+
|
|
46
|
+
_HINT_RADIO = f" {_K_ARROWS} navigate {_K_SPACE} select {_K_ENTER} confirm {_K_CANCEL}"
|
|
47
|
+
_HINT_CHECKBOX = f" {_K_ARROWS} navigate {_K_SPACE} toggle {_K_ENTER} confirm {_K_CANCEL}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_interactive() -> bool:
|
|
51
|
+
"""True when running in a real terminal (not a pipe or test)."""
|
|
52
|
+
if os.environ.get("CI") or os.environ.get("MY_CLI_NON_INTERACTIVE"):
|
|
53
|
+
return False
|
|
54
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _radio_select(prompt: str, options: list[str]) -> int:
|
|
58
|
+
"""Arrow-key single-select. Returns chosen index.
|
|
59
|
+
Raises KeyboardInterrupt on Ctrl+C.
|
|
60
|
+
"""
|
|
61
|
+
current = 0
|
|
62
|
+
n = len(options)
|
|
63
|
+
|
|
64
|
+
def _render(first: bool = False) -> None:
|
|
65
|
+
# In raw mode \n doesn't imply \r — always use \r\n explicitly.
|
|
66
|
+
w = sys.stdout.write
|
|
67
|
+
if not first:
|
|
68
|
+
w(f"\r\x1b[{n + 1}A\x1b[J")
|
|
69
|
+
w(f"{_B}{prompt}{_R}\r\n")
|
|
70
|
+
for i, opt in enumerate(options):
|
|
71
|
+
if i == current:
|
|
72
|
+
w(f" {_G}(●) {opt}{_R}\r\n")
|
|
73
|
+
else:
|
|
74
|
+
w(f" ( ) {opt}\r\n")
|
|
75
|
+
w(_HINT_RADIO)
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
|
|
78
|
+
fd = sys.stdin.fileno()
|
|
79
|
+
old = termios.tcgetattr(fd)
|
|
80
|
+
tty.setraw(fd)
|
|
81
|
+
try:
|
|
82
|
+
_render(first=True)
|
|
83
|
+
while True:
|
|
84
|
+
ch = os.read(fd, 1)
|
|
85
|
+
if ch == b"\x1b":
|
|
86
|
+
seq = os.read(fd, 2)
|
|
87
|
+
if seq == b"[A":
|
|
88
|
+
current = (current - 1) % n
|
|
89
|
+
elif seq == b"[B":
|
|
90
|
+
current = (current + 1) % n
|
|
91
|
+
elif ch in (b"\r", b"\n", b" "):
|
|
92
|
+
break
|
|
93
|
+
elif ch == b"\x03":
|
|
94
|
+
raise KeyboardInterrupt
|
|
95
|
+
_render()
|
|
96
|
+
finally:
|
|
97
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
98
|
+
|
|
99
|
+
sys.stdout.write(f"\r\x1b[{n + 1}A\x1b[J")
|
|
100
|
+
print(f"{_B}{prompt}{_R} {_G}{options[current]}{_R}")
|
|
101
|
+
return current
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _checkbox_select(prompt: str, options: list[str]) -> list[int]:
|
|
105
|
+
"""Arrow-key multi-select with Space to toggle.
|
|
106
|
+
Returns sorted list of selected indices.
|
|
107
|
+
Raises KeyboardInterrupt on Ctrl+C.
|
|
108
|
+
"""
|
|
109
|
+
current = 0
|
|
110
|
+
selected: set[int] = set()
|
|
111
|
+
n = len(options)
|
|
112
|
+
|
|
113
|
+
def _render(first: bool = False) -> None:
|
|
114
|
+
# In raw mode \n doesn't imply \r — always use \r\n explicitly.
|
|
115
|
+
w = sys.stdout.write
|
|
116
|
+
if not first:
|
|
117
|
+
w(f"\r\x1b[{n + 1}A\x1b[J")
|
|
118
|
+
w(f"{_B}{prompt}{_R}\r\n")
|
|
119
|
+
for i, opt in enumerate(options):
|
|
120
|
+
box = f"{_G}[x]{_R}" if i in selected else "[ ]"
|
|
121
|
+
if i == current:
|
|
122
|
+
w(f" {_G}{box} {opt}{_R}\r\n")
|
|
123
|
+
else:
|
|
124
|
+
w(f" {box} {opt}\r\n")
|
|
125
|
+
w(_HINT_CHECKBOX)
|
|
126
|
+
sys.stdout.flush()
|
|
127
|
+
|
|
128
|
+
fd = sys.stdin.fileno()
|
|
129
|
+
old = termios.tcgetattr(fd)
|
|
130
|
+
tty.setraw(fd)
|
|
131
|
+
try:
|
|
132
|
+
_render(first=True)
|
|
133
|
+
while True:
|
|
134
|
+
ch = os.read(fd, 1)
|
|
135
|
+
if ch == b"\x1b":
|
|
136
|
+
seq = os.read(fd, 2)
|
|
137
|
+
if seq == b"[A":
|
|
138
|
+
current = (current - 1) % n
|
|
139
|
+
elif seq == b"[B":
|
|
140
|
+
current = (current + 1) % n
|
|
141
|
+
elif ch == b" ":
|
|
142
|
+
selected.symmetric_difference_update({current})
|
|
143
|
+
elif ch in (b"\r", b"\n"):
|
|
144
|
+
break
|
|
145
|
+
elif ch == b"\x03":
|
|
146
|
+
raise KeyboardInterrupt
|
|
147
|
+
_render()
|
|
148
|
+
finally:
|
|
149
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
150
|
+
|
|
151
|
+
result = sorted(selected)
|
|
152
|
+
sys.stdout.write(f"\r\x1b[{n + 1}A\x1b[J")
|
|
153
|
+
if result:
|
|
154
|
+
names = ", ".join(options[i] for i in result)
|
|
155
|
+
print(f"{_B}{prompt}{_R} {_G}{names}{_R}")
|
|
156
|
+
else:
|
|
157
|
+
print(f"{_B}{prompt}{_R} {_D}(none){_R}")
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _step_header(step: int, total: int, title: str, hint: str) -> None:
|
|
162
|
+
"""Print a step header with number, title, and context hint."""
|
|
163
|
+
print(f"\n {_C}Step {step}/{total}{_R} {_D}·{_R} {_B}{title}{_R}")
|
|
164
|
+
print(f" {_D}{hint}{_R}\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_init(args) -> None:
|
|
168
|
+
"""Interactive setup wizard to configure mxctl."""
|
|
169
|
+
print(_BANNER)
|
|
170
|
+
|
|
171
|
+
# Check for existing config
|
|
172
|
+
if os.path.isfile(CONFIG_FILE):
|
|
173
|
+
existing = get_config()
|
|
174
|
+
default_acct = existing.get("mail", {}).get("default_account") or existing.get("default_account", "")
|
|
175
|
+
print(f" {_D}Existing config found. Default account:{_R} {_B}{default_acct or '(none)'}{_R}")
|
|
176
|
+
try:
|
|
177
|
+
answer = input(f" Reconfigure? [{_B}y{_R}/{_B}N{_R}]: ").strip().lower()
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
print(f"\n {_D}Setup cancelled.{_R}")
|
|
180
|
+
return
|
|
181
|
+
except EOFError:
|
|
182
|
+
answer = "n"
|
|
183
|
+
if answer != "y":
|
|
184
|
+
print(f" {_D}Keeping existing configuration.{_R}")
|
|
185
|
+
if getattr(args, "json", False):
|
|
186
|
+
format_output(args, "", json_data=existing)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# Detect accounts via AppleScript
|
|
190
|
+
script = f"""
|
|
191
|
+
tell application "Mail"
|
|
192
|
+
set output to ""
|
|
193
|
+
repeat with acct in (every account)
|
|
194
|
+
set acctName to name of acct
|
|
195
|
+
set acctEmail to user name of acct
|
|
196
|
+
set acctEnabled to enabled of acct
|
|
197
|
+
set output to output & acctName & "{FIELD_SEPARATOR}" & acctEmail & "{FIELD_SEPARATOR}" & acctEnabled & linefeed
|
|
198
|
+
end repeat
|
|
199
|
+
return output
|
|
200
|
+
end tell
|
|
201
|
+
"""
|
|
202
|
+
result = run(script)
|
|
203
|
+
|
|
204
|
+
if not result.strip():
|
|
205
|
+
print(f" {_G}!{_R} No mail accounts found. Make sure Mail.app is configured.")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Parse accounts
|
|
209
|
+
accounts = []
|
|
210
|
+
for line in result.strip().split("\n"):
|
|
211
|
+
if not line.strip():
|
|
212
|
+
continue
|
|
213
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
214
|
+
if len(parts) >= 3:
|
|
215
|
+
name, email, enabled_str = parts[0], parts[1], parts[2]
|
|
216
|
+
accounts.append({
|
|
217
|
+
"name": name,
|
|
218
|
+
"email": email,
|
|
219
|
+
"enabled": enabled_str.strip().lower() == "true",
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
enabled_accounts = [a for a in accounts if a["enabled"]]
|
|
223
|
+
|
|
224
|
+
if not enabled_accounts:
|
|
225
|
+
print(f" {_G}!{_R} No enabled mail accounts found.")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
total_steps = 3
|
|
229
|
+
|
|
230
|
+
# --- Step 1: Select primary account ---
|
|
231
|
+
_step_header(1, total_steps, "Default Account",
|
|
232
|
+
"Which account should commands use when you don't specify -a?")
|
|
233
|
+
|
|
234
|
+
if len(enabled_accounts) == 1:
|
|
235
|
+
chosen = enabled_accounts[0]
|
|
236
|
+
print(f" {_G}Auto-selected:{_R} {chosen['name']} ({chosen['email']})")
|
|
237
|
+
elif _is_interactive():
|
|
238
|
+
try:
|
|
239
|
+
opts = [f"{a['name']} ({a['email']})" for a in enabled_accounts]
|
|
240
|
+
idx = _radio_select(" Select primary account:", opts)
|
|
241
|
+
chosen = enabled_accounts[idx]
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
print(f"\n {_D}Setup cancelled.{_R}")
|
|
244
|
+
return
|
|
245
|
+
else:
|
|
246
|
+
# Non-interactive fallback (tests, pipes)
|
|
247
|
+
print(" Available mail accounts:")
|
|
248
|
+
for i, acct in enumerate(enabled_accounts, start=1):
|
|
249
|
+
print(f" {i}. {acct['name']} ({acct['email']})")
|
|
250
|
+
while True:
|
|
251
|
+
try:
|
|
252
|
+
raw = input(f"\n Select primary account [1-{len(enabled_accounts)}]: ").strip()
|
|
253
|
+
except KeyboardInterrupt:
|
|
254
|
+
print(f"\n {_D}Setup cancelled.{_R}")
|
|
255
|
+
return
|
|
256
|
+
except EOFError:
|
|
257
|
+
raw = "1"
|
|
258
|
+
if raw.isdigit() and 1 <= int(raw) <= len(enabled_accounts):
|
|
259
|
+
chosen = enabled_accounts[int(raw) - 1]
|
|
260
|
+
break
|
|
261
|
+
print(f" Please enter a number between 1 and {len(enabled_accounts)}.")
|
|
262
|
+
|
|
263
|
+
# --- Step 2: Select Gmail accounts ---
|
|
264
|
+
_step_header(2, total_steps, "Gmail Accounts",
|
|
265
|
+
"Gmail uses different mailbox names ([Gmail]/Spam instead of Junk).")
|
|
266
|
+
|
|
267
|
+
gmail_accounts: list[str] = []
|
|
268
|
+
if len(enabled_accounts) == 1:
|
|
269
|
+
try:
|
|
270
|
+
ans = input(f" Is '{enabled_accounts[0]['name']}' a Gmail account? [y/N]: ").strip().lower()
|
|
271
|
+
except (KeyboardInterrupt, EOFError):
|
|
272
|
+
ans = "n"
|
|
273
|
+
if ans == "y":
|
|
274
|
+
gmail_accounts = [enabled_accounts[0]["name"]]
|
|
275
|
+
elif _is_interactive():
|
|
276
|
+
try:
|
|
277
|
+
opts = [f"{a['name']} ({a['email']})" for a in enabled_accounts]
|
|
278
|
+
indices = _checkbox_select(" Select your Gmail accounts:", opts)
|
|
279
|
+
gmail_accounts = [enabled_accounts[i]["name"] for i in indices]
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
print(f"\n {_D}Setup cancelled.{_R}")
|
|
282
|
+
return
|
|
283
|
+
else:
|
|
284
|
+
# Non-interactive fallback
|
|
285
|
+
print(" Enter numbers separated by commas, or press Enter to skip.")
|
|
286
|
+
for i, acct in enumerate(enabled_accounts, start=1):
|
|
287
|
+
print(f" {i}. {acct['name']} ({acct['email']})")
|
|
288
|
+
try:
|
|
289
|
+
raw_gmail = input(" Gmail accounts: ").strip()
|
|
290
|
+
except (KeyboardInterrupt, EOFError):
|
|
291
|
+
raw_gmail = ""
|
|
292
|
+
if raw_gmail:
|
|
293
|
+
for part in raw_gmail.split(","):
|
|
294
|
+
part = part.strip()
|
|
295
|
+
if part.isdigit() and 1 <= int(part) <= len(enabled_accounts):
|
|
296
|
+
gmail_accounts.append(enabled_accounts[int(part) - 1]["name"])
|
|
297
|
+
|
|
298
|
+
if gmail_accounts:
|
|
299
|
+
print(f" {_D}Mailbox names will auto-translate for: {', '.join(gmail_accounts)}{_R}")
|
|
300
|
+
|
|
301
|
+
# --- Step 3: Todoist API token ---
|
|
302
|
+
_step_header(3, total_steps, "Todoist Integration",
|
|
303
|
+
"Turn emails into tasks with `mxctl to-todoist`.")
|
|
304
|
+
print(f" {_D}Get your token: Todoist Settings > Integrations > Developer{_R}")
|
|
305
|
+
|
|
306
|
+
todoist_token = ""
|
|
307
|
+
try:
|
|
308
|
+
raw_token = input(f"\n API token {_D}(Enter to skip){_R}: ").strip()
|
|
309
|
+
except KeyboardInterrupt:
|
|
310
|
+
print(f"\n {_D}Setup cancelled.{_R}")
|
|
311
|
+
return
|
|
312
|
+
except EOFError:
|
|
313
|
+
raw_token = ""
|
|
314
|
+
if raw_token:
|
|
315
|
+
if not re.match(r'^[a-f0-9]{40}$', raw_token):
|
|
316
|
+
print(f" {_D}Warning: Doesn't match expected format (40 hex chars). Saving anyway.{_R}")
|
|
317
|
+
todoist_token = raw_token
|
|
318
|
+
|
|
319
|
+
# Build and write config
|
|
320
|
+
config: dict = {
|
|
321
|
+
"mail": {
|
|
322
|
+
"default_account": chosen["name"],
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if gmail_accounts:
|
|
326
|
+
config["mail"]["gmail_accounts"] = gmail_accounts
|
|
327
|
+
if todoist_token:
|
|
328
|
+
config["todoist_api_token"] = todoist_token
|
|
329
|
+
|
|
330
|
+
_save_json(CONFIG_FILE, config)
|
|
331
|
+
|
|
332
|
+
# Success output
|
|
333
|
+
summary_parts = [f"{chosen['name']}"]
|
|
334
|
+
if gmail_accounts:
|
|
335
|
+
summary_parts.append(f"Gmail: {len(gmail_accounts)} account{'s' if len(gmail_accounts) != 1 else ''}")
|
|
336
|
+
if todoist_token:
|
|
337
|
+
summary_parts.append("Todoist: connected")
|
|
338
|
+
|
|
339
|
+
success_text = (
|
|
340
|
+
f"\n {_G}Setup complete!{_R}\n\n"
|
|
341
|
+
f" {_D}Config saved to {CONFIG_FILE}{_R}\n"
|
|
342
|
+
f" {_B}{' · '.join(summary_parts)}{_R}\n"
|
|
343
|
+
f"\n {_B}Get started:{_R}\n"
|
|
344
|
+
f" {_G}mxctl inbox{_R} {_D}Unread counts across all accounts{_R}\n"
|
|
345
|
+
f" {_G}mxctl summary{_R} {_D}AI-concise one-liner per unread{_R}\n"
|
|
346
|
+
f" {_G}mxctl triage{_R} {_D}Unread grouped by urgency{_R}\n"
|
|
347
|
+
f" {_G}mxctl --help{_R} {_D}See all 49 commands{_R}\n"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if getattr(args, "json", False):
|
|
351
|
+
redacted = dict(config)
|
|
352
|
+
if "todoist_api_token" in redacted:
|
|
353
|
+
redacted = {**redacted, "todoist_api_token": "****"}
|
|
354
|
+
format_output(args, success_text, json_data=redacted)
|
|
355
|
+
else:
|
|
356
|
+
print(success_text)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def register(subparsers) -> None:
|
|
360
|
+
p = subparsers.add_parser("init", help="Setup wizard for first-time configuration")
|
|
361
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
362
|
+
p.set_defaults(func=cmd_init)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""System mail commands: check, headers, rules."""
|
|
2
|
+
|
|
3
|
+
from mxctl.config import (
|
|
4
|
+
DEFAULT_MAILBOX,
|
|
5
|
+
FIELD_SEPARATOR,
|
|
6
|
+
resolve_account,
|
|
7
|
+
)
|
|
8
|
+
from mxctl.util.applescript import escape, run, validate_msg_id
|
|
9
|
+
from mxctl.util.formatting import die, format_output, truncate
|
|
10
|
+
from mxctl.util.mail_helpers import parse_email_headers
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# check — trigger mail fetch
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def cmd_check(args) -> None:
|
|
17
|
+
"""Trigger Mail.app to check for new mail."""
|
|
18
|
+
script = """
|
|
19
|
+
tell application "Mail"
|
|
20
|
+
check for new mail
|
|
21
|
+
return "ok"
|
|
22
|
+
end tell
|
|
23
|
+
"""
|
|
24
|
+
run(script)
|
|
25
|
+
format_output(args, "Mail check triggered.", json_data={"status": "checked"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# headers
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def cmd_headers(args) -> None:
|
|
33
|
+
"""Show email headers with authentication details."""
|
|
34
|
+
account = resolve_account(getattr(args, "account", None))
|
|
35
|
+
if not account:
|
|
36
|
+
die("Account required. Use -a ACCOUNT.")
|
|
37
|
+
mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
|
|
38
|
+
message_id = validate_msg_id(args.id)
|
|
39
|
+
|
|
40
|
+
acct_escaped = escape(account)
|
|
41
|
+
mb_escaped = escape(mailbox)
|
|
42
|
+
|
|
43
|
+
script = f"""
|
|
44
|
+
tell application "Mail"
|
|
45
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
46
|
+
set theMsg to first message of mb whose id is {message_id}
|
|
47
|
+
return all headers of theMsg
|
|
48
|
+
end tell
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
result = run(script)
|
|
52
|
+
raw = getattr(args, "raw", False)
|
|
53
|
+
|
|
54
|
+
if raw:
|
|
55
|
+
print(result)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Parse all headers
|
|
59
|
+
headers = parse_email_headers(result)
|
|
60
|
+
|
|
61
|
+
# Extract useful info
|
|
62
|
+
from_addr = headers.get("From", "?")
|
|
63
|
+
to_addr = headers.get("To", "?")
|
|
64
|
+
subject = headers.get("Subject", "?")
|
|
65
|
+
date = headers.get("Date", "?")
|
|
66
|
+
msg_id = headers.get("Message-Id") or headers.get("Message-ID", "?")
|
|
67
|
+
reply_to = headers.get("Reply-To", "")
|
|
68
|
+
in_reply_to = headers.get("In-Reply-To", "")
|
|
69
|
+
list_unsubscribe = headers.get("List-Unsubscribe", "")
|
|
70
|
+
|
|
71
|
+
# Authentication summary
|
|
72
|
+
auth_results = headers.get("Authentication-Results", "")
|
|
73
|
+
if isinstance(auth_results, list):
|
|
74
|
+
auth_results = " | ".join(auth_results)
|
|
75
|
+
spf = "?"
|
|
76
|
+
dkim = "?"
|
|
77
|
+
dmarc = "?"
|
|
78
|
+
if "spf=pass" in auth_results:
|
|
79
|
+
spf = "pass"
|
|
80
|
+
elif "spf=fail" in auth_results:
|
|
81
|
+
spf = "FAIL"
|
|
82
|
+
elif "spf=softfail" in auth_results:
|
|
83
|
+
spf = "softfail"
|
|
84
|
+
if "dkim=pass" in auth_results:
|
|
85
|
+
dkim = "pass"
|
|
86
|
+
elif "dkim=fail" in auth_results:
|
|
87
|
+
dkim = "FAIL"
|
|
88
|
+
if "dmarc=pass" in auth_results:
|
|
89
|
+
dmarc = "pass"
|
|
90
|
+
elif "dmarc=fail" in auth_results:
|
|
91
|
+
dmarc = "FAIL"
|
|
92
|
+
|
|
93
|
+
# Count hops
|
|
94
|
+
received = headers.get("Received", [])
|
|
95
|
+
if isinstance(received, str):
|
|
96
|
+
received = [received]
|
|
97
|
+
hops = len(received)
|
|
98
|
+
|
|
99
|
+
# Return path (bounce address)
|
|
100
|
+
return_path = headers.get("Return-Path", "")
|
|
101
|
+
|
|
102
|
+
text = f"From: {from_addr}\nTo: {to_addr}\nSubject: {subject}\nDate: {date}\nMessage-ID: {msg_id}"
|
|
103
|
+
if reply_to:
|
|
104
|
+
text += f"\nReply-To: {reply_to}"
|
|
105
|
+
if in_reply_to:
|
|
106
|
+
text += f"\nIn-Reply-To: {in_reply_to}"
|
|
107
|
+
if return_path:
|
|
108
|
+
text += f"\nReturn-Path: {return_path}"
|
|
109
|
+
text += f"\n\nAuth: SPF={spf} DKIM={dkim} DMARC={dmarc}"
|
|
110
|
+
text += f"\nHops: {hops}"
|
|
111
|
+
if list_unsubscribe:
|
|
112
|
+
text += f"\nUnsubscribe: {truncate(list_unsubscribe, 80)}"
|
|
113
|
+
format_output(args, text, json_data=headers)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# rules — list/enable/disable/apply mail rules
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def cmd_rules(args) -> None:
|
|
121
|
+
"""List or manage mail rules."""
|
|
122
|
+
action = getattr(args, "action", None)
|
|
123
|
+
rule_name = getattr(args, "rule_name", None)
|
|
124
|
+
if action == "enable" and rule_name:
|
|
125
|
+
_toggle_rule(args, rule_name, True)
|
|
126
|
+
elif action == "disable" and rule_name:
|
|
127
|
+
_toggle_rule(args, rule_name, False)
|
|
128
|
+
else:
|
|
129
|
+
_list_rules(args)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _list_rules(args) -> None:
|
|
133
|
+
script = f"""
|
|
134
|
+
tell application "Mail"
|
|
135
|
+
set output to ""
|
|
136
|
+
repeat with r in (every rule)
|
|
137
|
+
set rName to name of r
|
|
138
|
+
set rEnabled to enabled of r
|
|
139
|
+
set output to output & rName & "{FIELD_SEPARATOR}" & rEnabled & linefeed
|
|
140
|
+
end repeat
|
|
141
|
+
return output
|
|
142
|
+
end tell
|
|
143
|
+
"""
|
|
144
|
+
result = run(script)
|
|
145
|
+
if not result.strip():
|
|
146
|
+
format_output(args, "No mail rules found.")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
rules = []
|
|
150
|
+
for line in result.strip().split("\n"):
|
|
151
|
+
if not line.strip():
|
|
152
|
+
continue
|
|
153
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
154
|
+
if len(parts) >= 2:
|
|
155
|
+
rules.append({"name": parts[0], "enabled": parts[1].lower() == "true"})
|
|
156
|
+
|
|
157
|
+
text = "Mail Rules:"
|
|
158
|
+
for rule in rules:
|
|
159
|
+
status = "ON" if rule["enabled"] else "OFF"
|
|
160
|
+
text += f"\n [{status}] {rule['name']}"
|
|
161
|
+
format_output(args, text, json_data=rules)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _toggle_rule(args, name: str, enabled: bool) -> None:
|
|
165
|
+
name_escaped = escape(name)
|
|
166
|
+
val = "true" if enabled else "false"
|
|
167
|
+
script = f"""
|
|
168
|
+
tell application "Mail"
|
|
169
|
+
set r to first rule whose name is "{name_escaped}"
|
|
170
|
+
set enabled of r to {val}
|
|
171
|
+
return name of r
|
|
172
|
+
end tell
|
|
173
|
+
"""
|
|
174
|
+
result = run(script)
|
|
175
|
+
word = "enabled" if enabled else "disabled"
|
|
176
|
+
format_output(args, f"Rule '{result}' {word}.", json_data={"rule": result, "status": word})
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Registration
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def register(subparsers) -> None:
|
|
184
|
+
"""Register system mail subcommands."""
|
|
185
|
+
p = subparsers.add_parser("check", help="Trigger fetch for new mail")
|
|
186
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
187
|
+
p.set_defaults(func=cmd_check)
|
|
188
|
+
|
|
189
|
+
p = subparsers.add_parser("headers", help="Email header summary (auth, hops, reply-to)")
|
|
190
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
191
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
192
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
193
|
+
p.add_argument("--raw", action="store_true", help="Show full raw headers instead of summary")
|
|
194
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
195
|
+
p.set_defaults(func=cmd_headers)
|
|
196
|
+
|
|
197
|
+
p = subparsers.add_parser("rules", help="List/manage mail rules")
|
|
198
|
+
p.add_argument("action", nargs="?", choices=["enable", "disable"], help="Action to perform")
|
|
199
|
+
p.add_argument("rule_name", nargs="?", help="Rule name")
|
|
200
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
201
|
+
p.set_defaults(func=cmd_rules)
|
|
202
|
+
|