MailToolsBox 1.1.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-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox/imapClient.py +226 -63
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox/mailSender.py +319 -129
- 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-3.0.0/MailToolsBox/templates/example.html +7 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/PKG-INFO +163 -33
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/SOURCES.txt +11 -2
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/requires.txt +11 -1
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/PKG-INFO +163 -33
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/README.md +148 -10
- mailtoolsbox-3.0.0/pyproject.toml +88 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/tests/test_imap_agent.py +71 -59
- mailtoolsbox-3.0.0/tests/test_integration_smtp.py +111 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/tests/test_mail_sender.py +141 -56
- mailtoolsbox-3.0.0/tests/test_retry.py +86 -0
- mailtoolsbox-3.0.0/tests/test_session.py +111 -0
- mailtoolsbox-1.1.0/MailToolsBox/__init__.py +0 -6
- mailtoolsbox-1.1.0/setup.py +0 -75
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/LICENSE.txt +0 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/dependency_links.txt +0 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/top_level.txt +0 -0
- {mailtoolsbox-1.1.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."""
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import base64
|
|
4
|
+
import datetime as dt
|
|
4
5
|
import email
|
|
5
|
-
import
|
|
6
|
-
import os
|
|
6
|
+
import imaplib
|
|
7
7
|
import json
|
|
8
|
-
import
|
|
9
|
-
import
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import ssl
|
|
12
|
+
from contextlib import contextmanager
|
|
10
13
|
from dataclasses import dataclass, field
|
|
11
|
-
from typing import List, Optional, Iterable, Tuple, Dict, Any
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from enum import Enum
|
|
14
14
|
from email.header import decode_header, make_header
|
|
15
15
|
from email.message import Message
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple
|
|
16
18
|
|
|
19
|
+
from .exceptions import AuthenticationError, IMAPError
|
|
20
|
+
from .security import SecurityMode, build_ssl_context
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class SecurityMode(str, Enum):
|
|
21
|
-
AUTO = "auto" # 993 -> SSL on connect, else try STARTTLS if server advertises it
|
|
22
|
-
STARTTLS = "starttls"
|
|
23
|
-
SSL = "ssl" # implicit TLS on connect
|
|
24
|
-
NONE = "none" # plaintext, only for trusted LANs
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
logger.addHandler(logging.NullHandler())
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
# ----------------------------- Models -------------------------------
|
|
28
27
|
|
|
28
|
+
|
|
29
29
|
@dataclass
|
|
30
30
|
class MailAddress:
|
|
31
31
|
name: Optional[str]
|
|
@@ -58,6 +58,7 @@ class MailItem:
|
|
|
58
58
|
|
|
59
59
|
# --------------------------- Utilities ------------------------------
|
|
60
60
|
|
|
61
|
+
|
|
61
62
|
def _decode_header_value(value: Optional[str]) -> str:
|
|
62
63
|
if not value:
|
|
63
64
|
return ""
|
|
@@ -66,6 +67,16 @@ def _decode_header_value(value: Optional[str]) -> str:
|
|
|
66
67
|
except Exception:
|
|
67
68
|
return value
|
|
68
69
|
|
|
70
|
+
|
|
71
|
+
def _decode_filename(value: Optional[str]) -> str:
|
|
72
|
+
if not value:
|
|
73
|
+
return ""
|
|
74
|
+
try:
|
|
75
|
+
return str(make_header(decode_header(value)))
|
|
76
|
+
except Exception:
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
69
80
|
def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
|
|
70
81
|
if not value:
|
|
71
82
|
return []
|
|
@@ -75,21 +86,25 @@ def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
|
|
|
75
86
|
out.append(MailAddress(_decode_header_value(name) or None, addr or None))
|
|
76
87
|
return out
|
|
77
88
|
|
|
89
|
+
|
|
78
90
|
def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
|
|
79
91
|
if not date_hdr:
|
|
80
92
|
return None
|
|
81
93
|
try:
|
|
82
|
-
|
|
83
|
-
if
|
|
94
|
+
dt_obj = email.utils.parsedate_to_datetime(date_hdr)
|
|
95
|
+
if dt_obj is None:
|
|
84
96
|
return None
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
# Assume UTC when timezone missing to keep comparisons consistent.
|
|
98
|
+
if dt_obj.tzinfo is None:
|
|
99
|
+
dt_obj = dt_obj.replace(tzinfo=dt.timezone.utc)
|
|
100
|
+
return dt_obj
|
|
87
101
|
except Exception:
|
|
88
102
|
return None
|
|
89
103
|
|
|
90
104
|
|
|
91
105
|
# ----------------------------- Client --------------------------------
|
|
92
106
|
|
|
107
|
+
|
|
93
108
|
class ImapClient:
|
|
94
109
|
"""
|
|
95
110
|
Improved IMAP client with:
|
|
@@ -122,11 +137,7 @@ class ImapClient:
|
|
|
122
137
|
self.oauth2_access_token = oauth2_access_token
|
|
123
138
|
self.timeout = timeout
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
if allow_invalid_certs:
|
|
127
|
-
ctx.check_hostname = False
|
|
128
|
-
ctx.verify_mode = ssl.CERT_NONE
|
|
129
|
-
self.ssl_context = ctx
|
|
140
|
+
self.ssl_context = build_ssl_context(ssl_context, allow_invalid_certs=allow_invalid_certs)
|
|
130
141
|
|
|
131
142
|
self.conn: Optional[imaplib.IMAP4] = None
|
|
132
143
|
self.selected_mailbox: Optional[str] = None
|
|
@@ -134,7 +145,7 @@ class ImapClient:
|
|
|
134
145
|
# --------- factories
|
|
135
146
|
|
|
136
147
|
@classmethod
|
|
137
|
-
def from_env(cls) ->
|
|
148
|
+
def from_env(cls) -> ImapClient:
|
|
138
149
|
"""
|
|
139
150
|
Required: IMAP_EMAIL, IMAP_SERVER
|
|
140
151
|
Optional: IMAP_PASSWORD, IMAP_PORT, IMAP_SECURITY, IMAP_OAUTH2_TOKEN, IMAP_ALLOW_INVALID_CERTS
|
|
@@ -159,7 +170,7 @@ class ImapClient:
|
|
|
159
170
|
|
|
160
171
|
# --------- context manager
|
|
161
172
|
|
|
162
|
-
def __enter__(self) ->
|
|
173
|
+
def __enter__(self) -> ImapClient:
|
|
163
174
|
self.login()
|
|
164
175
|
return self
|
|
165
176
|
|
|
@@ -180,7 +191,9 @@ class ImapClient:
|
|
|
180
191
|
|
|
181
192
|
conn = imaplib.IMAP4(self.server_address, self.port)
|
|
182
193
|
conn.timeout = self.timeout
|
|
183
|
-
if mode == SecurityMode.STARTTLS or (
|
|
194
|
+
if mode == SecurityMode.STARTTLS or (
|
|
195
|
+
mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities
|
|
196
|
+
):
|
|
184
197
|
conn.starttls(self.ssl_context)
|
|
185
198
|
# capabilities may change after STARTTLS
|
|
186
199
|
conn.capabilities = conn.capability()[1][0].split() # refresh
|
|
@@ -189,17 +202,19 @@ class ImapClient:
|
|
|
189
202
|
def _auth(self, conn: imaplib.IMAP4) -> None:
|
|
190
203
|
if self.oauth2_access_token:
|
|
191
204
|
# XOAUTH2: base64("user=<email>\x01auth=Bearer <token>\x01\x01")
|
|
192
|
-
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()
|
|
193
206
|
xoauth = base64.b64encode(raw).decode("ascii")
|
|
194
207
|
typ, resp = conn.authenticate("XOAUTH2", lambda _: xoauth)
|
|
195
208
|
if typ != "OK":
|
|
196
|
-
raise
|
|
209
|
+
raise AuthenticationError(f"XOAUTH2 failed: {resp!r}")
|
|
197
210
|
return
|
|
198
211
|
if self.password is None:
|
|
199
|
-
raise
|
|
212
|
+
raise AuthenticationError(
|
|
213
|
+
"No password or OAuth2 token provided for IMAP authentication"
|
|
214
|
+
)
|
|
200
215
|
typ, resp = conn.login(self.email_account, self.password)
|
|
201
216
|
if typ != "OK":
|
|
202
|
-
raise
|
|
217
|
+
raise AuthenticationError(f"IMAP login failed: {resp!r}")
|
|
203
218
|
|
|
204
219
|
def login(self) -> None:
|
|
205
220
|
if self.conn:
|
|
@@ -232,8 +247,12 @@ class ImapClient:
|
|
|
232
247
|
# format: b'(\\HasNoChildren) "/" "INBOX/Sub"'
|
|
233
248
|
if not line:
|
|
234
249
|
continue
|
|
235
|
-
|
|
236
|
-
|
|
250
|
+
decoded = line.decode("utf-8", errors="ignore")
|
|
251
|
+
match = re.search(r'"((?:\\.|[^"])*)"$', decoded)
|
|
252
|
+
if match:
|
|
253
|
+
name = match.group(1).replace(r"\"", '"')
|
|
254
|
+
else:
|
|
255
|
+
name = decoded.split(" ", 1)[-1].strip('"')
|
|
237
256
|
names.append(name)
|
|
238
257
|
return names
|
|
239
258
|
|
|
@@ -241,7 +260,7 @@ class ImapClient:
|
|
|
241
260
|
self._ensure()
|
|
242
261
|
typ, _ = self.conn.select(mailbox, readonly=readonly)
|
|
243
262
|
if typ != "OK":
|
|
244
|
-
raise
|
|
263
|
+
raise IMAPError(f"Cannot select mailbox {mailbox}")
|
|
245
264
|
self.selected_mailbox = mailbox
|
|
246
265
|
|
|
247
266
|
# --------- search and fetch
|
|
@@ -265,7 +284,7 @@ class ImapClient:
|
|
|
265
284
|
self._ensure_selected()
|
|
266
285
|
typ, data = self.conn.uid("fetch", uid, "(RFC822 FLAGS)")
|
|
267
286
|
if typ != "OK" or not data or not isinstance(data[0], tuple):
|
|
268
|
-
raise
|
|
287
|
+
raise IMAPError(f"Failed to fetch UID {uid}")
|
|
269
288
|
raw: bytes = data[0][1]
|
|
270
289
|
# FLAGS come in a separate item depending on server, normalize
|
|
271
290
|
flags: List[str] = []
|
|
@@ -276,7 +295,7 @@ class ImapClient:
|
|
|
276
295
|
start = flags_blob.find("(")
|
|
277
296
|
end = flags_blob.find(")")
|
|
278
297
|
if start >= 0 and end > start:
|
|
279
|
-
flags = flags_blob[start + 1:end].split()
|
|
298
|
+
flags = flags_blob[start + 1 : end].split()
|
|
280
299
|
break
|
|
281
300
|
except Exception:
|
|
282
301
|
flags = []
|
|
@@ -293,18 +312,34 @@ class ImapClient:
|
|
|
293
312
|
text_body: Optional[str] = None
|
|
294
313
|
html_body: Optional[str] = None
|
|
295
314
|
attachments: List[MailPart] = []
|
|
315
|
+
used_names: Set[str] = set()
|
|
296
316
|
|
|
297
317
|
if msg.is_multipart():
|
|
298
318
|
for part in msg.walk():
|
|
299
319
|
ctype = part.get_content_type()
|
|
300
320
|
dispo = str(part.get("Content-Disposition") or "")
|
|
301
|
-
|
|
321
|
+
filename_raw = part.get_filename()
|
|
322
|
+
filename = _decode_filename(filename_raw)
|
|
323
|
+
if filename:
|
|
324
|
+
filename = Path(filename).name
|
|
325
|
+
if filename in used_names:
|
|
326
|
+
stem = Path(filename).stem
|
|
327
|
+
suffix = Path(filename).suffix
|
|
328
|
+
counter = 1
|
|
329
|
+
candidate = f"{stem}_{counter}{suffix}"
|
|
330
|
+
while candidate in used_names:
|
|
331
|
+
counter += 1
|
|
332
|
+
candidate = f"{stem}_{counter}{suffix}"
|
|
333
|
+
filename = candidate
|
|
334
|
+
used_names.add(filename)
|
|
302
335
|
is_attach = "attachment" in dispo.lower() or bool(filename)
|
|
303
336
|
charset = part.get_content_charset() or "utf-8"
|
|
304
337
|
|
|
305
338
|
if is_attach:
|
|
306
339
|
payload = part.get_payload(decode=True) or b""
|
|
307
|
-
attachments.append(
|
|
340
|
+
attachments.append(
|
|
341
|
+
MailPart(ctype, charset, payload, filename=filename, is_attachment=True)
|
|
342
|
+
)
|
|
308
343
|
continue
|
|
309
344
|
|
|
310
345
|
if ctype == "text/plain":
|
|
@@ -355,7 +390,8 @@ class ImapClient:
|
|
|
355
390
|
for uid in uids:
|
|
356
391
|
try:
|
|
357
392
|
out.append(self.fetch(uid))
|
|
358
|
-
except Exception:
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
logger.warning("Failed to fetch UID %s: %s", uid, exc)
|
|
359
395
|
continue
|
|
360
396
|
return out
|
|
361
397
|
|
|
@@ -410,11 +446,23 @@ class ImapClient:
|
|
|
410
446
|
|
|
411
447
|
# --------- legacy style exports (modernized)
|
|
412
448
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
416
456
|
"""
|
|
417
|
-
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:
|
|
418
466
|
self.select(mailbox)
|
|
419
467
|
uids = self.search("ALL")
|
|
420
468
|
lines: List[str] = []
|
|
@@ -429,21 +477,14 @@ class ImapClient:
|
|
|
429
477
|
)
|
|
430
478
|
out = Path(path) / "email.txt"
|
|
431
479
|
out.write_text("".join(lines), encoding="utf-8")
|
|
432
|
-
self.logout()
|
|
433
480
|
return out
|
|
434
481
|
|
|
435
|
-
def
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
mailbox: str = "INBOX",
|
|
442
|
-
) -> str:
|
|
443
|
-
"""
|
|
444
|
-
Return JSON of selected messages. Optionally save to disk.
|
|
445
|
-
"""
|
|
446
|
-
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:
|
|
447
488
|
self.select(mailbox)
|
|
448
489
|
uids = self.search(lookup)
|
|
449
490
|
items = self.fetch_many(uids)
|
|
@@ -467,14 +508,21 @@ class ImapClient:
|
|
|
467
508
|
if save:
|
|
468
509
|
out = Path(path) / file_name
|
|
469
510
|
out.write_text(s, encoding="utf-8")
|
|
470
|
-
self.logout()
|
|
471
511
|
return s
|
|
472
512
|
|
|
473
|
-
def
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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]:
|
|
478
526
|
self.select(mailbox)
|
|
479
527
|
uids = self.search(lookup)
|
|
480
528
|
out_paths: List[Path] = []
|
|
@@ -486,9 +534,15 @@ class ImapClient:
|
|
|
486
534
|
out = target_dir / file_name
|
|
487
535
|
out.write_bytes(item.raw)
|
|
488
536
|
out_paths.append(out)
|
|
489
|
-
self.logout()
|
|
490
537
|
return out_paths
|
|
491
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
|
+
|
|
492
546
|
# --------- internal guards
|
|
493
547
|
|
|
494
548
|
def _ensure(self) -> None:
|
|
@@ -499,3 +553,112 @@ class ImapClient:
|
|
|
499
553
|
self._ensure()
|
|
500
554
|
if not self.selected_mailbox:
|
|
501
555
|
self.select("INBOX")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ----------------------------- Legacy shim -----------------------------
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class ImapAgent(ImapClient):
|
|
562
|
+
"""
|
|
563
|
+
Backward compatible adapter that mirrors the old ImapAgent API
|
|
564
|
+
on top of the modern ImapClient.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
def __init__(
|
|
568
|
+
self,
|
|
569
|
+
email_account: str,
|
|
570
|
+
password: Optional[str],
|
|
571
|
+
server_address: str,
|
|
572
|
+
*,
|
|
573
|
+
port: int = 993,
|
|
574
|
+
security_mode: SecurityMode = SecurityMode.SSL,
|
|
575
|
+
oauth2_access_token: Optional[str] = None,
|
|
576
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
577
|
+
allow_invalid_certs: bool = False,
|
|
578
|
+
timeout: int = 30,
|
|
579
|
+
) -> None:
|
|
580
|
+
super().__init__(
|
|
581
|
+
email_account=email_account,
|
|
582
|
+
password=password,
|
|
583
|
+
server_address=server_address,
|
|
584
|
+
port=port,
|
|
585
|
+
security_mode=security_mode,
|
|
586
|
+
oauth2_access_token=oauth2_access_token,
|
|
587
|
+
ssl_context=ssl_context,
|
|
588
|
+
allow_invalid_certs=allow_invalid_certs,
|
|
589
|
+
timeout=timeout,
|
|
590
|
+
)
|
|
591
|
+
self.mail: Optional[imaplib.IMAP4] = None
|
|
592
|
+
|
|
593
|
+
def login_account(self) -> imaplib.IMAP4:
|
|
594
|
+
self.login()
|
|
595
|
+
self.mail = self.conn
|
|
596
|
+
return self.mail
|
|
597
|
+
|
|
598
|
+
def logout_account(self) -> None:
|
|
599
|
+
self.logout()
|
|
600
|
+
self.mail = None
|
|
601
|
+
|
|
602
|
+
def __enter__(self) -> ImapAgent:
|
|
603
|
+
self.login_account()
|
|
604
|
+
return self
|
|
605
|
+
|
|
606
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
607
|
+
self.logout_account()
|
|
608
|
+
|
|
609
|
+
def _sync_mail_alias(self) -> None:
|
|
610
|
+
if self.mail and self.conn is None:
|
|
611
|
+
self.conn = self.mail
|
|
612
|
+
|
|
613
|
+
def logout(self) -> None:
|
|
614
|
+
super().logout()
|
|
615
|
+
self.mail = None
|
|
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
|
+
|
|
621
|
+
def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
|
|
622
|
+
self._sync_mail_alias()
|
|
623
|
+
self.login()
|
|
624
|
+
try:
|
|
625
|
+
return self._dump_text(path, mailbox)
|
|
626
|
+
finally:
|
|
627
|
+
self.logout()
|
|
628
|
+
|
|
629
|
+
def download_mail_json(
|
|
630
|
+
self,
|
|
631
|
+
lookup: str = "ALL",
|
|
632
|
+
save: bool = False,
|
|
633
|
+
path: str = "",
|
|
634
|
+
file_name: str = "mail.json",
|
|
635
|
+
mailbox: str = "INBOX",
|
|
636
|
+
) -> str:
|
|
637
|
+
self._sync_mail_alias()
|
|
638
|
+
self.login()
|
|
639
|
+
try:
|
|
640
|
+
return self._dump_json(lookup, save, path, file_name, mailbox)
|
|
641
|
+
finally:
|
|
642
|
+
self.logout()
|
|
643
|
+
|
|
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)."""
|
|
648
|
+
self._sync_mail_alias()
|
|
649
|
+
self.login()
|
|
650
|
+
try:
|
|
651
|
+
self.select(mailbox)
|
|
652
|
+
uids = self.search(lookup)
|
|
653
|
+
out_paths: List[Path] = []
|
|
654
|
+
target_dir = Path(path or ".")
|
|
655
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
for i, uid in enumerate(uids):
|
|
657
|
+
item = self.fetch(uid)
|
|
658
|
+
file_name = f"email_{i}.msg"
|
|
659
|
+
out = target_dir / file_name
|
|
660
|
+
out.write_bytes(item.raw)
|
|
661
|
+
out_paths.append(out)
|
|
662
|
+
return out_paths
|
|
663
|
+
finally:
|
|
664
|
+
self.logout()
|