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,363 @@
1
+ """Message listing and reading commands: list, read, search."""
2
+
3
+ from datetime import timedelta
4
+
5
+ from mxctl.config import (
6
+ DEFAULT_BODY_LENGTH,
7
+ DEFAULT_MESSAGE_LIMIT,
8
+ FIELD_SEPARATOR,
9
+ resolve_account,
10
+ save_message_aliases,
11
+ validate_limit,
12
+ )
13
+ from mxctl.util.applescript import escape, run, validate_msg_id
14
+ from mxctl.util.dates import parse_date, to_applescript_date
15
+ from mxctl.util.formatting import format_output, truncate
16
+ from mxctl.util.mail_helpers import parse_message_line, resolve_mailbox, resolve_message_context
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # list
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def cmd_list(args) -> None:
23
+ """List messages in a mailbox with optional filtering."""
24
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
25
+ limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT))
26
+ unread_only = getattr(args, "unread", False)
27
+ after = getattr(args, "after", None)
28
+ before = getattr(args, "before", None)
29
+
30
+ filters = []
31
+ if unread_only:
32
+ filters.append("read status is false")
33
+ if after:
34
+ start_dt = parse_date(after)
35
+ filters.append(f'date received >= date "{to_applescript_date(start_dt)}"')
36
+ if before:
37
+ end_dt = parse_date(before) + timedelta(days=1)
38
+ filters.append(f'date received < date "{to_applescript_date(end_dt)}"')
39
+
40
+ filter_clause = " and ".join(filters) if filters else ""
41
+ whose_clause = f"whose {filter_clause}" if filter_clause else ""
42
+
43
+ script = f"""
44
+ tell application "Mail"
45
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
46
+ set allMsgs to (every message of mb {whose_clause})
47
+ set msgCount to count of allMsgs
48
+ set actualLimit to {limit}
49
+ if msgCount < actualLimit then set actualLimit to msgCount
50
+ if actualLimit = 0 then return ""
51
+ set output to ""
52
+ repeat with i from 1 to actualLimit
53
+ set m to item i of allMsgs
54
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (read status of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & linefeed
55
+ end repeat
56
+ return output
57
+ end tell
58
+ """
59
+
60
+ result = run(script)
61
+
62
+ if not result.strip():
63
+ filter_desc = []
64
+ if unread_only:
65
+ filter_desc.append("unread")
66
+ if after:
67
+ filter_desc.append(f"from {after}")
68
+ if before:
69
+ filter_desc.append(f"to {before}")
70
+ filter_str = f" ({', '.join(filter_desc)})" if filter_desc else ""
71
+ format_output(args, f"No messages found in {mailbox}{filter_str}.")
72
+ return
73
+
74
+ # Build JSON data and text output
75
+ messages = []
76
+ for line in result.strip().split("\n"):
77
+ if not line.strip():
78
+ continue
79
+ msg = parse_message_line(line, ["id", "subject", "sender", "date", "read", "flagged"], FIELD_SEPARATOR)
80
+ if msg is not None:
81
+ messages.append(msg)
82
+
83
+ save_message_aliases([m["id"] for m in messages])
84
+ for i, m in enumerate(messages, 1):
85
+ m["alias"] = i
86
+
87
+ text = f"Messages in {mailbox} [{account}] (showing up to {limit}):"
88
+ for m in messages:
89
+ status_icons = []
90
+ if not m["read"]:
91
+ status_icons.append("UNREAD")
92
+ if m["flagged"]:
93
+ status_icons.append("FLAGGED")
94
+ status_str = f" [{', '.join(status_icons)}]" if status_icons else ""
95
+ text += f"\n- [{m['alias']}] {truncate(m['subject'], 60)}{status_str}"
96
+ text += f"\n From: {m['sender']}"
97
+ text += f"\n Date: {m['date']}"
98
+ format_output(args, text, json_data=messages)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # read
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def cmd_read(args) -> None:
106
+ """Read full message details including headers and body."""
107
+ account, mailbox, acct_escaped, mb_escaped = resolve_message_context(args)
108
+ message_id = validate_msg_id(args.id)
109
+ short = getattr(args, "short", False)
110
+ body_limit = DEFAULT_BODY_LENGTH if not short else 500
111
+
112
+ script = f"""
113
+ tell application "Mail"
114
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
115
+ set theMsg to first message of mb whose id is {message_id}
116
+
117
+ set msgId to id of theMsg
118
+ set msgMessageId to message id of theMsg
119
+ set msgSubject to subject of theMsg
120
+ set msgSender to sender of theMsg
121
+ set msgDate to date received of theMsg
122
+ set msgRead to read status of theMsg
123
+ set msgFlagged to flagged status of theMsg
124
+ set msgJunk to junk mail status of theMsg
125
+ set msgDeleted to deleted status of theMsg
126
+ set msgForwarded to was forwarded of theMsg
127
+ set msgReplied to was replied to of theMsg
128
+
129
+ set toList to ""
130
+ repeat with r in (to recipients of theMsg)
131
+ set toList to toList & (address of r) & ","
132
+ end repeat
133
+
134
+ set ccList to ""
135
+ repeat with r in (cc recipients of theMsg)
136
+ set ccList to ccList & (address of r) & ","
137
+ end repeat
138
+
139
+ try
140
+ set msgReplyTo to reply to of theMsg
141
+ on error
142
+ set msgReplyTo to ""
143
+ end try
144
+
145
+ set msgContent to content of theMsg
146
+ set attCount to count of mail attachments of theMsg
147
+
148
+ return (msgId as text) & "{FIELD_SEPARATOR}" & msgMessageId & "{FIELD_SEPARATOR}" & msgSubject & "{FIELD_SEPARATOR}" & msgSender & "{FIELD_SEPARATOR}" & (msgDate as text) & "{FIELD_SEPARATOR}" & (msgRead as text) & "{FIELD_SEPARATOR}" & (msgFlagged as text) & "{FIELD_SEPARATOR}" & (msgJunk as text) & "{FIELD_SEPARATOR}" & (msgDeleted as text) & "{FIELD_SEPARATOR}" & (msgForwarded as text) & "{FIELD_SEPARATOR}" & (msgReplied as text) & "{FIELD_SEPARATOR}" & toList & "{FIELD_SEPARATOR}" & ccList & "{FIELD_SEPARATOR}" & msgReplyTo & "{FIELD_SEPARATOR}" & msgContent & "{FIELD_SEPARATOR}" & (attCount as text)
149
+ end tell
150
+ """
151
+
152
+ result = run(script)
153
+ parts = result.split(FIELD_SEPARATOR)
154
+
155
+ if len(parts) < 16:
156
+ format_output(args, f"Message details: {result}")
157
+ return
158
+
159
+ (
160
+ msg_id, message_id_header, subject, sender, date,
161
+ read, flagged, junk, deleted, forwarded, replied,
162
+ to_list, cc_list, reply_to, content, att_count,
163
+ ) = parts[:16]
164
+
165
+ # U+FFFC (object replacement character) appears where HTML emails embed
166
+ # inline images. Replace with a readable placeholder.
167
+ content = content.replace("\ufffc", "[image]")
168
+
169
+ # Build JSON data
170
+ data = {
171
+ "id": int(msg_id) if msg_id.isdigit() else msg_id,
172
+ "message_id": message_id_header,
173
+ "account": account,
174
+ "mailbox": mailbox,
175
+ "subject": subject,
176
+ "from": sender,
177
+ "to": [a.strip() for a in to_list.rstrip(",").split(",") if a.strip()],
178
+ "cc": [a.strip() for a in cc_list.rstrip(",").split(",") if a.strip()],
179
+ "reply_to": reply_to or None,
180
+ "date": date,
181
+ "read": read.lower() == "true",
182
+ "flagged": flagged.lower() == "true",
183
+ "junk": junk.lower() == "true",
184
+ "deleted": deleted.lower() == "true",
185
+ "forwarded": forwarded.lower() == "true",
186
+ "replied": replied.lower() == "true",
187
+ "attachments": int(att_count) if att_count.isdigit() else 0,
188
+ "body": truncate(content, body_limit),
189
+ }
190
+
191
+ # Build text output
192
+ text = f"Message Details:\nID: {msg_id}\nMessage-ID: {message_id_header}"
193
+ text += f"\nAccount: {account}\nMailbox: {mailbox}"
194
+ text += f"\n\nSubject: {subject}\nFrom: {sender}\nTo: {to_list.rstrip(',')}"
195
+ if cc_list.strip(","):
196
+ text += f"\nCC: {cc_list.rstrip(',')}"
197
+ if reply_to:
198
+ text += f"\nReply-To: {reply_to}"
199
+ text += f"\nDate: {date}"
200
+ text += "\n\nStatus:"
201
+ text += f"\n Read: {read} Flagged: {flagged} Junk: {junk}"
202
+ text += f"\n Forwarded: {forwarded} Replied: {replied}"
203
+ text += f"\n\nAttachments: {att_count}"
204
+ text += f"\n\n--- Body ---\n{truncate(content, body_limit)}"
205
+ format_output(args, text, json_data=data)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # search
210
+ # ---------------------------------------------------------------------------
211
+
212
+ def cmd_search(args) -> None:
213
+ """Search messages by subject or sender."""
214
+ query = args.query
215
+ field = "sender" if getattr(args, "sender", False) else "subject"
216
+ account = resolve_account(getattr(args, "account", None))
217
+ mailbox = getattr(args, "mailbox", None)
218
+ limit = validate_limit(getattr(args, "limit", DEFAULT_MESSAGE_LIMIT))
219
+
220
+ query_escaped = escape(query)
221
+ if mailbox and account:
222
+ mailbox = resolve_mailbox(account, mailbox)
223
+
224
+ if mailbox and account:
225
+ acct_escaped = escape(account)
226
+ mb_escaped = escape(mailbox)
227
+ script = f"""
228
+ tell application "Mail"
229
+ set mb to mailbox "{mb_escaped}" of account "{acct_escaped}"
230
+ set searchResults to (every message of mb whose {field} contains "{query_escaped}")
231
+ set resultCount to count of searchResults
232
+ set actualLimit to {limit}
233
+ if resultCount < actualLimit then set actualLimit to resultCount
234
+ if actualLimit = 0 then return ""
235
+ set output to ""
236
+ repeat with i from 1 to actualLimit
237
+ set m to item i of searchResults
238
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (read status of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & "{FIELD_SEPARATOR}" & "{mb_escaped}" & "{FIELD_SEPARATOR}" & "{acct_escaped}" & linefeed
239
+ end repeat
240
+ return output
241
+ end tell
242
+ """
243
+ elif account:
244
+ acct_escaped = escape(account)
245
+ script = f"""
246
+ tell application "Mail"
247
+ set acct to account "{acct_escaped}"
248
+ set output to ""
249
+ set totalFound to 0
250
+ repeat with mb in (every mailbox of acct)
251
+ if totalFound >= {limit} then exit repeat
252
+ set mbName to name of mb
253
+ set searchResults to (every message of mb whose {field} contains "{query_escaped}")
254
+ repeat with m in searchResults
255
+ if totalFound >= {limit} then exit repeat
256
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (read status of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & "{acct_escaped}" & linefeed
257
+ set totalFound to totalFound + 1
258
+ end repeat
259
+ end repeat
260
+ return output
261
+ end tell
262
+ """
263
+ else:
264
+ script = f"""
265
+ tell application "Mail"
266
+ set output to ""
267
+ set totalFound to 0
268
+ repeat with acct in (every account)
269
+ if totalFound >= {limit} then exit repeat
270
+ set acctName to name of acct
271
+ repeat with mb in (every mailbox of acct)
272
+ if totalFound >= {limit} then exit repeat
273
+ set mbName to name of mb
274
+ set searchResults to (every message of mb whose {field} contains "{query_escaped}")
275
+ repeat with m in searchResults
276
+ if totalFound >= {limit} then exit repeat
277
+ set output to output & (id of m) & "{FIELD_SEPARATOR}" & (subject of m) & "{FIELD_SEPARATOR}" & (sender of m) & "{FIELD_SEPARATOR}" & (date received of m) & "{FIELD_SEPARATOR}" & (read status of m) & "{FIELD_SEPARATOR}" & (flagged status of m) & "{FIELD_SEPARATOR}" & mbName & "{FIELD_SEPARATOR}" & acctName & linefeed
278
+ set totalFound to totalFound + 1
279
+ end repeat
280
+ end repeat
281
+ end repeat
282
+ return output
283
+ end tell
284
+ """
285
+
286
+ result = run(script)
287
+
288
+ if not result.strip():
289
+ scope = ""
290
+ if mailbox and account:
291
+ scope = f" in {mailbox} [{account}]"
292
+ elif account:
293
+ scope = f" in {account}"
294
+ format_output(args, f"No messages found matching '{query}' in {field}{scope}.")
295
+ return
296
+
297
+ # Build JSON data and text output
298
+ messages = []
299
+ for line in result.strip().split("\n"):
300
+ if not line.strip():
301
+ continue
302
+ msg = parse_message_line(
303
+ line,
304
+ ["id", "subject", "sender", "date", "read", "flagged", "mailbox", "account"],
305
+ FIELD_SEPARATOR,
306
+ )
307
+ if msg is not None:
308
+ messages.append(msg)
309
+
310
+ save_message_aliases([m["id"] for m in messages])
311
+ for i, m in enumerate(messages, 1):
312
+ m["alias"] = i
313
+
314
+ text = f"Search results for '{query}' in {field} (up to {limit}):"
315
+ for m in messages:
316
+ status_icons = []
317
+ if not m["read"]:
318
+ status_icons.append("UNREAD")
319
+ if m["flagged"]:
320
+ status_icons.append("FLAGGED")
321
+ status_str = f" [{', '.join(status_icons)}]" if status_icons else ""
322
+ text += f"\n- [{m['alias']}] {truncate(m['subject'], 50)}{status_str}"
323
+ text += f"\n From: {m['sender']}"
324
+ text += f"\n Date: {m['date']}"
325
+ text += f"\n Location: {m['mailbox']} [{m['account']}]"
326
+ format_output(args, text, json_data=messages)
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Registration
331
+ # ---------------------------------------------------------------------------
332
+
333
+ def register(subparsers) -> None:
334
+ """Register message listing and reading subcommands."""
335
+ # list
336
+ p = subparsers.add_parser("list", help="List messages in a mailbox")
337
+ p.add_argument("-m", "--mailbox", default=None, help="Mailbox name (default: INBOX)")
338
+ p.add_argument("-a", "--account", help="Mail account name")
339
+ p.add_argument("--unread", action="store_true", help="Only show unread messages")
340
+ p.add_argument("--limit", type=int, default=DEFAULT_MESSAGE_LIMIT, help="Max messages to show")
341
+ p.add_argument("--after", help="Filter messages after date (YYYY-MM-DD)")
342
+ p.add_argument("--before", help="Filter messages before date (YYYY-MM-DD)")
343
+ p.add_argument("--json", action="store_true", help="Output as JSON")
344
+ p.set_defaults(func=cmd_list)
345
+
346
+ # read
347
+ p = subparsers.add_parser("read", help="Read full message details")
348
+ p.add_argument("id", type=int, help="Message ID")
349
+ p.add_argument("-a", "--account", help="Mail account name")
350
+ p.add_argument("-m", "--mailbox", help="Mailbox name (default: INBOX)")
351
+ p.add_argument("--short", action="store_true", help="Truncate body to 500 chars")
352
+ p.add_argument("--json", action="store_true", help="Output as JSON")
353
+ p.set_defaults(func=cmd_read)
354
+
355
+ # search
356
+ p = subparsers.add_parser("search", help="Search messages by subject or sender")
357
+ p.add_argument("query", help="Search term")
358
+ p.add_argument("--sender", action="store_true", help="Search in sender instead of subject")
359
+ p.add_argument("-a", "--account", help="Limit to specific account")
360
+ p.add_argument("-m", "--mailbox", help="Limit to specific mailbox (requires -a)")
361
+ p.add_argument("--limit", type=int, default=DEFAULT_MESSAGE_LIMIT, help="Max results")
362
+ p.add_argument("--json", action="store_true", help="Output as JSON")
363
+ p.set_defaults(func=cmd_search)