From 22b1f6c943d5c1fb7b6c6d68489d2838833259de Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Wed, 1 Apr 2026 16:39:13 +0300 Subject: [PATCH] feat: add async SMTP support and Message.send_async() --- .gitignore | 3 + emails/backend/smtp/__init__.py | 7 +- emails/backend/smtp/aio_backend.py | 152 ++++++++++++ emails/backend/smtp/aio_client.py | 148 ++++++++++++ emails/backend/smtp/backend.py | 6 +- emails/message.py | 97 ++++++-- emails/testsuite/message/test_send_async.py | 141 +++++++++++ .../testsuite/message/test_send_async_e2e.py | 59 +++++ emails/testsuite/smtp/test_aio_client.py | 217 +++++++++++++++++ .../testsuite/smtp/test_async_smtp_backend.py | 219 ++++++++++++++++++ requirements/tests-base.txt | 2 + setup.cfg | 7 + setup.py | 1 + 13 files changed, 1033 insertions(+), 26 deletions(-) create mode 100644 emails/backend/smtp/aio_backend.py create mode 100644 emails/backend/smtp/aio_client.py create mode 100644 emails/testsuite/message/test_send_async.py create mode 100644 emails/testsuite/message/test_send_async_e2e.py create mode 100644 emails/testsuite/smtp/test_aio_client.py create mode 100644 emails/testsuite/smtp/test_async_smtp_backend.py diff --git a/.gitignore b/.gitignore index d58b43e..e385e79 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ docs/plans/ # CodeQL .codeql-db codeql-results.sarif + +# ralphex progress logs +.ralphex/progress/ diff --git a/emails/backend/smtp/__init__.py b/emails/backend/smtp/__init__.py index 9988a73..0789dcf 100644 --- a/emails/backend/smtp/__init__.py +++ b/emails/backend/smtp/__init__.py @@ -1,2 +1,7 @@ -from .backend import SMTPBackend \ No newline at end of file +from .backend import SMTPBackend + +try: + from .aio_backend import AsyncSMTPBackend +except ImportError: + pass diff --git a/emails/backend/smtp/aio_backend.py b/emails/backend/smtp/aio_backend.py new file mode 100644 index 0000000..dc24893 --- /dev/null +++ b/emails/backend/smtp/aio_backend.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiosmtplib + +from ..response import SMTPResponse +from .aio_client import AsyncSMTPClientWithResponse +from ...utils import DNS_NAME +from .exceptions import SMTPConnectNetworkError + + +__all__ = ['AsyncSMTPBackend'] + +logger = logging.getLogger(__name__) + + +class AsyncSMTPBackend: + + """ + AsyncSMTPBackend manages an async SMTP connection using aiosmtplib. + """ + + DEFAULT_SOCKET_TIMEOUT = 5 + + response_cls = SMTPResponse + + def __init__(self, ssl: bool = False, fail_silently: bool = True, + mail_options: list[str] | None = None, **kwargs: Any) -> None: + + self.ssl = ssl + self.tls = kwargs.get('tls') + if self.ssl and self.tls: + raise ValueError( + "ssl/tls are mutually exclusive, so only set " + "one of those settings to True.") + + kwargs.setdefault('timeout', self.DEFAULT_SOCKET_TIMEOUT) + kwargs.setdefault('local_hostname', DNS_NAME.get_fqdn()) + kwargs['port'] = int(kwargs.get('port', 0)) + + self.smtp_cls_kwargs = kwargs + + self.host: str | None = kwargs.get('host') + self.port: int = kwargs['port'] + self.fail_silently = fail_silently + self.mail_options = mail_options or [] + + self._client: AsyncSMTPClientWithResponse | None = None + self._lock = asyncio.Lock() + + async def get_client(self) -> AsyncSMTPClientWithResponse: + async with self._lock: + return await self._get_client_unlocked() + + async def _get_client_unlocked(self) -> AsyncSMTPClientWithResponse: + if self._client is None: + client = AsyncSMTPClientWithResponse( + parent=self, ssl=self.ssl, **self.smtp_cls_kwargs + ) + await client.initialize() + self._client = client + return self._client + + async def close(self) -> None: + """Closes the connection to the email server.""" + async with self._lock: + await self._close_unlocked() + + async def _close_unlocked(self) -> None: + if self._client: + try: + await self._client.quit() + except Exception: + if self.fail_silently: + return + raise + finally: + self._client = None + + def make_response(self, exception: Exception | None = None) -> SMTPResponse: + return self.response_cls(backend=self, exception=exception) + + async def _send(self, **kwargs: Any) -> SMTPResponse | None: + response = None + try: + client = await self._get_client_unlocked() + except aiosmtplib.SMTPConnectError as exc: + cause = exc.__cause__ + if isinstance(cause, IOError): + response = self.make_response( + exception=SMTPConnectNetworkError.from_ioerror(cause)) + else: + response = self.make_response(exception=exc) + if not self.fail_silently: + raise + except aiosmtplib.SMTPException as exc: + response = self.make_response(exception=exc) + if not self.fail_silently: + raise + except IOError as exc: + response = self.make_response( + exception=SMTPConnectNetworkError.from_ioerror(exc)) + if not self.fail_silently: + raise + + if response: + return response + else: + return await client.sendmail(**kwargs) + + async def _send_with_retry(self, **kwargs: Any) -> SMTPResponse | None: + async with self._lock: + try: + return await self._send(**kwargs) + except aiosmtplib.SMTPServerDisconnected: + logger.debug('SMTPServerDisconnected, retry once') + await self._close_unlocked() + return await self._send(**kwargs) + + async def sendmail(self, from_addr: str, to_addrs: str | list[str], + msg: Any, mail_options: list[str] | None = None, + rcpt_options: list[str] | None = None) -> SMTPResponse | None: + + if not to_addrs: + return None + + if not isinstance(to_addrs, (list, tuple)): + to_addrs = [to_addrs] + + response = await self._send_with_retry( + from_addr=from_addr, + to_addrs=to_addrs, + msg=msg.as_bytes(), + mail_options=mail_options or self.mail_options, + rcpt_options=rcpt_options, + ) + + if response and not self.fail_silently: + response.raise_if_needed() + + return response + + async def __aenter__(self) -> AsyncSMTPBackend: + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: Any | None) -> None: + await self.close() diff --git a/emails/backend/smtp/aio_client.py b/emails/backend/smtp/aio_client.py new file mode 100644 index 0000000..ca6cf5c --- /dev/null +++ b/emails/backend/smtp/aio_client.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +__all__ = ['AsyncSMTPClientWithResponse'] + +import logging +from typing import TYPE_CHECKING + +import aiosmtplib + +from ..response import SMTPResponse +from ...utils import sanitize_email + +if TYPE_CHECKING: + from .aio_backend import AsyncSMTPBackend + +logger = logging.getLogger(__name__) + + +class AsyncSMTPClientWithResponse: + """Async SMTP client built on aiosmtplib that returns SMTPResponse objects.""" + + def __init__(self, parent: AsyncSMTPBackend, **kwargs): + self.parent = parent + self.make_response = parent.make_response + + self.tls = kwargs.pop('tls', False) + self.ssl = kwargs.pop('ssl', False) + self.debug = kwargs.pop('debug', 0) + self.user = kwargs.pop('user', None) + self.password = kwargs.pop('password', None) + + # aiosmtplib uses use_tls for implicit TLS (SMTPS) and + # start_tls for STARTTLS after connect + smtp_kwargs = dict(kwargs) + smtp_kwargs['use_tls'] = self.ssl + smtp_kwargs['start_tls'] = self.tls + + # aiosmtplib uses 'hostname' instead of 'host' + if 'host' in smtp_kwargs: + smtp_kwargs['hostname'] = smtp_kwargs.pop('host') + + self._smtp = aiosmtplib.SMTP(**smtp_kwargs) + + async def initialize(self): + await self._smtp.connect() + try: + if self._smtp.is_ehlo_or_helo_needed: + await self._smtp.ehlo() + if self.user: + await self._smtp.login(self.user, self.password) + except Exception: + await self.quit() + raise + + async def quit(self): + """Closes the connection to the email server.""" + try: + await self._smtp.quit() + except (aiosmtplib.SMTPServerDisconnected, ConnectionError): + self._smtp.close() + + async def _rset(self): + try: + await self._smtp.rset() + except (aiosmtplib.SMTPServerDisconnected, ConnectionError): + pass + + async def sendmail(self, from_addr: str, to_addrs: list[str] | str, + msg: bytes, mail_options: list[str] | None = None, + rcpt_options: list[str] | None = None) -> SMTPResponse | None: + + if not to_addrs: + return None + + rcpt_options = rcpt_options or [] + mail_options = mail_options or [] + esmtp_opts = [] + if self._smtp.supports_esmtp: + if self._smtp.supports_extension('size'): + esmtp_opts.append("size=%d" % len(msg)) + for option in mail_options: + esmtp_opts.append(option) + + response = self.make_response() + + from_addr = sanitize_email(from_addr) + + response.from_addr = from_addr + response.esmtp_opts = esmtp_opts[:] + + try: + resp = await self._smtp.mail(from_addr, options=esmtp_opts) + except aiosmtplib.SMTPSenderRefused as exc: + response.set_status('mail', exc.code, exc.message.encode() if isinstance(exc.message, str) else exc.message) + response.set_exception(exc) + await self._rset() + return response + + response.set_status('mail', resp.code, resp.message.encode() if isinstance(resp.message, str) else resp.message) + + if resp.code != 250: + await self._rset() + response.set_exception( + aiosmtplib.SMTPSenderRefused(resp.code, resp.message, from_addr)) + return response + + if not isinstance(to_addrs, (list, tuple)): + to_addrs = [to_addrs] + + to_addrs = [sanitize_email(e) for e in to_addrs] + + response.to_addrs = to_addrs + response.rcpt_options = rcpt_options[:] + response.refused_recipients = {} + + for a in to_addrs: + try: + resp = await self._smtp.rcpt(a, options=rcpt_options) + code = resp.code + resp_msg = resp.message.encode() if isinstance(resp.message, str) else resp.message + except aiosmtplib.SMTPRecipientRefused as exc: + code = exc.code + resp_msg = exc.message.encode() if isinstance(exc.message, str) else exc.message + + response.set_status('rcpt', code, resp_msg, recipient=a) + if (code != 250) and (code != 251): + response.refused_recipients[a] = (code, resp_msg) + + if len(response.refused_recipients) == len(to_addrs): + await self._rset() + refused_list = [ + aiosmtplib.SMTPRecipientRefused(code, msg.decode() if isinstance(msg, bytes) else msg, addr) + for addr, (code, msg) in response.refused_recipients.items() + ] + response.set_exception(aiosmtplib.SMTPRecipientsRefused(refused_list)) + return response + + resp = await self._smtp.data(msg) + resp_msg = resp.message.encode() if isinstance(resp.message, str) else resp.message + response.set_status('data', resp.code, resp_msg) + if resp.code != 250: + await self._rset() + response.set_exception( + aiosmtplib.SMTPDataError(resp.code, resp.message)) + return response + + response._finished = True + return response diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index 0b2500e..62ecc64 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -69,7 +69,7 @@ def close(self) -> None: if self._client: try: self._client.quit() - except: + except Exception: if self.fail_silently: return raise @@ -86,7 +86,7 @@ def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse | None: return func(*args, **kwargs) except smtplib.SMTPServerDisconnected: # If server disconected, clear old client - logging.debug('SMTPServerDisconnected, retry once') + logger.debug('SMTPServerDisconnected, retry once') self.close() return func(*args, **kwargs) return wrapper @@ -106,8 +106,6 @@ def _send(self, **kwargs: Any) -> SMTPResponse | None: raise if response: - if not self.fail_silently: - response.raise_if_needed() return response else: return client.sendmail(**kwargs) diff --git a/emails/message.py b/emails/message.py index 230a33e..1a2eea4 100644 --- a/emails/message.py +++ b/emails/message.py @@ -381,29 +381,18 @@ class MessageSendMixin: def smtp_pool(self) -> ObjectFactory: return self.smtp_pool_factory(cls=self.smtp_cls) - def send(self, - to: _AddressList = None, - set_mail_to: bool = True, - mail_from: _Address = None, - set_mail_from: bool = False, - render: dict[str, Any] | None = None, - smtp_mail_options: list[str] | None = None, - smtp_rcpt_options: list[str] | None = None, - smtp: dict[str, Any] | SMTPBackend | None = None) -> Any: + def _prepare_send_params(self, + to: _AddressList = None, + set_mail_to: bool = True, + mail_from: _Address = None, + set_mail_from: bool = False, + render: dict[str, Any] | None = None, + smtp_mail_options: list[str] | None = None, + smtp_rcpt_options: list[str] | None = None) -> dict[str, Any]: if render is not None: self.render(**render) - if smtp is None: - smtp = {'host': 'localhost', 'port': 25, 'timeout': 5} - - if isinstance(smtp, dict): - smtp = self.smtp_pool[smtp] - - if not hasattr(smtp, 'sendmail'): - raise ValueError( - "smtp must be a dict or an object with method 'sendmail'. got %s" % type(smtp)) - to_addrs = None if to: @@ -430,11 +419,77 @@ def send(self, if not from_addr: raise ValueError('No "from" addr') - params = dict(from_addr=from_addr, to_addrs=to_addrs, msg=self, - mail_options=smtp_mail_options, rcpt_options=smtp_rcpt_options) + return dict(from_addr=from_addr, to_addrs=to_addrs, msg=self, + mail_options=smtp_mail_options, rcpt_options=smtp_rcpt_options) + + def send(self, + to: _AddressList = None, + set_mail_to: bool = True, + mail_from: _Address = None, + set_mail_from: bool = False, + render: dict[str, Any] | None = None, + smtp_mail_options: list[str] | None = None, + smtp_rcpt_options: list[str] | None = None, + smtp: dict[str, Any] | SMTPBackend | None = None) -> Any: + + if smtp is None: + smtp = {'host': 'localhost', 'port': 25, 'timeout': 5} + + if isinstance(smtp, dict): + smtp = self.smtp_pool[smtp] + + if not hasattr(smtp, 'sendmail'): + raise ValueError( + "smtp must be a dict or an object with method 'sendmail'. got %s" % type(smtp)) + + params = self._prepare_send_params( + to=to, set_mail_to=set_mail_to, mail_from=mail_from, + set_mail_from=set_mail_from, render=render, + smtp_mail_options=smtp_mail_options, smtp_rcpt_options=smtp_rcpt_options) return smtp.sendmail(**params) + async def send_async(self, + to: _AddressList = None, + set_mail_to: bool = True, + mail_from: _Address = None, + set_mail_from: bool = False, + render: dict[str, Any] | None = None, + smtp_mail_options: list[str] | None = None, + smtp_rcpt_options: list[str] | None = None, + smtp: dict[str, Any] | Any | None = None) -> Any: + + try: + from .backend.smtp.aio_backend import AsyncSMTPBackend + except ImportError: + raise ImportError( + "send_async() requires aiosmtplib. " + "Install it with: pip install emails[async]" + ) from None + + if smtp is None: + smtp = {'host': 'localhost', 'port': 25, 'timeout': 5} + + own_backend = False + if isinstance(smtp, dict): + smtp = AsyncSMTPBackend(**smtp) + own_backend = True + + if not hasattr(smtp, 'sendmail'): + raise ValueError( + "smtp must be a dict or an AsyncSMTPBackend. got %s" % type(smtp)) + + params = self._prepare_send_params( + to=to, set_mail_to=set_mail_to, mail_from=mail_from, + set_mail_from=set_mail_from, render=render, + smtp_mail_options=smtp_mail_options, smtp_rcpt_options=smtp_rcpt_options) + + try: + return await smtp.sendmail(**params) + finally: + if own_backend: + await smtp.close() + class MessageTransformerMixin: diff --git a/emails/testsuite/message/test_send_async.py b/emails/testsuite/message/test_send_async.py new file mode 100644 index 0000000..2aaa8b7 --- /dev/null +++ b/emails/testsuite/message/test_send_async.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import emails +from emails.backend.smtp.aio_backend import AsyncSMTPBackend + +from .helpers import common_email_data + + +@pytest.fixture +def mock_smtp(): + """Patch aiosmtplib.SMTP so no real connection is made.""" + with patch('emails.backend.smtp.aio_client.aiosmtplib.SMTP') as mock_cls: + instance = MagicMock() + instance.connect = AsyncMock() + instance.ehlo = AsyncMock() + instance.helo = AsyncMock() + instance._ehlo_or_helo_if_needed = AsyncMock() + instance.login = AsyncMock() + instance.quit = AsyncMock() + instance.close = MagicMock() + instance.mail = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.rcpt = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.data = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.rset = AsyncMock() + instance.is_ehlo_or_helo_needed = True + instance.supports_esmtp = True + instance.supports_extension = MagicMock(return_value=False) + mock_cls.return_value = instance + yield instance + + +@pytest.mark.asyncio +async def test_send_async_with_dict(mock_smtp): + """send_async(smtp={...}) creates backend, sends, and closes.""" + msg = emails.html(**common_email_data(subject='Async dict test')) + response = await msg.send_async(smtp={'host': 'localhost', 'port': 2525}) + assert response is not None + assert response.success + # Backend should have been closed (quit called) + mock_smtp.quit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_async_with_backend_object(mock_smtp): + """send_async(smtp=AsyncSMTPBackend(...)) uses the provided backend.""" + msg = emails.html(**common_email_data(subject='Async backend test')) + backend = AsyncSMTPBackend(host='localhost', port=2525) + response = await msg.send_async(smtp=backend) + assert response is not None + assert response.success + # Backend should NOT have been closed (caller manages lifecycle) + mock_smtp.quit.assert_not_awaited() + # Clean up + await backend.close() + + +@pytest.mark.asyncio +async def test_send_async_with_default_smtp(mock_smtp): + """send_async() without smtp uses default localhost:25.""" + msg = emails.html(**common_email_data(subject='Async default test')) + response = await msg.send_async() + assert response is not None + assert response.success + + +def test_sync_send_unchanged(): + """message.send() still works the sync path (uses SMTPBackend, not async).""" + msg = emails.html(**common_email_data(subject='Sync unchanged test')) + + mock_backend = MagicMock() + mock_response = MagicMock(success=True) + mock_backend.sendmail.return_value = mock_response + + response = msg.send(smtp=mock_backend) + assert response is mock_response + assert response.success + mock_backend.sendmail.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_async_with_render(mock_smtp): + """send_async() applies render data before sending.""" + msg = emails.html(**common_email_data(subject='Render test')) + response = await msg.send_async( + smtp={'host': 'localhost', 'port': 2525}, + render={'name': 'World'}, + ) + assert response is not None + assert response.success + + +@pytest.mark.asyncio +async def test_send_async_with_to_override(mock_smtp): + """send_async(to=...) overrides mail_to.""" + msg = emails.html(**common_email_data(subject='To override')) + response = await msg.send_async( + to='other@example.com', + smtp={'host': 'localhost', 'port': 2525}, + ) + assert response is not None + assert response.success + # Verify the override address was used as recipient + assert 'other@example.com' in response.to_addrs + + +@pytest.mark.asyncio +async def test_send_async_invalid_smtp_type(): + """send_async() raises ValueError for invalid smtp type.""" + msg = emails.html(**common_email_data(subject='Invalid smtp')) + with pytest.raises(ValueError, match="smtp must be a dict"): + await msg.send_async(smtp=42) + + +@pytest.mark.asyncio +async def test_send_async_no_from_raises(): + """send_async() raises when no from address.""" + msg = emails.html( + subject='No from', + mail_to='to@example.com', + html='

