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