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,145 @@
|
|
|
1
|
+
"""Email templates management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from mxctl.config import CONFIG_DIR, TEMPLATES_FILE, file_lock
|
|
7
|
+
from mxctl.util.formatting import die, format_output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_templates() -> dict:
|
|
11
|
+
"""Load templates from disk."""
|
|
12
|
+
if os.path.isfile(TEMPLATES_FILE):
|
|
13
|
+
with file_lock(TEMPLATES_FILE), open(TEMPLATES_FILE) as f:
|
|
14
|
+
try:
|
|
15
|
+
return json.load(f)
|
|
16
|
+
except (json.JSONDecodeError, OSError):
|
|
17
|
+
return {}
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _save_templates(templates: dict) -> None:
|
|
22
|
+
"""Save templates to disk."""
|
|
23
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
24
|
+
with file_lock(TEMPLATES_FILE), open(TEMPLATES_FILE, "w") as f:
|
|
25
|
+
json.dump(templates, f, indent=2)
|
|
26
|
+
os.chmod(TEMPLATES_FILE, 0o600)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_templates_list(args) -> None:
|
|
30
|
+
"""List all saved templates."""
|
|
31
|
+
templates = _load_templates()
|
|
32
|
+
|
|
33
|
+
if not templates:
|
|
34
|
+
format_output(args, "No templates saved.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Build JSON data
|
|
38
|
+
template_list = [
|
|
39
|
+
{"name": name, "subject": data.get("subject", ""), "body": data.get("body", "")}
|
|
40
|
+
for name, data in templates.items()
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Build text output
|
|
44
|
+
text = "Email Templates:"
|
|
45
|
+
for name, data in templates.items():
|
|
46
|
+
subject = data.get("subject", "")
|
|
47
|
+
body = data.get("body", "")
|
|
48
|
+
text += f"\n\n{name}:"
|
|
49
|
+
text += f"\n Subject: {subject}"
|
|
50
|
+
text += f"\n Body: {body[:80]}{'...' if len(body) > 80 else ''}"
|
|
51
|
+
|
|
52
|
+
format_output(args, text, json_data=template_list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_templates_create(args) -> None:
|
|
56
|
+
"""Create or update a template."""
|
|
57
|
+
name = args.name
|
|
58
|
+
templates = _load_templates()
|
|
59
|
+
|
|
60
|
+
# Check if interactive or flag-based
|
|
61
|
+
if args.subject is None or args.body is None:
|
|
62
|
+
# Interactive mode
|
|
63
|
+
print(f"Creating template '{name}'")
|
|
64
|
+
print("Enter subject (use {{original_subject}} as placeholder):")
|
|
65
|
+
subject = input("> ").strip()
|
|
66
|
+
print("Enter body:")
|
|
67
|
+
body = input("> ").strip()
|
|
68
|
+
else:
|
|
69
|
+
subject = args.subject
|
|
70
|
+
body = args.body
|
|
71
|
+
|
|
72
|
+
templates[name] = {"subject": subject, "body": body}
|
|
73
|
+
_save_templates(templates)
|
|
74
|
+
|
|
75
|
+
data = {"name": name, "subject": subject, "body": body}
|
|
76
|
+
text = f"Template '{name}' saved successfully!\n\nSubject: {subject}\nBody: {body}"
|
|
77
|
+
format_output(args, text, json_data=data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_templates_show(args) -> None:
|
|
81
|
+
"""Show a specific template."""
|
|
82
|
+
name = args.name
|
|
83
|
+
templates = _load_templates()
|
|
84
|
+
|
|
85
|
+
if name not in templates:
|
|
86
|
+
die(f"Template '{name}' not found. Use 'mxctl templates list' to see available templates.")
|
|
87
|
+
|
|
88
|
+
template = templates[name]
|
|
89
|
+
subject = template.get("subject", "")
|
|
90
|
+
body = template.get("body", "")
|
|
91
|
+
|
|
92
|
+
data = {"name": name, "subject": subject, "body": body}
|
|
93
|
+
text = f"Template: {name}\n\nSubject: {subject}\n\nBody:\n{body}"
|
|
94
|
+
format_output(args, text, json_data=data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_templates_delete(args) -> None:
|
|
98
|
+
"""Delete a template."""
|
|
99
|
+
name = args.name
|
|
100
|
+
templates = _load_templates()
|
|
101
|
+
|
|
102
|
+
if name not in templates:
|
|
103
|
+
die(f"Template '{name}' not found.")
|
|
104
|
+
|
|
105
|
+
del templates[name]
|
|
106
|
+
_save_templates(templates)
|
|
107
|
+
|
|
108
|
+
data = {"name": name, "deleted": True}
|
|
109
|
+
text = f"Template '{name}' deleted successfully."
|
|
110
|
+
format_output(args, text, json_data=data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def register(subparsers) -> None:
|
|
114
|
+
"""Register email templates subcommands."""
|
|
115
|
+
# Create templates subcommand group
|
|
116
|
+
templates_parser = subparsers.add_parser("templates", help="Manage email templates")
|
|
117
|
+
templates_sub = templates_parser.add_subparsers(dest="templates_command")
|
|
118
|
+
|
|
119
|
+
# list
|
|
120
|
+
p = templates_sub.add_parser("list", help="List all saved templates")
|
|
121
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
122
|
+
p.set_defaults(func=cmd_templates_list)
|
|
123
|
+
|
|
124
|
+
# create
|
|
125
|
+
p = templates_sub.add_parser("create", help="Create or update a template")
|
|
126
|
+
p.add_argument("name", help="Template name")
|
|
127
|
+
p.add_argument("--subject", help="Template subject (use {original_subject} as placeholder)")
|
|
128
|
+
p.add_argument("--body", help="Template body")
|
|
129
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
130
|
+
p.set_defaults(func=cmd_templates_create)
|
|
131
|
+
|
|
132
|
+
# show
|
|
133
|
+
p = templates_sub.add_parser("show", help="Show a specific template")
|
|
134
|
+
p.add_argument("name", help="Template name")
|
|
135
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
136
|
+
p.set_defaults(func=cmd_templates_show)
|
|
137
|
+
|
|
138
|
+
# delete
|
|
139
|
+
p = templates_sub.add_parser("delete", help="Delete a template")
|
|
140
|
+
p.add_argument("name", help="Template name")
|
|
141
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
142
|
+
p.set_defaults(func=cmd_templates_delete)
|
|
143
|
+
|
|
144
|
+
# If `mxctl templates` is run with no subcommand, show help
|
|
145
|
+
templates_parser.set_defaults(func=lambda _: templates_parser.print_help())
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Todoist integration: create tasks from emails."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import ssl
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from mxctl.config import (
|
|
9
|
+
APPLESCRIPT_TIMEOUT_SHORT,
|
|
10
|
+
FIELD_SEPARATOR,
|
|
11
|
+
get_config,
|
|
12
|
+
)
|
|
13
|
+
from mxctl.util.applescript import run, validate_msg_id
|
|
14
|
+
from mxctl.util.formatting import die, format_output
|
|
15
|
+
from mxctl.util.mail_helpers import resolve_message_context
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# to-todoist — create a Todoist task from an email
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def cmd_to_todoist(args) -> None:
|
|
22
|
+
"""Create a Todoist task from an email."""
|
|
23
|
+
account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
|
|
24
|
+
message_id = validate_msg_id(args.id)
|
|
25
|
+
project = getattr(args, "project", None)
|
|
26
|
+
priority = getattr(args, "priority", 1)
|
|
27
|
+
due = getattr(args, "due", None)
|
|
28
|
+
|
|
29
|
+
# Get Todoist API token from config
|
|
30
|
+
cfg = get_config()
|
|
31
|
+
token = cfg.get("todoist_api_token")
|
|
32
|
+
if not token:
|
|
33
|
+
die("Todoist API token not configured. Add 'todoist_api_token' to ~/.config/mxctl/config.json")
|
|
34
|
+
# Validate token format before making any network calls (prevents silent hangs
|
|
35
|
+
# caused by malformed auth headers or non-string token values)
|
|
36
|
+
if not isinstance(token, str) or not token.strip():
|
|
37
|
+
die("Todoist API token is invalid. Check 'todoist_api_token' in ~/.config/mxctl/config.json")
|
|
38
|
+
|
|
39
|
+
ssl_context = ssl.create_default_context(cafile="/etc/ssl/cert.pem")
|
|
40
|
+
|
|
41
|
+
# Read the email via AppleScript
|
|
42
|
+
script = f"""
|
|
43
|
+
tell application "Mail"
|
|
44
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
45
|
+
set theMsg to first message of mb whose id is {message_id}
|
|
46
|
+
set msgSubject to subject of theMsg
|
|
47
|
+
set msgSender to sender of theMsg
|
|
48
|
+
set msgDate to date received of theMsg
|
|
49
|
+
return msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & (msgDate as text)
|
|
50
|
+
end tell
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_SHORT)
|
|
54
|
+
parts = result.split(FIELD_SEPARATOR)
|
|
55
|
+
if len(parts) < 3:
|
|
56
|
+
die(f"Failed to read message {message_id}.")
|
|
57
|
+
|
|
58
|
+
subject, sender, date = parts[0], parts[1], parts[2]
|
|
59
|
+
|
|
60
|
+
# Resolve project name to ID if provided
|
|
61
|
+
project_id = None
|
|
62
|
+
if project:
|
|
63
|
+
projects_req = urllib.request.Request(
|
|
64
|
+
"https://api.todoist.com/api/v1/projects",
|
|
65
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
66
|
+
method="GET",
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(projects_req, context=ssl_context, timeout=APPLESCRIPT_TIMEOUT_SHORT) as resp:
|
|
70
|
+
projects_data = json.loads(resp.read().decode("utf-8"))
|
|
71
|
+
# API v1 returns paginated {"results": [...], "next_cursor": ...}
|
|
72
|
+
projects = projects_data.get("results", projects_data) if isinstance(projects_data, dict) else projects_data
|
|
73
|
+
match = next((p for p in projects if p.get("name", "").lower() == project.lower()), None)
|
|
74
|
+
if match is None:
|
|
75
|
+
die(f"Todoist project '{project}' not found. Check the name and try again.")
|
|
76
|
+
project_id = match["id"]
|
|
77
|
+
except (ssl.SSLError, ssl.CertificateError):
|
|
78
|
+
die("SSL certificate error. Try running: /usr/bin/python3 /Applications/Python*/Install\\ Certificates.command")
|
|
79
|
+
except urllib.error.HTTPError as e:
|
|
80
|
+
die(f"Todoist API error resolving project ({e.code}): {e.read().decode('utf-8')}")
|
|
81
|
+
except urllib.error.URLError as e:
|
|
82
|
+
die(f"Network error resolving project: {e.reason}")
|
|
83
|
+
except TimeoutError:
|
|
84
|
+
die(f"Todoist API timed out resolving project (>{APPLESCRIPT_TIMEOUT_SHORT}s). Check your network or try again.")
|
|
85
|
+
|
|
86
|
+
# Build Todoist task payload
|
|
87
|
+
task_data = {
|
|
88
|
+
"content": subject,
|
|
89
|
+
"description": f"From: {sender}\nDate: {date}\nMessage ID: {message_id}",
|
|
90
|
+
"priority": priority,
|
|
91
|
+
}
|
|
92
|
+
if project_id:
|
|
93
|
+
task_data["project_id"] = project_id
|
|
94
|
+
if due:
|
|
95
|
+
task_data["due_string"] = due
|
|
96
|
+
|
|
97
|
+
# Make request to Todoist API
|
|
98
|
+
url = "https://api.todoist.com/api/v1/tasks"
|
|
99
|
+
headers = {
|
|
100
|
+
"Authorization": f"Bearer {token}",
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
}
|
|
103
|
+
req = urllib.request.Request(
|
|
104
|
+
url,
|
|
105
|
+
data=json.dumps(task_data).encode("utf-8"),
|
|
106
|
+
headers=headers,
|
|
107
|
+
method="POST"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, context=ssl_context, timeout=APPLESCRIPT_TIMEOUT_SHORT) as response:
|
|
112
|
+
response_data = json.loads(response.read().decode("utf-8"))
|
|
113
|
+
task_url = response_data.get("url")
|
|
114
|
+
|
|
115
|
+
text = f"Created Todoist task: {subject}"
|
|
116
|
+
if task_url:
|
|
117
|
+
text += f"\nURL: {task_url}"
|
|
118
|
+
|
|
119
|
+
format_output(args, text, json_data=response_data)
|
|
120
|
+
except (ssl.SSLError, ssl.CertificateError):
|
|
121
|
+
die("SSL certificate error. Try running: /usr/bin/python3 /Applications/Python*/Install\\ Certificates.command")
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
error_body = e.read().decode("utf-8")
|
|
124
|
+
die(f"Todoist API error ({e.code}): {error_body}")
|
|
125
|
+
except urllib.error.URLError as e:
|
|
126
|
+
die(f"Network error: {e.reason}")
|
|
127
|
+
except TimeoutError:
|
|
128
|
+
die(f"Todoist API timed out creating task (>{APPLESCRIPT_TIMEOUT_SHORT}s). Check your network or try again.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Registration
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def register(subparsers) -> None:
|
|
136
|
+
# to-todoist
|
|
137
|
+
p = subparsers.add_parser("to-todoist", help="Create Todoist task from email")
|
|
138
|
+
p.add_argument("id", type=int, help="Message ID")
|
|
139
|
+
p.add_argument("-a", "--account", help="Mail account name")
|
|
140
|
+
p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
|
|
141
|
+
p.add_argument("--project", help="Todoist project name (resolves to project ID via API; task goes to Inbox if omitted)")
|
|
142
|
+
p.add_argument("--priority", type=int, choices=[1, 2, 3, 4], default=1, help="Priority (1-4, 4=highest)")
|
|
143
|
+
p.add_argument("--due", help="Due date (natural language, e.g. 'tomorrow')")
|
|
144
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
145
|
+
p.set_defaults(func=cmd_to_todoist)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Batch operation undo: undo, undo --list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from mxctl.config import (
|
|
10
|
+
APPLESCRIPT_TIMEOUT_LONG,
|
|
11
|
+
CONFIG_DIR,
|
|
12
|
+
UNDO_LOG_FILE,
|
|
13
|
+
file_lock,
|
|
14
|
+
)
|
|
15
|
+
from mxctl.util.applescript import escape, run
|
|
16
|
+
from mxctl.util.formatting import die, format_output
|
|
17
|
+
|
|
18
|
+
MAX_UNDO_OPERATIONS = 10
|
|
19
|
+
UNDO_MAX_AGE_MINUTES = 30
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _entry_age_minutes(entry: dict) -> float | None:
|
|
23
|
+
"""Return the age of an undo entry in minutes, or None if timestamp is missing/invalid."""
|
|
24
|
+
ts = entry.get("timestamp")
|
|
25
|
+
if not ts:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
entry_time = datetime.fromisoformat(ts)
|
|
29
|
+
return (datetime.now() - entry_time).total_seconds() / 60
|
|
30
|
+
except (ValueError, TypeError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_fresh(entry: dict) -> bool:
|
|
35
|
+
"""Return True if the undo entry is younger than UNDO_MAX_AGE_MINUTES."""
|
|
36
|
+
age = _entry_age_minutes(entry)
|
|
37
|
+
if age is None:
|
|
38
|
+
return False
|
|
39
|
+
return age < UNDO_MAX_AGE_MINUTES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_undo_log(include_stale: bool = False) -> list[dict]:
|
|
43
|
+
"""Load undo log from disk.
|
|
44
|
+
|
|
45
|
+
By default, only returns entries younger than UNDO_MAX_AGE_MINUTES.
|
|
46
|
+
Pass include_stale=True to load all entries regardless of age.
|
|
47
|
+
"""
|
|
48
|
+
if not os.path.isfile(UNDO_LOG_FILE):
|
|
49
|
+
return []
|
|
50
|
+
with file_lock(UNDO_LOG_FILE), open(UNDO_LOG_FILE) as f:
|
|
51
|
+
try:
|
|
52
|
+
raw = json.load(f)
|
|
53
|
+
except (json.JSONDecodeError, OSError):
|
|
54
|
+
return []
|
|
55
|
+
if include_stale:
|
|
56
|
+
return list(raw)
|
|
57
|
+
return [entry for entry in raw if _is_fresh(entry)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _save_undo_log(operations: list[dict]) -> None:
|
|
61
|
+
"""Save undo log to disk, keeping only the last MAX_UNDO_OPERATIONS."""
|
|
62
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
63
|
+
# Keep only the most recent operations
|
|
64
|
+
trimmed = operations[-MAX_UNDO_OPERATIONS:]
|
|
65
|
+
with file_lock(UNDO_LOG_FILE), open(UNDO_LOG_FILE, "w") as f:
|
|
66
|
+
json.dump(trimmed, f, indent=2)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def log_batch_operation(
|
|
70
|
+
operation_type: str,
|
|
71
|
+
account: str,
|
|
72
|
+
message_ids: list[int],
|
|
73
|
+
source_mailbox: str | None = None,
|
|
74
|
+
dest_mailbox: str | None = None,
|
|
75
|
+
sender: str | None = None,
|
|
76
|
+
older_than_days: int | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Log a batch operation for potential undo."""
|
|
79
|
+
operations = _load_undo_log()
|
|
80
|
+
operations.append({
|
|
81
|
+
"timestamp": datetime.now().isoformat(),
|
|
82
|
+
"operation": operation_type,
|
|
83
|
+
"account": account,
|
|
84
|
+
"message_ids": message_ids,
|
|
85
|
+
"source_mailbox": source_mailbox,
|
|
86
|
+
"dest_mailbox": dest_mailbox,
|
|
87
|
+
"sender": sender,
|
|
88
|
+
"older_than_days": older_than_days,
|
|
89
|
+
})
|
|
90
|
+
_save_undo_log(operations)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def log_fence_operation(operation_type: str) -> None:
|
|
94
|
+
"""Log a fence sentinel for operations that cannot be undone (e.g. batch-read, batch-flag).
|
|
95
|
+
|
|
96
|
+
This claims the undo slot so that a subsequent `mxctl undo` does not silently
|
|
97
|
+
skip past these operations and accidentally undo an earlier undoable entry.
|
|
98
|
+
"""
|
|
99
|
+
operations = _load_undo_log(include_stale=True)
|
|
100
|
+
operations.append({
|
|
101
|
+
"type": "fence",
|
|
102
|
+
"operation": operation_type,
|
|
103
|
+
"timestamp": datetime.now().isoformat(),
|
|
104
|
+
})
|
|
105
|
+
_save_undo_log(operations)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_undo_list(args) -> None:
|
|
109
|
+
"""List recent undoable operations."""
|
|
110
|
+
operations = _load_undo_log()
|
|
111
|
+
if not operations:
|
|
112
|
+
format_output(args, "No recent batch operations to undo.",
|
|
113
|
+
json_data={"operations": []})
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Build text output
|
|
117
|
+
text = f"Recent batch operations ({len(operations)}):"
|
|
118
|
+
for i, op in enumerate(reversed(operations), 1):
|
|
119
|
+
is_fence = op.get("type") == "fence"
|
|
120
|
+
prefix = "[no undo] " if is_fence else ""
|
|
121
|
+
ts = op.get("timestamp", "")
|
|
122
|
+
text += f"\n {i}. {prefix}{op['operation']} — {ts}"
|
|
123
|
+
if not is_fence:
|
|
124
|
+
if op.get("sender"):
|
|
125
|
+
text += f" from {op['sender']}"
|
|
126
|
+
if op.get("source_mailbox"):
|
|
127
|
+
text += f" from {op['source_mailbox']}"
|
|
128
|
+
if op.get("dest_mailbox"):
|
|
129
|
+
text += f" to {op['dest_mailbox']}"
|
|
130
|
+
if op.get("older_than_days"):
|
|
131
|
+
text += f" (older than {op['older_than_days']} days)"
|
|
132
|
+
text += f" ({len(op.get('message_ids', []))} messages)"
|
|
133
|
+
|
|
134
|
+
format_output(args, text, json_data={"operations": list(reversed(operations))})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_undo(args) -> None:
|
|
138
|
+
"""Undo the most recent batch operation."""
|
|
139
|
+
force = getattr(args, "force", False)
|
|
140
|
+
|
|
141
|
+
# Load all entries (including stale) so we can give a helpful message when
|
|
142
|
+
# there ARE entries but they're older than the freshness window.
|
|
143
|
+
all_ops = _load_undo_log(include_stale=True)
|
|
144
|
+
fresh_ops = [op for op in all_ops if _is_fresh(op)]
|
|
145
|
+
|
|
146
|
+
if not all_ops:
|
|
147
|
+
die("No recent batch operations to undo.")
|
|
148
|
+
|
|
149
|
+
if not fresh_ops and not force:
|
|
150
|
+
# There are entries but they're all stale — tell the user.
|
|
151
|
+
most_recent = all_ops[-1]
|
|
152
|
+
age = _entry_age_minutes(most_recent)
|
|
153
|
+
age_str = f"{int(age)} minutes ago" if age is not None else "unknown time ago"
|
|
154
|
+
die(
|
|
155
|
+
f"Nothing recent to undo (most recent operation was {age_str}). "
|
|
156
|
+
f"Run `mxctl undo --list` to see older operations and use "
|
|
157
|
+
f"`mxctl undo --force` to run them."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
operations = fresh_ops if not force else all_ops
|
|
161
|
+
if not operations:
|
|
162
|
+
die("No batch operations to undo.")
|
|
163
|
+
|
|
164
|
+
# Pop the most recent operation — do NOT write the log yet;
|
|
165
|
+
# only commit removal after the restore work succeeds.
|
|
166
|
+
last_op = operations.pop()
|
|
167
|
+
|
|
168
|
+
# Fence sentinel: operation was run but cannot be undone.
|
|
169
|
+
if last_op.get("type") == "fence":
|
|
170
|
+
op_name = last_op.get("operation", "unknown")
|
|
171
|
+
if not force:
|
|
172
|
+
die(
|
|
173
|
+
f"The most recent operation ({op_name}) cannot be undone. "
|
|
174
|
+
f"Use `mxctl undo --list` to see older undoable operations, "
|
|
175
|
+
f"or `mxctl undo --force` to skip to the next undoable entry."
|
|
176
|
+
)
|
|
177
|
+
# --force: pop the fence and continue to the next entry
|
|
178
|
+
if not operations:
|
|
179
|
+
die("No undoable operations remain after skipping the fence.")
|
|
180
|
+
last_op = operations.pop()
|
|
181
|
+
|
|
182
|
+
operation_type = last_op["operation"]
|
|
183
|
+
account = last_op["account"]
|
|
184
|
+
message_ids = last_op.get("message_ids", [])
|
|
185
|
+
|
|
186
|
+
if not message_ids:
|
|
187
|
+
die(f"No message IDs recorded for operation '{operation_type}'. Cannot undo.")
|
|
188
|
+
|
|
189
|
+
acct_escaped = escape(account)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
if operation_type == "batch-move":
|
|
193
|
+
# Reverse move: move messages back from dest
|
|
194
|
+
# Note: batch-move can pull from multiple source mailboxes, so we move back to INBOX as default
|
|
195
|
+
dest_mailbox = last_op.get("dest_mailbox")
|
|
196
|
+
if not dest_mailbox:
|
|
197
|
+
die("Incomplete operation data. Cannot undo batch-move.")
|
|
198
|
+
|
|
199
|
+
# Move messages back from dest to INBOX (safest default since source could be multiple mailboxes)
|
|
200
|
+
dest_escaped = escape(dest_mailbox)
|
|
201
|
+
inbox_escaped = escape("INBOX")
|
|
202
|
+
|
|
203
|
+
# Build AppleScript to move messages back
|
|
204
|
+
# We'll iterate through message_ids and try to move them
|
|
205
|
+
id_list = ", ".join(str(mid) for mid in message_ids)
|
|
206
|
+
|
|
207
|
+
script = f"""
|
|
208
|
+
tell application "Mail"
|
|
209
|
+
set acct to account "{acct_escaped}"
|
|
210
|
+
set destMb to mailbox "{dest_escaped}" of acct
|
|
211
|
+
set inboxMb to mailbox "{inbox_escaped}" of acct
|
|
212
|
+
set movedCount to 0
|
|
213
|
+
set targetIds to {{{id_list}}}
|
|
214
|
+
repeat with targetId in targetIds
|
|
215
|
+
try
|
|
216
|
+
set msgs to (every message of destMb whose id is targetId)
|
|
217
|
+
if (count of msgs) > 0 then
|
|
218
|
+
set m to item 1 of msgs
|
|
219
|
+
move m to inboxMb
|
|
220
|
+
set movedCount to movedCount + 1
|
|
221
|
+
end if
|
|
222
|
+
end try
|
|
223
|
+
end repeat
|
|
224
|
+
return movedCount
|
|
225
|
+
end tell
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
229
|
+
moved = int(result) if result.isdigit() else 0
|
|
230
|
+
sender = last_op.get("sender", "unknown sender")
|
|
231
|
+
_save_undo_log(operations) # commit removal only on success
|
|
232
|
+
total = len(message_ids)
|
|
233
|
+
if moved == 0:
|
|
234
|
+
msg = f"Nothing to restore (0 of {total} messages found — they may have already been moved or deleted)."
|
|
235
|
+
else:
|
|
236
|
+
msg = f"Undid batch-move: moved {moved}/{total} messages from '{sender}' back to INBOX from '{dest_mailbox}'."
|
|
237
|
+
format_output(args, msg,
|
|
238
|
+
json_data={
|
|
239
|
+
"operation": "undo-batch-move",
|
|
240
|
+
"account": account,
|
|
241
|
+
"from_mailbox": dest_mailbox,
|
|
242
|
+
"to_mailbox": "INBOX",
|
|
243
|
+
"sender": sender,
|
|
244
|
+
"restored": moved,
|
|
245
|
+
"total": len(message_ids),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
elif operation_type == "batch-delete":
|
|
249
|
+
# Reverse delete: move messages from Trash back to source_mailbox (or INBOX if unknown)
|
|
250
|
+
source_mailbox = last_op.get("source_mailbox")
|
|
251
|
+
restore_mailbox = source_mailbox if source_mailbox else "INBOX"
|
|
252
|
+
restore_note = None if source_mailbox else "Original mailbox unknown; restored to INBOX."
|
|
253
|
+
|
|
254
|
+
trash_escaped = escape("Trash")
|
|
255
|
+
restore_escaped = escape(restore_mailbox)
|
|
256
|
+
|
|
257
|
+
id_list = ", ".join(str(mid) for mid in message_ids)
|
|
258
|
+
|
|
259
|
+
script = f"""
|
|
260
|
+
tell application "Mail"
|
|
261
|
+
set acct to account "{acct_escaped}"
|
|
262
|
+
set trashMb to mailbox "{trash_escaped}" of acct
|
|
263
|
+
set restoreMb to mailbox "{restore_escaped}" of acct
|
|
264
|
+
set movedCount to 0
|
|
265
|
+
set targetIds to {{{id_list}}}
|
|
266
|
+
repeat with targetId in targetIds
|
|
267
|
+
try
|
|
268
|
+
set msgs to (every message of trashMb whose id is targetId)
|
|
269
|
+
if (count of msgs) > 0 then
|
|
270
|
+
set m to item 1 of msgs
|
|
271
|
+
move m to restoreMb
|
|
272
|
+
set movedCount to movedCount + 1
|
|
273
|
+
end if
|
|
274
|
+
end try
|
|
275
|
+
end repeat
|
|
276
|
+
return movedCount
|
|
277
|
+
end tell
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
result = run(script, timeout=APPLESCRIPT_TIMEOUT_LONG)
|
|
281
|
+
moved = int(result) if result.isdigit() else 0
|
|
282
|
+
sender = last_op.get("sender", "unknown sender")
|
|
283
|
+
total = len(message_ids)
|
|
284
|
+
if moved == 0:
|
|
285
|
+
msg = f"Nothing to restore (0 of {total} messages found — they may have already been moved or deleted)."
|
|
286
|
+
else:
|
|
287
|
+
msg = f"Undid batch-delete: moved {moved}/{total} messages from Trash back to '{restore_mailbox}'."
|
|
288
|
+
if restore_note and moved > 0:
|
|
289
|
+
msg += f" Note: {restore_note}"
|
|
290
|
+
json_result = {
|
|
291
|
+
"operation": "undo-batch-delete",
|
|
292
|
+
"account": account,
|
|
293
|
+
"from_mailbox": "Trash",
|
|
294
|
+
"to_mailbox": restore_mailbox,
|
|
295
|
+
"sender": sender,
|
|
296
|
+
"restored": moved,
|
|
297
|
+
"total": len(message_ids),
|
|
298
|
+
}
|
|
299
|
+
if restore_note:
|
|
300
|
+
json_result["note"] = restore_note
|
|
301
|
+
_save_undo_log(operations) # commit removal only on success
|
|
302
|
+
format_output(args, msg, json_data=json_result)
|
|
303
|
+
|
|
304
|
+
else:
|
|
305
|
+
die(f"Unknown operation type '{operation_type}'. Cannot undo.")
|
|
306
|
+
|
|
307
|
+
except (Exception, KeyboardInterrupt):
|
|
308
|
+
operations.append(last_op)
|
|
309
|
+
_save_undo_log(operations) # put it back
|
|
310
|
+
raise
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# Registration
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def register(subparsers) -> None:
|
|
318
|
+
"""Register undo mail subcommands."""
|
|
319
|
+
p = subparsers.add_parser("undo", help="Undo most recent batch operation")
|
|
320
|
+
p.add_argument("--list", action="store_true", dest="list_operations", help="List recent undoable operations")
|
|
321
|
+
p.add_argument("--force", action="store_true", help="Bypass the 30-minute freshness check")
|
|
322
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
323
|
+
p.set_defaults(func=lambda args: cmd_undo_list(args) if args.list_operations else cmd_undo(args))
|