Skip to content

Commit e57a4f9

Browse files
authored
fix(smtp): prevent double STARTTLS by passing start_tls=False to aiosmtplib (#20)
## Summary Fix SMTP STARTTLS double execution that causes `EmailConnectionError: SMTP connection error: Connection already using TLS` when using `SMTPConfig(use_tls=True, use_ssl=False)` with any STARTTLS-capable server (port 587). Closes #19 ## Problem When connecting to an SMTP server that supports STARTTLS (e.g., `smtp.office365.com:587`), the TLS upgrade is executed **twice**: | Step | Actor | What happens | |------|-------|-------------| | 1 | litestar-email | Creates `aiosmtplib.SMTP` **without passing `start_tls`** (defaults to `None`) | | 2 | aiosmtplib | `start_tls=None` → auto-detect → server supports STARTTLS → **auto-upgrades** ✅ | | 3 | litestar-email | `use_tls=True` → **manually calls `starttls()` again** → 💥 `Connection already using TLS` | This affects **all SMTP servers requiring STARTTLS** (Office 365, Gmail, AWS SES, etc.), making the documented `use_tls=True` + `use_ssl=False` configuration completely broken. ## Solution Explicitly pass `start_tls=False` to `aiosmtplib.SMTP()` to disable its auto-STARTTLS negotiation, letting litestar-email manage the STARTTLS upgrade manually as intended: ```python self._connection = aiosmtplib.SMTP( hostname=self._config.host, port=self._config.port, timeout=self._config.timeout, use_tls=self._config.use_ssl, start_tls=False, # Disable auto-STARTTLS; we manage it manually below ) ``` This is the minimal, non-breaking fix (Option A from the issue). The existing manual `starttls()` call remains the single point of TLS upgrade. ## Changes - **`src/litestar_email/backends/smtp.py`** — Add `start_tls=False` parameter to `aiosmtplib.SMTP()` constructor - **`src/tests/test_smtp_backend.py`** — Add 3 regression tests: - `test_smtp_backend_starttls_no_double_call` — Verifies `start_tls=False` is passed and manual `starttls()` is called exactly once - `test_smtp_backend_ssl_does_not_call_starttls` — Verifies implicit SSL (`use_ssl=True`) does not trigger manual STARTTLS - `test_smtp_backend_no_tls_no_ssl` — Verifies plain connection does not call STARTTLS
1 parent 7e5bbee commit e57a4f9

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

src/litestar_email/backends/smtp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ async def open(self) -> bool:
116116
port=self._config.port,
117117
timeout=self._config.timeout,
118118
use_tls=self._config.use_ssl, # use_tls in aiosmtplib means implicit SSL
119+
start_tls=False, # Disable auto-STARTTLS
119120
)
120121

121122
try:

src/tests/test_smtp_backend.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,105 @@ async def test_smtp_backend_starttls_upgrade() -> None:
336336
await backend.open()
337337

338338
mock_smtp.starttls.assert_called_once()
339+
340+
341+
async def test_smtp_backend_starttls_no_double_call() -> None:
342+
"""Test that start_tls=False is passed to aiosmtplib to prevent double STARTTLS.
343+
344+
Regression test for: SMTP STARTTLS executed twice causes
345+
'Connection already using TLS' error when aiosmtplib auto-detects
346+
STARTTLS support and litestar-email also calls starttls() manually.
347+
"""
348+
from litestar_email.backends.smtp import SMTPBackend
349+
from litestar_email.config import SMTPConfig
350+
351+
config = SMTPConfig(
352+
host="smtp.example.com",
353+
port=587,
354+
use_tls=True,
355+
use_ssl=False,
356+
)
357+
backend = SMTPBackend(config=config)
358+
359+
with patch("aiosmtplib.SMTP") as mock_smtp_class:
360+
mock_smtp = AsyncMock()
361+
mock_smtp.connect = AsyncMock()
362+
mock_smtp.starttls = AsyncMock()
363+
mock_smtp_class.return_value = mock_smtp
364+
365+
await backend.open()
366+
367+
# Verify start_tls=False was passed to disable auto-STARTTLS
368+
mock_smtp_class.assert_called_once_with(
369+
hostname="smtp.example.com",
370+
port=587,
371+
timeout=30,
372+
use_tls=False,
373+
start_tls=False,
374+
)
375+
# Verify manual STARTTLS is still called exactly once
376+
mock_smtp.starttls.assert_called_once()
377+
378+
379+
async def test_smtp_backend_ssl_does_not_call_starttls() -> None:
380+
"""Test that implicit SSL (use_ssl=True) does not trigger manual STARTTLS."""
381+
from litestar_email.backends.smtp import SMTPBackend
382+
from litestar_email.config import SMTPConfig
383+
384+
config = SMTPConfig(
385+
host="smtp.example.com",
386+
port=465,
387+
use_tls=False,
388+
use_ssl=True,
389+
)
390+
backend = SMTPBackend(config=config)
391+
392+
with patch("aiosmtplib.SMTP") as mock_smtp_class:
393+
mock_smtp = AsyncMock()
394+
mock_smtp.connect = AsyncMock()
395+
mock_smtp.starttls = AsyncMock()
396+
mock_smtp_class.return_value = mock_smtp
397+
398+
await backend.open()
399+
400+
# use_tls=True in aiosmtplib means implicit SSL
401+
mock_smtp_class.assert_called_once_with(
402+
hostname="smtp.example.com",
403+
port=465,
404+
timeout=30,
405+
use_tls=True,
406+
start_tls=False,
407+
)
408+
# No manual STARTTLS for implicit SSL
409+
mock_smtp.starttls.assert_not_called()
410+
411+
412+
async def test_smtp_backend_no_tls_no_ssl() -> None:
413+
"""Test plain connection without TLS or SSL does not call starttls."""
414+
from litestar_email.backends.smtp import SMTPBackend
415+
from litestar_email.config import SMTPConfig
416+
417+
config = SMTPConfig(
418+
host="localhost",
419+
port=25,
420+
use_tls=False,
421+
use_ssl=False,
422+
)
423+
backend = SMTPBackend(config=config)
424+
425+
with patch("aiosmtplib.SMTP") as mock_smtp_class:
426+
mock_smtp = AsyncMock()
427+
mock_smtp.connect = AsyncMock()
428+
mock_smtp.starttls = AsyncMock()
429+
mock_smtp_class.return_value = mock_smtp
430+
431+
await backend.open()
432+
433+
mock_smtp_class.assert_called_once_with(
434+
hostname="localhost",
435+
port=25,
436+
timeout=30,
437+
use_tls=False,
438+
start_tls=False,
439+
)
440+
mock_smtp.starttls.assert_not_called()

0 commit comments

Comments
 (0)