Hello

', + ) + with pytest.raises((ValueError, TypeError)): + await msg.send_async(smtp={'host': 'localhost', 'port': 2525}) + + +@pytest.mark.asyncio +async def test_send_async_closes_on_error(mock_smtp): + """send_async(smtp={...}) closes backend even if sendmail fails.""" + mock_smtp.mail.side_effect = Exception('send failed') + msg = emails.html(**common_email_data(subject='Error close test')) + + with pytest.raises(Exception, match='send failed'): + await msg.send_async( + smtp={'host': 'localhost', 'port': 2525, 'fail_silently': False}, + ) + # Backend should still have been closed + mock_smtp.quit.assert_awaited() diff --git a/emails/testsuite/message/test_send_async_e2e.py b/emails/testsuite/message/test_send_async_e2e.py new file mode 100644 index 0000000..6092951 --- /dev/null +++ b/emails/testsuite/message/test_send_async_e2e.py @@ -0,0 +1,59 @@ +""" +End-to-end async SMTP tests. + +These tests require a running SMTP server (e.g. Mailpit) and are +skipped unless SMTP_TEST_SETS is set in the environment. They +mirror the sync e2e tests in test_send.py but use +``message.send_async()`` and ``AsyncSMTPBackend``. +""" +from __future__ import annotations + +import pytest + +import emails +from emails.backend.smtp.aio_backend import AsyncSMTPBackend + +from .helpers import common_email_data +from emails.testsuite.smtp_servers import get_servers + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_send_async_simple(): + """send_async(smtp={...}) delivers a message through a real SMTP server.""" + message = emails.html(**common_email_data(subject='Async simple e2e test')) + for tag, server in get_servers(): + server.patch_message(message) + response = await message.send_async(smtp=server.params) + assert response.success + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_send_async_with_backend_object(): + """send_async(smtp=AsyncSMTPBackend(...)) delivers a message.""" + for tag, server in get_servers(): + backend = AsyncSMTPBackend(**server.params) + try: + message = emails.html(**common_email_data(subject='Async backend obj e2e')) + server.patch_message(message) + response = await message.send_async(smtp=backend) + assert response.success + finally: + await backend.close() + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_send_async_with_context_manager(): + """AsyncSMTPBackend works as an async context manager for multiple sends.""" + for _, server in get_servers(): + async with AsyncSMTPBackend(**server.params) as backend: + for n in range(2): + data = common_email_data(subject='async context manager {0}'.format(n)) + message = emails.html(**data) + server.patch_message(message) + response = await message.send_async(smtp=backend) + assert response.success or response.status_code in (421, 451), \ + 'error sending to {0}'.format(server.params) + assert backend._client is None diff --git a/emails/testsuite/smtp/test_aio_client.py b/emails/testsuite/smtp/test_aio_client.py new file mode 100644 index 0000000..359db96 --- /dev/null +++ b/emails/testsuite/smtp/test_aio_client.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from emails.backend.response import SMTPResponse + + +# Helper to build a mock aiosmtplib response +def _aio_resp(code: int = 250, message: str = 'OK'): + r = MagicMock() + r.code = code + r.message = message + return r + + +class FakeAsyncSMTPBackend: + """Minimal stand-in for AsyncSMTPBackend so we can test the client in isolation.""" + + response_cls = SMTPResponse + + def make_response(self, exception=None): + return self.response_cls(backend=self, exception=exception) + + +@pytest.fixture +def parent(): + return FakeAsyncSMTPBackend() + + +@pytest.mark.asyncio +async def test_sendmail_success(parent): + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + mock_smtp_instance.supports_extension = MagicMock(return_value=False) + mock_smtp_instance.mail.return_value = _aio_resp(250, 'OK') + mock_smtp_instance.rcpt.return_value = _aio_resp(250, 'OK') + mock_smtp_instance.data.return_value = _aio_resp(250, 'OK') + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + await client.initialize() + + response = await client.sendmail( + from_addr='sender@example.com', + to_addrs=['rcpt@example.com'], + msg=b'Subject: test\r\n\r\nHello', + ) + + assert response is not None + assert isinstance(response, SMTPResponse) + assert response.success + assert response.status_code == 250 + assert response.from_addr == 'sender@example.com' + assert response.to_addrs == ['rcpt@example.com'] + + +@pytest.mark.asyncio +async def test_sendmail_empty_to_addrs(parent): + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + + response = await client.sendmail( + from_addr='sender@example.com', + to_addrs=[], + msg=b'Subject: test\r\n\r\nHello', + ) + assert response is None + + +@pytest.mark.asyncio +async def test_sendmail_recipient_refused(parent): + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + mock_smtp_instance.supports_extension = MagicMock(return_value=False) + mock_smtp_instance.mail.return_value = _aio_resp(250, 'OK') + + # All recipients refused + exc = MagicMock() + exc.code = 550 + exc.message = 'User unknown' + mock_aio.SMTPRecipientRefused = type('SMTPRecipientRefused', (Exception,), {}) + refuse_exc = mock_aio.SMTPRecipientRefused(550, 'User unknown', 'bad@example.com') + refuse_exc.code = 550 + refuse_exc.message = 'User unknown' + mock_smtp_instance.rcpt.side_effect = refuse_exc + mock_aio.SMTPRecipientsRefused = type('SMTPRecipientsRefused', (Exception,), {}) + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + + response = await client.sendmail( + from_addr='sender@example.com', + to_addrs=['bad@example.com'], + msg=b'Subject: test\r\n\r\nHello', + ) + + assert response is not None + assert not response.success + assert 'bad@example.com' in response.refused_recipients + + +@pytest.mark.asyncio +async def test_sendmail_sender_refused(parent): + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + mock_smtp_instance.supports_extension = MagicMock(return_value=False) + + # Sender refused via exception + mock_aio.SMTPSenderRefused = type('SMTPSenderRefused', (Exception,), {}) + exc = mock_aio.SMTPSenderRefused(553, 'Sender rejected', 'bad@sender.com') + exc.code = 553 + exc.message = 'Sender rejected' + mock_smtp_instance.mail.side_effect = exc + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + + response = await client.sendmail( + from_addr='bad@sender.com', + to_addrs=['rcpt@example.com'], + msg=b'Subject: test\r\n\r\nHello', + ) + + assert response is not None + assert not response.success + assert response.error is not None + + +@pytest.mark.asyncio +async def test_ssl_and_tls_flags(parent): + """Test that ssl=True sets use_tls=True and tls=True sets start_tls=True.""" + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + + # ssl=True should pass use_tls=True + client_ssl = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=465, ssl=True) + call_kwargs = mock_aio.SMTP.call_args + assert call_kwargs[1]['use_tls'] is True + assert call_kwargs[1]['start_tls'] is False + + # tls=True should pass start_tls=True + client_tls = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=587, tls=True) + call_kwargs = mock_aio.SMTP.call_args + assert call_kwargs[1]['start_tls'] is True + assert call_kwargs[1]['use_tls'] is False + + +@pytest.mark.asyncio +async def test_quit_handles_disconnect(parent): + """Test that quit() handles SMTPServerDisconnected gracefully.""" + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + mock_aio.SMTPServerDisconnected = type('SMTPServerDisconnected', (Exception,), {}) + mock_smtp_instance.quit.side_effect = mock_aio.SMTPServerDisconnected() + mock_smtp_instance.close = MagicMock() + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + + # Should not raise + await client.quit() + mock_smtp_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_initialize_with_login(parent): + """Test that initialize() performs connect and login when credentials provided.""" + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse( + parent=parent, host='localhost', port=587, + tls=True, user='testuser', password='testpass', + ) + await client.initialize() + + mock_smtp_instance.connect.assert_awaited_once() + mock_smtp_instance.login.assert_awaited_once_with('testuser', 'testpass') + + +@pytest.mark.asyncio +async def test_sendmail_string_to_addrs(parent): + """Test that sendmail handles a string to_addrs (not list).""" + with patch('emails.backend.smtp.aio_client.aiosmtplib') as mock_aio: + mock_smtp_instance = AsyncMock() + mock_aio.SMTP.return_value = mock_smtp_instance + mock_smtp_instance.supports_extension = MagicMock(return_value=False) + mock_smtp_instance.mail.return_value = _aio_resp(250, 'OK') + mock_smtp_instance.rcpt.return_value = _aio_resp(250, 'OK') + mock_smtp_instance.data.return_value = _aio_resp(250, 'OK') + + from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + client = AsyncSMTPClientWithResponse(parent=parent, host='localhost', port=25) + + response = await client.sendmail( + from_addr='sender@example.com', + to_addrs='rcpt@example.com', # string, not list + msg=b'Subject: test\r\n\r\nHello', + ) + + assert response is not None + assert response.success + assert response.to_addrs == ['rcpt@example.com'] diff --git a/emails/testsuite/smtp/test_async_smtp_backend.py b/emails/testsuite/smtp/test_async_smtp_backend.py new file mode 100644 index 0000000..cbc4490 --- /dev/null +++ b/emails/testsuite/smtp/test_async_smtp_backend.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import socket +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosmtplib +import pytest +from emails.backend.smtp.aio_backend import AsyncSMTPBackend +from emails.backend.smtp.aio_client import AsyncSMTPClientWithResponse + + +@pytest.fixture +def mock_msg(): + msg = MagicMock() + msg.as_bytes.return_value = b"Subject: test\r\n\r\nHello" + return msg + + +@pytest.fixture +def mock_smtp(): + """Patch aiosmtplib.SMTP so no real connection is made.""" + with patch('emails.backend.smtp.aio_client.aiosmtplib.SMTP') as mock_cls: + instance = MagicMock() + instance.connect = AsyncMock() + instance.ehlo = AsyncMock() + instance.helo = AsyncMock() + instance._ehlo_or_helo_if_needed = AsyncMock() + instance.login = AsyncMock() + instance.quit = AsyncMock() + instance.close = MagicMock() + instance.mail = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.rcpt = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.data = AsyncMock(return_value=MagicMock(code=250, message='OK')) + instance.rset = AsyncMock() + instance.is_ehlo_or_helo_needed = True + instance.supports_esmtp = True + instance.supports_extension = MagicMock(return_value=False) + mock_cls.return_value = instance + yield instance + + +@pytest.mark.asyncio +async def test_lifecycle_connect_send_close(mock_smtp, mock_msg): + """Full lifecycle: get_client -> sendmail -> close.""" + backend = AsyncSMTPBackend(host='localhost', port=2525) + + # get_client creates and initializes the client + client = await backend.get_client() + assert client is not None + assert backend._client is client + mock_smtp.connect.assert_awaited_once() + + # sendmail sends the message + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + assert response is not None + assert response.success + + # close shuts down the connection + await backend.close() + assert backend._client is None + mock_smtp.quit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_client_reuses_connection(mock_smtp, mock_msg): + """get_client returns the same client on subsequent calls.""" + backend = AsyncSMTPBackend(host='localhost', port=2525) + client1 = await backend.get_client() + client2 = await backend.get_client() + assert client1 is client2 + # connect called only once + mock_smtp.connect.assert_awaited_once() + await backend.close() + + +@pytest.mark.asyncio +async def test_get_client_with_login(mock_smtp): + """get_client logs in when user/password provided.""" + backend = AsyncSMTPBackend(host='localhost', port=2525, user='me', password='secret') + await backend.get_client() + mock_smtp.login.assert_awaited_once_with('me', 'secret') + await backend.close() + + +@pytest.mark.asyncio +async def test_reconnect_after_disconnect(mock_smtp, mock_msg): + """After SMTPServerDisconnected during send, backend reconnects and retries.""" + backend = AsyncSMTPBackend(host='localhost', port=2525) + + # First get_client succeeds, first _send raises disconnect, second _send succeeds + call_count = 0 + original_mail = mock_smtp.mail + + async def mail_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise aiosmtplib.SMTPServerDisconnected('gone') + return MagicMock(code=250, message='OK') + + mock_smtp.mail = AsyncMock(side_effect=mail_side_effect) + + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + assert response is not None + assert response.success + # Should have connected twice (initial + reconnect) + assert mock_smtp.connect.await_count == 2 + await backend.close() + + +@pytest.mark.asyncio +async def test_fail_silently_true_on_connect_error(mock_smtp, mock_msg): + """With fail_silently=True, connection errors return error response without raising.""" + mock_smtp.connect.side_effect = OSError(socket.EAI_NONAME, 'Name not found') + + backend = AsyncSMTPBackend(host='invalid.example', port=2525, fail_silently=True) + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + assert response is not None + assert not response.success + assert response.error is not None + + +@pytest.mark.asyncio +async def test_fail_silently_false_raises(mock_smtp, mock_msg): + """With fail_silently=False, connection errors propagate as exceptions.""" + mock_smtp.connect.side_effect = aiosmtplib.SMTPConnectError('refused') + + backend = AsyncSMTPBackend(host='invalid.example', port=2525, fail_silently=False) + with pytest.raises(aiosmtplib.SMTPConnectError): + await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + + +@pytest.mark.asyncio +async def test_empty_to_addrs_returns_none(mock_msg): + """sendmail with empty to_addrs returns None.""" + backend = AsyncSMTPBackend(host='localhost', port=2525) + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs=[], + msg=mock_msg, + ) + assert response is None + + +@pytest.mark.asyncio +async def test_ssl_tls_mutually_exclusive(): + """Cannot set both ssl and tls.""" + with pytest.raises(ValueError): + AsyncSMTPBackend(host='localhost', port=465, ssl=True, tls=True) + + +@pytest.mark.asyncio +async def test_context_manager(mock_smtp, mock_msg): + """AsyncSMTPBackend works as an async context manager.""" + async with AsyncSMTPBackend(host='localhost', port=2525) as backend: + client = await backend.get_client() + assert client is not None + # after exiting, client should be None + assert backend._client is None + + +@pytest.mark.asyncio +async def test_close_clears_client_on_error(mock_smtp): + """close() clears the client even if quit raises (when fail_silently=True).""" + mock_smtp.quit.side_effect = aiosmtplib.SMTPServerDisconnected('already gone') + + backend = AsyncSMTPBackend(host='localhost', port=2525, fail_silently=True) + await backend.get_client() + assert backend._client is not None + + await backend.close() + assert backend._client is None + + +@pytest.mark.asyncio +async def test_string_to_addrs_converted_to_list(mock_smtp, mock_msg): + """A single string to_addrs is converted to a list.""" + backend = AsyncSMTPBackend(host='localhost', port=2525) + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + assert response is not None + assert response.success + await backend.close() + + +@pytest.mark.asyncio +async def test_mail_options_passed_through(mock_smtp, mock_msg): + """mail_options from constructor are used if not overridden in sendmail.""" + backend = AsyncSMTPBackend(host='localhost', port=2525, mail_options=['BODY=8BITMIME']) + response = await backend.sendmail( + from_addr='a@b.com', + to_addrs='c@d.com', + msg=mock_msg, + ) + assert response is not None + assert response.success + # Verify BODY=8BITMIME was passed to the SMTP mail command + mail_call_args = mock_smtp.mail.call_args + assert 'BODY=8BITMIME' in mail_call_args.kwargs.get('options', []) + await backend.close() diff --git a/requirements/tests-base.txt b/requirements/tests-base.txt index d202133..99d2c70 100644 --- a/requirements/tests-base.txt +++ b/requirements/tests-base.txt @@ -3,4 +3,6 @@ mako speaklater pytest pytest-cov +pytest-asyncio html5lib +aiosmtplib diff --git a/setup.cfg b/setup.cfg index e78e4a0..86e0181 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,13 @@ disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no- [mypy-emails.backend.smtp.client] disable_error_code = attr-defined, no-redef, override, no-any-return, assignment +# aiosmtplib is an optional dependency (pip install emails[async]) +[mypy-aiosmtplib] +ignore_missing_imports = true + +[mypy-aiosmtplib.*] +ignore_missing_imports = true + # Optional dependency stubs [mypy-requests.*] ignore_missing_imports = true diff --git a/setup.py b/setup.py index ac120d9..401c0ed 100644 --- a/setup.py +++ b/setup.py @@ -129,6 +129,7 @@ def find_version(*file_paths): extras_require={ 'html': ['cssutils', 'lxml', 'chardet', 'requests', 'premailer'], 'jinja': ['jinja2'], + 'async': ['aiosmtplib'], }, zip_safe=False, classifiers=(