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,52 @@
1
+ """Output formatting helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import NoReturn
8
+
9
+
10
+ def truncate(s: str, max_length: int) -> str:
11
+ """Truncate a string with ellipsis."""
12
+ if not s or len(s) <= max_length:
13
+ return s or ""
14
+ return s[: max_length - 3] + "..."
15
+
16
+
17
+ def _convert_dates_with_keys(obj: object, key: str | None = None) -> object:
18
+ """Recursively convert AppleScript dates to ISO 8601, using key context."""
19
+ if isinstance(obj, dict):
20
+ return {k: _convert_dates_with_keys(v, k) for k, v in obj.items()}
21
+ elif isinstance(obj, list):
22
+ return [_convert_dates_with_keys(item, key) for item in obj]
23
+ elif isinstance(obj, str) and key and "date" in key.lower():
24
+ # Apply date conversion only for keys containing "date"
25
+ # Import here to avoid circular dependency
26
+ from mxctl.util.dates import parse_applescript_date
27
+
28
+ return parse_applescript_date(obj)
29
+ else:
30
+ return obj
31
+
32
+
33
+ def output(text: str, *, json_data: object = None, use_json: bool = False) -> None:
34
+ """Print text output, or JSON if --json was passed."""
35
+ if use_json and json_data is not None:
36
+ # Convert AppleScript dates to ISO 8601 before serializing
37
+ converted_data = _convert_dates_with_keys(json_data)
38
+ print(json.dumps(converted_data, indent=2, default=str))
39
+ else:
40
+ print(text)
41
+
42
+
43
+ def format_output(args: object, text: str, *, json_data: object = None) -> None:
44
+ """Extract use_json from args and call output(). DRY wrapper for commands."""
45
+ use_json = getattr(args, "json", False)
46
+ output(text, json_data=json_data, use_json=use_json)
47
+
48
+
49
+ def die(msg: str, code: int = 1) -> NoReturn:
50
+ """Print error and exit."""
51
+ print(f"Error: {msg}", file=sys.stderr)
52
+ sys.exit(code)
@@ -0,0 +1,192 @@
1
+ """Shared helper functions for mail commands to eliminate code duplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ from argparse import Namespace
9
+ from email.utils import parseaddr
10
+
11
+ from mxctl.config import CONFIG_FILE, DEFAULT_MAILBOX, get_gmail_accounts, resolve_account
12
+ from mxctl.util.applescript import escape
13
+ from mxctl.util.formatting import die
14
+
15
+ # Friendly name → Gmail IMAP folder name
16
+ GMAIL_MAILBOX_MAP: dict[str, str] = {
17
+ "trash": "[Gmail]/Trash",
18
+ "spam": "[Gmail]/Spam",
19
+ "junk": "[Gmail]/Spam",
20
+ "sent": "[Gmail]/Sent Mail",
21
+ "sent messages": "[Gmail]/Sent Mail",
22
+ "archive": "[Gmail]/All Mail",
23
+ "all mail": "[Gmail]/All Mail",
24
+ "drafts": "[Gmail]/Drafts",
25
+ "starred": "[Gmail]/Starred",
26
+ "important": "[Gmail]/Important",
27
+ }
28
+
29
+
30
+ def resolve_mailbox(account: str, mailbox: str) -> str:
31
+ """Translate friendly mailbox names to Gmail IMAP names when applicable.
32
+
33
+ If the account is configured as a Gmail account and the mailbox name
34
+ matches a known alias, returns the correct [Gmail]/... folder name.
35
+ Otherwise returns the mailbox unchanged.
36
+
37
+ Examples:
38
+ resolve_mailbox("ASU Gmail", "Spam") -> "[Gmail]/Spam"
39
+ resolve_mailbox("ASU Gmail", "Trash") -> "[Gmail]/Trash"
40
+ resolve_mailbox("iCloud", "Trash") -> "Trash"
41
+ resolve_mailbox("ASU Gmail", "[Gmail]/Spam") -> "[Gmail]/Spam" (passthrough)
42
+ resolve_mailbox("ASU Gmail", "INBOX") -> "INBOX" (passthrough)
43
+ """
44
+ if account not in get_gmail_accounts():
45
+ return mailbox
46
+ # Already a [Gmail]/... path or INBOX — pass through unchanged
47
+ if mailbox.startswith("[Gmail]/") or mailbox.upper() == "INBOX":
48
+ return mailbox
49
+ return GMAIL_MAILBOX_MAP.get(mailbox.lower(), mailbox)
50
+
51
+
52
+ def resolve_message_context(args: Namespace) -> tuple[str, str, str, str]:
53
+ """Resolve and escape account/mailbox from args.
54
+
55
+ Returns tuple: (account, mailbox, acct_escaped, mb_escaped)
56
+ Dies if account is not set.
57
+ """
58
+ account = resolve_account(getattr(args, "account", None))
59
+ if not account:
60
+ if not os.path.isfile(CONFIG_FILE):
61
+ die("No account configured. Run `mxctl init` to get started.")
62
+ else:
63
+ die("No default account set. Run `mxctl init` to configure one, or use -a ACCOUNT.")
64
+ if not os.path.isfile(CONFIG_FILE):
65
+ print(f"Note: No config file found. Using last-used account '{account}'. Run `mxctl init` to create a config.", file=sys.stderr)
66
+ mailbox = getattr(args, "mailbox", None) or DEFAULT_MAILBOX
67
+ mailbox = resolve_mailbox(account, mailbox)
68
+
69
+ acct_escaped = escape(account)
70
+ mb_escaped = escape(mailbox)
71
+
72
+ return account, mailbox, acct_escaped, mb_escaped
73
+
74
+
75
+ def parse_email_headers(raw: str) -> dict[str, str | list[str]]:
76
+ """Parse raw email headers into a dict (multi-value keys become lists)."""
77
+ headers: dict[str, str | list[str]] = {}
78
+ current_key: str | None = None
79
+ for line in raw.split("\n"):
80
+ if ": " in line and not line.startswith(" ") and not line.startswith("\t"):
81
+ key, _, val = line.partition(": ")
82
+ current_key = key
83
+ if key in headers:
84
+ if isinstance(headers[key], list):
85
+ headers[key].append(val)
86
+ else:
87
+ headers[key] = [headers[key], val]
88
+ else:
89
+ headers[key] = val
90
+ elif current_key and (line.startswith(" ") or line.startswith("\t")):
91
+ if isinstance(headers[current_key], list):
92
+ headers[current_key][-1] += " " + line.strip()
93
+ else:
94
+ headers[current_key] += " " + line.strip()
95
+ return headers
96
+
97
+
98
+ def extract_email(sender_str: str) -> str:
99
+ """Extract email address from sender string.
100
+
101
+ Examples:
102
+ "John Doe <john@example.com>" -> "john@example.com"
103
+ "jane@example.com" -> "jane@example.com"
104
+ "<admin@site.org>" -> "admin@site.org"
105
+ """
106
+ _, email = parseaddr(sender_str)
107
+ return email if email else sender_str
108
+
109
+
110
+ def extract_display_name(sender: str) -> str:
111
+ """Extract the display name from a sender string.
112
+
113
+ Examples:
114
+ '"John Doe" <john@example.com>' -> 'John Doe'
115
+ 'John Doe <john@example.com>' -> 'John Doe'
116
+ 'jane@example.com' -> 'jane@example.com'
117
+ """
118
+ if "<" in sender:
119
+ return sender.split("<")[0].strip().strip('"')
120
+ return sender
121
+
122
+
123
+ def parse_message_line(
124
+ line: str,
125
+ fields: list[str],
126
+ separator: str,
127
+ ) -> dict | None:
128
+ """Parse a single FIELD_SEPARATOR-delimited AppleScript output line into a dict.
129
+
130
+ Args:
131
+ line: A single raw output line from AppleScript.
132
+ fields: Ordered list of field names corresponding to each split part.
133
+ The last field name absorbs all remaining parts (useful when a field
134
+ like 'body' or 'content' may itself contain the separator).
135
+ separator: The field separator string (typically FIELD_SEPARATOR).
136
+
137
+ Returns:
138
+ A dict mapping field names to string values, or None if the line has
139
+ fewer parts than required (i.e. len(fields) fields).
140
+
141
+ Special coercions applied automatically:
142
+ - Fields named 'id' or ending in '_id': coerced to int when the value
143
+ is a digit string, otherwise kept as-is.
144
+ - Fields named 'read', 'flagged', 'junk', 'deleted', 'forwarded',
145
+ 'replied': coerced to bool (True when value lower() == 'true').
146
+
147
+ Example::
148
+ parse_message_line(line, ["id", "subject", "sender", "date"], SEP)
149
+ # -> {"id": 42, "subject": "Hello", "sender": "...", "date": "..."}
150
+ """
151
+ parts = line.split(separator)
152
+ if len(parts) < len(fields):
153
+ return None
154
+
155
+ _BOOL_FIELDS = {"read", "flagged", "junk", "deleted", "forwarded", "replied"}
156
+ result: dict = {}
157
+ for i, name in enumerate(fields):
158
+ if i == len(fields) - 1:
159
+ # Last field absorbs remaining parts
160
+ raw = separator.join(parts[i:])
161
+ else:
162
+ raw = parts[i]
163
+
164
+ if name == "id" or name.endswith("_id"):
165
+ result[name] = int(raw) if raw.isdigit() else raw
166
+ elif name in _BOOL_FIELDS:
167
+ result[name] = raw.lower() == "true"
168
+ else:
169
+ result[name] = raw
170
+
171
+ return result
172
+
173
+
174
+ def normalize_subject(subject: str) -> str:
175
+ """Normalize email subject by removing Re:/Fwd:/Fw:/AW:/SV:/VS: prefixes.
176
+
177
+ Handles international reply prefixes:
178
+ - Re: (English/most languages)
179
+ - Fwd/Fw: (English forward)
180
+ - AW: (German - Antwort)
181
+ - SV: (Swedish/Norwegian - Svar)
182
+ - VS: (Finnish - Vastaus)
183
+
184
+ Handles multiple nested prefixes like "Re: Re: Fwd: Original Subject".
185
+ """
186
+ # Loop to handle multiple nested prefixes
187
+ while True:
188
+ normalized = re.sub(r'^(Re|Fwd|Fw|AW|SV|VS):\s*', '', subject, flags=re.IGNORECASE).strip()
189
+ if normalized == subject:
190
+ break
191
+ subject = normalized
192
+ return subject
@@ -0,0 +1,439 @@
1
+ Metadata-Version: 2.4
2
+ Name: mxctl
3
+ Version: 0.3.0
4
+ Summary: Apple Mail from your terminal
5
+ Project-URL: Homepage, https://github.com/Jscoats/mxctl
6
+ Project-URL: Repository, https://github.com/Jscoats/mxctl
7
+ Project-URL: Issues, https://github.com/Jscoats/mxctl/issues
8
+ Project-URL: Changelog, https://github.com/Jscoats/mxctl/blob/main/CHANGELOG.md
9
+ Author: Jscoats
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: apple-mail,applescript,cli,email,macos,productivity,terminal
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Communications :: Email
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: pre-commit; extra == 'dev'
28
+ Requires-Dist: pytest; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: ruff; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # mxctl
34
+
35
+ [![CI](https://github.com/Jscoats/mxctl/actions/workflows/ci.yml/badge.svg)](https://github.com/Jscoats/mxctl/actions/workflows/ci.yml)
36
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
37
+ [![Coverage: 87%](https://img.shields.io/badge/coverage-87%25-yellowgreen.svg)](https://github.com/Jscoats/mxctl)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
39
+
40
+ > Apple Mail from your terminal.
41
+
42
+ **49 commands.** Triage with AI, batch-process newsletters, turn emails into Todoist tasks — all from the terminal. Every command supports `--json` for scripting and AI workflows. Zero external dependencies.
43
+
44
+ <p align="center">
45
+ <img src="demo/demo.gif" alt="mxctl demo — inbox, triage, summary, and batch operations" width="700">
46
+ </p>
47
+
48
+ ## Table of Contents
49
+
50
+ - [Key Features](#key-features)
51
+ - [Installation](#installation)
52
+ - [Quick Start](#quick-start)
53
+ - [Example Output](#example-output)
54
+ - [Command Categories](#command-categories)
55
+ - [Requirements](#requirements)
56
+ - [Usage Tips](#usage-tips)
57
+ - [Built for AI Workflows](#built-for-ai-workflows)
58
+ - [AI Demos](#ai-demos)
59
+ - [Architecture](#architecture)
60
+ - [Why Not X?](#why-not-x)
61
+ - [Contributing](#contributing)
62
+
63
+ ## Key Features
64
+
65
+ - **49 Commands** - Everything from basic operations to advanced batch processing
66
+ - **Any Account, One Interface** - iCloud, Gmail, Outlook, Exchange, IMAP -- whatever Mail.app has, this works with
67
+ - **Gmail Mailbox Translation** - Automatically maps standard names (`Trash`, `Spam`, `Sent`) to Gmail's `[Gmail]/...` paths
68
+ - **Built for AI Workflows** - Every command supports `--json` output designed for AI assistants to read and act on
69
+ - **Todoist Integration** - Turn any email into a task with `mxctl to-todoist` (project, priority, due date)
70
+ - **Batch Operations with Undo** - Process hundreds of emails safely with rollback support
71
+ - **Zero Dependencies** - Pure Python stdlib, no external packages required
72
+ - **Works with Your Existing Setup** - Doesn't replace Mail.app, extends it
73
+
74
+ ## Installation
75
+
76
+ ```bash
77
+ # Requires Python 3.10+ and macOS
78
+ pip install git+https://github.com/Jscoats/mxctl
79
+
80
+ # Or with uv (faster)
81
+ uv tool install git+https://github.com/Jscoats/mxctl
82
+ ```
83
+
84
+ Then run the setup wizard — it detects your Mail.app accounts, configures Gmail mailbox translation, and optionally connects Todoist:
85
+
86
+ <p align="center">
87
+ <img src="demo/init-demo.gif" alt="mxctl init — setup wizard detecting accounts, configuring Gmail, and connecting Todoist" width="700">
88
+ </p>
89
+
90
+ ## Quick Start
91
+
92
+ ```bash
93
+ # First time? Set up your default account
94
+ mxctl init
95
+
96
+ # See what's in your inbox
97
+ mxctl inbox
98
+
99
+ # Smart summary of unread emails (concise, one-liner per email)
100
+ mxctl summary
101
+
102
+ # Triage unread emails by urgency
103
+ mxctl triage
104
+
105
+ # Search for messages
106
+ mxctl search "project update" --sender
107
+
108
+ # Mark all unread as read (with undo support!)
109
+ mxctl batch-read -m INBOX
110
+
111
+ # Oops, undo that
112
+ mxctl undo
113
+
114
+ # Create email from template
115
+ mxctl draft --to colleague@company.com --template "weekly-update"
116
+
117
+ # Send email to Todoist as a task
118
+ mxctl to-todoist 123 --project Work
119
+ ```
120
+
121
+ ## Example Output
122
+
123
+ ### `mxctl inbox`
124
+ ```
125
+ Inbox Overview
126
+ ------------------------------------------
127
+ iCloud 3 unread (47 total)
128
+ Work Email 12 unread (203 total)
129
+ Johnny.Coats84@gmail.com 0 unread (18 total)
130
+ ------------------------------------------
131
+ Total 15 unread
132
+ ```
133
+
134
+ ### `mxctl triage`
135
+ ```
136
+ Triage -- 15 Unread Messages
137
+ ==========================================
138
+
139
+ [URGENT -- 2]
140
+ #4821 Sarah Johnson Re: Contract review deadline TODAY
141
+ #4819 boss@company.com Q4 budget approval needed
142
+
143
+ [PEOPLE -- 5]
144
+ #4820 mom@gmail.com Thanksgiving plans?
145
+ #4818 john.smith@work.com Project kickoff Thursday?
146
+ #4817 recruiter@corp.com Opportunity at TechCorp
147
+ #4815 friend@gmail.com Weekend hiking trip
148
+ #4814 alice@work.com Coffee catch-up?
149
+
150
+ [NOTIFICATIONS -- 8]
151
+ #4816 GitHub [mxctl] PR #12 merged
152
+ #4813 noreply@bank.com Statement available
153
+ ... and 6 more
154
+ ```
155
+
156
+ ### `mxctl summary`
157
+ ```
158
+ 15 unread -- iCloud + Work Email
159
+
160
+ * Contract review deadline TODAY -- Sarah Johnson (urgent, reply needed)
161
+ * Q4 budget approval -- boss@company.com (action required)
162
+ * Thanksgiving plans -- mom@gmail.com (personal, low urgency)
163
+ * Project kickoff Thursday -- john.smith@work.com (confirm attendance)
164
+ * PR #12 merged -- GitHub notification (no action needed)
165
+ * 10 more notifications and newsletters
166
+ ```
167
+
168
+ ## Command Categories
169
+
170
+ ### Setup
171
+ - `init` - First-time setup wizard (auto-detects Mail accounts, configures default account and optional Todoist token)
172
+
173
+ ### Account & Mailbox Management
174
+ - `inbox` - Overview of all accounts with unread counts
175
+ - `accounts` - List all mail accounts
176
+ - `mailboxes` - List mailboxes in an account
177
+ - `create-mailbox`, `delete-mailbox` - Manage folders
178
+ - `count` - Unread message count (for scripting and status bars)
179
+ - `empty-trash` - Empty trash with confirmation dialog
180
+
181
+ ### Message Operations
182
+ - `list` - List messages with filters (unread, date range)
183
+ - `read` - Display full message
184
+ - `search` - Find messages by subject/sender
185
+ - `mark-read`, `mark-unread`, `flag`, `unflag` - Message actions
186
+ - `move`, `delete` - Organize messages
187
+ - `junk`, `not-junk` - Mark as spam / restore from spam (moves to INBOX)
188
+ - `open` - Open message in Mail.app GUI
189
+ - `unsubscribe` - Unsubscribe from mailing lists via List-Unsubscribe header (supports one-click RFC 8058)
190
+ - `attachments`, `save-attachment` - Handle attachments
191
+
192
+ ### AI-Ready Features
193
+ - `summary` - Ultra-concise summaries optimized for AI assistants
194
+ - `triage` - Smart categorization by urgency (flagged -> people -> notifications)
195
+ - `context` - Thread messages with parent/child relationships
196
+ - `find-related` - Discover similar messages
197
+ - `process-inbox` - Diagnostic inbox categorization
198
+
199
+ ### Batch Operations
200
+ - `batch-read` - Mark all unread as read
201
+ - `batch-flag` - Flag all from sender
202
+ - `batch-move` - Move messages by sender
203
+ - `batch-delete` - Delete messages by sender and/or age
204
+ - `undo`, `undo --list` - Rollback batch operations
205
+
206
+ ### Analytics & Tools
207
+ - `stats` - Message statistics for timeframe
208
+ - `top-senders` - Most frequent senders
209
+ - `digest` - Grouped unread summary
210
+ - `show-flagged` - List flagged messages
211
+ - `weekly-review` - Past 7 days summary
212
+ - `clean-newsletters` - Archive/delete newsletter subscriptions
213
+
214
+ ### System
215
+ - `check` - Trigger Mail.app to fetch new mail
216
+ - `headers` - Full email header analysis (SPF, DKIM, DMARC, hop count, return path)
217
+ - `rules` - List, enable, or disable mail rules
218
+
219
+ ### Compose & Templates
220
+ - `draft` - Create email draft (supports templates)
221
+ - `templates list/create/show/delete` - Manage email templates
222
+ - `reply`, `forward` - Create response drafts
223
+ - `thread` - Show full conversation thread for a message
224
+
225
+ ### Integrations
226
+ - `to-todoist` - Send email to Todoist as task
227
+ - `export` - Export messages as markdown (use `--to` for destination path/directory)
228
+
229
+ ## Requirements
230
+
231
+ - **macOS 12 or later** (uses AppleScript to communicate with Mail.app)
232
+ - **Python 3.10+**
233
+ - **Mail.app** with at least one configured account
234
+ - **Permissions:** First run will prompt for Mail.app automation permission in System Settings
235
+ - **Note:** Mail.app will be launched automatically if it is not already running -- this is normal macOS/AppleScript behavior
236
+
237
+ ## Usage Tips
238
+
239
+ ### Multi-Account Support
240
+
241
+ Works with any combination of iCloud, Gmail, Outlook, Exchange, or custom IMAP accounts -- whatever you have configured in Mail.app.
242
+
243
+ ```bash
244
+ # Commands default to your primary account (set during init)
245
+ mxctl list
246
+
247
+ # Switch accounts with -a
248
+ mxctl list -a "Work Email"
249
+ mxctl list -a "Personal"
250
+
251
+ # Commands like inbox, summary, and triage scan ALL accounts automatically
252
+ mxctl inbox
253
+ ```
254
+
255
+ **Three-tier account resolution:** Commands use the first available: (1) explicit `-a` flag, (2) default account from config, (3) last-used account from state.
256
+
257
+ ### Gmail Mailbox Translation
258
+
259
+ Gmail uses non-standard mailbox names (`[Gmail]/Spam` instead of `Junk`, `[Gmail]/Sent Mail` instead of `Sent Messages`, etc.). If you tag your Gmail accounts during `mxctl init`, the CLI auto-translates standard names so you don't have to remember Gmail's conventions.
260
+
261
+ ```bash
262
+ # These just work -- no need to type [Gmail]/... paths
263
+ mxctl list -a "Work Gmail" -m Trash # -> [Gmail]/Trash
264
+ mxctl list -a "Work Gmail" -m Spam # -> [Gmail]/Spam
265
+ mxctl list -a "Work Gmail" -m Sent # -> [Gmail]/Sent Mail
266
+ mxctl list -a "Work Gmail" -m Archive # -> [Gmail]/All Mail
267
+
268
+ # iCloud and other accounts pass through unchanged
269
+ mxctl list -a "iCloud" -m Trash # -> Trash (no translation)
270
+ ```
271
+
272
+ Supported translations: `Trash`, `Spam`/`Junk`, `Sent`/`Sent Messages`, `Archive`/`All Mail`, `Drafts`, `Starred`, `Important`.
273
+
274
+ ### Todoist Integration
275
+
276
+ Turn any email into a Todoist task without leaving the terminal. The task includes the email subject, sender, and a link back to the message.
277
+
278
+ ```bash
279
+ # Set up during init, or add manually to ~/.config/mxctl/config.json
280
+ mxctl init # step 3 prompts for your Todoist API token
281
+
282
+ # Send an email to Todoist
283
+ mxctl to-todoist 123
284
+
285
+ # With project and priority
286
+ mxctl to-todoist 123 --project "Work" --priority 3
287
+
288
+ # With a due date (natural language)
289
+ mxctl to-todoist 123 --due "next Monday"
290
+ ```
291
+
292
+ To get your token: [Todoist Settings -> Integrations -> Developer](https://todoist.com/prefs/integrations)
293
+
294
+ ### Short Message Aliases
295
+
296
+ Listing commands assign short numbers starting from `[1]` -- no more copying 5-digit IDs:
297
+
298
+ ```bash
299
+ mxctl list # Shows [1], [2], [3]...
300
+ mxctl read 1 # Read message [1]
301
+ mxctl flag 2 # Flag message [2]
302
+ mxctl move 3 --to Archive # Move message [3]
303
+ ```
304
+
305
+ Aliases update each time you run a listing command (`list`, `inbox`, `search`, `triage`, `summary`, etc.). Full message IDs still work if you prefer them. JSON output includes both `id` (real) and `alias` (short number).
306
+
307
+ ### JSON Output for Automation
308
+ ```bash
309
+ # Every command supports --json
310
+ mxctl inbox --json | jq '.accounts[0].unread_count'
311
+ mxctl search "invoice" --json | jq '.[].subject'
312
+ ```
313
+
314
+ ### Export Messages
315
+ ```bash
316
+ # Export a single message
317
+ mxctl export 123 --to ~/Documents/mail/ -a "iCloud"
318
+
319
+ # Bulk export all messages in a mailbox
320
+ mxctl export "Work" --to ~/Documents/mail/ -a "Work Email"
321
+
322
+ # Bulk export messages after a date
323
+ mxctl export "INBOX" --to ~/Documents/mail/ -a "iCloud" --after 2026-01-01
324
+ ```
325
+
326
+ Note: The destination flag is `--to` (not `--dest`).
327
+
328
+ ### Email Templates
329
+ ```bash
330
+ # Create a template
331
+ mxctl templates create "meeting-followup" \
332
+ --subject "Re: {original_subject}" \
333
+ --body "Thanks for the meeting today..."
334
+
335
+ # Use it
336
+ mxctl draft --to client@company.com --template "meeting-followup"
337
+ ```
338
+
339
+ ## Built for AI Workflows
340
+
341
+ Every command supports `--json`, making your inbox data available to any AI assistant. Commands like `summary`, `triage`, and `context` are specifically designed to give AI a structured understanding of your inbox in seconds.
342
+
343
+ ### With Claude Code
344
+ ```bash
345
+ # Just ask Claude to check your mail
346
+ "Run mxctl triage and tell me what's urgent"
347
+ "Summarize my unread mail and create Todoist tasks for anything that needs action"
348
+ ```
349
+
350
+ ### With any AI tool
351
+ ```bash
352
+ # Pipe structured data to any LLM CLI
353
+ mxctl summary --json | llm "What needs my attention?"
354
+
355
+ # Feed triage results to AI for prioritization
356
+ mxctl triage --json | llm "Draft responses for the urgent items"
357
+ ```
358
+
359
+ ### For scripting and automation
360
+ ```bash
361
+ # Unread count for your status bar
362
+ mxctl count
363
+
364
+ # Export to JSON for any workflow
365
+ mxctl inbox --json | jq '.accounts[].unread_count'
366
+ ```
367
+
368
+ The CLI is the bridge between Mail.app and whatever tools you use -- AI, scripts, or both.
369
+
370
+ ## AI Demos
371
+
372
+ These demos show how an AI assistant (like Claude Code) uses mxctl to manage your inbox conversationally. You say what you want in plain English, and the AI picks the right commands, checks before acting, and reports back.
373
+
374
+ ### Inbox triage and drafting
375
+
376
+ The AI triages your inbox, marks newsletters as read, flags important messages for follow-up, and drafts a reply to your mom -- all from a single request.
377
+
378
+ <p align="center">
379
+ <img src="demo/ai-demo.gif" alt="AI assistant triaging inbox, flagging messages, and drafting a reply" width="700">
380
+ </p>
381
+
382
+ ### Bulk sender cleanup
383
+
384
+ The AI finds your noisiest senders, dry-runs the deletes so you can see what would be removed, then cleans up 60 marketing emails in seconds. Everything is undoable with `mxctl undo`.
385
+
386
+ <p align="center">
387
+ <img src="demo/batch-delete-demo.gif" alt="AI assistant finding top spammy senders and batch-deleting 60 messages" width="700">
388
+ </p>
389
+
390
+ ### Newsletter unsubscribe
391
+
392
+ The AI analyzes which newsletters you actually read vs. ignore, then unsubscribes from the ones with 0% open rate while leaving the ones you engage with. One-click unsubscribe when the header supports it, browser fallback when it doesn't.
393
+
394
+ <p align="center">
395
+ <img src="demo/unsubscribe-demo.gif" alt="AI assistant analyzing newsletter read rates and unsubscribing from unread ones" width="700">
396
+ </p>
397
+
398
+ ## Architecture
399
+
400
+ Built with modern Python patterns:
401
+ - **Zero runtime dependencies** (stdlib only)
402
+ - **Comprehensive test suite** (422 tests)
403
+ - **Modular command structure** (16 focused modules)
404
+ - **AppleScript bridge** for Mail.app communication
405
+ - **Three-tier account resolution** (explicit flag -> config default -> last-used)
406
+
407
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed architecture documentation.
408
+
409
+ ## Why Not X?
410
+
411
+ **Why not mutt or neomutt?**
412
+ Mutt replaces Mail.app -- you lose native macOS notifications, calendar event detection, FaceTime/iMessage continuity, and Rules. This CLI *extends* Mail.app rather than replacing it: your mail is still managed natively, but now also scriptable from the terminal.
413
+
414
+ **Why not the Gmail API or Outlook API?**
415
+ Those are per-provider -- separate SDKs, separate auth flows, separate data models. `mxctl` works with any account configured in Mail.app (iCloud, Gmail, Outlook, Exchange, custom IMAP) through a single unified interface. Add a new account to Mail.app and it just works.
416
+
417
+ **Why not raw AppleScript or Hammerspoon?**
418
+ You could wire this up yourself -- but this gives you 49 structured commands with `--json` output, batch operations with undo, template support, Todoist integration, and an AI-ready interface, all without writing a single line of AppleScript. The hard parts (field parsing, timeout handling, account resolution, error recovery) are already done.
419
+
420
+ ## Contributing
421
+
422
+ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
423
+
424
+ ## License
425
+
426
+ MIT License - see [LICENSE](LICENSE) file for details.
427
+
428
+ ## Acknowledgments
429
+
430
+ Built to automate email workflows without leaving the terminal.
431
+
432
+ ## Contact
433
+
434
+ - **GitHub:** [@Jscoats](https://github.com/Jscoats)
435
+ - **Issues:** [Report bugs or request features](https://github.com/Jscoats/mxctl/issues)
436
+
437
+ ---
438
+
439
+ **Like this project?** Star it on GitHub and share it with fellow terminal enthusiasts!