From 48ccfc09c11a4273b8b4eaa910a2220a4bb4a345 Mon Sep 17 00:00:00 2001 From: Manu Date: Sun, 30 Aug 2026 16:53:38 +0200 Subject: [PATCH] Migrate SMTP integration to aiosmtplib (#180707) Co-authored-by: Franck Nijhof --- homeassistant/components/smtp/__init__.py | 44 +++---- homeassistant/components/smtp/config_flow.py | 81 +++++------- homeassistant/components/smtp/manifest.json | 3 +- homeassistant/components/smtp/notify.py | 52 +++----- homeassistant/components/smtp/strings.json | 1 - requirements_all.txt | 3 + tests/components/smtp/conftest.py | 33 ++++- tests/components/smtp/test_config_flow.py | 116 ++++++++++------- tests/components/smtp/test_init.py | 22 ++-- tests/components/smtp/test_notify.py | 129 +++++++------------ 10 files changed, 224 insertions(+), 260 deletions(-) diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 48a6ff3f90f6..397bf4953eba 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1,8 +1,8 @@ """The smtp integration.""" import logging -from smtplib import SMTPAuthenticationError -from socket import gaierror + +from aiosmtplib import SMTP, SMTPAuthenticationError, SMTPException from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -11,7 +11,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_RECIPIENT, - CONF_SENDER, CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, @@ -25,23 +24,14 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.util.ssl import create_client_context +from homeassistant.util.ssl import client_context, client_context_no_verify -from .const import ( - CONF_ENCRYPTION, - CONF_ENTRY, - CONF_OLD_RECIPIENT, - CONF_SENDER_NAME, - CONF_SERVER, - DEFAULT_TIMEOUT, - DOMAIN, -) -from .helpers import SmtpClient +from .const import CONF_ENCRYPTION, CONF_ENTRY, CONF_OLD_RECIPIENT, CONF_SERVER, DOMAIN from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -type SmtpConfigEntry = ConfigEntry[SmtpClient] +type SmtpConfigEntry = ConfigEntry[SMTP] PLATFORMS: list[Platform] = [Platform.NOTIFY] @@ -75,30 +65,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool {}, ) ) - client = SmtpClient( - server=entry.data[CONF_SERVER], + + client = SMTP( + hostname=entry.data[CONF_SERVER], port=entry.data[CONF_PORT], - timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - sender=entry.data[CONF_SENDER], - encryption=entry.data[CONF_ENCRYPTION], username=entry.data.get(CONF_USERNAME), password=entry.data.get(CONF_PASSWORD), - sender_name=entry.data.get(CONF_SENDER_NAME), - verify_ssl=entry.data[CONF_VERIFY_SSL], - ssl_context=( - await hass.async_add_executor_job(create_client_context) + timeout=entry.options.get(CONF_TIMEOUT), + use_tls=entry.data[CONF_ENCRYPTION] == "tls", + start_tls=entry.data[CONF_ENCRYPTION] == "starttls", + tls_context=( + client_context() if entry.data[CONF_VERIFY_SSL] - else None + else client_context_no_verify() ), ) try: - await hass.async_add_executor_job(lambda: client.connect().quit()) + async with client: + pass except SMTPAuthenticationError as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e - except (gaierror, ConnectionRefusedError) as e: + except SMTPException as e: _LOGGER.debug("Full exception:", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index a2306edc1322..61a91ab02ff2 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -1,13 +1,10 @@ """Config flow for the SMTP integration.""" from collections.abc import Mapping -from contextlib import suppress import logging -from smtplib import SMTP, SMTP_SSL, SMTPAuthenticationError, SMTPException -import socket -from ssl import SSLCertVerificationError from typing import Any, override +from aiosmtplib import SMTP, SMTPAuthenticationError, SMTPException, SMTPTimeoutError import voluptuous as vol from homeassistant import data_entry_flow @@ -33,7 +30,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, UnitOfTime, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, @@ -46,7 +43,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) -from homeassistant.util.ssl import create_client_context +from homeassistant.util.ssl import client_context, client_context_no_verify from . import SmtpConfigEntry from .const import ( @@ -169,9 +166,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): ) entry_data = user_input.copy() options = entry_data.pop(SECTION_OPTIONS) - errors = await self.hass.async_add_executor_job( - validate_input, entry_data, options - ) + errors = await validate_input(self.hass, entry_data, options) if not errors: return self.async_create_entry( title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), @@ -223,9 +218,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job( - validate_input, user_input, dict(entry.options) - ) + errors = await validate_input(self.hass, user_input, dict(entry.options)) if not errors: return self.async_update_and_abort( entry, @@ -255,8 +248,8 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): entry = self._get_reauth_entry() if user_input is not None: - errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input}, dict(entry.options) + errors = await validate_input( + self.hass, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -279,9 +272,8 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job( - validate_input, import_info, options - ) + errors = await validate_input(self.hass, import_info, options) + if not errors: title = ( import_info.get(CONF_NAME) @@ -306,49 +298,36 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=errors["base"]) -def validate_input( - user_input: dict[str, Any], options: dict[str, Any] +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any], options: dict[str, Any] ) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} - ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None - mail: SMTP_SSL | SMTP | None = None try: - if user_input[CONF_ENCRYPTION] == "tls": - mail = SMTP_SSL( - user_input[CONF_SERVER], - user_input[CONF_PORT], - timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - context=ssl_context, - ) - else: - mail = SMTP( - user_input[CONF_SERVER], - user_input[CONF_PORT], - timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - mail.ehlo_or_helo_if_needed() - if user_input[CONF_ENCRYPTION] == "starttls": - mail.starttls(context=ssl_context) - mail.ehlo() - if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD): - mail.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - - except TimeoutError: + async with SMTP( + hostname=user_input[CONF_SERVER], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + timeout=options.get(CONF_TIMEOUT), + use_tls=user_input[CONF_ENCRYPTION] == "tls", + start_tls=user_input[CONF_ENCRYPTION] == "starttls", + tls_context=( + client_context() + if user_input[CONF_VERIFY_SSL] + else client_context_no_verify() + ), + ): + pass + except SMTPTimeoutError: errors["base"] = "timeout_connect" except SMTPAuthenticationError: errors["base"] = "invalid_auth" - except SSLCertVerificationError: - errors["base"] = "invalid_cert" - except socket.gaierror, ConnectionRefusedError, SMTPException: + except SMTPException: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - finally: - if mail is not None: - with suppress(SMTPException): - mail.quit() return errors @@ -362,11 +341,13 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): """User flow to add a new recipient.""" if user_input is not None: - return self.async_create_entry( + result = self.async_create_entry( title=user_input.get(CONF_NAME, user_input[CONF_RECIPIENT]), data={}, unique_id=user_input[CONF_RECIPIENT], ) + self.hass.config_entries.async_schedule_reload(self._get_entry().entry_id) + return result return self.async_show_form( step_id="user", data_schema=vol.Schema( diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json index ad47c06066a2..65a64dfc4e6e 100644 --- a/homeassistant/components/smtp/manifest.json +++ b/homeassistant/components/smtp/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smtp", "integration_type": "service", - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "requirements": ["aiosmtplib==5.1.2"] } diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 112b4bf74d3e..71248c2923cb 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -7,17 +7,11 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import email.utils import logging -from smtplib import ( - SMTP, - SMTP_SSL, - SMTPAuthenticationError, - SMTPException, - SMTPServerDisconnected, -) -from socket import gaierror +from smtplib import SMTPException, SMTPServerDisconnected from ssl import SSLContext from typing import TYPE_CHECKING, Any, override +import aiosmtplib import voluptuous as vol from homeassistant.components.notify import ( @@ -92,6 +86,8 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +RETRIES = 2 + PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), @@ -198,7 +194,7 @@ class MailNotifyEntity(NotifyEntity): self, entry: SmtpConfigEntry, subentry: ConfigSubentry, - client: SmtpClient, + client: aiosmtplib.SMTP, ) -> None: """Initialize the notify entity.""" @@ -214,14 +210,14 @@ class MailNotifyEntity(NotifyEntity): self._attr_name = subentry.title @override - def send_message(self, message: str, title: str | None = None) -> None: + async def async_send_message(self, message: str, title: str | None = None) -> None: """Send an email message via notify.send_message action.""" msg = EmailMessage() msg.set_content(message) msg["Subject"] = title or ATTR_TITLE_DEFAULT - self._send_email(msg=msg) + await self._send_email(msg=msg) async def smtp_send_message( self, @@ -285,10 +281,10 @@ class MailNotifyEntity(NotifyEntity): filename=target_filename, ) - await self.hass.async_add_executor_job(self._send_email, msg) + await self._send_email(msg) self._async_record_notification() - def _send_email(self, msg: EmailMessage) -> None: + async def _send_email(self, msg: EmailMessage) -> None: """Send the message.""" if TYPE_CHECKING: assert self._subentry.unique_id @@ -307,41 +303,25 @@ class MailNotifyEntity(NotifyEntity): msg.add_header("Date", email.utils.format_datetime(dt_util.now())) msg.add_header("Message-Id", email.utils.make_msgid()) - client: SMTP_SSL | SMTP | None = None - for attempt in range(self._client.tries): + for attempt in range(RETRIES): try: - client = self._client.connect() - except SMTPAuthenticationError as e: + async with self._client as client: + await client.send_message(msg) + break + except aiosmtplib.SMTPAuthenticationError as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e - except (gaierror, ConnectionRefusedError, SMTPException) as e: - _LOGGER.debug("Full exception:", exc_info=True) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_mail_connection_error", - ) from e - - try: - client.sendmail( - self._entry.data[CONF_SENDER], - self._subentry.unique_id, - msg.as_string(), - ) - break - except SMTPException as e: + except aiosmtplib.SMTPException as e: _LOGGER.debug( "Error sending mail at attempt %s:", attempt + 1, exc_info=True ) - if attempt == self._client.tries - 1: + if attempt == RETRIES - 1: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="send_mail_connection_error", ) from e - finally: - with suppress(SMTPException): - client.quit() class MailNotificationService(SmtpClient, BaseNotificationService): diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 0a428705cb66..d5dbfbcd3ec7 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -8,7 +8,6 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_cert": "Invalid certificate", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/requirements_all.txt b/requirements_all.txt index 77d85a815d07..7644590742d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -434,6 +434,9 @@ aioskybell==22.7.0 # homeassistant.components.slimproto aioslimproto==3.0.0 +# homeassistant.components.smtp +aiosmtplib==5.1.2 + # homeassistant.components.solaredge aiosolaredge==1.0.2 diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 0a0b00af2542..247b3abfff60 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -49,12 +49,23 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_smtp() -> Generator[MagicMock]: """Mock smtplib.SMTP.""" + with patch( + "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + ) as mock_client: + client = mock_client.return_value + client.cls = mock_client + yield client + + +@pytest.fixture(name="aiosmtplib") +def mock_aiosmtplib() -> Generator[AsyncMock]: + """Mock aiosmtplib.""" + with ( patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True + "homeassistant.components.smtp.config_flow.SMTP", autospec=True ) as mock_client, - patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), - patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), + patch("homeassistant.components.smtp.SMTP", new=mock_client), ): client = mock_client.return_value client.cls = mock_client @@ -83,6 +94,22 @@ def mock_randrange() -> Generator[None]: yield +@pytest.fixture(name="client_context") +def mock_client_context() -> Generator[None]: + """Mock client_context.""" + + with ( + patch( + "homeassistant.components.smtp.config_flow.client_context" + ) as mock_client, + patch( + "homeassistant.components.smtp.client_context", + new=mock_client, + ), + ): + yield mock_client + + @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 2708daee3e90..211c23b36bde 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -1,14 +1,11 @@ """Test the SMTP config flow.""" -from smtplib import SMTPAuthenticationError, SMTPServerDisconnected -from socket import gaierror -from ssl import SSLCertVerificationError from unittest.mock import AsyncMock, MagicMock +from aiosmtplib import SMTPAuthenticationError, SMTPException, SMTPTimeoutError import pytest from homeassistant.components.smtp.const import ( - CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, SECTION_OPTIONS, @@ -38,9 +35,12 @@ from .conftest import USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.parametrize("encryption", ["tls", "starttls"]) +@pytest.mark.usefixtures("smtp") async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -53,7 +53,6 @@ async def test_form( result["flow_id"], { **USER_INPUT, - CONF_ENCRYPTION: encryption, SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) @@ -61,10 +60,7 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" - assert result["data"] == { - **USER_INPUT, - CONF_ENCRYPTION: encryption, - } + assert result["data"] == USER_INPUT assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -81,11 +77,19 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" - assert smtp.cls.call_args[0] == ("mail.example.com", 587) - assert smtp.cls.call_args[1]["timeout"] == 60 + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="test-username", + password="test-password", + timeout=60, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -117,18 +121,16 @@ async def test_form_already_configured( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (TimeoutError, "timeout_connect"), - (SMTPServerDisconnected, "cannot_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) +@pytest.mark.usefixtures("smtp") async def test_form_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, - smtp: MagicMock, + aiosmtplib: MagicMock, exception: Exception, text_error: str, ) -> None: @@ -137,7 +139,7 @@ async def test_form_errors( DOMAIN, context={"source": SOURCE_USER} ) - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -150,7 +152,7 @@ async def test_form_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -168,7 +170,7 @@ async def test_form_errors( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_recipient_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -198,7 +200,7 @@ async def test_form_recipient_already_configured( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_options_flow( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -229,8 +231,12 @@ async def test_options_flow( } +@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test reconfigure flow.""" @@ -263,10 +269,19 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 - smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="new-username", + password="new-password", + timeout=1312, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_reconfigure_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -308,11 +323,8 @@ async def test_form_reconfigure_already_configured( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (SMTPServerDisconnected, "cannot_connect"), - (TimeoutError, "timeout_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) @@ -320,13 +332,13 @@ async def test_form_reconfigure_already_configured( async def test_form_reconfigure_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, text_error: str, ) -> None: """Test reconfigure flow connection errors.""" - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception config_entry.add_to_hass(hass) @@ -348,7 +360,7 @@ async def test_form_reconfigure_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -372,8 +384,12 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 +@pytest.mark.usefixtures("smtp") async def test_form_reauth( - hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + aiosmtplib: AsyncMock, + client_context: MagicMock, ) -> None: """Test reauth flow.""" @@ -403,16 +419,24 @@ async def test_form_reauth( } assert len(hass.config_entries.async_entries()) == 1 - smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) + aiosmtplib.cls.assert_called_once_with( + hostname="mail.example.com", + port=587, + username="new-username", + password="new-password", + timeout=1312, + use_tls=False, + start_tls=True, + tls_context=client_context(), + ) @pytest.mark.parametrize( ("exception", "text_error"), [ (SMTPAuthenticationError(0, ""), "invalid_auth"), - (ConnectionRefusedError, "cannot_connect"), - (gaierror, "cannot_connect"), - (SSLCertVerificationError, "invalid_cert"), + (SMTPException(""), "cannot_connect"), + (SMTPTimeoutError(""), "timeout_connect"), (ValueError, "unknown"), ], ) @@ -420,13 +444,13 @@ async def test_form_reauth( async def test_form_reauth_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, text_error: str, ) -> None: """Test reauth flow connection errors.""" - smtp.login.side_effect = exception + aiosmtplib.cls.side_effect = exception config_entry.add_to_hass(hass) @@ -446,7 +470,7 @@ async def test_form_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - smtp.login.side_effect = None + aiosmtplib.cls.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -467,7 +491,7 @@ async def test_form_reauth_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -508,7 +532,7 @@ async def test_form_subentry_reconfigure( assert entity.unique_id == "123456789_changed@example.com" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure_already_configured( hass: HomeAssistant, ) -> None: @@ -555,7 +579,7 @@ async def test_form_subentry_reconfigure_already_configured( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_form_subentry_reconfigure_updates_title( hass: HomeAssistant, ) -> None: diff --git a/tests/components/smtp/test_init.py b/tests/components/smtp/test_init.py index 065bef21636a..a7ac325f71e1 100644 --- a/tests/components/smtp/test_init.py +++ b/tests/components/smtp/test_init.py @@ -1,9 +1,8 @@ """Tests for the SMTP integration.""" -from smtplib import SMTPAuthenticationError -from socket import gaierror -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from aiosmtplib import SMTPAuthenticationError, SMTPException import pytest from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN @@ -33,7 +32,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_entry_setup_unload( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -57,21 +56,21 @@ async def test_entry_setup_unload( @pytest.mark.parametrize( ("exception", "state"), [ - (ConnectionRefusedError, ConfigEntryState.SETUP_RETRY), - (gaierror, ConfigEntryState.SETUP_RETRY), + (SMTPException(""), ConfigEntryState.SETUP_RETRY), (SMTPAuthenticationError(0, ""), ConfigEntryState.SETUP_ERROR), ], ) +@pytest.mark.usefixtures("smtp") async def test_config_entry_not_ready( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, exception: Exception, state: ConfigEntryState, ) -> None: """Test config entry not ready.""" - smtp.login.side_effect = exception + aiosmtplib.__aenter__.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -80,7 +79,7 @@ async def test_config_entry_not_ready( assert config_entry.state is state -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_import( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -203,14 +202,15 @@ async def test_import_already_configured( ) +@pytest.mark.usefixtures("smtp") async def test_import_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry, - smtp: MagicMock, + aiosmtplib: AsyncMock, ) -> None: """Test yaml triggers import flow, aborts with errors, and creates error issue.""" - smtp.login.side_effect = ValueError + aiosmtplib.__aenter__.side_effect = ValueError await async_setup_component( hass, diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index e91a41ac7ddc..eae90eceecbf 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -2,16 +2,10 @@ from pathlib import Path import re -from smtplib import ( - SMTPAuthenticationError, - SMTPException, - SMTPHeloError, - SMTPSenderRefused, - SMTPServerDisconnected, -) -from socket import gaierror -from unittest.mock import MagicMock, patch +from smtplib import SMTPException, SMTPServerDisconnected +from unittest.mock import AsyncMock, MagicMock, patch +import aiosmtplib import pytest from syrupy.assertion import SnapshotAssertion @@ -211,7 +205,7 @@ def test_send_target_message(target, hass: HomeAssistant, message) -> None: assert recipient == expected_recipient -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_notify_platform( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -229,12 +223,12 @@ async def test_notify_platform( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) -@pytest.mark.usefixtures("make_msgid") +@pytest.mark.usefixtures("make_msgid", "smtp") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_notify_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via notify.send_message action.""" @@ -263,30 +257,26 @@ async def test_notify_send_message( assert state assert state.state == "2026-05-03T03:09:37+00:00" - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot @pytest.mark.parametrize( - ("call_method", "exception", "translation_key"), + ("exception", "translation_key", "call_count"), [ - ("login", SMTPAuthenticationError(0, ""), "authentication_error"), - ("login", gaierror, "send_mail_connection_error"), - ("login", ConnectionRefusedError, "send_mail_connection_error"), - ("login", SMTPHeloError(0, ""), "send_mail_connection_error"), - ("sendmail", SMTPServerDisconnected, "send_mail_connection_error"), - ("sendmail", SMTPSenderRefused(0, b"", ""), "send_mail_connection_error"), + (aiosmtplib.SMTPAuthenticationError(0, ""), "authentication_error", 1), + (aiosmtplib.SMTPException(""), "send_mail_connection_error", 2), ], ) +@pytest.mark.usefixtures("make_msgid", "smtp") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_notify_send_message_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, - call_method: str, + aiosmtplib: AsyncMock, exception: Exception, translation_key: str, + call_count: int, ) -> None: """Test exceptions via notify.send_message action.""" @@ -296,7 +286,7 @@ async def test_notify_send_message_exceptions( assert config_entry.state is ConfigEntryState.LOADED - getattr(smtp, call_method).side_effect = exception + aiosmtplib.__aenter__.return_value.send_message.side_effect = exception with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( @@ -310,40 +300,11 @@ async def test_notify_send_message_exceptions( ) assert e.value.translation_key == translation_key - - -@pytest.mark.usefixtures("make_msgid") -@pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") -async def test_notify_retry_on_disconnect_with_broken_quit( - hass: HomeAssistant, - config_entry: MockConfigEntry, - smtp: MagicMock, -) -> None: - """Test retry succeeds when quit() raises on a dead connection.""" - - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - smtp.sendmail.side_effect = [SMTPServerDisconnected("gone"), None] - smtp.quit.side_effect = SMTPServerDisconnected("please run connect() first") - - await hass.services.async_call( - NOTIFY_DOMAIN, - SERVICE_SEND_MESSAGE, - { - ATTR_ENTITY_ID: "notify.home_assistant_recipient", - ATTR_MESSAGE: "Hello World", - }, - blocking=True, - ) - - assert smtp.sendmail.call_count == 2 + assert aiosmtplib.__aenter__.return_value.send_message.call_count == call_count @pytest.mark.parametrize("exception", [SMTPServerDisconnected, SMTPException]) +@pytest.mark.usefixtures("aiosmtplib") async def test_legacy_notify_exception( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -375,12 +336,12 @@ async def test_legacy_notify_exception( assert smtp.sendmail.call_count == 2 -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action.""" @@ -411,17 +372,16 @@ async def test_smtp_send_message( assert state assert state.state == "2026-05-03T03:09:37+00:00" - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_local_media_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from local media source.""" @@ -453,17 +413,16 @@ async def test_smtp_send_message_local_media_source( blocking=True, ) - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_camera_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from camera source snapshot.""" @@ -500,17 +459,16 @@ async def test_smtp_send_message_camera_source( blocking=True, ) mock_get_image.assert_called_once_with(hass, "camera.demo_camera") - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_image_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with attachment from image source.""" @@ -552,17 +510,16 @@ async def test_smtp_send_message_image_source( blocking=True, ) mock_get_image.assert_called_with(hass, "image.test") - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("make_msgid", "randrange") +@pytest.mark.usefixtures("make_msgid", "smtp", "randrange") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_tts_source( hass: HomeAssistant, config_entry: MockConfigEntry, - smtp: MagicMock, + aiosmtplib: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test sending an email message via smtp.send_message action with audio attachment from tts source.""" @@ -575,7 +532,7 @@ async def test_smtp_send_message_tts_source( with patch( "homeassistant.components.tts.async_get_media_source_audio", return_value=("mp3", b"Hello World!"), - ): + ) as mock_get_media_source_audio: await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -596,12 +553,14 @@ async def test_smtp_send_message_tts_source( blocking=True, ) - assert smtp.sendmail.call_args[0][0] == "email@example.com" - assert smtp.sendmail.call_args[0][1] == "recipient@example.com" - assert smtp.sendmail.call_args[0][2] == snapshot + mock_get_media_source_audio.assert_called_with( + hass, "media-source://tts/demo?message=Hello+World%21&language=en" + ) + msg = aiosmtplib.__aenter__.return_value.send_message.call_args[0][0] + assert msg.as_string() == snapshot -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_media_source_not_supported( hass: HomeAssistant, @@ -650,7 +609,7 @@ async def test_smtp_send_message_media_source_not_supported( } -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") @pytest.mark.freeze_time("2026-05-03T03:09:37+00:00") async def test_smtp_send_message_media_source_missing_filename( hass: HomeAssistant, @@ -696,7 +655,7 @@ async def test_smtp_send_message_media_source_missing_filename( } -@pytest.mark.usefixtures("smtp") +@pytest.mark.usefixtures("smtp", "aiosmtplib") async def test_deprecated_legacy_notify_action( hass: HomeAssistant, config_entry: MockConfigEntry,