myl 0.8.12__py3-none-any.whl → 0.9.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: myl
3
- Version: 0.8.12
3
+ Version: 0.9.0
4
4
  Summary: Dead simple IMAP CLI client
5
5
  Author-email: Philipp Schmitt <philipp@schmitt.co>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -688,7 +688,7 @@ Requires-Python: >=3.8
688
688
  Description-Content-Type: text/markdown
689
689
  License-File: LICENSE
690
690
  Requires-Dist: imap-tools <2.0.0,>=1.5.0
691
- Requires-Dist: myl-discovery >=0.6.0
691
+ Requires-Dist: myl-discovery >=0.6.1
692
692
  Requires-Dist: rich <14.0.0,>=13.0.0
693
693
  Requires-Dist: html2text >=2024.2.26
694
694
 
@@ -0,0 +1,7 @@
1
+ myl.py,sha256=EthLDnMEB9lqE0T0em1DaUE9oOPHMm43Ng2AyBTCDTc,13680
2
+ myl-0.9.0.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ myl-0.9.0.dist-info/METADATA,sha256=ghRv6c27QA4-RCYNTqeG5Yrg3-0rSVltMQBmPbJuadY,43317
4
+ myl-0.9.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
5
+ myl-0.9.0.dist-info/entry_points.txt,sha256=q6nr0Kzim7JzreXQE3BTU4asLh2sx5-D0w1yLBOcHxc,33
6
+ myl-0.9.0.dist-info/top_level.txt,sha256=Wn88OJVVWyYSsKVoqzlHXxfFxh5IbrJ_Yw-ldNLe7Po,4
7
+ myl-0.9.0.dist-info/RECORD,,
myl.py CHANGED
@@ -2,13 +2,20 @@
2
2
  # coding: utf-8
3
3
 
4
4
  import argparse
5
- import html2text
6
- import json
7
5
  import logging
8
6
  import ssl
9
7
  import sys
8
+ from json import dumps as json_dumps
10
9
 
11
- import imap_tools
10
+ import html2text
11
+ from imap_tools.consts import MailMessageFlags
12
+ from imap_tools.mailbox import (
13
+ BaseMailBox,
14
+ MailBox,
15
+ MailBoxTls,
16
+ MailBoxUnencrypted,
17
+ )
18
+ from imap_tools.query import AND
12
19
  from myldiscovery import autodiscover
13
20
  from rich import print, print_json
14
21
  from rich.console import Console
@@ -23,13 +30,88 @@ GMAIL_SENT_FOLDER = "[Gmail]/Sent Mail"
23
30
  # GMAIL_ALL_FOLDER = "[Gmail]/All Mail"
24
31
 
25
32
 
33
+ class MissingServerException(Exception):
34
+ pass
35
+
36
+
26
37
  def error_msg(msg):
27
38
  print(f"[red]{msg}[/red]", file=sys.stderr)
28
39
 
29
40
 
41
+ def mail_to_dict(msg, date_format="%Y-%m-%d %H:%M:%S"):
42
+ return {
43
+ "uid": msg.uid,
44
+ "subject": msg.subject,
45
+ "from": msg.from_,
46
+ "to": msg.to,
47
+ "date": msg.date.strftime(date_format),
48
+ "timestamp": str(int(msg.date.timestamp())),
49
+ "unread": mail_is_unread(msg),
50
+ "flags": msg.flags,
51
+ "content": {
52
+ "raw": msg.obj.as_string(),
53
+ "html": msg.html,
54
+ "text": msg.text,
55
+ },
56
+ "attachments": msg.attachments,
57
+ }
58
+
59
+
60
+ def mail_to_json(msg, date_format="%Y-%m-%d %H:%M:%S"):
61
+ return json_dumps(mail_to_dict(msg, date_format))
62
+
63
+
64
+ def mail_is_unread(msg):
65
+ return MailMessageFlags.SEEN not in msg.flags
66
+
67
+
30
68
  def parse_args():
31
69
  parser = argparse.ArgumentParser()
