MailToolsBox 2.0.0__tar.gz → 3.0.0__tar.gz

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.
Files changed (26) hide show
  1. mailtoolsbox-3.0.0/MailToolsBox/__init__.py +48 -0
  2. mailtoolsbox-3.0.0/MailToolsBox/_version.py +3 -0
  3. mailtoolsbox-3.0.0/MailToolsBox/exceptions.py +40 -0
  4. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/imapClient.py +89 -58
  5. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/mailSender.py +282 -110
  6. mailtoolsbox-3.0.0/MailToolsBox/py.typed +0 -0
  7. mailtoolsbox-3.0.0/MailToolsBox/retry.py +108 -0
  8. mailtoolsbox-3.0.0/MailToolsBox/security.py +42 -0
  9. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/PKG-INFO +122 -33
  10. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/SOURCES.txt +10 -2
  11. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/requires.txt +3 -0
  12. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/PKG-INFO +122 -33
  13. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/README.md +110 -8
  14. mailtoolsbox-3.0.0/pyproject.toml +88 -0
  15. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/tests/test_imap_agent.py +35 -51
  16. mailtoolsbox-3.0.0/tests/test_integration_smtp.py +111 -0
  17. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/tests/test_mail_sender.py +51 -48
  18. mailtoolsbox-3.0.0/tests/test_retry.py +86 -0
  19. mailtoolsbox-3.0.0/tests/test_session.py +111 -0
  20. mailtoolsbox-2.0.0/MailToolsBox/__init__.py +0 -6
  21. mailtoolsbox-2.0.0/setup.py +0 -82
  22. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/LICENSE.txt +0 -0
  23. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/templates/example.html +0 -0
  24. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/dependency_links.txt +0 -0
  25. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/top_level.txt +0 -0
  26. {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/setup.cfg +0 -0
@@ -0,0 +1,48 @@
1
+ """MailToolsBox — modern sync/async SMTP sending and IMAP reading for Python."""
2
+
3
+ from ._version import __version__
4
+ from .exceptions import (
5
+ AuthenticationError,
6
+ ConfigurationError,
7
+ ConnectionError,
8
+ EmailValidationError,
9
+ IMAPError,
10
+ MailToolsBoxError,
11
+ SendError,
12
+ )
13
+ from .imapClient import (
14
+ ImapAgent,
15
+ ImapClient,
16
+ MailAddress,
17
+ MailItem,
18
+ MailPart,
19
+ )
20
+ from .mailSender import EmailSender, SendAgent, SmtpSession
21
+ from .retry import RateLimiter, RetryPolicy
22
+ from .security import SecurityMode
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ # SMTP
27
+ "EmailSender",
28
+ "SmtpSession",
29
+ "SendAgent",
30
+ # IMAP
31
+ "ImapClient",
32
+ "ImapAgent",
33
+ "MailItem",
34
+ "MailAddress",
35
+ "MailPart",
36
+ # Shared
37
+ "SecurityMode",
38
+ "RetryPolicy",
39
+ "RateLimiter",
40
+ # Exceptions
41
+ "MailToolsBoxError",
42
+ "ConfigurationError",
43
+ "ConnectionError",
44
+ "AuthenticationError",
45
+ "SendError",
46
+ "IMAPError",
47
+ "EmailValidationError",
48
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "3.0.0"
@@ -0,0 +1,40 @@
1
+ """Structured exception hierarchy for MailToolsBox.
2
+
3
+ All errors raised by the library derive from :class:`MailToolsBoxError`, so
4
+ callers can catch everything with a single ``except`` while still being able to
5
+ distinguish connection, authentication, send, and validation failures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class MailToolsBoxError(Exception):
12
+ """Base class for every error raised by MailToolsBox."""
13
+
14
+
15
+ class ConfigurationError(MailToolsBoxError):
16
+ """Invalid or missing configuration (bad arguments, env vars, etc.)."""
17
+
18
+
19
+ class EmailValidationError(MailToolsBoxError, ValueError):
20
+ """An email address failed validation/normalization."""
21
+
22
+
23
+ class ConnectionError(MailToolsBoxError):
24
+ """Failed to establish or maintain a server connection."""
25
+
26
+
27
+ class AuthenticationError(MailToolsBoxError):
28
+ """The server rejected the supplied credentials or OAuth2 token."""
29
+
30
+
31
+ class SendError(MailToolsBoxError):
32
+ """A message could not be sent."""
33
+
34
+ def __init__(self, message: str, *, recipient: str | None = None) -> None:
35
+ super().__init__(message)
36
+ self.recipient = recipient
37
+
38
+
39
+ class IMAPError(MailToolsBoxError):
40
+ """An IMAP operation failed."""
@@ -9,28 +9,23 @@ import logging
9
9
  import os
10
10
  import re
11
11
  import ssl
12
+ from contextlib import contextmanager
12
13
  from dataclasses import dataclass, field
13
14
  from email.header import decode_header, make_header
14
15
  from email.message import Message
15
- from enum import Enum
16
16
  from pathlib import Path
17
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
17
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple
18
+
19
+ from .exceptions import AuthenticationError, IMAPError
20
+ from .security import SecurityMode, build_ssl_context
18
21
 
19
22
  logger = logging.getLogger(__name__)
20
23
  logger.addHandler(logging.NullHandler())
21
24
 
22
25
 
23
- # ----------------------------- Security -----------------------------
24
-
25
- class SecurityMode(str, Enum):
26
- AUTO = "auto" # 993 -> SSL on connect, else try STARTTLS if server advertises it
27
- STARTTLS = "starttls"
28
- SSL = "ssl" # implicit TLS on connect
29
- NONE = "none" # plaintext, only for trusted LANs
30
-
31
-
32
26
  # ----------------------------- Models -------------------------------
33
27
 
28
+
34
29
  @dataclass
35
30
  class MailAddress:
36
31
  name: Optional[str]
@@ -63,6 +58,7 @@ class MailItem:
63
58
 
64
59
  # --------------------------- Utilities ------------------------------
65
60
 
61
+
66
62
  def _decode_header_value(value: Optional[str]) -> str:
67
63
  if not value:
68
64
  return ""
@@ -80,6 +76,7 @@ def _decode_filename(value: Optional[str]) -> str:
80
76
  except Exception:
81
77
  return value
82
78
 
79
+
83
80
  def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
84
81
  if not value:
85
82
  return []
@@ -89,6 +86,7 @@ def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
89
86
  out.append(MailAddress(_decode_header_value(name) or None, addr or None))
90
87
  return out
91
88
 
89
+
92
90
  def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
93
91
  if not date_hdr:
94
92
  return None
@@ -106,6 +104,7 @@ def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
106
104
 
107
105
  # ----------------------------- Client --------------------------------
108
106
 
107
+
109
108
  class ImapClient:
110
109
  """
111
110
  Improved IMAP client with:
@@ -138,11 +137,7 @@ class ImapClient:
138
137
  self.oauth2_access_token = oauth2_access_token
139
138
  self.timeout = timeout
140
139
 
141
- ctx = ssl_context or ssl.create_default_context()
142
- if allow_invalid_certs:
143
- ctx.check_hostname = False
144
- ctx.verify_mode = ssl.CERT_NONE
145
- self.ssl_context = ctx
140
+ self.ssl_context = build_ssl_context(ssl_context, allow_invalid_certs=allow_invalid_certs)
146
141
 
147
142
  self.conn: Optional[imaplib.IMAP4] = None
148
143
  self.selected_mailbox: Optional[str] = None
@@ -150,7 +145,7 @@ class ImapClient:
150
145
  # --------- factories
151
146
 
152
147
  @classmethod
153
- def from_env(cls) -> "ImapClient":
148
+ def from_env(cls) -> ImapClient:
154
149
  """
155
150
  Required: IMAP_EMAIL, IMAP_SERVER
156
151
  Optional: IMAP_PASSWORD, IMAP_PORT, IMAP_SECURITY, IMAP_OAUTH2_TOKEN, IMAP_ALLOW_INVALID_CERTS
@@ -175,7 +170,7 @@ class ImapClient:
175
170
 
176
171
  # --------- context manager
177
172
 
178
- def __enter__(self) -> "ImapClient":
173
+ def __enter__(self) -> ImapClient:
179
174
  self.login()
180
175
  return self
181
176
 
@@ -196,7 +191,9 @@ class ImapClient:
196
191
 
197
192
  conn = imaplib.IMAP4(self.server_address, self.port)
198
193
  conn.timeout = self.timeout
199
- if mode == SecurityMode.STARTTLS or (mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities):
194
+ if mode == SecurityMode.STARTTLS or (
195
+ mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities
196
+ ):
200
197
  conn.starttls(self.ssl_context)
201
198
  # capabilities may change after STARTTLS
202
199
  conn.capabilities = conn.capability()[1][0].split() # refresh
@@ -205,17 +202,19 @@ class ImapClient:
205
202
  def _auth(self, conn: imaplib.IMAP4) -> None:
206
203
  if self.oauth2_access_token:
207
204
  # XOAUTH2: base64("user=<email>\x01auth=Bearer <token>\x01\x01")
208
- raw = f"user={self.email_account}\x01auth=Bearer {self.oauth2_access_token}\x01\x01".encode("utf-8")
205
+ raw = f"user={self.email_account}\x01auth=Bearer {self.oauth2_access_token}\x01\x01".encode()
209
206
  xoauth = base64.b64encode(raw).decode("ascii")
210
207
  typ, resp = conn.authenticate("XOAUTH2", lambda _: xoauth)
211
208
  if typ != "OK":
212
- raise RuntimeError(f"XOAUTH2 failed: {resp}")
209
+ raise AuthenticationError(f"XOAUTH2 failed: {resp!r}")
213
210
  return
214
211
  if self.password is None:
215
- raise RuntimeError("No password or OAuth2 token provided for IMAP authentication")
212
+ raise AuthenticationError(
213
+ "No password or OAuth2 token provided for IMAP authentication"
214
+ )
216
215
  typ, resp = conn.login(self.email_account, self.password)
217
216
  if typ != "OK":
218
- raise RuntimeError(f"IMAP login failed: {resp}")
217
+ raise AuthenticationError(f"IMAP login failed: {resp!r}")
219
218
 
220
219
  def login(self) -> None:
221
220
  if self.conn:
@@ -261,7 +260,7 @@ class ImapClient:
261
260
  self._ensure()
262
261
  typ, _ = self.conn.select(mailbox, readonly=readonly)
263
262
  if typ != "OK":
264
- raise RuntimeError(f"Cannot select mailbox {mailbox}")
263
+ raise IMAPError(f"Cannot select mailbox {mailbox}")
265
264
  self.selected_mailbox = mailbox
266
265
 
267
266
  # --------- search and fetch
@@ -285,7 +284,7 @@ class ImapClient:
285
284
  self._ensure_selected()
286
285
  typ, data = self.conn.uid("fetch", uid, "(RFC822 FLAGS)")
287
286
  if typ != "OK" or not data or not isinstance(data[0], tuple):
288
- raise RuntimeError(f"Failed to fetch UID {uid}")
287
+ raise IMAPError(f"Failed to fetch UID {uid}")
289
288
  raw: bytes = data[0][1]
290
289
  # FLAGS come in a separate item depending on server, normalize
291
290
  flags: List[str] = []
@@ -296,7 +295,7 @@ class ImapClient:
296
295
  start = flags_blob.find("(")
297
296
  end = flags_blob.find(")")
298
297
  if start >= 0 and end > start:
299
- flags = flags_blob[start + 1:end].split()
298
+ flags = flags_blob[start + 1 : end].split()
300
299
  break
301
300
  except Exception:
302
301
  flags = []
@@ -338,7 +337,9 @@ class ImapClient:
338
337
 
339
338
  if is_attach:
340
339
  payload = part.get_payload(decode=True) or b""
341
- attachments.append(MailPart(ctype, charset, payload, filename=filename, is_attachment=True))
340
+ attachments.append(
341
+ MailPart(ctype, charset, payload, filename=filename, is_attachment=True)
342
+ )
342
343
  continue
343
344
 
344
345
  if ctype == "text/plain":
@@ -445,11 +446,23 @@ class ImapClient:
445
446
 
446
447
  # --------- legacy style exports (modernized)
447
448
 
448
- def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
449
- """
450
- Dump all messages in a mailbox to a single UTF-8 text file.
449
+ @contextmanager
450
+ def _managed_session(self) -> Iterator[None]:
451
+ """Ensure a connection for the duration of a one-shot export.
452
+
453
+ Only tears the connection down if *this* call opened it; a connection
454
+ the caller already established (e.g. inside ``with ImapClient(...)``)
455
+ is left open.
451
456
  """
452
- self.login()
457
+ owned = self.conn is None
458
+ self._ensure()
459
+ try:
460
+ yield
461
+ finally:
462
+ if owned:
463
+ self.logout()
464
+
465
+ def _dump_text(self, path: str, mailbox: str) -> Path:
453
466
  self.select(mailbox)
454
467
  uids = self.search("ALL")
455
468
  lines: List[str] = []
@@ -464,21 +477,14 @@ class ImapClient:
464
477
  )
