Migrate SMTP integration to aiosmtplib (#180707)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Manu
2026-08-30 16:53:38 +02:00
committed by GitHub
co-authored by Franck Nijhof
parent f23cda199d
commit 48ccfc09c1
10 changed files with 224 additions and 260 deletions
+17 -27
View File
@@ -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,
+31 -50
View File
@@ -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(
+2 -1
View File
@@ -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"]
}
+16 -36
View File
@@ -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):
@@ -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%]"
},
+3
View File
@@ -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
+30 -3
View File
@@ -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."""
+70 -46
View File
@@ -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:
+11 -11
View File
@@ -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,
+44 -85
View File
@@ -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,