MailToolsBox 1.1.0__tar.gz → 2.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-2.0.0/MailToolsBox/__init__.py +6 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox/imapClient.py +148 -16
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox/mailSender.py +50 -32
- mailtoolsbox-2.0.0/MailToolsBox/templates/example.html +7 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox.egg-info/PKG-INFO +48 -7
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox.egg-info/SOURCES.txt +1 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox.egg-info/requires.txt +8 -1
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/PKG-INFO +48 -7
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/README.md +40 -4
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/setup.py +10 -3
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/tests/test_imap_agent.py +40 -12
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/tests/test_mail_sender.py +92 -10
- mailtoolsbox-1.1.0/MailToolsBox/__init__.py +0 -6
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/LICENSE.txt +0 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox.egg-info/dependency_links.txt +0 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/MailToolsBox.egg-info/top_level.txt +0 -0
- {mailtoolsbox-1.1.0 → mailtoolsbox-2.0.0}/setup.cfg +0 -0
|
@@ -1,18 +1,23 @@
|
|
|
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
|
|
10
12
|
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
13
|
from email.header import decode_header, make_header
|
|
15
14
|
from email.message import Message
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
logger.addHandler(logging.NullHandler())
|
|
16
21
|
|
|
17
22
|
|
|
18
23
|
# ----------------------------- Security -----------------------------
|
|
@@ -66,6 +71,15 @@ def _decode_header_value(value: Optional[str]) -> str:
|
|
|
66
71
|
except Exception:
|
|
67
72
|
return value
|
|
68
73
|
|
|
74
|
+
|
|
75
|
+
def _decode_filename(value: Optional[str]) -> str:
|
|
76
|
+
if not value:
|
|
77
|
+
return ""
|
|
78
|
+
try:
|
|
79
|
+
return str(make_header(decode_header(value)))
|
|
80
|
+
except Exception:
|
|
81
|
+
return value
|
|
82
|
+
|
|
69
83
|
def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
|
|
70
84
|
if not value:
|
|
71
85
|
return []
|
|
@@ -79,11 +93,13 @@ def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
|
|
|
79
93
|
if not date_hdr:
|
|
80
94
|
return None
|
|
81
95
|
try:
|
|
82
|
-
|
|
83
|
-
if
|
|
96
|
+
dt_obj = email.utils.parsedate_to_datetime(date_hdr)
|
|
97
|
+
if dt_obj is None:
|
|
84
98
|
return None
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
# Assume UTC when timezone missing to keep comparisons consistent.
|
|
100
|
+
if dt_obj.tzinfo is None:
|
|
101
|
+
dt_obj = dt_obj.replace(tzinfo=dt.timezone.utc)
|
|
102
|
+
return dt_obj
|
|
87
103
|
except Exception:
|
|
88
104
|
return None
|
|
89
105
|
|
|
@@ -232,8 +248,12 @@ class ImapClient:
|
|
|
232
248
|
# format: b'(\\HasNoChildren) "/" "INBOX/Sub"'
|
|
233
249
|
if not line:
|
|
234
250
|
continue
|
|
235
|
-
|
|
236
|
-
|
|
251
|
+
decoded = line.decode("utf-8", errors="ignore")
|
|
252
|
+
match = re.search(r'"((?:\\.|[^"])*)"$', decoded)
|
|
253
|
+
if match:
|
|
254
|
+
name = match.group(1).replace(r"\"", '"')
|
|
255
|
+
else:
|
|
256
|
+
name = decoded.split(" ", 1)[-1].strip('"')
|
|
237
257
|
names.append(name)
|
|
238
258
|
return names
|
|
239
259
|
|
|
@@ -293,12 +313,26 @@ class ImapClient:
|
|
|
293
313
|
text_body: Optional[str] = None
|
|
294
314
|
html_body: Optional[str] = None
|
|
295
315
|
attachments: List[MailPart] = []
|
|
316
|
+
used_names: Set[str] = set()
|
|
296
317
|
|
|
297
318
|
if msg.is_multipart():
|
|
298
319
|
for part in msg.walk():
|
|
299
320
|
ctype = part.get_content_type()
|
|
300
321
|
dispo = str(part.get("Content-Disposition") or "")
|
|
301
|
-
|
|
322
|
+
filename_raw = part.get_filename()
|
|
323
|
+
filename = _decode_filename(filename_raw)
|
|
324
|
+
if filename:
|
|
325
|
+
filename = Path(filename).name
|
|
326
|
+
if filename in used_names:
|
|
327
|
+
stem = Path(filename).stem
|
|
328
|
+
suffix = Path(filename).suffix
|
|
329
|
+
counter = 1
|
|
330
|
+
candidate = f"{stem}_{counter}{suffix}"
|
|
331
|
+
while candidate in used_names:
|
|
332
|
+
counter += 1
|
|
333
|
+
candidate = f"{stem}_{counter}{suffix}"
|
|
334
|
+
filename = candidate
|
|
335
|
+
used_names.add(filename)
|
|
302
336
|
is_attach = "attachment" in dispo.lower() or bool(filename)
|
|
303
337
|
charset = part.get_content_charset() or "utf-8"
|
|
304
338
|
|
|
@@ -355,7 +389,8 @@ class ImapClient:
|
|
|
355
389
|
for uid in uids:
|
|
356
390
|
try:
|
|
357
391
|
out.append(self.fetch(uid))
|
|
358
|
-
except Exception:
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
logger.warning("Failed to fetch UID %s: %s", uid, exc)
|
|
359
394
|
continue
|
|
360
395
|
return out
|
|
361
396
|
|
|
@@ -499,3 +534,100 @@ class ImapClient:
|
|
|
499
534
|
self._ensure()
|
|
500
535
|
if not self.selected_mailbox:
|
|
501
536
|
self.select("INBOX")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ----------------------------- Legacy shim -----------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class ImapAgent(ImapClient):
|
|
543
|
+
"""
|
|
544
|
+
Backward compatible adapter that mirrors the old ImapAgent API
|
|
545
|
+
on top of the modern ImapClient.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
def __init__(
|
|
549
|
+
self,
|
|
550
|
+
email_account: str,
|
|
551
|
+
password: Optional[str],
|
|
552
|
+
server_address: str,
|
|
553
|
+
*,
|
|
554
|
+
port: int = 993,
|
|
555
|
+
security_mode: SecurityMode = SecurityMode.SSL,
|
|
556
|
+
oauth2_access_token: Optional[str] = None,
|
|
557
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
558
|
+
allow_invalid_certs: bool = False,
|
|
559
|
+
timeout: int = 30,
|
|
560
|
+
) -> None:
|
|
561
|
+
super().__init__(
|
|
562
|
+
email_account=email_account,
|
|
563
|
+
password=password,
|
|
564
|
+
server_address=server_address,
|
|
565
|
+
port=port,
|
|
566
|
+
security_mode=security_mode,
|
|
567
|
+
oauth2_access_token=oauth2_access_token,
|
|
568
|
+
ssl_context=ssl_context,
|
|
569
|
+
allow_invalid_certs=allow_invalid_certs,
|
|
570
|
+
timeout=timeout,
|
|
571
|
+
)
|
|
572
|
+
self.mail: Optional[imaplib.IMAP4] = None
|
|
573
|
+
|
|
574
|
+
def login_account(self) -> imaplib.IMAP4:
|
|
575
|
+
self.login()
|
|
576
|
+
self.mail = self.conn
|
|
577
|
+
return self.mail
|
|
578
|
+
|
|
579
|
+
def logout_account(self) -> None:
|
|
580
|
+
self.logout()
|
|
581
|
+
self.mail = None
|
|
582
|
+
|
|
583
|
+
def __enter__(self) -> "ImapAgent":
|
|
584
|
+
self.login_account()
|
|
585
|
+
return self
|
|
586
|
+
|
|
587
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
588
|
+
self.logout_account()
|
|
589
|
+
|
|
590
|
+
def _sync_mail_alias(self) -> None:
|
|
591
|
+
if self.mail and self.conn is None:
|
|
592
|
+
self.conn = self.mail
|
|
593
|
+
|
|
594
|
+
def logout(self) -> None:
|
|
595
|
+
super().logout()
|
|
596
|
+
self.mail = None
|
|
597
|
+
|
|
598
|
+
def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
|
|
599
|
+
self._sync_mail_alias()
|
|
600
|
+
return super().download_mail_text(path=path, mailbox=mailbox)
|
|
601
|
+
|
|
602
|
+
def download_mail_json(
|
|
603
|
+
self,
|
|
604
|
+
lookup: str = "ALL",
|
|
605
|
+
save: bool = False,
|
|
606
|
+
path: str = "",
|
|
607
|
+
file_name: str = "mail.json",
|
|
608
|
+
mailbox: str = "INBOX",
|
|
609
|
+
) -> str:
|
|
610
|
+
self._sync_mail_alias()
|
|
611
|
+
return super().download_mail_json(lookup=lookup, save=save, path=path, file_name=file_name, mailbox=mailbox)
|
|
612
|
+
|
|
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
|
+
"""
|
|
617
|
+
self._sync_mail_alias()
|
|
618
|
+
self.login()
|
|
619
|
+
try:
|
|
620
|
+
self.select(mailbox)
|
|
621
|
+
uids = self.search(lookup)
|
|
622
|
+
out_paths: List[Path] = []
|
|
623
|
+
target_dir = Path(path or ".")
|
|
624
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
625
|
+
for i, uid in enumerate(uids):
|
|
626
|
+
item = self.fetch(uid)
|
|
627
|
+
file_name = f"email_{i}.msg"
|
|
628
|
+
out = target_dir / file_name
|
|
629
|
+
out.write_bytes(item.raw)
|
|
630
|
+
out_paths.append(out)
|
|
631
|
+
return out_paths
|
|
632
|
+
finally:
|
|
633
|
+
self.logout()
|
|
@@ -20,7 +20,7 @@ import mimetypes
|
|
|
20
20
|
|
|
21
21
|
from enum import Enum
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from typing import List, Optional,
|
|
23
|
+
from typing import Iterable, List, Optional, Tuple
|
|
24
24
|
|
|
25
25
|
from email.mime.text import MIMEText
|
|
26
26
|
from email.mime.multipart import MIMEMultipart
|
|
@@ -28,7 +28,6 @@ from email.mime.application import MIMEApplication
|
|
|
28
28
|
from email.mime.base import MIMEBase
|
|
29
29
|
from email import encoders
|
|
30
30
|
from email.utils import formatdate
|
|
31
|
-
from email_validator import validate_email, EmailNotValidError
|
|
32
31
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
33
32
|
|
|
34
33
|
# ---------------------------------------------------------------------
|
|
@@ -38,6 +37,22 @@ logger = logging.getLogger(__name__)
|
|
|
38
37
|
logger.addHandler(logging.NullHandler())
|
|
39
38
|
|
|
40
39
|
|
|
40
|
+
# ---------------------------------------------------------------------
|
|
41
|
+
# Optional imports
|
|
42
|
+
# ---------------------------------------------------------------------
|
|
43
|
+
def _load_email_validator():
|
|
44
|
+
"""Import email-validator only when validation is requested."""
|
|
45
|
+
try:
|
|
46
|
+
from email_validator import validate_email, EmailNotValidError # type: ignore
|
|
47
|
+
except Exception as exc: # ImportError or runtime import issues
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"Email validation requested but the optional dependency `email-validator` "
|
|
50
|
+
"is not installed. Install it with `pip install \"MailToolsBox[validation]\"` "
|
|
51
|
+
"or set validate_emails=False."
|
|
52
|
+
) from exc
|
|
53
|
+
return validate_email, EmailNotValidError
|
|
54
|
+
|
|
55
|
+
|
|
41
56
|
# ---------------------------------------------------------------------
|
|
42
57
|
# Security modes
|
|
43
58
|
# ---------------------------------------------------------------------
|
|
@@ -75,7 +90,7 @@ class EmailSender:
|
|
|
75
90
|
*,
|
|
76
91
|
port: int = 587,
|
|
77
92
|
timeout: int = 30,
|
|
78
|
-
validate_emails: bool =
|
|
93
|
+
validate_emails: bool = False,
|
|
79
94
|
template_dir: Optional[str] = None,
|
|
80
95
|
security_mode: SecurityMode = SecurityMode.AUTO,
|
|
81
96
|
oauth2_access_token: Optional[str] = None,
|
|
@@ -117,6 +132,16 @@ class EmailSender:
|
|
|
117
132
|
self.user_email, self.server_smtp_address, self.port, self.security_mode
|
|
118
133
|
)
|
|
119
134
|
|
|
135
|
+
def _resolve_security_mode(
|
|
136
|
+
self,
|
|
137
|
+
security_mode: Optional[SecurityMode],
|
|
138
|
+
use_tls: Optional[bool],
|
|
139
|
+
) -> SecurityMode:
|
|
140
|
+
mode = SecurityMode(security_mode) if security_mode else self.security_mode
|
|
141
|
+
if use_tls is not None:
|
|
142
|
+
mode = SecurityMode.STARTTLS if use_tls else SecurityMode.NONE
|
|
143
|
+
return mode
|
|
144
|
+
|
|
120
145
|
# ----------------- Convenience constructors -----------------
|
|
121
146
|
|
|
122
147
|
@classmethod
|
|
@@ -176,10 +201,11 @@ class EmailSender:
|
|
|
176
201
|
|
|
177
202
|
def _validate_email(self, email_address: str) -> str:
|
|
178
203
|
"""Validate and normalize email address using email-validator."""
|
|
204
|
+
validate_email, email_error = _load_email_validator()
|
|
179
205
|
try:
|
|
180
206
|
result = validate_email(email_address, check_deliverability=False)
|
|
181
207
|
return result.normalized
|
|
182
|
-
except
|
|
208
|
+
except email_error as e:
|
|
183
209
|
logger.error("Invalid email address: %s", email_address)
|
|
184
210
|
raise ValueError(f"Invalid email address: {email_address}") from e
|
|
185
211
|
|
|
@@ -309,9 +335,9 @@ class EmailSender:
|
|
|
309
335
|
|
|
310
336
|
# ----------------- Connection helpers -----------------
|
|
311
337
|
|
|
312
|
-
def _open_sync(self) -> smtplib.SMTP:
|
|
338
|
+
def _open_sync(self, mode: Optional[SecurityMode] = None) -> smtplib.SMTP:
|
|
313
339
|
"""Open a synchronous SMTP connection with requested security behavior."""
|
|
314
|
-
mode = self.security_mode
|
|
340
|
+
mode = mode or self.security_mode
|
|
315
341
|
if mode == SecurityMode.AUTO and self.port == 465:
|
|
316
342
|
mode = SecurityMode.SSL
|
|
317
343
|
|
|
@@ -340,29 +366,19 @@ class EmailSender:
|
|
|
340
366
|
# mode NONE means keep plain
|
|
341
367
|
return server
|
|
342
368
|
|
|
343
|
-
def _aiosmtp(self) -> aiosmtplib.SMTP:
|
|
369
|
+
def _aiosmtp(self, mode: SecurityMode) -> aiosmtplib.SMTP:
|
|
344
370
|
"""Create an aiosmtplib.SMTP instance with proper TLS flags."""
|
|
345
|
-
mode = self.security_mode
|
|
346
371
|
if mode == SecurityMode.AUTO and self.port == 465:
|
|
347
372
|
mode = SecurityMode.SSL
|
|
348
373
|
|
|
349
374
|
use_tls = mode == SecurityMode.SSL
|
|
350
|
-
if mode == SecurityMode.STARTTLS:
|
|
351
|
-
start_tls = True
|
|
352
|
-
elif mode == SecurityMode.NONE:
|
|
353
|
-
start_tls = False
|
|
354
|
-
elif mode == SecurityMode.SSL:
|
|
355
|
-
start_tls = False # implicit TLS already
|
|
356
|
-
else:
|
|
357
|
-
start_tls = None # auto upgrade if server advertises STARTTLS
|
|
358
|
-
|
|
359
375
|
# Note: no duplicated parameters here
|
|
360
376
|
return aiosmtplib.SMTP(
|
|
361
377
|
hostname=self.server_smtp_address,
|
|
362
378
|
port=self.port,
|
|
363
379
|
timeout=self.timeout,
|
|
364
380
|
use_tls=use_tls,
|
|
365
|
-
start_tls=
|
|
381
|
+
start_tls=False, # handle STARTTLS manually after connect
|
|
366
382
|
tls_context=self.ssl_context,
|
|
367
383
|
)
|
|
368
384
|
|
|
@@ -410,6 +426,7 @@ class EmailSender:
|
|
|
410
426
|
attachments: Optional[Iterable[str]] = None,
|
|
411
427
|
html: bool = False,
|
|
412
428
|
security_mode: Optional[SecurityMode] = None,
|
|
429
|
+
use_tls: Optional[bool] = None,
|
|
413
430
|
) -> None:
|
|
414
431
|
"""Send a single message synchronously."""
|
|
415
432
|
recipients_list = list(recipients)
|
|
@@ -426,13 +443,9 @@ class EmailSender:
|
|
|
426
443
|
if attachments:
|
|
427
444
|
self._add_attachments(msg, attachments)
|
|
428
445
|
|
|
429
|
-
|
|
430
|
-
original_mode = self.security_mode
|
|
431
|
-
if security_mode:
|
|
432
|
-
self.security_mode = SecurityMode(security_mode)
|
|
433
|
-
|
|
446
|
+
mode = self._resolve_security_mode(security_mode, use_tls)
|
|
434
447
|
try:
|
|
435
|
-
with self._open_sync() as server:
|
|
448
|
+
with self._open_sync(mode) as server:
|
|
436
449
|
self._smtp_login_sync(server)
|
|
437
450
|
all_recipients = list(validated_to)
|
|
438
451
|
if validated_cc:
|
|
@@ -446,8 +459,6 @@ class EmailSender:
|
|
|
446
459
|
except Exception as e:
|
|
447
460
|
logger.error("Unexpected error: %s", str(e))
|
|
448
461
|
raise
|
|
449
|
-
finally:
|
|
450
|
-
self.security_mode = original_mode
|
|
451
462
|
|
|
452
463
|
def send_bulk(
|
|
453
464
|
self,
|
|
@@ -478,6 +489,7 @@ class EmailSender:
|
|
|
478
489
|
bcc: Optional[Iterable[str]] = None,
|
|
479
490
|
attachments: Optional[Iterable[str]] = None,
|
|
480
491
|
security_mode: Optional[SecurityMode] = None,
|
|
492
|
+
use_tls: Optional[bool] = None,
|
|
481
493
|
) -> None:
|
|
482
494
|
"""Render a Jinja2 template and send as HTML."""
|
|
483
495
|
template = self.template_env.get_template(template_name)
|
|
@@ -491,6 +503,7 @@ class EmailSender:
|
|
|
491
503
|
attachments=attachments,
|
|
492
504
|
html=True,
|
|
493
505
|
security_mode=security_mode,
|
|
506
|
+
use_tls=use_tls,
|
|
494
507
|
)
|
|
495
508
|
|
|
496
509
|
# ----------------- Public API: async -----------------
|
|
@@ -506,6 +519,7 @@ class EmailSender:
|
|
|
506
519
|
attachments: Optional[Iterable[str]] = None,
|
|
507
520
|
html: bool = False,
|
|
508
521
|
security_mode: Optional[SecurityMode] = None,
|
|
522
|
+
use_tls: Optional[bool] = None,
|
|
509
523
|
) -> None:
|
|
510
524
|
"""Send a single message asynchronously using aiosmtplib."""
|
|
511
525
|
recipients_list = list(recipients)
|
|
@@ -522,14 +536,20 @@ class EmailSender:
|
|
|
522
536
|
if attachments:
|
|
523
537
|
await self._add_attachments_async(msg, attachments)
|
|
524
538
|
|
|
525
|
-
|
|
526
|
-
if security_mode:
|
|
527
|
-
self.security_mode = SecurityMode(security_mode)
|
|
539
|
+
mode = self._resolve_security_mode(security_mode, use_tls)
|
|
528
540
|
|
|
529
541
|
try:
|
|
530
|
-
server = self._aiosmtp()
|
|
542
|
+
server = self._aiosmtp(mode)
|
|
531
543
|
await server.connect()
|
|
532
544
|
try:
|
|
545
|
+
if mode == SecurityMode.STARTTLS or (
|
|
546
|
+
mode == SecurityMode.AUTO and server.supports_extension("starttls")
|
|
547
|
+
):
|
|
548
|
+
await server.starttls(context=self.ssl_context)
|
|
549
|
+
if self.ehlo_hostname:
|
|
550
|
+
await server.ehlo(self.ehlo_hostname)
|
|
551
|
+
else:
|
|
552
|
+
await server.ehlo()
|
|
533
553
|
await self._smtp_login_async(server)
|
|
534
554
|
all_recipients = list(validated_to)
|
|
535
555
|
if validated_cc:
|
|
@@ -545,8 +565,6 @@ class EmailSender:
|
|
|
545
565
|
except Exception as e:
|
|
546
566
|
logger.error("Unexpected error: %s", str(e))
|
|
547
567
|
raise
|
|
548
|
-
finally:
|
|
549
|
-
self.security_mode = original_mode
|
|
550
568
|
|
|
551
569
|
async def send_bulk_async(
|
|
552
570
|
self,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MailToolsBox
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Modern sync and async SMTP with optional TLS/SSL, OAuth2 XOAUTH2, Jinja2 templates, and attachments.
|
|
5
5
|
Home-page: https://github.com/rambod/MailToolsBox
|
|
6
|
-
Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/
|
|
6
|
+
Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/v2.0.0.tar.gz
|
|
7
7
|
Author: Rambod Ghashghai
|
|
8
8
|
Author-email: gh.rambod@gmail.com
|
|
9
9
|
License: MIT
|
|
@@ -26,15 +26,20 @@ Requires-Python: >=3.8
|
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
License-File: LICENSE.txt
|
|
28
28
|
Requires-Dist: Jinja2>=3.0.2
|
|
29
|
-
Requires-Dist: email-validator>=2.0.0
|
|
30
29
|
Requires-Dist: aiosmtplib>=2.0.0
|
|
31
30
|
Requires-Dist: aiofiles>=23.0.0
|
|
31
|
+
Provides-Extra: validation
|
|
32
|
+
Requires-Dist: email-validator>=2.0.0; extra == "validation"
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
35
|
Requires-Dist: pytest-asyncio>=0.20; extra == "dev"
|
|
35
36
|
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
36
37
|
Requires-Dist: black>=24.0; extra == "dev"
|
|
37
38
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
39
|
+
Requires-Dist: email-validator>=2.0.0; extra == "dev"
|
|
40
|
+
Provides-Extra: test
|
|
41
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
42
|
+
Requires-Dist: pytest-asyncio>=0.20; extra == "test"
|
|
38
43
|
Provides-Extra: docs
|
|
39
44
|
Requires-Dist: mkdocs>=1.5; extra == "docs"
|
|
40
45
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
@@ -70,7 +75,7 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
70
75
|
- Jinja2 templates with auto plain text fallback
|
|
71
76
|
- MIME smart attachment handling
|
|
72
77
|
- Bulk sending helpers
|
|
73
|
-
-
|
|
78
|
+
- Optional email validation (opt-in extra)
|
|
74
79
|
- Environment variable configuration
|
|
75
80
|
- Backward compatibility shim `SendAgent`
|
|
76
81
|
|
|
@@ -80,6 +85,9 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
80
85
|
|
|
81
86
|
```bash
|
|
82
87
|
pip install MailToolsBox
|
|
88
|
+
|
|
89
|
+
# Optional: include address validation support
|
|
90
|
+
pip install "MailToolsBox[validation]"
|
|
83
91
|
```
|
|
84
92
|
|
|
85
93
|
---
|
|
@@ -97,6 +105,8 @@ sender = EmailSender(
|
|
|
97
105
|
user_email_password="password",
|
|
98
106
|
port=587, # typical for STARTTLS
|
|
99
107
|
security_mode="starttls" # or "auto"
|
|
108
|
+
# validation is off by default to keep dependencies light;
|
|
109
|
+
# enable with validate_emails=True after installing the [validation] extra
|
|
100
110
|
)
|
|
101
111
|
|
|
102
112
|
sender.send(
|
|
@@ -109,7 +119,7 @@ sender.send(
|
|
|
109
119
|
### Read emails
|
|
110
120
|
|
|
111
121
|
```python
|
|
112
|
-
from MailToolsBox
|
|
122
|
+
from MailToolsBox import ImapClient
|
|
113
123
|
|
|
114
124
|
with ImapClient(
|
|
115
125
|
email_account="you@example.com",
|
|
@@ -140,6 +150,8 @@ with ImapClient(
|
|
|
140
150
|
- `ssl`: implicit SSL on connect, typical for port 465.
|
|
141
151
|
- `none`: no TLS. Use only inside trusted networks.
|
|
142
152
|
|
|
153
|
+
You can also pass `use_tls=True` to force STARTTLS regardless of the configured `security_mode` (or `use_tls=False` to force plaintext for trusted relays).
|
|
154
|
+
|
|
143
155
|
### Gmail and Exchange recipes
|
|
144
156
|
|
|
145
157
|
```python
|
|
@@ -150,6 +162,17 @@ sender.send(["to@example.com"], "Hi", "Body")
|
|
|
150
162
|
# Exchange Online with SMTP AUTH
|
|
151
163
|
exchange = EmailSender.for_exchange_smtp_auth("you@company.com", "password")
|
|
152
164
|
exchange.send(["person@company.com"], "Status", "Body")
|
|
165
|
+
|
|
166
|
+
# Exchange on-prem with self-signed certs (only on trusted networks)
|
|
167
|
+
exchange_on_prem = EmailSender(
|
|
168
|
+
user_email="you@corp.local",
|
|
169
|
+
server_smtp_address="mail.corp.local",
|
|
170
|
+
user_email_password="password",
|
|
171
|
+
port=587,
|
|
172
|
+
security_mode="starttls",
|
|
173
|
+
allow_invalid_certs=True, # accept self-signed certs on trusted networks
|
|
174
|
+
)
|
|
175
|
+
exchange_on_prem.send(["admin@corp.local"], "Status", "Body")
|
|
153
176
|
```
|
|
154
177
|
|
|
155
178
|
### OAuth2 XOAUTH2
|
|
@@ -232,7 +255,8 @@ imap = ImapClient(
|
|
|
232
255
|
password="password",
|
|
233
256
|
server_address="imap.example.com",
|
|
234
257
|
port=993,
|
|
235
|
-
security_mode="ssl"
|
|
258
|
+
security_mode="ssl",
|
|
259
|
+
allow_invalid_certs=False, # set True to accept self-signed certs
|
|
236
260
|
)
|
|
237
261
|
imap.login()
|
|
238
262
|
imap.select("INBOX")
|
|
@@ -256,6 +280,8 @@ export IMAP_PORT=993
|
|
|
256
280
|
export IMAP_SECURITY=ssl
|
|
257
281
|
# Optional OAuth token
|
|
258
282
|
export IMAP_OAUTH2_TOKEN=ya29.a0Af...
|
|
283
|
+
# Accept self-signed certs for dev/on-prem only
|
|
284
|
+
export IMAP_ALLOW_INVALID_CERTS=1
|
|
259
285
|
```
|
|
260
286
|
|
|
261
287
|
### Search and fetch
|
|
@@ -317,7 +343,7 @@ with imap:
|
|
|
317
343
|
|
|
318
344
|
## Validation and templates
|
|
319
345
|
|
|
320
|
-
- Addresses are normalized with `email-validator` when
|
|
346
|
+
- Addresses are normalized with `email-validator` only when you opt in via `validate_emails=True` and install the optional extra: `pip install "MailToolsBox[validation]"`.
|
|
321
347
|
- Templates use Jinja2 with autoescape for HTML and XML.
|
|
322
348
|
- HTML sending includes a plain text alternative for better deliverability.
|
|
323
349
|
|
|
@@ -358,17 +384,32 @@ legacy = SendAgent("you@example.com", "smtp.example.com", "pw", port=587)
|
|
|
358
384
|
legacy.send_mail(["to@example.com"], "Subject", "Body", tls=True)
|
|
359
385
|
```
|
|
360
386
|
|
|
387
|
+
`ImapAgent` remains as a thin adapter over `ImapClient` for projects that still import the legacy name.
|
|
388
|
+
|
|
361
389
|
---
|
|
362
390
|
|
|
363
391
|
## Security notes
|
|
364
392
|
|
|
365
393
|
- Prefer `ssl` on 465 or `starttls` on 587.
|
|
394
|
+
- To accept self-signed certificates for SMTP/IMAP on trusted networks, pass `allow_invalid_certs=True`.
|
|
395
|
+
- To intentionally skip TLS (inside trusted networks only), use `security_mode="none"`.
|
|
396
|
+
- For on-prem Exchange or legacy servers with self-signed certificates, pass `allow_invalid_certs=True` (SMTP/IMAP) only on trusted networks.
|
|
366
397
|
- Use app passwords when your provider offers them.
|
|
367
398
|
- Prefer OAuth2 tokens for long term services.
|
|
368
399
|
- Use `none` only on trusted networks.
|
|
369
400
|
|
|
370
401
|
---
|
|
371
402
|
|
|
403
|
+
## Testing
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
pip install -e ".[dev]" # or: pip install -r requirements-dev.txt
|
|
407
|
+
pytest
|
|
408
|
+
```
|
|
409
|
+
Tests are network-free and rely on local fakes, so they run quickly.
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
372
413
|
## Troubleshooting
|
|
373
414
|
|
|
374
415
|
- Authentication errors on Gmail usually mean you need an app password or OAuth2.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
Jinja2>=3.0.2
|
|
2
|
-
email-validator>=2.0.0
|
|
3
2
|
aiosmtplib>=2.0.0
|
|
4
3
|
aiofiles>=23.0.0
|
|
5
4
|
|
|
@@ -9,7 +8,15 @@ pytest-asyncio>=0.20
|
|
|
9
8
|
mypy>=1.0
|
|
10
9
|
black>=24.0
|
|
11
10
|
ruff>=0.5
|
|
11
|
+
email-validator>=2.0.0
|
|
12
12
|
|
|
13
13
|
[docs]
|
|
14
14
|
mkdocs>=1.5
|
|
15
15
|
mkdocs-material>=9.5
|
|
16
|
+
|
|
17
|
+
[test]
|
|
18
|
+
pytest>=7.0
|
|
19
|
+
pytest-asyncio>=0.20
|
|
20
|
+
|
|
21
|
+
[validation]
|
|
22
|
+
email-validator>=2.0.0
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: MailToolsBox
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Modern sync and async SMTP with optional TLS/SSL, OAuth2 XOAUTH2, Jinja2 templates, and attachments.
|
|
5
5
|
Home-page: https://github.com/rambod/MailToolsBox
|
|
6
|
-
Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/
|
|
6
|
+
Download-URL: https://github.com/rambod/MailToolsBox/archive/refs/tags/v2.0.0.tar.gz
|
|
7
7
|
Author: Rambod Ghashghai
|
|
8
8
|
Author-email: gh.rambod@gmail.com
|
|
9
9
|
License: MIT
|
|
@@ -26,15 +26,20 @@ Requires-Python: >=3.8
|
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
License-File: LICENSE.txt
|
|
28
28
|
Requires-Dist: Jinja2>=3.0.2
|
|
29
|
-
Requires-Dist: email-validator>=2.0.0
|
|
30
29
|
Requires-Dist: aiosmtplib>=2.0.0
|
|
31
30
|
Requires-Dist: aiofiles>=23.0.0
|
|
31
|
+
Provides-Extra: validation
|
|
32
|
+
Requires-Dist: email-validator>=2.0.0; extra == "validation"
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
35
|
Requires-Dist: pytest-asyncio>=0.20; extra == "dev"
|
|
35
36
|
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
36
37
|
Requires-Dist: black>=24.0; extra == "dev"
|
|
37
38
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
39
|
+
Requires-Dist: email-validator>=2.0.0; extra == "dev"
|
|
40
|
+
Provides-Extra: test
|
|
41
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
42
|
+
Requires-Dist: pytest-asyncio>=0.20; extra == "test"
|
|
38
43
|
Provides-Extra: docs
|
|
39
44
|
Requires-Dist: mkdocs>=1.5; extra == "docs"
|
|
40
45
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
@@ -70,7 +75,7 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
70
75
|
- Jinja2 templates with auto plain text fallback
|
|
71
76
|
- MIME smart attachment handling
|
|
72
77
|
- Bulk sending helpers
|
|
73
|
-
-
|
|
78
|
+
- Optional email validation (opt-in extra)
|
|
74
79
|
- Environment variable configuration
|
|
75
80
|
- Backward compatibility shim `SendAgent`
|
|
76
81
|
|
|
@@ -80,6 +85,9 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
80
85
|
|
|
81
86
|
```bash
|
|
82
87
|
pip install MailToolsBox
|
|
88
|
+
|
|
89
|
+
# Optional: include address validation support
|
|
90
|
+
pip install "MailToolsBox[validation]"
|
|
83
91
|
```
|
|
84
92
|
|
|
85
93
|
---
|
|
@@ -97,6 +105,8 @@ sender = EmailSender(
|
|
|
97
105
|
user_email_password="password",
|
|
98
106
|
port=587, # typical for STARTTLS
|
|
99
107
|
security_mode="starttls" # or "auto"
|
|
108
|
+
# validation is off by default to keep dependencies light;
|
|
109
|
+
# enable with validate_emails=True after installing the [validation] extra
|
|
100
110
|
)
|
|
101
111
|
|
|
102
112
|
sender.send(
|
|
@@ -109,7 +119,7 @@ sender.send(
|
|
|
109
119
|
### Read emails
|
|
110
120
|
|
|
111
121
|
```python
|
|
112
|
-
from MailToolsBox
|
|
122
|
+
from MailToolsBox import ImapClient
|
|
113
123
|
|
|
114
124
|
with ImapClient(
|
|
115
125
|
email_account="you@example.com",
|
|
@@ -140,6 +150,8 @@ with ImapClient(
|
|
|
140
150
|
- `ssl`: implicit SSL on connect, typical for port 465.
|
|
141
151
|
- `none`: no TLS. Use only inside trusted networks.
|
|
142
152
|
|
|
153
|
+
You can also pass `use_tls=True` to force STARTTLS regardless of the configured `security_mode` (or `use_tls=False` to force plaintext for trusted relays).
|
|
154
|
+
|
|
143
155
|
### Gmail and Exchange recipes
|
|
144
156
|
|
|
145
157
|
```python
|
|
@@ -150,6 +162,17 @@ sender.send(["to@example.com"], "Hi", "Body")
|
|
|
150
162
|
# Exchange Online with SMTP AUTH
|
|
151
163
|
exchange = EmailSender.for_exchange_smtp_auth("you@company.com", "password")
|
|
152
164
|
exchange.send(["person@company.com"], "Status", "Body")
|
|
165
|
+
|
|
166
|
+
# Exchange on-prem with self-signed certs (only on trusted networks)
|
|
167
|
+
exchange_on_prem = EmailSender(
|
|
168
|
+
user_email="you@corp.local",
|
|
169
|
+
server_smtp_address="mail.corp.local",
|
|
170
|
+
user_email_password="password",
|
|
171
|
+
port=587,
|
|
172
|
+
security_mode="starttls",
|
|
173
|
+
allow_invalid_certs=True, # accept self-signed certs on trusted networks
|
|
174
|
+
)
|
|
175
|
+
exchange_on_prem.send(["admin@corp.local"], "Status", "Body")
|
|
153
176
|
```
|
|
154
177
|
|
|
155
178
|
### OAuth2 XOAUTH2
|
|
@@ -232,7 +255,8 @@ imap = ImapClient(
|
|
|
232
255
|
password="password",
|
|
233
256
|
server_address="imap.example.com",
|
|
234
257
|
port=993,
|
|
235
|
-
security_mode="ssl"
|
|
258
|
+
security_mode="ssl",
|
|
259
|
+
allow_invalid_certs=False, # set True to accept self-signed certs
|
|
236
260
|
)
|
|
237
261
|
imap.login()
|
|
238
262
|
imap.select("INBOX")
|
|
@@ -256,6 +280,8 @@ export IMAP_PORT=993
|
|
|
256
280
|
export IMAP_SECURITY=ssl
|
|
257
281
|
# Optional OAuth token
|
|
258
282
|
export IMAP_OAUTH2_TOKEN=ya29.a0Af...
|
|
283
|
+
# Accept self-signed certs for dev/on-prem only
|
|
284
|
+
export IMAP_ALLOW_INVALID_CERTS=1
|
|
259
285
|
```
|
|
260
286
|
|
|
261
287
|
### Search and fetch
|
|
@@ -317,7 +343,7 @@ with imap:
|
|
|
317
343
|
|
|
318
344
|
## Validation and templates
|
|
319
345
|
|
|
320
|
-
- Addresses are normalized with `email-validator` when
|
|
346
|
+
- Addresses are normalized with `email-validator` only when you opt in via `validate_emails=True` and install the optional extra: `pip install "MailToolsBox[validation]"`.
|
|
321
347
|
- Templates use Jinja2 with autoescape for HTML and XML.
|
|
322
348
|
- HTML sending includes a plain text alternative for better deliverability.
|
|
323
349
|
|
|
@@ -358,17 +384,32 @@ legacy = SendAgent("you@example.com", "smtp.example.com", "pw", port=587)
|
|
|
358
384
|
legacy.send_mail(["to@example.com"], "Subject", "Body", tls=True)
|
|
359
385
|
```
|
|
360
386
|
|
|
387
|
+
`ImapAgent` remains as a thin adapter over `ImapClient` for projects that still import the legacy name.
|
|
388
|
+
|
|
361
389
|
---
|
|
362
390
|
|
|
363
391
|
## Security notes
|
|
364
392
|
|
|
365
393
|
- Prefer `ssl` on 465 or `starttls` on 587.
|
|
394
|
+
- To accept self-signed certificates for SMTP/IMAP on trusted networks, pass `allow_invalid_certs=True`.
|
|
395
|
+
- To intentionally skip TLS (inside trusted networks only), use `security_mode="none"`.
|
|
396
|
+
- For on-prem Exchange or legacy servers with self-signed certificates, pass `allow_invalid_certs=True` (SMTP/IMAP) only on trusted networks.
|
|
366
397
|
- Use app passwords when your provider offers them.
|
|
367
398
|
- Prefer OAuth2 tokens for long term services.
|
|
368
399
|
- Use `none` only on trusted networks.
|
|
369
400
|
|
|
370
401
|
---
|
|
371
402
|
|
|
403
|
+
## Testing
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
pip install -e ".[dev]" # or: pip install -r requirements-dev.txt
|
|
407
|
+
pytest
|
|
408
|
+
```
|
|
409
|
+
Tests are network-free and rely on local fakes, so they run quickly.
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
372
413
|
## Troubleshooting
|
|
373
414
|
|
|
374
415
|
- Authentication errors on Gmail usually mean you need an app password or OAuth2.
|
|
@@ -14,7 +14,7 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
14
14
|
- Jinja2 templates with auto plain text fallback
|
|
15
15
|
- MIME smart attachment handling
|
|
16
16
|
- Bulk sending helpers
|
|
17
|
-
-
|
|
17
|
+
- Optional email validation (opt-in extra)
|
|
18
18
|
- Environment variable configuration
|
|
19
19
|
- Backward compatibility shim `SendAgent`
|
|
20
20
|
|
|
@@ -24,6 +24,9 @@ MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean
|
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
pip install MailToolsBox
|
|
27
|
+
|
|
28
|
+
# Optional: include address validation support
|
|
29
|
+
pip install "MailToolsBox[validation]"
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
---
|
|
@@ -41,6 +44,8 @@ sender = EmailSender(
|
|
|
41
44
|
user_email_password="password",
|
|
42
45
|
port=587, # typical for STARTTLS
|
|
43
46
|
security_mode="starttls" # or "auto"
|
|
47
|
+
# validation is off by default to keep dependencies light;
|
|
48
|
+
# enable with validate_emails=True after installing the [validation] extra
|
|
44
49
|
)
|
|
45
50
|
|
|
46
51
|
sender.send(
|
|
@@ -53,7 +58,7 @@ sender.send(
|
|
|
53
58
|
### Read emails
|
|
54
59
|
|
|
55
60
|
```python
|
|
56
|
-
from MailToolsBox
|
|
61
|
+
from MailToolsBox import ImapClient
|
|
57
62
|
|
|
58
63
|
with ImapClient(
|
|
59
64
|
email_account="you@example.com",
|
|
@@ -84,6 +89,8 @@ with ImapClient(
|
|
|
84
89
|
- `ssl`: implicit SSL on connect, typical for port 465.
|
|
85
90
|
- `none`: no TLS. Use only inside trusted networks.
|
|
86
91
|
|
|
92
|
+
You can also pass `use_tls=True` to force STARTTLS regardless of the configured `security_mode` (or `use_tls=False` to force plaintext for trusted relays).
|
|
93
|
+
|
|
87
94
|
### Gmail and Exchange recipes
|
|
88
95
|
|
|
89
96
|
```python
|
|
@@ -94,6 +101,17 @@ sender.send(["to@example.com"], "Hi", "Body")
|
|
|
94
101
|
# Exchange Online with SMTP AUTH
|
|
95
102
|
exchange = EmailSender.for_exchange_smtp_auth("you@company.com", "password")
|
|
96
103
|
exchange.send(["person@company.com"], "Status", "Body")
|
|
104
|
+
|
|
105
|
+
# Exchange on-prem with self-signed certs (only on trusted networks)
|
|
106
|
+
exchange_on_prem = EmailSender(
|
|
107
|
+
user_email="you@corp.local",
|
|
108
|
+
server_smtp_address="mail.corp.local",
|
|
109
|
+
user_email_password="password",
|
|
110
|
+
port=587,
|
|
111
|
+
security_mode="starttls",
|
|
112
|
+
allow_invalid_certs=True, # accept self-signed certs on trusted networks
|
|
113
|
+
)
|
|
114
|
+
exchange_on_prem.send(["admin@corp.local"], "Status", "Body")
|
|
97
115
|
```
|
|
98
116
|
|
|
99
117
|
### OAuth2 XOAUTH2
|
|
@@ -176,7 +194,8 @@ imap = ImapClient(
|
|
|
176
194
|
password="password",
|
|
177
195
|
server_address="imap.example.com",
|
|
178
196
|
port=993,
|
|
179
|
-
security_mode="ssl"
|
|
197
|
+
security_mode="ssl",
|
|
198
|
+
allow_invalid_certs=False, # set True to accept self-signed certs
|
|
180
199
|
)
|
|
181
200
|
imap.login()
|
|
182
201
|
imap.select("INBOX")
|
|
@@ -200,6 +219,8 @@ export IMAP_PORT=993
|
|
|
200
219
|
export IMAP_SECURITY=ssl
|
|
201
220
|
# Optional OAuth token
|
|
202
221
|
export IMAP_OAUTH2_TOKEN=ya29.a0Af...
|
|
222
|
+
# Accept self-signed certs for dev/on-prem only
|
|
223
|
+
export IMAP_ALLOW_INVALID_CERTS=1
|
|
203
224
|
```
|
|
204
225
|
|
|
205
226
|
### Search and fetch
|
|
@@ -261,7 +282,7 @@ with imap:
|
|
|
261
282
|
|
|
262
283
|
## Validation and templates
|
|
263
284
|
|
|
264
|
-
- Addresses are normalized with `email-validator` when
|
|
285
|
+
- Addresses are normalized with `email-validator` only when you opt in via `validate_emails=True` and install the optional extra: `pip install "MailToolsBox[validation]"`.
|
|
265
286
|
- Templates use Jinja2 with autoescape for HTML and XML.
|
|
266
287
|
- HTML sending includes a plain text alternative for better deliverability.
|
|
267
288
|
|
|
@@ -302,17 +323,32 @@ legacy = SendAgent("you@example.com", "smtp.example.com", "pw", port=587)
|
|
|
302
323
|
legacy.send_mail(["to@example.com"], "Subject", "Body", tls=True)
|
|
303
324
|
```
|
|
304
325
|
|
|
326
|
+
`ImapAgent` remains as a thin adapter over `ImapClient` for projects that still import the legacy name.
|
|
327
|
+
|
|
305
328
|
---
|
|
306
329
|
|
|
307
330
|
## Security notes
|
|
308
331
|
|
|
309
332
|
- Prefer `ssl` on 465 or `starttls` on 587.
|
|
333
|
+
- To accept self-signed certificates for SMTP/IMAP on trusted networks, pass `allow_invalid_certs=True`.
|
|
334
|
+
- To intentionally skip TLS (inside trusted networks only), use `security_mode="none"`.
|
|
335
|
+
- For on-prem Exchange or legacy servers with self-signed certificates, pass `allow_invalid_certs=True` (SMTP/IMAP) only on trusted networks.
|
|
310
336
|
- Use app passwords when your provider offers them.
|
|
311
337
|
- Prefer OAuth2 tokens for long term services.
|
|
312
338
|
- Use `none` only on trusted networks.
|
|
313
339
|
|
|
314
340
|
---
|
|
315
341
|
|
|
342
|
+
## Testing
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
pip install -e ".[dev]" # or: pip install -r requirements-dev.txt
|
|
346
|
+
pytest
|
|
347
|
+
```
|
|
348
|
+
Tests are network-free and rely on local fakes, so they run quickly.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
316
352
|
## Troubleshooting
|
|
317
353
|
|
|
318
354
|
- Authentication errors on Gmail usually mean you need an app password or OAuth2.
|
|
@@ -6,7 +6,7 @@ README = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
|
6
6
|
|
|
7
7
|
setup(
|
|
8
8
|
name="MailToolsBox",
|
|
9
|
-
version="
|
|
9
|
+
version="2.0.0",
|
|
10
10
|
description="Modern sync and async SMTP with optional TLS/SSL, OAuth2 XOAUTH2, Jinja2 templates, and attachments.",
|
|
11
11
|
long_description=README,
|
|
12
12
|
long_description_content_type="text/markdown",
|
|
@@ -14,7 +14,7 @@ setup(
|
|
|
14
14
|
author="Rambod Ghashghai",
|
|
15
15
|
author_email="gh.rambod@gmail.com",
|
|
16
16
|
url="https://github.com/rambod/MailToolsBox",
|
|
17
|
-
download_url="https://github.com/rambod/MailToolsBox/archive/refs/tags/
|
|
17
|
+
download_url="https://github.com/rambod/MailToolsBox/archive/refs/tags/v2.0.0.tar.gz",
|
|
18
18
|
packages=find_packages(exclude=("tests", "examples")),
|
|
19
19
|
include_package_data=True, # needed with package_data to ship templates
|
|
20
20
|
package_data={
|
|
@@ -23,17 +23,24 @@ setup(
|
|
|
23
23
|
},
|
|
24
24
|
install_requires=[
|
|
25
25
|
"Jinja2>=3.0.2",
|
|
26
|
-
"email-validator>=2.0.0",
|
|
27
26
|
"aiosmtplib>=2.0.0",
|
|
28
27
|
"aiofiles>=23.0.0",
|
|
29
28
|
],
|
|
30
29
|
extras_require={
|
|
30
|
+
"validation": [
|
|
31
|
+
"email-validator>=2.0.0",
|
|
32
|
+
],
|
|
31
33
|
"dev": [
|
|
32
34
|
"pytest>=7.0",
|
|
33
35
|
"pytest-asyncio>=0.20",
|
|
34
36
|
"mypy>=1.0",
|
|
35
37
|
"black>=24.0",
|
|
36
38
|
"ruff>=0.5",
|
|
39
|
+
"email-validator>=2.0.0",
|
|
40
|
+
],
|
|
41
|
+
"test": [
|
|
42
|
+
"pytest>=7.0",
|
|
43
|
+
"pytest-asyncio>=0.20",
|
|
37
44
|
],
|
|
38
45
|
"docs": [
|
|
39
46
|
"mkdocs>=1.5",
|
|
@@ -5,6 +5,7 @@ from unittest import mock
|
|
|
5
5
|
import os
|
|
6
6
|
import types
|
|
7
7
|
import sys
|
|
8
|
+
import ssl
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
11
|
|
|
@@ -27,15 +28,8 @@ sys.modules.setdefault(
|
|
|
27
28
|
select_autoescape=lambda x: None,
|
|
28
29
|
),
|
|
29
30
|
)
|
|
30
|
-
sys.modules.setdefault(
|
|
31
|
-
"email_validator",
|
|
32
|
-
types.SimpleNamespace(
|
|
33
|
-
validate_email=lambda email, check_deliverability=False: types.SimpleNamespace(normalized=email),
|
|
34
|
-
EmailNotValidError=Exception,
|
|
35
|
-
),
|
|
36
|
-
)
|
|
37
31
|
|
|
38
|
-
from MailToolsBox.imapClient import ImapAgent
|
|
32
|
+
from MailToolsBox.imapClient import ImapAgent, SecurityMode
|
|
39
33
|
|
|
40
34
|
|
|
41
35
|
class DummyMail:
|
|
@@ -46,7 +40,8 @@ class DummyMail:
|
|
|
46
40
|
self.selected = None
|
|
47
41
|
def login(self, user, password):
|
|
48
42
|
self.logged_in = True
|
|
49
|
-
|
|
43
|
+
return ("OK", [b""])
|
|
44
|
+
def select(self, mailbox, readonly=True):
|
|
50
45
|
self.selected = mailbox
|
|
51
46
|
return ('OK', [b''])
|
|
52
47
|
def uid(self, command, *args):
|
|
@@ -75,7 +70,7 @@ def sample_message_bytes():
|
|
|
75
70
|
|
|
76
71
|
def test_login_account(monkeypatch):
|
|
77
72
|
dummy = DummyMail(sample_message_bytes())
|
|
78
|
-
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda
|
|
73
|
+
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda *args, **kwargs: dummy)
|
|
79
74
|
agent = ImapAgent('user', 'pass', 'imap.example.com')
|
|
80
75
|
agent.login_account()
|
|
81
76
|
assert agent.mail is dummy
|
|
@@ -104,7 +99,7 @@ def test_download_mail_json(tmp_path, monkeypatch, trailing):
|
|
|
104
99
|
message_bytes = sample_message_bytes()
|
|
105
100
|
dummy = DummyMail(message_bytes)
|
|
106
101
|
monkeypatch.setattr(ImapAgent, 'login_account', lambda self: None)
|
|
107
|
-
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda
|
|
102
|
+
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda *args, **kwargs: dummy)
|
|
108
103
|
|
|
109
104
|
agent = ImapAgent('user', 'pass', 'imap.example.com')
|
|
110
105
|
path = str(tmp_path) + (os.sep if trailing else "")
|
|
@@ -124,10 +119,16 @@ def test_download_mail_msg(tmp_path, trailing):
|
|
|
124
119
|
dummy = DummyMail(message_bytes)
|
|
125
120
|
agent = ImapAgent('user', 'pass', 'imap.example.com')
|
|
126
121
|
agent.login_account = lambda: setattr(agent, 'mail', dummy)
|
|
122
|
+
# avoid real network in _open
|
|
123
|
+
import imaplib as _imaplib
|
|
124
|
+
_orig_ssl = _imaplib.IMAP4_SSL
|
|
125
|
+
_imaplib.IMAP4_SSL = lambda *a, **k: dummy
|
|
127
126
|
|
|
128
127
|
path = str(tmp_path) + (os.sep if trailing else "")
|
|
129
128
|
agent.download_mail_msg(path=path)
|
|
130
129
|
|
|
130
|
+
_imaplib.IMAP4_SSL = _orig_ssl
|
|
131
|
+
|
|
131
132
|
file_path = tmp_path / 'email_0.msg'
|
|
132
133
|
assert file_path.exists()
|
|
133
134
|
|
|
@@ -146,7 +147,7 @@ def test_from_env(monkeypatch):
|
|
|
146
147
|
|
|
147
148
|
def test_context_manager(monkeypatch):
|
|
148
149
|
dummy = DummyMail(sample_message_bytes())
|
|
149
|
-
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda
|
|
150
|
+
monkeypatch.setattr('imaplib.IMAP4_SSL', lambda *args, **kwargs: dummy)
|
|
150
151
|
|
|
151
152
|
with ImapAgent('user', 'pass', 'imap.example.com') as agent:
|
|
152
153
|
assert agent.mail is dummy
|
|
@@ -155,3 +156,30 @@ def test_context_manager(monkeypatch):
|
|
|
155
156
|
assert agent.mail is None
|
|
156
157
|
|
|
157
158
|
|
|
159
|
+
def test_imap_allow_invalid_certs_sets_context():
|
|
160
|
+
client = ImapAgent("user@example.com", "pw", "imap.example.com", allow_invalid_certs=True)
|
|
161
|
+
assert client.ssl_context.check_hostname is False
|
|
162
|
+
assert client.ssl_context.verify_mode == ssl.CERT_NONE
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_imap_plain_connection(monkeypatch):
|
|
166
|
+
class PlainIMAP:
|
|
167
|
+
def __init__(self, *args, **kwargs):
|
|
168
|
+
self.started_tls = False
|
|
169
|
+
self.timeout = None
|
|
170
|
+
|
|
171
|
+
def starttls(self, ctx):
|
|
172
|
+
self.started_tls = True
|
|
173
|
+
|
|
174
|
+
monkeypatch.setattr('imaplib.IMAP4', PlainIMAP)
|
|
175
|
+
|
|
176
|
+
client = ImapAgent(
|
|
177
|
+
"user@example.com",
|
|
178
|
+
"pw",
|
|
179
|
+
"imap.example.com",
|
|
180
|
+
port=143,
|
|
181
|
+
security_mode=SecurityMode.NONE,
|
|
182
|
+
)
|
|
183
|
+
conn = client._open()
|
|
184
|
+
assert isinstance(conn, PlainIMAP)
|
|
185
|
+
assert not getattr(conn, "started_tls", False)
|
|
@@ -6,6 +6,7 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from email.mime.multipart import MIMEMultipart
|
|
8
8
|
import time
|
|
9
|
+
import ssl
|
|
9
10
|
import pytest
|
|
10
11
|
|
|
11
12
|
# Provide dummy aiosmtplib to satisfy imports
|
|
@@ -27,13 +28,6 @@ sys.modules.setdefault(
|
|
|
27
28
|
select_autoescape=lambda x: None,
|
|
28
29
|
),
|
|
29
30
|
)
|
|
30
|
-
sys.modules.setdefault(
|
|
31
|
-
"email_validator",
|
|
32
|
-
types.SimpleNamespace(
|
|
33
|
-
validate_email=lambda email, check_deliverability=False: types.SimpleNamespace(normalized=email),
|
|
34
|
-
EmailNotValidError=Exception,
|
|
35
|
-
),
|
|
36
|
-
)
|
|
37
31
|
|
|
38
32
|
from MailToolsBox.mailSender import EmailSender
|
|
39
33
|
|
|
@@ -62,7 +56,7 @@ def test_email_sender_send(monkeypatch):
|
|
|
62
56
|
use_tls=True,
|
|
63
57
|
)
|
|
64
58
|
|
|
65
|
-
smtp_class.assert_called_with("smtp.example.com", 25, timeout=
|
|
59
|
+
smtp_class.assert_called_with("smtp.example.com", 25, timeout=30)
|
|
66
60
|
smtp_instance.starttls.assert_called()
|
|
67
61
|
smtp_instance.login.assert_called_with("user@example.com", "pass")
|
|
68
62
|
smtp_instance.send_message.assert_called()
|
|
@@ -210,12 +204,90 @@ def test_send_template_custom_directory(monkeypatch):
|
|
|
210
204
|
assert captured["body"] == "rendered"
|
|
211
205
|
|
|
212
206
|
|
|
207
|
+
def test_email_validation_is_lazy(monkeypatch):
|
|
208
|
+
from MailToolsBox import mailSender as ms
|
|
209
|
+
|
|
210
|
+
called = {"loader": False}
|
|
211
|
+
|
|
212
|
+
def blow_up():
|
|
213
|
+
called["loader"] = True
|
|
214
|
+
raise ImportError("no validator")
|
|
215
|
+
|
|
216
|
+
monkeypatch.setattr(ms, "_load_email_validator", blow_up)
|
|
217
|
+
|
|
218
|
+
smtp_instance = mock.MagicMock()
|
|
219
|
+
smtp_instance.__enter__.return_value = smtp_instance
|
|
220
|
+
smtp_instance.__exit__.return_value = None
|
|
221
|
+
smtp_class = mock.MagicMock(return_value=smtp_instance)
|
|
222
|
+
monkeypatch.setattr(smtplib, "SMTP", smtp_class)
|
|
223
|
+
|
|
224
|
+
sender = EmailSender(
|
|
225
|
+
user_email="user@example.com",
|
|
226
|
+
server_smtp_address="smtp.example.com",
|
|
227
|
+
user_email_password="pass",
|
|
228
|
+
validate_emails=False, # default path should not import validator
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
sender.send(recipients=["to@example.com"], subject="Subj", message_body="Body")
|
|
232
|
+
|
|
233
|
+
assert called["loader"] is False
|
|
234
|
+
smtp_instance.send_message.assert_called()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_email_validation_requires_optional_dependency(monkeypatch):
|
|
238
|
+
from MailToolsBox import mailSender as ms
|
|
239
|
+
|
|
240
|
+
def blow_up():
|
|
241
|
+
raise ImportError("no validator")
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(ms, "_load_email_validator", blow_up)
|
|
244
|
+
|
|
245
|
+
with pytest.raises(ImportError):
|
|
246
|
+
EmailSender(
|
|
247
|
+
user_email="user@example.com",
|
|
248
|
+
server_smtp_address="smtp.example.com",
|
|
249
|
+
validate_emails=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_email_validation_normalizes(monkeypatch):
|
|
254
|
+
from MailToolsBox import mailSender as ms
|
|
255
|
+
|
|
256
|
+
def loader():
|
|
257
|
+
def validator(addr, check_deliverability=False):
|
|
258
|
+
return types.SimpleNamespace(normalized=addr.lower())
|
|
259
|
+
|
|
260
|
+
return validator, ValueError
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(ms, "_load_email_validator", loader)
|
|
263
|
+
|
|
264
|
+
sender = EmailSender(
|
|
265
|
+
user_email="USER@Example.COM",
|
|
266
|
+
server_smtp_address="smtp.example.com",
|
|
267
|
+
validate_emails=True,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
assert sender.user_email == "user@example.com"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_allow_invalid_certs_sets_context():
|
|
274
|
+
sender = EmailSender(
|
|
275
|
+
user_email="user@example.com",
|
|
276
|
+
server_smtp_address="smtp.example.com",
|
|
277
|
+
allow_invalid_certs=True,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
assert sender.ssl_context.check_hostname is False
|
|
281
|
+
assert sender.ssl_context.verify_mode == ssl.CERT_NONE
|
|
282
|
+
|
|
283
|
+
|
|
213
284
|
class DummyAsyncSMTP:
|
|
214
285
|
def __init__(self):
|
|
215
286
|
self.started_tls = False
|
|
216
287
|
self.logged_in = None
|
|
217
288
|
self.sent_message = None
|
|
218
289
|
self.to_addrs = None
|
|
290
|
+
self.extensions = {"starttls"}
|
|
219
291
|
|
|
220
292
|
async def __aenter__(self):
|
|
221
293
|
return self
|
|
@@ -223,9 +295,18 @@ class DummyAsyncSMTP:
|
|
|
223
295
|
async def __aexit__(self, exc_type, exc, tb):
|
|
224
296
|
pass
|
|
225
297
|
|
|
298
|
+
async def connect(self):
|
|
299
|
+
return None
|
|
300
|
+
|
|
226
301
|
async def starttls(self, context=None):
|
|
227
302
|
self.started_tls = True
|
|
228
303
|
|
|
304
|
+
def supports_extension(self, ext):
|
|
305
|
+
return ext.lower() in self.extensions
|
|
306
|
+
|
|
307
|
+
async def ehlo(self, hostname=None):
|
|
308
|
+
return None
|
|
309
|
+
|
|
229
310
|
async def login(self, user, password):
|
|
230
311
|
self.logged_in = (user, password)
|
|
231
312
|
|
|
@@ -233,6 +314,9 @@ class DummyAsyncSMTP:
|
|
|
233
314
|
self.sent_message = msg
|
|
234
315
|
self.to_addrs = to_addrs
|
|
235
316
|
|
|
317
|
+
async def quit(self):
|
|
318
|
+
return None
|
|
319
|
+
|
|
236
320
|
|
|
237
321
|
def test_send_async_uses_async_attachment(monkeypatch):
|
|
238
322
|
smtp_instance = DummyAsyncSMTP()
|
|
@@ -426,5 +510,3 @@ def test_send_bulk_async_concurrent(monkeypatch):
|
|
|
426
510
|
)
|
|
427
511
|
|
|
428
512
|
assert start["b@example.com"] < end["a@example.com"]
|
|
429
|
-
|
|
430
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|