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