32
- parser.add_argument("-d", "--debug", help="Debug", action="store_true")
70
+ subparsers = parser.add_subparsers(
71
+ dest="command", help="Available commands"
72
+ )
73
+
74
+ # Default command: list all emails
75
+ subparsers.add_parser("list", help="List all emails")
76
+
77
+ # Get/show email command
78
+ get_parser = subparsers.add_parser(
79
+ "get", help="Retrieve a specific email or attachment"
80
+ )
81
+ get_parser.add_argument("MAILID", help="Mail ID to fetch", type=int)
82
+ get_parser.add_argument(
83
+ "ATTACHMENT",
84
+ help="Name of the attachment to fetch",
85
+ nargs="?",
86
+ default=None,
87
+ )
88
+
89
+ # Delete email command
90
+ delete_parser = subparsers.add_parser("delete", help="Delete an email")
91
+ delete_parser.add_argument(
92
+ "MAILIDS", help="Mail ID(s) to delete", type=int, nargs="+"
93
+ )
94
+
95
+ # Mark email as read/unread
96
+ mark_read_parser = subparsers.add_parser(
97
+ "read", help="mark an email as read"
98
+ )
99
+ mark_read_parser.add_argument(
100
+ "MAILIDS", help="Mail ID(s) to mark as read", type=int, nargs="+"
101
+ )
102
+ mark_unread_parser = subparsers.add_parser(
103
+ "unread", help="mark an email as unread"
104
+ )
105
+ mark_unread_parser.add_argument(
106
+ "MAILIDS", help="Mail ID(s) to mark as unread", type=int, nargs="+"
107
+ )
108
+
109
+ # Optional arguments
110
+ parser.add_argument(
111
+ "-d", "--debug", help="Enable debug mode", action="store_true"
112
+ )
113
+
114
+ # IMAP connection settings
33
115
  parser.add_argument(
34
116
  "-s", "--server", help="IMAP server address", required=False
35
117
  )
@@ -45,7 +127,7 @@ def parse_args():
45
127
  "--auto",
46
128
  help="Autodiscovery of the required server and port",
47
129
  action="store_true",
48
- default=False,
130
+ default=True,
49
131
  )
50
132
  parser.add_argument(
51
133
  "-P", "--port", help="IMAP server port", default=IMAP_PORT
@@ -60,16 +142,8 @@ def parse_args():
60
142
  action="store_true",
61
143
  default=False,
62
144
  )
63
- parser.add_argument(
64
- "-c",
65
- "--count",
66
- help="Number of messages to fetch",
67
- default=10,
68
- type=int,
69
- )
70
- parser.add_argument(
71
- "-m", "--mark-seen", help="Mark seen", action="store_true"
72
- )
145
+
146
+ # Credentials
73
147
  parser.add_argument(
74
148
  "-u", "--username", help="IMAP username", required=True
75
149
  )
@@ -80,9 +154,32 @@ def parse_args():
80
154
  help="IMAP password (file path)",
81
155
  type=argparse.FileType("r"),
82
156
  )
157
+
158
+ # Display preferences
159
+ parser.add_argument(
160
+ "-c",
161
+ "--count",
162
+ help="Number of messages to fetch",
163
+ default=10,
164
+ type=int,
165
+ )
83
166
  parser.add_argument(
84
167
  "-t", "--no-title", help="Do not show title", action="store_true"
85
168
  )
169
+ parser.add_argument(
170
+ "--date-format", help="Date format", default="%H:%M %d/%m/%Y"
171
+ )
172
+
173
+ # IMAP actions
174
+ parser.add_argument(
175
+ "-m",
176
+ "--mark-seen",
177
+ help="Mark seen",
178
+ action="store_true",
179
+ default=False,
180
+ )
181
+
182
+ # Email filtering
86
183
  parser.add_argument("-f", "--folder", help="IMAP folder", default="INBOX")
87
184
  parser.add_argument(
88
185
  "--sent",
@@ -90,7 +187,21 @@ def parse_args():
90
187
  action="store_true",
91
188
  )
92
189
  parser.add_argument("-S", "--search", help="Search string", default="ALL")
93
- parser.add_argument("-H", "--html", help="Show HTML", action="store_true")
190
+ parser.add_argument(
191
+ "--unread",
192
+ help="Limit to unread emails",
193
+ action="store_true",
194
+ default=False,
195
+ )
196
+
197
+ # Output preferences
198
+ parser.add_argument(
199
+ "-H",
200
+ "--html",
201
+ help="Show HTML email",
202
+ action="store_true",
203
+ default=False,
204
+ )
94
205
  parser.add_argument(
95
206
  "-j",
96
207
  "--json",
@@ -105,24 +216,11 @@ def parse_args():
105
216
  action="store_true",
106
217
  default=False,
107
218
  )
108
- parser.add_argument("MAILID", help="Mail ID to fetch", nargs="?")
109
- parser.add_argument(
110
- "ATTACHMENT", help="Name of the attachment to fetch", nargs="?"
111
- )
112
219
 
113
220
  return parser.parse_args()
114
221
 
115
222
 
