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