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/__init__.py
ADDED
mxctl/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mail command modules."""
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Account and mailbox listing commands: inbox, accounts, mailboxes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from mxctl.config import CONFIG_FILE, FIELD_SEPARATOR, resolve_account, save_message_aliases
|
|
6
|
+
from mxctl.util.applescript import escape, run
|
|
7
|
+
from mxctl.util.formatting import format_output, truncate
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# inbox
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def cmd_inbox(args) -> None:
|
|
14
|
+
"""List unread counts and recent messages, optionally scoped to one account."""
|
|
15
|
+
# Use only the explicitly-passed -a flag, not the config default.
|
|
16
|
+
# resolve_account() would return the default account (e.g. iCloud) when no
|
|
17
|
+
# flag is given, causing inbox to show only one account instead of all.
|
|
18
|
+
account = getattr(args, "account", None)
|
|
19
|
+
|
|
20
|
+
if account:
|
|
21
|
+
acct_escaped = escape(account)
|
|
22
|
+
script = f"""
|
|
23
|
+
tell application "Mail"
|
|
24
|
+
set output to ""
|
|
25
|
+
set acct to account "{acct_escaped}"
|
|
26
|
+
set acctName to name of acct
|
|
27
|
+
repeat with mbox in (mailboxes of acct)
|
|
28
|
+
if name of mbox is "INBOX" then
|
|
29
|
+
try
|
|
30
|
+
set unreadCount to unread count of mbox
|
|
31
|
+
set totalCount to count of messages of mbox
|
|
32
|
+
set output to output & acctName & "{FIELD_SEPARATOR}" & unreadCount & "{FIELD_SEPARATOR}" & totalCount & linefeed
|
|
33
|
+
if unreadCount > 0 then
|
|
34
|
+
set unreadMsgs to (every message of mbox whose read status is false)
|
|
35
|
+
set previewCount to 3
|
|
36
|
+
if (count of unreadMsgs) < previewCount then set previewCount to count of unreadMsgs
|
|
37
|
+
repeat with j from 1 to previewCount
|
|
38
|
+
set m to item j of unreadMsgs
|
|
39
|
+
set output to output & "MSG" & "{FIELD_SEPARATOR}" & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed
|
|
40
|
+
end repeat
|
|
41
|
+
end if
|
|
42
|
+
end try
|
|
43
|
+
exit repeat
|
|
44
|
+
end if
|
|
45
|
+
end repeat
|
|
46
|
+
return output
|
|
47
|
+
end tell
|
|
48
|
+
"""
|
|
49
|
+
else:
|
|
50
|
+
script = f"""
|
|
51
|
+
tell application "Mail"
|
|
52
|
+
set output to ""
|
|
53
|
+
set acctList to every account
|
|
54
|
+
repeat with i from 1 to (count of acctList)
|
|
55
|
+
set acct to item i of acctList
|
|
56
|
+
set acctName to name of acct
|
|
57
|
+
set acctEnabled to enabled of acct
|
|
58
|
+
if acctEnabled then
|
|
59
|
+
repeat with mbox in (mailboxes of acct)
|
|
60
|
+
if name of mbox is "INBOX" then
|
|
61
|
+
try
|
|
62
|
+
set unreadCount to unread count of mbox
|
|
63
|
+
set totalCount to count of messages of mbox
|
|
64
|
+
set output to output & acctName & "{FIELD_SEPARATOR}" & unreadCount & "{FIELD_SEPARATOR}" & totalCount & linefeed
|
|
65
|
+
if unreadCount > 0 then
|
|
66
|
+
set unreadMsgs to (every message of mbox whose read status is false)
|
|
67
|
+
set previewCount to 3
|
|
68
|
+
if (count of unreadMsgs) < previewCount then set previewCount to count of unreadMsgs
|
|
69
|
+
repeat with j from 1 to previewCount
|
|
70
|
+
set m to item j of unreadMsgs
|
|
71
|
+
set output to output & "MSG" & "{FIELD_SEPARATOR}" & acctName & "{FIELD_SEPARATOR}" & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & linefeed
|
|
72
|
+
end repeat
|
|
73
|
+
end if
|
|
74
|
+
end try
|
|
75
|
+
exit repeat
|
|
76
|
+
end if
|
|
77
|
+
end repeat
|
|
78
|
+
end if
|
|
79
|
+
end repeat
|
|
80
|
+
return output
|
|
81
|
+
end tell
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
result = run(script)
|
|
85
|
+
|
|
86
|
+
if not result.strip():
|
|
87
|
+
if not os.path.isfile(CONFIG_FILE):
|
|
88
|
+
format_output(
|
|
89
|
+
args,
|
|
90
|
+
"No mail accounts found or no INBOX mailboxes available.\n"
|
|
91
|
+
"Run `mxctl init` to configure your default account.",
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
format_output(args, "No mail accounts found or no INBOX mailboxes available.")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Parse once into structured data
|
|
98
|
+
accounts = []
|
|
99
|
+
current = None
|
|
100
|
+
for line in result.strip().split("\n"):
|
|
101
|
+
if not line.strip():
|
|
102
|
+
continue
|
|
103
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
104
|
+
if parts[0] == "MSG" and len(parts) >= 6:
|
|
105
|
+
_, acct, msg_id, subject, sender, date = parts[:6]
|
|
106
|
+
if current:
|
|
107
|
+
current["recent_unread"].append({
|
|
108
|
+
"id": int(msg_id) if msg_id.isdigit() else msg_id,
|
|
109
|
+
"subject": subject,
|
|
110
|
+
"sender": sender,
|
|
111
|
+
"date": date,
|
|
112
|
+
})
|
|
113
|
+
elif len(parts) >= 3:
|
|
114
|
+
acct, unread, total = parts[:3]
|
|
115
|
+
current = {
|
|
116
|
+
"account": acct,
|
|
117
|
+
"unread": int(unread) if unread.isdigit() else 0,
|
|
118
|
+
"total": int(total) if total.isdigit() else 0,
|
|
119
|
+
"recent_unread": [],
|
|
120
|
+
}
|
|
121
|
+
accounts.append(current)
|
|
122
|
+
|
|
123
|
+
# Assign sequential aliases across all accounts
|
|
124
|
+
all_msg_ids = []
|
|
125
|
+
for acct_data in accounts:
|
|
126
|
+
for msg in acct_data["recent_unread"]:
|
|
127
|
+
all_msg_ids.append(msg["id"])
|
|
128
|
+
if all_msg_ids:
|
|
129
|
+
save_message_aliases(all_msg_ids)
|
|
130
|
+
alias_num = 0
|
|
131
|
+
for acct_data in accounts:
|
|
132
|
+
for msg in acct_data["recent_unread"]:
|
|
133
|
+
alias_num += 1
|
|
134
|
+
msg["alias"] = alias_num
|
|
135
|
+
|
|
136
|
+
# Build text from parsed data
|
|
137
|
+
text = "Inbox Summary\n" + "=" * 50
|
|
138
|
+
total_unread = 0
|
|
139
|
+
for acct_data in accounts:
|
|
140
|
+
total_unread += acct_data["unread"]
|
|
141
|
+
text += f"\n\n{acct_data['account']}:"
|
|
142
|
+
text += f"\n Unread: {acct_data['unread']} / Total: {acct_data['total']}"
|
|
143
|
+
if acct_data["unread"] > 0:
|
|
144
|
+
text += "\n Recent unread:"
|
|
145
|
+
for msg in acct_data["recent_unread"]:
|
|
146
|
+
text += f"\n [{msg['alias']}] {truncate(msg['subject'], 45)}"
|
|
147
|
+
text += f"\n From: {msg['sender']}"
|
|
148
|
+
text += f"\n\n{'=' * 50}"
|
|
149
|
+
text += f"\nTotal unread across all accounts: {total_unread}"
|
|
150
|
+
format_output(args, text, json_data=accounts)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# accounts
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def cmd_accounts(args) -> None:
|
|
158
|
+
"""List configured mail accounts."""
|
|
159
|
+
script = f"""
|
|
160
|
+
tell application "Mail"
|
|
161
|
+
set output to ""
|
|
162
|
+
repeat with acct in (every account)
|
|
163
|
+
set acctName to name of acct
|
|
164
|
+
set acctFullName to full name of acct
|
|
165
|
+
set acctEmail to user name of acct
|
|
166
|
+
set acctEnabled to enabled of acct
|
|
167
|
+
set output to output & acctName & "{FIELD_SEPARATOR}" & acctFullName & "{FIELD_SEPARATOR}" & acctEmail & "{FIELD_SEPARATOR}" & acctEnabled & linefeed
|
|
168
|
+
end repeat
|
|
169
|
+
return output
|
|
170
|
+
end tell
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
result = run(script)
|
|
174
|
+
|
|
175
|
+
if not result.strip():
|
|
176
|
+
format_output(args, "No mail accounts found.")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Parse once into structured data
|
|
180
|
+
accounts = []
|
|
181
|
+
for line in result.strip().split("\n"):
|
|
182
|
+
if not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
185
|
+
if len(parts) >= 4:
|
|
186
|
+
accounts.append({
|
|
187
|
+
"name": parts[0],
|
|
188
|
+
"full_name": parts[1],
|
|
189
|
+
"email": parts[2],
|
|
190
|
+
"enabled": parts[3].lower() == "true",
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
# Build text from parsed data
|
|
194
|
+
text = "Mail Accounts:"
|
|
195
|
+
for acct in accounts:
|
|
196
|
+
status = "enabled" if acct["enabled"] else "disabled"
|
|
197
|
+
text += f"\n- {acct['name']}\n Email: {acct['email']}\n Name: {acct['full_name']}\n Status: {status}"
|
|
198
|
+
format_output(args, text, json_data=accounts)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# mailboxes
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def cmd_mailboxes(args) -> None:
|
|
206
|
+
"""List mailboxes with unread counts."""
|
|
207
|
+
account = resolve_account(getattr(args, "account", None))
|
|
208
|
+
|
|
209
|
+
if account:
|
|
210
|
+
acct_escaped = escape(account)
|
|
211
|
+
script = f"""
|
|
212
|
+
tell application "Mail"
|
|
213
|
+
set acct to account "{acct_escaped}"
|
|
214
|
+
set output to ""
|
|
215
|
+
repeat with mb in (every mailbox of acct)
|
|
216
|
+
set mbName to name of mb
|
|
217
|
+
set mbUnread to unread count of mb
|
|
218
|
+
set output to output & mbName & "{FIELD_SEPARATOR}" & mbUnread & linefeed
|
|
219
|
+
end repeat
|
|
220
|
+
return output
|
|
221
|
+
end tell
|
|
222
|
+
"""
|
|
223
|
+
else:
|
|
224
|
+
script = f"""
|
|
225
|
+
tell application "Mail"
|
|
226
|
+
set output to ""
|
|
227
|
+
repeat with acct in (every account)
|
|
228
|
+
set acctName to name of acct
|
|
229
|
+
repeat with mb in (every mailbox of acct)
|
|
230
|
+
set mbName to name of mb
|
|
231
|
+
set mbUnread to unread count of mb
|
|
232
|
+
set output to output & acctName & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & mbUnread & linefeed
|
|
233
|
+
end repeat
|
|
234
|
+
end repeat
|
|
235
|
+
return output
|
|
236
|
+
end tell
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
result = run(script)
|
|
240
|
+
|
|
241
|
+
if not result.strip():
|
|
242
|
+
msg = f"No mailboxes found in account '{account}'." if account else "No mailboxes found."
|
|
243
|
+
format_output(args, msg)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Parse once into structured data
|
|
247
|
+
mailboxes = []
|
|
248
|
+
for line in result.strip().split("\n"):
|
|
249
|
+
if not line.strip():
|
|
250
|
+
continue
|
|
251
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
252
|
+
if account and len(parts) >= 2:
|
|
253
|
+
mailboxes.append({"name": parts[0], "unread": int(parts[1]) if parts[1].isdigit() else 0})
|
|
254
|
+
elif not account and len(parts) >= 3:
|
|
255
|
+
mailboxes.append({
|
|
256
|
+
"account": parts[0],
|
|
257
|
+
"name": parts[1],
|
|
258
|
+
"unread": int(parts[2]) if parts[2].isdigit() else 0,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
# Build text from parsed data
|
|
262
|
+
header = f"Mailboxes in {account}:" if account else "All Mailboxes:"
|
|
263
|
+
text = header
|
|
264
|
+
for mb in mailboxes:
|
|
265
|
+
unread_str = f" ({mb['unread']} unread)" if mb["unread"] > 0 else ""
|
|
266
|
+
if account:
|
|
267
|
+
text += f"\n- {mb['name']}{unread_str}"
|
|
268
|
+
else:
|
|
269
|
+
text += f"\n- {mb['name']}{unread_str} [{mb['account']}]"
|
|
270
|
+
format_output(args, text, json_data=mailboxes)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# count
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def cmd_count(args) -> None:
|
|
278
|
+
"""Print unread message count."""
|
|
279
|
+
account = resolve_account(getattr(args, "account", None))
|
|
280
|
+
mailbox = getattr(args, "mailbox", None)
|
|
281
|
+
|
|
282
|
+
if account:
|
|
283
|
+
acct_escaped = escape(account)
|
|
284
|
+
mb = mailbox or "INBOX"
|
|
285
|
+
mb_escaped = escape(mb)
|
|
286
|
+
script = f'''
|
|
287
|
+
tell application "Mail"
|
|
288
|
+
set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
|
|
289
|
+
return unread count of mb
|
|
290
|
+
end tell
|
|
291
|
+
'''
|
|
292
|
+
result = run(script)
|
|
293
|
+
count = int(result.strip()) if result.strip().isdigit() else 0
|
|
294
|
+
format_output(args, str(count),
|
|
295
|
+
json_data={"unread": count, "account": account, "mailbox": mb})
|
|
296
|
+
else:
|
|
297
|
+
script = '''
|
|
298
|
+
tell application "Mail"
|
|
299
|
+
set totalUnread to 0
|
|
300
|
+
repeat with acct in (every account)
|
|
301
|
+
if enabled of acct then
|
|
302
|
+
repeat with mbox in (mailboxes of acct)
|
|
303
|
+
if name of mbox is "INBOX" then
|
|
304
|
+
set totalUnread to totalUnread + (unread count of mbox)
|
|
305
|
+
exit repeat
|
|
306
|
+
end if
|
|
307
|
+
end repeat
|
|
308
|
+
end if
|
|
309
|
+
end repeat
|
|
310
|
+
return totalUnread
|
|
311
|
+
end tell
|
|
312
|
+
'''
|
|
313
|
+
result = run(script)
|
|
314
|
+
count = int(result.strip()) if result.strip().isdigit() else 0
|
|
315
|
+
format_output(args, str(count),
|
|
316
|
+
json_data={"unread": count, "account": "all"})
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Registration
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def register(subparsers) -> None:
|
|
324
|
+
"""Register account-related mail subcommands."""
|
|
325
|
+
# inbox
|
|
326
|
+
p = subparsers.add_parser("inbox", help="Unread counts + recent messages across all accounts")
|
|
327
|
+
p.add_argument("-a", "--account", help="Filter to a specific account")
|
|
328
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
329
|
+
p.set_defaults(func=cmd_inbox)
|
|
330
|
+
|
|
331
|
+
# accounts
|
|
332
|
+
p = subparsers.add_parser("accounts", help="List configured mail accounts")
|
|
333
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
334
|
+
p.set_defaults(func=cmd_accounts)
|
|
335
|
+
|
|
336
|
+
# mailboxes
|
|
337
|
+
p = subparsers.add_parser("mailboxes", help="List mailboxes with unread counts")
|
|
338
|
+
p.add_argument("-a", "--account", help="Filter to a specific account")
|
|
339
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
340
|
+
p.set_defaults(func=cmd_mailboxes)
|
|
341
|
+
|
|
342
|
+
# count
|
|
343
|
+
p = subparsers.add_parser("count", help="Unread message count (for scripting)")
|
|
344
|
+
p.add_argument("-a", "--account", help="Specific account")
|
|
345
|
+
p.add_argument("-m", "--mailbox", help="Specific mailbox (default: INBOX)")
|
|
346
|
+
p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
347
|
+
p.set_defaults(func=cmd_count)
|