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 ADDED
@@ -0,0 +1,3 @@
1
+ """mxctl: Apple Mail from your terminal."""
2
+
3
+ __version__ = "0.3.0"
mxctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m mxctl`."""
2
+
3
+ from mxctl.main import main
4
+
5
+ main()
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)