116
- def main():
117
- console = Console()
118
- args = parse_args()
119
- logging.basicConfig(
120
- format="%(message)s",
121
- handlers=[RichHandler(console=console)],
122
- level=logging.DEBUG if args.debug else logging.INFO,
123
- )
124
- LOGGER.debug(args)
125
-
223
+ def mb_connect(console, args) -> BaseMailBox:
126
224
  imap_password = args.password or (
127
225
  args.password_file and args.password_file.read()
128
226
  )
@@ -140,13 +238,16 @@ def main():
140
238
  if args.auto:
141
239
  try:
142
240
  settings = autodiscover(
143
- args.username, password=imap_password
144
- ).get("imap")
241
+ args.username,
242
+ password=imap_password,
243
+ insecure=args.insecure,
244
+ ).get("imap", {})
145
245
  except Exception:
146
246
  error_msg("Failed to autodiscover IMAP settings")
147
247
  if args.debug:
148
248
  console.print_exception(show_locals=True)
149
- return 1
249
+ raise
250
+
150
251
  LOGGER.debug(f"Discovered settings: {settings})")
151
252
  args.server = settings.get("server")
152
253
  args.port = settings.get("port", IMAP_PORT)
@@ -164,11 +265,87 @@ def main():
164
265
  "- set --google if you are using a Gmail account\n"
165
266
  "- use --auto to attempt autodiscovery"
166
267
  )
167
- return 2
268
+ raise MissingServerException()
269
+
270
+ ssl_context = ssl.create_default_context()
271
+ if args.insecure:
272
+ ssl_context.check_hostname = False
273
+ ssl_context.verify_mode = ssl.CERT_NONE
274
+
275
+ mb_kwargs = {"host": args.server, "port": args.port}
276
+ if args.ssl:
277
+ mb = MailBox
278
+ mb_kwargs["ssl_context"] = ssl_context
279
+ elif args.starttls:
280
+ mb = MailBoxTls
281
+ mb_kwargs["ssl_context"] = ssl_context
282
+ else:
283
+ mb = MailBoxUnencrypted
284
+
285
+ mailbox = mb(**mb_kwargs)
286
+ mailbox.login(args.username, imap_password, args.folder)
287
+ return mailbox
288
+
289
+
290
+ def display_single_mail(
291
+ mailbox: BaseMailBox,
292
+ mail_id: int,
293
+ attachment: str | None = None,
294
+ mark_seen: bool = False,
295
+ raw: bool = False,
296
+ html: bool = False,
297
+ json: bool = False,
298
+ ):
299
+ LOGGER.debug("Fetch mail %s", mail_id)
300
+ msg = next(mailbox.fetch(f"UID {mail_id}", mark_seen=mark_seen))
301
+ LOGGER.debug("Fetched mail %s", msg)
302
+
303
+ if attachment:
304
+ for att in msg.attachments:
305
+ if att.filename == attachment:
306
+ sys.stdout.buffer.write(att.payload)
307
+ return 0
308
+ print(
309
+ f"attachment {attachment} not found",
310
+ file=sys.stderr,
311
+ )
312
+ return 1
313
+
314
+ if html:
315
+ output = msg.text
316
+ if raw:
317
+ output = msg.html
318
+ else:
319
+ output = html2text.html2text(msg.html)
320
+ print(output)
321
+ elif raw:
322
+ print(msg.obj.as_string())
323
+ return 0
324
+ elif json:
325
+ print_json(mail_to_json(msg))
326
+ return 0
327
+ else:
328
+ print(msg.text)
168
329
 
330
+ for att in msg.attachments:
331
+ print(f"📎 Attachment: {att.filename}", file=sys.stderr)
332
+ return 0
333
+
334
+
335
+ def display_emails(
336
+ mailbox,
337
+ console,
338
+ no_title=False,
339
+ search="ALL",
340
+ unread_only=False,
341
+ count=10,
342
+ mark_seen=False,
343
+ json=False,
344
+ date_format="%H:%M %d/%m/%Y",
345
+ ):
169
346
  json_data = []
