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.
@@ -0,0 +1,6 @@
1
+ from MailToolsBox.mailSender import EmailSender, SendAgent
2
+ from MailToolsBox.imapClient import ImapAgent, ImapClient
3
+
4
+ __version__ = "2.0.0"
5
+
6
+ __all__ = ["EmailSender", "SendAgent", "ImapClient", "ImapAgent", "__version__"]
@@ -1,18 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- import imaplib
3
+ import base64
4
+ import datetime as dt
4
5
  import email
5
- import ssl
6
- import os
6
+ import imaplib
7
7
  import json
8
- import datetime as dt
9
- import base64
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
- t = email.utils.parsedate_tz(date_hdr)
83
- if not t:
96
+ dt_obj = email.utils.parsedate_to_datetime(date_hdr)
97
+ if dt_obj is None:
84
98
  return None
85
- timestamp = email.utils.mktime_tz(t)
86
- return dt.datetime.fromtimestamp(timestamp)
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
- parts = line.decode("utf-8", errors="ignore").split(" ")
236
- name = parts[-1].strip('"')
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
- filename = part.get_filename()
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, Iterable, Tuple
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 = True,
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 EmailNotValidError as e:
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=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
- # Temporarily override security if provided at call site
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
- original_mode = self.security_mode
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,
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <h1>{{ subject or "Hello" }}</h1>
5
+ <p>{{ body or "This is a sample template shipped with MailToolsBox." }}</p>
6
+ </body>
7
+ </html>
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MailToolsBox
3
- Version: 1.1.0
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/v1.1.0.tar.gz
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
- - Email validation
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.imap_client import ImapClient # or the path you placed it under
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 validation is enabled.
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.
@@ -9,5 +9,6 @@ MailToolsBox.egg-info/SOURCES.txt
9
9
  MailToolsBox.egg-info/dependency_links.txt
10
10
  MailToolsBox.egg-info/requires.txt
11
11
  MailToolsBox.egg-info/top_level.txt
12
+ MailToolsBox/templates/example.html
12
13
  tests/test_imap_agent.py
13
14
  tests/test_mail_sender.py
@@ -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: 1.1.0
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/v1.1.0.tar.gz
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
- - Email validation
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.imap_client import ImapClient # or the path you placed it under
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 validation is enabled.
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
- - Email validation
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.imap_client import ImapClient # or the path you placed it under
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 validation is enabled.
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="1.1.0",
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/v1.1.0.tar.gz",
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
- def select(self, mailbox):
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 addr: dummy)
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 addr: dummy)
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 addr: dummy)
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=10)
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
-
@@ -1,6 +0,0 @@
1
- from MailToolsBox.mailSender import EmailSender, SendAgent
2
- from MailToolsBox.imapClient import ImapAgent
3
-
4
- __version__ = "1.0.1"
5
-
6
- __all__ = ["EmailSender", "SendAgent", "ImapAgent", "__version__"]
File without changes
File without changes