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.
- mailtoolsbox-3.0.0/MailToolsBox/__init__.py +48 -0
- mailtoolsbox-3.0.0/MailToolsBox/_version.py +3 -0
- mailtoolsbox-3.0.0/MailToolsBox/exceptions.py +40 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/imapClient.py +89 -58
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/mailSender.py +282 -110
- mailtoolsbox-3.0.0/MailToolsBox/py.typed +0 -0
- mailtoolsbox-3.0.0/MailToolsBox/retry.py +108 -0
- mailtoolsbox-3.0.0/MailToolsBox/security.py +42 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/PKG-INFO +122 -33
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/SOURCES.txt +10 -2
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/requires.txt +3 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/PKG-INFO +122 -33
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/README.md +110 -8
- mailtoolsbox-3.0.0/pyproject.toml +88 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/tests/test_imap_agent.py +35 -51
- mailtoolsbox-3.0.0/tests/test_integration_smtp.py +111 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/tests/test_mail_sender.py +51 -48
- mailtoolsbox-3.0.0/tests/test_retry.py +86 -0
- mailtoolsbox-3.0.0/tests/test_session.py +111 -0
- mailtoolsbox-2.0.0/MailToolsBox/__init__.py +0 -6
- mailtoolsbox-2.0.0/setup.py +0 -82
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/LICENSE.txt +0 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox/templates/example.html +0 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/dependency_links.txt +0 -0
- {mailtoolsbox-2.0.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/top_level.txt +0 -0
- {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,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
|
-
|
|
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) ->
|
|
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) ->
|
|
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 (
|
|
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(
|
|
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
|
|
209
|
+
raise AuthenticationError(f"XOAUTH2 failed: {resp!r}")
|
|
213
210
|
return
|
|
214
211
|
if self.password is None:
|
|
215
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
614
|
-
"""
|
|
615
|
-
|
|
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:
|