465
478
  out = Path(path) / "email.txt"
466
479
  out.write_text("".join(lines), encoding="utf-8")
467
- self.logout()
468
480
  return out
469
481
 
470
- def download_mail_json(
471
- self,
472
- lookup: str = "ALL",
473
- save: bool = False,
474
- path: str = "",
475
- file_name: str = "mail.json",
476
- mailbox: str = "INBOX",
477
- ) -> str:
478
- """
479
- Return JSON of selected messages. Optionally save to disk.
480
- """
481
- self.login()
482
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
483
+ """Dump all messages in a mailbox to a single UTF-8 text file."""
484
+ with self._managed_session():
485
+ return self._dump_text(path, mailbox)
486
+
487
+ def _dump_json(self, lookup: str, save: bool, path: str, file_name: str, mailbox: str) -> str:
482
488
  self.select(mailbox)
483
489
  uids = self.search(lookup)
484
490
  items = self.fetch_many(uids)
@@ -502,14 +508,21 @@ class ImapClient:
502
508
  if save:
503
509
  out = Path(path) / file_name
504
510
  out.write_text(s, encoding="utf-8")
505
- self.logout()
506
511
  return s
507
512
 
508
- def download_mail_eml(self, directory: str = "", lookup: str = "ALL", mailbox: str = "INBOX") -> List[Path]:
509
- """
510
- Save each selected message as an .eml file.
511
- """
512
- self.login()
513
+ def download_mail_json(
514
+ self,
515
+ lookup: str = "ALL",
516
+ save: bool = False,
517
+ path: str = "",
518
+ file_name: str = "mail.json",
519
+ mailbox: str = "INBOX",
520
+ ) -> str:
521
+ """Return JSON of selected messages. Optionally save to disk."""
522
+ with self._managed_session():
523
+ return self._dump_json(lookup, save, path, file_name, mailbox)
524
+
525
+ def _dump_eml(self, directory: str, lookup: str, mailbox: str) -> List[Path]:
513
526
  self.select(mailbox)
