myl 0.8.13__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.13
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
@@ -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
  )
@@ -143,12 +241,13 @@ def main():
143
241
  args.username,
144
242
  password=imap_password,
145
243
  insecure=args.insecure,
146
- ).get("imap")
244
+ ).get("imap", {})
147
245
  except Exception:
148
246
  error_msg("Failed to autodiscover IMAP settings")
149
247
  if args.debug:
150
248
  console.print_exception(show_locals=True)
151
- return 1
249
+ raise
250
+
152
251
  LOGGER.debug(f"Discovered settings: {settings})")
153
252
  args.server = settings.get("server")
154
253
  args.port = settings.get("port", IMAP_PORT)
@@ -166,11 +265,87 @@ def main():
166
265
  "- set --google if you are using a Gmail account\n"
167
266
  "- use --auto to attempt autodiscovery"
168
267
  )
169
- 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)
170
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
+ ):
171
346
  json_data = []
172
347
  table = Table(
173
- show_header=not args.no_title,
348
+ show_header=not no_title,
174
349
  header_style="bold",
175
350
  expand=True,
176
351
  show_lines=False,
@@ -184,138 +359,134 @@ def main():
184
359
  table.add_column("From", style="blue", no_wrap=True, ratio=2)
185
360
  table.add_column("Date", style="cyan", no_wrap=True)
186
361
 
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
362
+ if unread_only:
363
+ search = AND(seen=False)
191
364
 
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
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))
199
392
  else:
200
- 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)
201
437
 
202
438
  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
- )
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,
211
452
  )
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
453
 
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>"
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,
277
465
  )
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,
466
+
467
+ # mark emails as read
468
+ elif args.command in ["read"]:
469
+ return mark_read(
470
+ mailbox=mailbox,
471
+ mail_ids=args.MAILIDS,
317
472
  )
318
- return 0
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
+
319
490
  except Exception:
320
491
  console.print_exception(show_locals=True)
321
492
  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