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