514
527
  uids = self.search(lookup)
515
528
  out_paths: List[Path] = []
@@ -521,9 +534,15 @@ class ImapClient:
521
534
  out = target_dir / file_name
522
535
  out.write_bytes(item.raw)
523
536
  out_paths.append(out)
524
- self.logout()
525
537
  return out_paths
526
538
 
539
+ def download_mail_eml(
540
+ self, directory: str = "", lookup: str = "ALL", mailbox: str = "INBOX"
541
+ ) -> List[Path]:
542
+ """Save each selected message as an .eml file."""
543
+ with self._managed_session():
544
+ return self._dump_eml(directory, lookup, mailbox)
545
+
527
546
  # --------- internal guards
528
547
 
529
548
  def _ensure(self) -> None:
@@ -580,7 +599,7 @@ class ImapAgent(ImapClient):
580
599
  self.logout()
581
600
  self.mail = None
582
601
 
583
- def __enter__(self) -> "ImapAgent":
602
+ def __enter__(self) -> ImapAgent:
584
603
  self.login_account()
585
604
  return self
586
605
 
@@ -595,9 +614,17 @@ class ImapAgent(ImapClient):
595
614
  super().logout()
596
615
  self.mail = None
597
616
 
617
+ # Legacy semantics: these one-shot exports always close the connection
618
+ # when done (the modern ImapClient versions leave a caller-owned
619
+ # connection open).
620
+
598
621
  def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