170
347
  table = Table(
171
- show_header=not args.no_title,
348
+ show_header=not no_title,
172
349
  header_style="bold",
173
350
  expand=True,
174
351
  show_lines=False,
@@ -182,135 +359,134 @@ def main():
182
359
  table.add_column("From", style="blue", no_wrap=True, ratio=2)
183
360
  table.add_column("Date", style="cyan", no_wrap=True)
184
361
 
185
- ssl_context = ssl.create_default_context()
186
- if args.insecure:
187
- ssl_context.check_hostname = False
188
- ssl_context.verify_mode = ssl.CERT_NONE
362
+ if unread_only:
363
+ search = AND(seen=False)
189
364
 
190
- mb_kwargs = {"host": args.server, "port": args.port}
191
- if args.ssl:
192
- mb = imap_tools.MailBox
193
- mb_kwargs["ssl_context"] = ssl_context
194
- elif args.starttls:
195
- mb = imap_tools.MailBoxTls
196
- mb_kwargs["ssl_context"] = ssl_context
365
+ for msg in mailbox.fetch(
366
+ criteria=search,
367
+ reverse=True,
368
+ bulk=True,
369
+ limit=count,
370
+ mark_seen=mark_seen,
371
+ headers_only=False, # required for attachments
372
+ ):
373
+ subj_prefix = "🆕 " if mail_is_unread(msg) else ""
374
+ subj_prefix += "📎 " if len(msg.attachments) > 0 else ""
375
+ subject = (
376
+ msg.subject.replace("\n", "") if msg.subject else "<no-subject>"
377
+ )
378
+ if json:
379
+ json_data.append(mail_to_dict(msg))
380
+ else:
381
+ table.add_row(
382
+ msg.uid if msg.uid else "???",
383
+ f"{subj_prefix}{subject}",
384
+ msg.from_,
385
+ (msg.date.strftime(date_format) if msg.date else "???"),
386
+ )
387
+ if table.row_count >= count:
388
+ break
389
+
390
+ if json:
391
+ print_json(json_dumps(json_data))
197
392
  else:
198
- mb = imap_tools.MailBoxUnencrypted
393
+ console.print(table)
394
+ if table.row_count == 0:
395
+ print(
396
+ "[yellow italic]No messages[/yellow italic]",
397
+ file=sys.stderr,
398
+ )
399
+ return 0
400
+
401
+
402
+ def delete_emails(mailbox: BaseMailBox, mail_ids: list):
403
+ LOGGER.warning("Deleting mails %s", mail_ids)
404
+ mailbox.delete([str(x) for x in mail_ids])
405
+ return 0
406
+
407
+
408
+ def set_seen(mailbox: BaseMailBox, mail_ids: list, value=True):
409
+ LOGGER.info(
410
+ "Marking mails as %s: %s", "read" if value else "unread", mail_ids
411
+ )
412
+ mailbox.flag(
413
+ [str(x) for x in mail_ids],
414
+ flag_set=(MailMessageFlags.SEEN),
415
+ value=value,
416
+ )
417
+ return 0
418
+
419
+
420
+ def mark_read(mailbox: BaseMailBox, mail_ids: list):
421
+ return set_seen(mailbox, mail_ids, value=True)
422
+
423
+
424
+ def mark_unread(mailbox: BaseMailBox, mail_ids: list):
425
+ return set_seen(mailbox, mail_ids, value=False)
426
+
427
+
428
+ def main() -> int:
429
+ console = Console()
430
+ args = parse_args()
431
+ logging.basicConfig(
432
+ format="%(message)s",
433
+ handlers=[RichHandler(console=console)],
434
+ level=logging.DEBUG if args.debug else logging.INFO,
435
+ )
436
+ LOGGER.debug(args)
199
437
 
200
438
  try:
201
- with mb(**mb_kwargs).login(
202
- args.username, imap_password, args.folder
203
- ) as mailbox:
204
- if args.MAILID:
205
- msg = next(
206
- mailbox.fetch(
207
- f"UID {args.MAILID}", mark_seen=args.mark_seen
208
- )
439
+ with mb_connect(console, args) as mailbox:
440
+ # inbox display
441
+ if args.command in ["list", None]:
442
+ return display_emails(
443
+ mailbox=mailbox,
444
+ console=console,
445
+ no_title=args.no_title,
446
+ search=args.search,
447
+ unread_only=args.unread,
448
+ count=args.count,
449
+ mark_seen=args.mark_seen,
450
+ json=args.json,
451
+ date_format=args.date_format,
209
452
  )
210
- if args.ATTACHMENT:
211
- for att in msg.attachments:
212
- if att.filename == args.ATTACHMENT:
213
- sys.stdout.buffer.write(att.payload)
214
- return 0
215
- print(
216
- f"Attachment {args.ATTACHMENT} not found",
217
- file=sys.stderr,
218
- )
219
- return 1
220
- else:
221
- if args.raw:
222
- print(msg.obj.as_string())
223
- return 0
224
- elif args.json:
225
- print_json(
226
- json.dumps(
227
- {
228
- "uid": msg.uid,
229
- "subject": msg.subject,
230
- "from": msg.from_,
231
- "to": msg.to,
232
- "date": msg.date.strftime(
233
- "%Y-%m-%d %H:%M:%S"
234
- ),
235
- "timestamp": str(
236
- int(msg.date.timestamp())
237
- ),
238
- "content": {
239
- "raw": msg.obj.as_string(),
240
- "html": msg.html,
241
- "text": msg.text,
242
- },
243
- "attachments": msg.attachments,
244
- }
245
- )
246
- )
247
- return 0
248
-
249
- output = msg.text
250
- if args.html:
251
- if args.raw:
252
- output = msg.html
253
- else:
254
- output = html2text.html2text(msg.html)
255
- print(output)
256
- for att in msg.attachments:
257
- print(
258
- f"📎 Attachment: {att.filename}", file=sys.stderr
259
- )
260
- return 0
261
453
 
262
- for msg in mailbox.fetch(
263
- criteria=args.search,
264
- reverse=True,
265
- bulk=True,
266
- limit=args.count,
267
- mark_seen=args.mark_seen,
268
- headers_only=False, # required for attachments
269
- ):
270
- subj_prefix = "📎 " if len(msg.attachments) > 0 else ""
271
- subject = (
272
- msg.subject.replace("\n", "")
273
- if msg.subject
274
- else "<no-subject>"
454
+ # single email
455
+ # FIXME $ myl 219 raises an argparse error
456
+ elif args.command in ["get", "show", "display"]:
457
+ return display_single_mail(
458
+ mailbox=mailbox,
459
+ mail_id=args.MAILID,
460
+ attachment=args.ATTACHMENT,
461
+ mark_seen=args.mark_seen,
462
+ raw=args.raw,
463
+ html=args.html,
464
+ json=args.json,
275
465
  )
276
- if args.json:
277
- json_data.append(
278
- {
279
- "uid": msg.uid,
280
- "subject": msg.subject,
281
- "from": msg.from_,
282
- "to": msg.to,
283
- "date": msg.date.strftime("%Y-%m-%d %H:%M:%S"),
284
- "timestamp": str(int(msg.date.timestamp())),
285
- "content": {
286
- "raw": msg.obj.as_string(),
287
- "html": msg.html,
288
- "text": msg.text,
289
- },
290
- "attachments": msg.attachments,
291
- }
292
- )
293
- else:
294
- table.add_row(
295
- msg.uid if msg.uid else "???",
296
- f"{subj_prefix}{subject}",
297
- msg.from_,
298
- (
299
- msg.date.strftime("%H:%M %d/%m/%Y")
300
- if msg.date
301
- else "???"
302
- ),
303
- )
304
- if table.row_count >= args.count:
305
- break
306
-
307
- if args.json:
308
- print_json(json.dumps(json_data))
309
- else:
310
- console.print(table)
311
- if table.row_count == 0:
312
- print("[yellow italic]No messages[/yellow italic]", file=sys.stderr)
313
- return 0
466
+
467
+ # mark emails as read
468
+ elif args.command in ["read"]:
469
+ return mark_read(
470
+ mailbox=mailbox,
471
+ mail_ids=args.MAILIDS,
472
+ )
473
+
474
+ elif args.command in ["unread"]:
475
+ return mark_unread(
476
+ mailbox=mailbox,
477
+ mail_ids=args.MAILIDS,
478
+ )
479
+
480
+ # delete email
481
+ elif args.command in ["delete", "remove"]:
482
+ return delete_emails(
483
+ mailbox=mailbox,
484
+ mail_ids=args.MAILIDS,
485
+ )
486
+ else:
487
+ error_msg(f"Unknown command: {args.command}")
488
+ return 1
489
+
314
490
  except Exception:
315
491
  console.print_exception(show_locals=True)
316
492
  return 1
@@ -1,7 +0,0 @@
1
- myl.py,sha256=l3hgASE9E3Po7C5pB-yxaFjteMh15GKHwsnW693Z-pc,10591
2
- myl-0.8.12.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- myl-0.8.12.dist-info/METADATA,sha256=LkmLWD0Q6gwkDDbDRveC8UFtn4xLCe2EzORSffywUB4,43318
4
- myl-0.8.12.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
5
- myl-0.8.12.dist-info/entry_points.txt,sha256=q6nr0Kzim7JzreXQE3BTU4asLh2sx5-D0w1yLBOcHxc,33
6
- myl-0.8.12.dist-info/top_level.txt,sha256=Wn88OJVVWyYSsKVoqzlHXxfFxh5IbrJ_Yw-ldNLe7Po,4
7
- myl-0.8.12.dist-info/RECORD,,
File without changes
File without changes