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/util/formatting.py
ADDED
|
@@ -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
|
+
[](https://github.com/Jscoats/mxctl/actions/workflows/ci.yml)
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://github.com/Jscoats/mxctl)
|
|
38
|
+
[](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!
|