599
622
  self._sync_mail_alias()
600
- return super().download_mail_text(path=path, mailbox=mailbox)
623
+ self.login()
624
+ try:
625
+ return self._dump_text(path, mailbox)
626
+ finally:
627
+ self.logout()
601
628
 
602
629
  def download_mail_json(
603
630
  self,
@@ -608,12 +635,16 @@ class ImapAgent(ImapClient):
608
635
  mailbox: str = "INBOX",
609
636
  ) -> str:
610
637
  self._sync_mail_alias()
611
- return super().download_mail_json(lookup=lookup, save=save, path=path, file_name=file_name, mailbox=mailbox)
638
+ self.login()
639
+ try:
640
+ return self._dump_json(lookup, save, path, file_name, mailbox)
641
+ finally:
642
+ self.logout()
612
643
 
613
- def download_mail_msg(self, path: str = "", lookup: str = "ALL", mailbox: str = "INBOX") -> List[Path]:
614
- """
615
- Save each selected message as a .msg file (wrapper over .eml bytes).
616
- """
644
+ def download_mail_msg(
645
+ self, path: str = "", lookup: str = "ALL", mailbox: str = "INBOX"
646
+ ) -> List[Path]:
647
+ """Save each selected message as a .msg file (wrapper over .eml bytes)."""
617
648
  self._sync_mail_alias()
618
649
  self.login()
619
650
  try: