From 27f44f83fb021e06f381d9de4dd513dc4d8ea818 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 17 Jun 2026 13:59:43 +0200 Subject: [PATCH] Add reauthentication flow to SMTP (#174092) Co-authored-by: Erwin Douna --- homeassistant/components/smtp/__init__.py | 4 +- homeassistant/components/smtp/config_flow.py | 50 +++++ homeassistant/components/smtp/notify.py | 4 +- homeassistant/components/smtp/strings.json | 12 ++ tests/components/smtp/conftest.py | 22 +- tests/components/smtp/test_config_flow.py | 204 ++++++++++--------- 6 files changed, 186 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index 32be18d05a69..9af706eea96d 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.util.ssl import create_client_context @@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool try: await hass.async_add_executor_job(lambda: client.connect().quit()) except SMTPAuthenticationError as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index b8b1cb4d4296..6f702124b6df 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the SMTP integration.""" +from collections.abc import Mapping import logging from smtplib import SMTP, SMTP_SSL, SMTPAuthenticationError import socket @@ -95,6 +96,22 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_VERIFY_SSL, default=True): cv.boolean, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } +) OPTIONS_SCHEMA = vol.Schema( { @@ -201,6 +218,39 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + 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} + ) + if not errors: + return self.async_update_and_abort( + entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, + suggested_values=user_input + or {CONF_USERNAME: entry.data.get(CONF_USERNAME)}, + ), + errors=errors, + ) + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Import config from yaml.""" diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 12b209881a4f..7b0c1be23ddf 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -208,7 +208,7 @@ class MailNotifyEntity(NotifyEntity): try: client = self._client.connect() except SMTPAuthenticationError as e: - raise HomeAssistantError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="authentication_error", ) from e diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index fb998cd7ddea..5f3d7cc25bb1 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { @@ -11,6 +12,17 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::smtp::config::step::user::data_description::password%]", + "username": "[%key:component::smtp::config::step::user::data_description::username%]" + }, + "title": "Re-authenticate SMTP" + }, "reconfigure": { "data": { "encryption": "[%key:component::smtp::config::step::user::data::encryption%]", diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index e6e574cb38d9..4af1b2e703e5 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -24,6 +24,17 @@ from homeassistant.const import ( from tests.common import MockConfigEntry +USER_INPUT = { + CONF_SENDER: "email@example.com", + CONF_SENDER_NAME: "Home Assistant", + CONF_SERVER: "mail.example.com", + CONF_PORT: 587, + CONF_ENCRYPTION: "starttls", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_VERIFY_SSL: True, +} + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -76,16 +87,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title="Home Assistant", - data={ - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, - }, + data=USER_INPUT, options={ CONF_TIMEOUT: 5, }, diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 196f2e56991c..c8180204ed32 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -10,7 +10,6 @@ import pytest from homeassistant.components.smtp.const import ( CONF_ENCRYPTION, CONF_SENDER_NAME, - CONF_SERVER, DOMAIN, SUBENTRY_TYPE_RECIPIENT, ) @@ -18,16 +17,16 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, FlowType from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, - CONF_PORT, CONF_RECIPIENT, CONF_SENDER, CONF_TIMEOUT, CONF_USERNAME, - CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import USER_INPUT + from tests.common import MockConfigEntry @@ -46,14 +45,8 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, + **USER_INPUT, CONF_ENCRYPTION: encryption, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, }, ) await hass.async_block_till_done() @@ -61,14 +54,8 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, + **USER_INPUT, CONF_ENCRYPTION: encryption, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, } assert len(mock_setup_entry.mock_calls) == 1 @@ -104,16 +91,7 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "tls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, - }, + USER_INPUT, ) await hass.async_block_till_done() @@ -147,16 +125,7 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -166,31 +135,13 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, - }, + USER_INPUT, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" - assert result["data"] == { - CONF_SENDER: "email@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, - } + assert result["data"] == USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 @@ -271,14 +222,10 @@ async def test_form_reconfigure( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_SENDER: "email@example.com", + **USER_INPUT, CONF_SENDER_NAME: "New sender name", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password", - CONF_VERIFY_SSL: True, }, ) await hass.async_block_till_done() @@ -287,14 +234,10 @@ async def test_form_reconfigure( assert result["reason"] == "reconfigure_successful" assert config_entry.data == { - CONF_SENDER: "email@example.com", + **USER_INPUT, CONF_SENDER_NAME: "New sender name", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password", - CONF_VERIFY_SSL: True, } assert len(hass.config_entries.async_entries()) == 1 @@ -310,14 +253,8 @@ async def test_form_reconfigure_already_configured( domain=DOMAIN, title="Home Assistant", data={ + **USER_INPUT, CONF_SENDER: "already_configured@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, }, entry_id="987654321", ).add_to_hass(hass) @@ -332,14 +269,8 @@ async def test_form_reconfigure_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], { + **USER_INPUT, CONF_SENDER: "already_configured@example.com", - CONF_SENDER_NAME: "Home Assistant", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_VERIFY_SSL: True, }, ) await hass.async_block_till_done() @@ -382,14 +313,10 @@ async def test_form_reconfigure_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_SENDER: "email@example.com", + **USER_INPUT, CONF_SENDER_NAME: "New sender name", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password", - CONF_VERIFY_SSL: True, }, ) @@ -401,14 +328,10 @@ async def test_form_reconfigure_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_SENDER: "email@example.com", + **USER_INPUT, CONF_SENDER_NAME: "New sender name", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password", - CONF_VERIFY_SSL: True, }, ) @@ -416,13 +339,102 @@ async def test_form_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == { - CONF_SENDER: "email@example.com", + **USER_INPUT, CONF_SENDER_NAME: "New sender name", - CONF_SERVER: "mail.example.com", - CONF_PORT: 587, - CONF_ENCRYPTION: "starttls", CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password", - CONF_VERIFY_SSL: True, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test reauth flow.""" + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data == { + **USER_INPUT, + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "text_error"), + [ + (SMTPAuthenticationError(0, ""), "invalid_auth"), + (ConnectionRefusedError, "cannot_connect"), + (gaierror, "cannot_connect"), + (SSLCertVerificationError, "invalid_cert"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("smtp") +async def test_form_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + exception: Exception, + text_error: str, +) -> None: + """Test reauth flow connection errors.""" + + smtp.login.side_effect = exception + + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + smtp.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data == { + **USER_INPUT, + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1