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/config.py ADDED
@@ -0,0 +1,227 @@
1
+ """Configuration management for mxctl.
2
+
3
+ Three-tier account resolution (most specific wins):
4
+ 1. Explicit --account flag
5
+ 2. Default in ~/.config/mxctl/config.json -> {"mail": {"default_account": "iCloud"}}
6
+ 3. Last-used account saved in ~/.config/mxctl/state.json -> {"mail": {"last_account": "iCloud"}}
7
+
8
+ Migration note: Backward compatibility is maintained. Config lookups check the new
9
+ namespaced format first (e.g., config["mail"]["default_account"]), then fall back
10
+ to the legacy flat format (config["default_account"]) if the namespace key is missing.
11
+ This allows existing configs to continue working without modification.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import fcntl
17
+ import json
18
+ import os
19
+ import shutil
20
+ import time
21
+ from contextlib import contextmanager
22
+
23
+ from mxctl.util.formatting import die
24
+
25
+ CONFIG_DIR = os.path.expanduser("~/.config/mxctl")
26
+ _LEGACY_CONFIG_DIR = os.path.expanduser("~/.config/my")
27
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
28
+ STATE_FILE = os.path.join(CONFIG_DIR, "state.json")
29
+ TEMPLATES_FILE = os.path.join(CONFIG_DIR, "mail-templates.json")
30
+ UNDO_LOG_FILE = os.path.join(CONFIG_DIR, "mail-undo.json")
31
+
32
+ DEFAULT_MESSAGE_LIMIT = 25
33
+ MAX_MESSAGE_LIMIT = 100
34
+ DEFAULT_BODY_LENGTH = 10000
35
+ DEFAULT_MAILBOX = "INBOX"
36
+
37
+ # Caps for various operations
38
+ MAX_MESSAGES_BATCH = 500
39
+ DEFAULT_DIGEST_LIMIT = 50
40
+ DEFAULT_TOP_SENDERS_LIMIT = 10
41
+ MAX_EXPORT_BULK_LIMIT = 100
42
+
43
+ # AppleScript timeout values (seconds)
44
+ APPLESCRIPT_TIMEOUT_SHORT = 15
45
+ APPLESCRIPT_TIMEOUT_DEFAULT = 30
46
+ APPLESCRIPT_TIMEOUT_LONG = 60
47
+ APPLESCRIPT_TIMEOUT_BATCH = 120
48
+
49
+ # Data separators for AppleScript field/record parsing
50
+ FIELD_SEPARATOR = "\x1F"
51
+ RECORD_SEPARATOR = "\x1eEND\x1e"
52
+
53
+ # Common patterns for identifying no-reply / automated senders
54
+ NOREPLY_PATTERNS = [
55
+ "noreply", "no-reply", "notifications", "mailer-daemon", "donotreply",
56
+ "updates@", "news@", "info@", "support@", "billing@",
57
+ ]
58
+
59
+ _migrated: bool = False
60
+
61
+
62
+ def _migrate_legacy_config() -> None:
63
+ """One-time migration from ~/.config/my/ to ~/.config/mxctl/."""
64
+ global _migrated
65
+ if _migrated:
66
+ return
67
+ _migrated = True
68
+
69
+ if os.path.isdir(CONFIG_DIR):
70
+ return # Already migrated or fresh install
71
+ if not os.path.isdir(_LEGACY_CONFIG_DIR):
72
+ return # No legacy config to migrate
73
+
74
+ import sys
75
+ shutil.copytree(_LEGACY_CONFIG_DIR, CONFIG_DIR)
76
+ print(
77
+ f"Migrated config from {_LEGACY_CONFIG_DIR} to {CONFIG_DIR}",
78
+ file=sys.stderr,
79
+ )
80
+
81
+
82
+ def _ensure_dir() -> None:
83
+ _migrate_legacy_config()
84
+ os.makedirs(CONFIG_DIR, exist_ok=True)
85
+
86
+
87
+ @contextmanager
88
+ def file_lock(path: str):
89
+ """Context manager for file-based locking with retry."""
90
+ lock_path = path + ".lock"
91
+ max_retries = 10
92
+ retry_delay = 0.05 # 50ms
93
+
94
+ os.makedirs(os.path.dirname(lock_path), exist_ok=True)
95
+ for attempt in range(max_retries):
96
+ try:
97
+ with open(lock_path, "w") as lock_file:
98
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
99
+ try:
100
+ yield lock_file
101
+ finally:
102
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
103
+ try:
104
+ os.unlink(lock_path)
105
+ except OSError:
106
+ pass
107
+ break
108
+ except BlockingIOError:
109
+ if attempt < max_retries - 1:
110
+ time.sleep(retry_delay)
111
+ else:
112
+ die(f"Could not acquire file lock for {path} after {max_retries} attempts. Another process may be holding it.")
113
+
114
+
115
+ def _load_json(path: str) -> dict:
116
+ _migrate_legacy_config()
117
+ if os.path.isfile(path):
118
+ try:
119
+ with file_lock(path), open(path) as f:
120
+ content = f.read().strip()
121
+ if not content: # Handle empty/truncated files
122
+ return {}
123
+ return json.loads(content)
124
+ except json.JSONDecodeError:
125
+ import sys
126
+ print(f"Warning: {path} contains invalid JSON. Using defaults.", file=sys.stderr)
127
+ return {}
128
+ except OSError:
129
+ return {}
130
+ return {}
131
+
132
+
133
+ _SENSITIVE_FILES = {CONFIG_FILE, STATE_FILE, UNDO_LOG_FILE, TEMPLATES_FILE}
134
+
135
+
136
+ def _save_json(path: str, data: dict) -> None:
137
+ _ensure_dir()
138
+ with file_lock(path):
139
+ with open(path, "w") as f:
140
+ json.dump(data, f, indent=2)
141
+ if path in _SENSITIVE_FILES:
142
+ os.chmod(path, 0o600)
143
+
144
+
145
+ _config_warned: bool = False
146
+
147
+
148
+ def get_config(required: bool = False, warn: bool = True) -> dict:
149
+ global _config_warned
150
+ if not os.path.isfile(CONFIG_FILE):
151
+ _migrate_legacy_config()
152
+ if not os.path.isfile(CONFIG_FILE):
153
+ if required:
154
+ die("No config found. Run `mxctl init` to set up your default account.")
155
+ if warn and not _config_warned:
156
+ import sys
157
+ print(
158
+ "No config found. Run `mxctl init` to set up your default account.",
159
+ file=sys.stderr,
160
+ )
161
+ _config_warned = True
162
+ return _load_json(CONFIG_FILE)
163
+
164
+
165
+ def get_state() -> dict:
166
+ return _load_json(STATE_FILE)
167
+
168
+
169
+ def save_message_aliases(aliases: list[int]) -> None:
170
+ """Save ordered list of message IDs as session aliases to state."""
171
+ state = get_state()
172
+ state.setdefault("mail", {})["aliases"] = {
173
+ str(i + 1): mid for i, mid in enumerate(aliases)
174
+ }
175
+ _save_json(STATE_FILE, state)
176
+
177
+
178
+ def resolve_alias(value) -> int | None:
179
+ """If value matches a saved alias number, resolve to real message ID."""
180
+ try:
181
+ n = int(value)
182
+ except (TypeError, ValueError):
183
+ return None
184
+ if n <= 0:
185
+ return None
186
+ state = get_state()
187
+ aliases = state.get("mail", {}).get("aliases", {})
188
+ real_id = aliases.get(str(n))
189
+ if real_id is not None:
190
+ return int(real_id)
191
+ return None
192
+
193
+
194
+ def save_last_account(account: str) -> None:
195
+ state = get_state()
196
+ if "mail" not in state:
197
+ state["mail"] = {}
198
+ state["mail"]["last_account"] = account
199
+ _save_json(STATE_FILE, state)
200
+
201
+
202
+ def resolve_account(explicit: str | None) -> str | None:
203
+ """Resolve account using three-tier strategy. Returns None if nothing set."""
204
+ if explicit:
205
+ save_last_account(explicit)
206
+ return explicit
207
+
208
+ cfg = get_config(required=False, warn=False)
209
+ # Check namespaced key first, fall back to legacy flat key
210
+ default_account = cfg.get("mail", {}).get("default_account") or cfg.get("default_account")
211
+ if default_account:
212
+ return default_account
213
+
214
+ state = get_state()
215
+ # Check namespaced key first, fall back to legacy flat key
216
+ return state.get("mail", {}).get("last_account") or state.get("last_account")
217
+
218
+
219
+ def validate_limit(limit: int) -> int:
220
+ """Clamp limit to [1, MAX_MESSAGE_LIMIT]."""
221
+ return max(1, min(limit, MAX_MESSAGE_LIMIT))
222
+
223
+
224
+ def get_gmail_accounts() -> list[str]:
225
+ """Return list of account names configured as Gmail accounts."""
226
+ cfg = get_config(required=False, warn=False)
227
+ return cfg.get("mail", {}).get("gmail_accounts", [])
mxctl/main.py ADDED
@@ -0,0 +1,89 @@
1
+ """Top-level argparse router for mxctl."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from mxctl import __version__
7
+ from mxctl.commands.mail.accounts import register as register_accounts
8
+ from mxctl.commands.mail.actions import register as register_actions
9
+ from mxctl.commands.mail.ai import register as register_ai
10
+ from mxctl.commands.mail.analytics import register as register_analytics
11
+ from mxctl.commands.mail.attachments import register as register_attachments
12
+ from mxctl.commands.mail.batch import register as register_batch
13
+ from mxctl.commands.mail.compose import register as register_compose
14
+ from mxctl.commands.mail.composite import register as register_composite
15
+ from mxctl.commands.mail.inbox_tools import register as register_inbox_tools
16
+ from mxctl.commands.mail.manage import register as register_manage
17
+ from mxctl.commands.mail.messages import register as register_messages
18
+ from mxctl.commands.mail.setup import register as register_setup
19
+ from mxctl.commands.mail.system import register as register_system
20
+ from mxctl.commands.mail.templates import register as register_templates
21
+ from mxctl.commands.mail.todoist_integration import register as register_todoist
22
+ from mxctl.commands.mail.undo import register as register_undo
23
+
24
+ _GROUPED_HELP = """\
25
+ Apple Mail from your terminal.
26
+
27
+ Commands by category:
28
+
29
+ Setup: init, check, accounts, mailboxes
30
+ Reading: inbox, count, list, read, search, thread, context, headers
31
+ Actions: mark-read, mark-unread, flag, unflag, move, delete
32
+ junk, not-junk, unsubscribe, open, rules
33
+ Compose: draft, reply, forward, templates
34
+ Manage: create-mailbox, delete-mailbox, empty-trash
35
+ Batch: batch-read, batch-flag, batch-move, batch-delete, undo
36
+ AI & Analytics: summary, triage, find-related, digest, top-senders,
37
+ show-flagged, weekly-review, process-inbox,
38
+ clean-newsletters, stats
39
+ Export: export, attachments, save-attachment, to-todoist
40
+
41
+ Run `mxctl <command> --help` for details on any command.
42
+ """
43
+
44
+
45
+ def main() -> None:
46
+ parser = argparse.ArgumentParser(
47
+ prog="mxctl",
48
+ description=_GROUPED_HELP,
49
+ formatter_class=argparse.RawDescriptionHelpFormatter,
50
+ )
51
+ parser.add_argument(
52
+ "--version",
53
+ action="version",
54
+ version=f"mxctl {__version__}",
55
+ )
56
+ subparsers = parser.add_subparsers(dest="command")
57
+
58
+ register_accounts(subparsers)
59
+ register_messages(subparsers)
60
+ register_actions(subparsers)
61
+ register_compose(subparsers)
62
+ register_attachments(subparsers)
63
+ register_manage(subparsers)
64
+ register_batch(subparsers)
65
+ register_analytics(subparsers)
66
+ register_system(subparsers)
67
+ register_composite(subparsers)
68
+ register_ai(subparsers)
69
+ register_todoist(subparsers)
70
+ register_inbox_tools(subparsers)
71
+ register_templates(subparsers)
72
+ register_undo(subparsers)
73
+ register_setup(subparsers)
74
+
75
+ args = parser.parse_args()
76
+
77
+ if args.command is None:
78
+ parser.print_help()
79
+ sys.exit(0)
80
+
81
+ # Dispatch to the handler set by set_defaults(func=...)
82
+ try:
83
+ if hasattr(args, "func"):
84
+ args.func(args)
85
+ else:
86
+ parser.parse_args([args.command, "--help"])
87
+ except KeyboardInterrupt:
88
+ print("\nCancelled.", file=sys.stderr)
89
+ sys.exit(130)
mxctl/util/__init__.py ADDED
File without changes
@@ -0,0 +1,139 @@
1
+ """AppleScript execution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+
10
+ from mxctl.config import APPLESCRIPT_TIMEOUT_DEFAULT, STATE_FILE
11
+
12
+ _automation_warned: bool = False
13
+
14
+
15
+ def _warn_automation_once() -> None:
16
+ """Print a one-time heads-up about macOS Automation permission if needed."""
17
+ global _automation_warned
18
+ if _automation_warned:
19
+ return
20
+
21
+ # Check if we've already shown the prompt in a previous session
22
+ from mxctl.config import _save_json, get_state
23
+
24
+ state = get_state()
25
+ if state.get("automation_prompted"):
26
+ _automation_warned = True
27
+ return
28
+
29
+ # Check terminal app name for a friendlier message
30
+ terminal_app = os.environ.get("TERM_PROGRAM", "your terminal app")
31
+ if terminal_app == "iTerm.app":
32
+ terminal_app = "iTerm"
33
+ elif terminal_app == "Apple_Terminal":
34
+ terminal_app = "Terminal"
35
+
36
+ print(
37
+ "Note: macOS will ask for Automation permission to control Mail.app. "
38
+ "If prompted, click Allow.",
39
+ file=sys.stderr,
40
+ )
41
+ print(
42
+ f" If you see 'not authorized': System Settings → Privacy & Security → "
43
+ f"Automation → {terminal_app} → enable Mail.",
44
+ file=sys.stderr,
45
+ )
46
+
47
+ # Mark as warned for this session and persist to state
48
+ _automation_warned = True
49
+ state["automation_prompted"] = True
50
+ _save_json(STATE_FILE, state)
51
+
52
+
53
+ def validate_msg_id(value) -> int:
54
+ """Validate that value is a positive integer suitable for use as a message ID.
55
+
56
+ Supports short session aliases: if value matches a saved alias (e.g. "1"),
57
+ resolves to the real message ID from the most recent listing command.
58
+
59
+ Raises SystemExit via die() if the value is not a positive integer.
60
+ Returns the integer value on success.
61
+ """
62
+ from mxctl.config import resolve_alias
63
+ from mxctl.util.formatting import die
64
+
65
+ # Reject floats explicitly — int(1.5) == 1 without error, which is misleading
66
+ if isinstance(value, float):
67
+ die(f"Invalid message ID '{value}': must be a positive integer.")
68
+
69
+ # Try alias resolution first
70
+ resolved = resolve_alias(value)
71
+ if resolved is not None:
72
+ return resolved
73
+
74
+ # Fall through to real ID validation
75
+ try:
76
+ int_val = int(value)
77
+ except (TypeError, ValueError):
78
+ die(f"Invalid message ID '{value}': must be a positive integer.")
79
+ if int_val <= 0:
80
+ die(f"Invalid message ID '{value}': must be a positive integer.")
81
+ return int_val
82
+
83
+
84
+ def escape(s: str | None) -> str:
85
+ """Escape a string for safe use in AppleScript."""
86
+ if s is None:
87
+ return ""
88
+ s = str(s).replace("\\", "\\\\")
89
+ s = s.replace('"', '\\"')
90
+ s = s.replace("\n", "\\n")
91
+ s = s.replace("\r", "\\r")
92
+ s = re.sub(r'[\x00-\x1f]', '', s)
93
+ return s
94
+
95
+
96
+ def sanitize_path(path: str) -> str:
97
+ """Expand and resolve a file path."""
98
+ return os.path.abspath(os.path.expanduser(path))
99
+
100
+
101
+ def run(script: str, timeout: int = APPLESCRIPT_TIMEOUT_DEFAULT) -> str:
102
+ """Execute AppleScript and return stdout. Exits on error."""
103
+ _warn_automation_once()
104
+ try:
105
+ result = subprocess.run(
106
+ ["osascript", "-e", script],
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=timeout,
110
+ )
111
+ except FileNotFoundError:
112
+ print("Error: osascript not found. This tool requires macOS.", file=sys.stderr)
113
+ sys.exit(1)
114
+ except subprocess.TimeoutExpired:
115
+ print(
116
+ "Error: Mail operation timed out. Try reducing --limit or narrowing the date range.",
117
+ file=sys.stderr,
118
+ )
119
+ sys.exit(1)
120
+
121
+ if result.returncode != 0:
122
+ err = result.stderr.strip()
123
+ err_lower = err.lower().replace("\u2018", "'").replace("\u2019", "'")
124
+ if "not authorized" in err_lower:
125
+ msg = "Mail access denied. Grant access in System Settings > Privacy & Security > Automation."
126
+ elif "application isn't running" in err_lower:
127
+ msg = "Mail.app failed to launch. Try opening Mail.app manually and try again."
128
+ elif "can't get account" in err_lower:
129
+ msg = "Account not found. Run `mxctl accounts` to see available accounts."
130
+ elif "can't get mailbox" in err_lower:
131
+ msg = "Mailbox not found. Run `mxctl mailboxes` to see available mailboxes."
132
+ elif "can't get message" in err_lower:
133
+ msg = "Message not found — it may have been moved or deleted."
134
+ else:
135
+ msg = f"AppleScript error: {err}"
136
+ print(f"Error: {msg}", file=sys.stderr)
137
+ sys.exit(1)
138
+
139
+ return result.stdout.strip()
@@ -0,0 +1,185 @@
1
+ """Reusable AppleScript template functions to reduce duplication.
2
+
3
+ Common patterns extracted from command modules:
4
+ 1. Inbox iteration across all accounts
5
+ 2. Message iteration with cap/limit
6
+ 3. Single message lookup by ID
7
+ 4. Field output assembly with separators
8
+ 5. All-mailboxes iteration across one or all accounts
9
+
10
+ Each template function returns a complete AppleScript string.
11
+ Use FIELD_SEPARATOR from config.py in the templates.
12
+ """
13
+
14
+
15
+ def inbox_iterator_all_accounts(inner_operations: str, cap: int = 20, account: str | None = None) -> str:
16
+ """Generate AppleScript to iterate over INBOX in all enabled accounts (or one).
17
+
18
+ Args:
19
+ inner_operations: AppleScript code to execute for each INBOX message.
20
+ Available variables: m (message), acct (account),
21
+ acctName (account name), mbox (INBOX mailbox)
22
+ cap: Maximum number of messages per inbox
23
+ account: If provided, scope iteration to this single account name
24
+
25
+ Returns:
26
+ Complete AppleScript string
27
+
28
+ Example:
29
+ inner_ops = f'set output to output & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & linefeed'
30
+ script = inbox_iterator_all_accounts(inner_ops, cap=30)
31
+ script = inbox_iterator_all_accounts(inner_ops, cap=30, account="iCloud")
32
+ """
33
+ if account:
34
+ from mxctl.util.applescript import escape
35
+ acct_escaped = escape(account)
36
+ outer_open = f'set acct to account "{acct_escaped}"\n set acctName to name of acct'
37
+ outer_close = ""
38
+ else:
39
+ outer_open = (
40
+ "repeat with acct in (every account)\n"
41
+ " if enabled of acct then\n"
42
+ " set acctName to name of acct"
43
+ )
44
+ outer_close = " end if\n end repeat"
45
+
46
+ return f"""
47
+ tell application "Mail"
48
+ set output to ""
49
+ set totalFound to 0
50
+ {outer_open}
51
+ repeat with mbox in (mailboxes of acct)
52
+ if name of mbox is "INBOX" then
53
+ try
54
+ set unreadMsgs to (every message of mbox whose read status is false)
55
+ set cap to {cap}
56
+ if (count of unreadMsgs) < cap then set cap to (count of unreadMsgs)
57
+ repeat with j from 1 to cap
58
+ set m to item j of unreadMsgs
59
+ {inner_operations}
60
+ set totalFound to totalFound + 1
61
+ end repeat
62
+ end try
63
+ exit repeat
64
+ end if
65
+ end repeat
66
+ {outer_close}
67
+ return output
68
+ end tell
69
+ """
70
+
71
+
72
+ def set_message_property(
73
+ account_var: str,
74
+ mailbox_var: str,
75
+ message_id: int,
76
+ property_name: str,
77
+ property_value: str
78
+ ) -> str:
79
+ """Generate AppleScript to set a message property and return subject.
80
+
81
+ Args:
82
+ account_var: Variable name or escaped account name
83
+ mailbox_var: Variable name or escaped mailbox name
84
+ message_id: Message ID
85
+ property_name: Property to set (e.g., 'read status', 'flagged status')
86
+ property_value: Value to set (e.g., 'true', 'false')
87
+
88
+ Returns:
89
+ Complete AppleScript string
90
+
91
+ Example:
92
+ script = set_message_property(
93
+ '"iCloud"', '"INBOX"', 12345,
94
+ 'read status', 'true'
95
+ )
96
+ """
97
+ return f"""
98
+ tell application "Mail"
99
+ set mb to mailbox {mailbox_var} of account {account_var}
100
+ set theMsg to first message of mb whose id is {message_id}
101
+ set {property_name} of theMsg to {property_value}
102
+ return subject of theMsg
103
+ end tell
104
+ """
105
+
106
+
107
+ def mailbox_iterator(inner_operations: str, account: str | None = None) -> str:
108
+ """Generate AppleScript to iterate over every mailbox in one or all accounts.
109
+
110
+ Unlike inbox_iterator_all_accounts (which only looks at the INBOX mailbox),
111
+ this iterates over ALL mailboxes of the account(s). Useful for whole-account
112
+ scans such as flagged-message or attachment searches.
113
+
114
+ Args:
115
+ inner_operations: AppleScript code to execute inside each mailbox.
116
+ Available variables: mb (mailbox), acct (account)
117
+ account: If provided, scope iteration to this single (already-escaped)
118
+ account name string, e.g. ``escape(account)``
119
+
120
+ Returns:
121
+ Complete AppleScript string
122
+
123
+ Example:
124
+ inner_ops = (
125
+ 'set flaggedMsgs to (every message of mb whose flagged status is true)\\n'
126
+ 'repeat with m in flaggedMsgs\\n'
127
+ ' set output to output & (id of m) & linefeed\\n'
128
+ 'end repeat'
129
+ )
130
+ script = mailbox_iterator(inner_ops, account="iCloud")
131
+ script = mailbox_iterator(inner_ops) # all accounts
132
+ """
133
+ if account:
134
+ acct_block = f'set acct to account "{account}"\n repeat with mb in (every mailbox of acct)\n {inner_operations}\n end repeat'
135
+ else:
136
+ acct_block = (
137
+ "repeat with acct in (every account)\n"
138
+ " if enabled of acct then\n"
139
+ f" repeat with mb in (every mailbox of acct)\n"
140
+ f" {inner_operations}\n"
141
+ " end repeat\n"
142
+ " end if\n"
143
+ " end repeat"
144
+ )
145
+
146
+ return f"""
147
+ tell application "Mail"
148
+ set output to ""
149
+ {acct_block}
150
+ return output
151
+ end tell
152
+ """
153
+
154
+
155
+ def list_attachments(
156
+ account_var: str,
157
+ mailbox_var: str,
158
+ message_id: int
159
+ ) -> str:
160
+ """Generate AppleScript to list message attachments.
161
+
162
+ Args:
163
+ account_var: Variable name or escaped account name
164
+ mailbox_var: Variable name or escaped mailbox name
165
+ message_id: Message ID
166
+
167
+ Returns:
168
+ Complete AppleScript string (subject on first line, attachments below)
169
+
170
+ Example:
171
+ script = list_attachments('"iCloud"', '"INBOX"', 12345)
172
+ """
173
+ return f"""
174
+ tell application "Mail"
175
+ set mb to mailbox {mailbox_var} of account {account_var}
176
+ set theMsg to first message of mb whose id is {message_id}
177
+ set msgSubject to subject of theMsg
178
+ set output to msgSubject & linefeed
179
+ repeat with att in (mail attachments of theMsg)
180
+ set attName to name of att
181
+ set output to output & attName & linefeed
182
+ end repeat
183
+ return output
184
+ end tell
185
+ """
mxctl/util/dates.py ADDED
@@ -0,0 +1,54 @@
1
+ """Date parsing and conversion helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+ from mxctl.util.formatting import die
8
+
9
+
10
+ def parse_date(date_str: str) -> datetime:
11
+ """Parse YYYY-MM-DD string."""
12
+ try:
13
+ return datetime.strptime(date_str, "%Y-%m-%d")
14
+ except ValueError:
15
+ die(f"Invalid date format: '{date_str}'. Use YYYY-MM-DD (e.g. 2026-02-14).")
16
+
17
+
18
+ def to_applescript_date(dt: datetime) -> str:
19
+ """Convert datetime to AppleScript date string (e.g. 'January 15, 2026')."""
20
+ return dt.strftime("%B %d, %Y")
21
+
22
+
23
+ def days_ago(n: int) -> str:
24
+ """Return YYYY-MM-DD for N days ago."""
25
+ return (datetime.now() - timedelta(days=n)).strftime("%Y-%m-%d")
26
+
27
+
28
+ def today() -> str:
29
+ """Return today as YYYY-MM-DD."""
30
+ return datetime.now().strftime("%Y-%m-%d")
31
+
32
+
33
+ def parse_applescript_date(date_str: str) -> str:
34
+ """Parse AppleScript date to ISO 8601 format.
35
+
36
+ Converts dates like:
37
+ - "Tuesday, January 14, 2026 at 2:30:00 PM" → "2026-01-14T14:30:00"
38
+ - "January 14, 2026 at 2:30:00 PM" → "2026-01-14T14:30:00"
39
+
40
+ Returns original string if parsing fails (don't crash).
41
+ """
42
+ # Try with weekday first
43
+ for fmt in [
44
+ "%A, %B %d, %Y at %I:%M:%S %p", # With weekday
45
+ "%B %d, %Y at %I:%M:%S %p", # Without weekday
46
+ ]:
47
+ try:
48
+ dt = datetime.strptime(date_str, fmt)
49
+ return dt.strftime("%Y-%m-%dT%H:%M:%S")
50
+ except ValueError:
51
+ continue
52
+
53
+ # Return original if parsing fails
54
+ return date_str