From 83b56ab0054e7da78edb0de32dcd426f4ab134ae Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 2 Apr 2024 23:05:05 +0200 Subject: [PATCH] Add IMAP seen, move and delete service (#114501) * Add seen, move and delete IMAP services * Add entry_id to the imap_content event data * Use config validation library * Add tests * Add logging * Typo in docstr * Add guard --- homeassistant/components/imap/__init__.py | 170 ++++++++++++++++++- homeassistant/components/imap/coordinator.py | 3 +- homeassistant/components/imap/icons.json | 5 + homeassistant/components/imap/services.yaml | 45 +++++ homeassistant/components/imap/strings.json | 78 +++++++++ tests/components/imap/conftest.py | 18 +- tests/components/imap/test_init.py | 137 +++++++++++++++ 7 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/imap/services.yaml diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 7504446f3fb..6c90889a7d6 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -2,16 +2,23 @@ from __future__ import annotations -from aioimaplib import IMAP4_SSL, AioImapException +import asyncio +import logging +from typing import TYPE_CHECKING + +from aioimaplib import IMAP4_SSL, AioImapException, Response +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, + ServiceValidationError, ) +import homeassistant.helpers.config_validation as cv from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( @@ -23,6 +30,70 @@ from .errors import InvalidAuth, InvalidFolder PLATFORMS: list[Platform] = [Platform.SENSOR] +CONF_ENTRY = "entry" +CONF_SEEN = "seen" +CONF_UID = "uid" +CONF_TARGET_FOLDER = "target_folder" + +_LOGGER = logging.getLogger(__name__) + +_SERVICE_UID_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTRY): cv.string, + vol.Required(CONF_UID): cv.string, + } +) + +SERVICE_SEEN_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend( + { + vol.Optional(CONF_SEEN): cv.boolean, + vol.Required(CONF_TARGET_FOLDER): cv.string, + } +) +SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA + + +async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: + """Get IMAP client and connect.""" + if hass.data[DOMAIN].get(entry_id) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry", + ) + entry = hass.config_entries.async_get_entry(entry_id) + if TYPE_CHECKING: + assert entry is not None + try: + client = await connect_to_server(entry.data) + except InvalidAuth as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from exc + except InvalidFolder as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_folder" + ) from exc + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + return client + + +@callback +def raise_on_error(response: Response, translation_key: str) -> None: + """Get error message from response.""" + if response.result != "OK": + error: str = response.lines[0].decode("utf-8") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"error": error}, + ) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up imap from a config entry.""" @@ -49,6 +120,97 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() + async def async_seen(call: ServiceCall) -> None: + """Process mark as seen service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Mark message %s as seen. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(hass, entry_id) + try: + response = await client.store(uid, "+FLAGS (\\Seen)") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + raise_on_error(response, "seen_failed") + await client.close() + + if not hass.services.has_service(DOMAIN, "seen"): + hass.services.async_register(DOMAIN, "seen", async_seen, SERVICE_SEEN_SCHEMA) + + async def async_move(call: ServiceCall) -> None: + """Process move email service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + seen = bool(call.data.get(CONF_SEEN)) + target_folder: str = call.data[CONF_TARGET_FOLDER] + _LOGGER.debug( + "Move message %s to folder %s. Mark as seen: %s. Entry: %s", + uid, + target_folder, + seen, + entry_id, + ) + client = await async_get_imap_client(hass, entry_id) + try: + if seen: + response = await client.store(uid, "+FLAGS (\\Seen)") + raise_on_error(response, "seen_failed") + response = await client.copy(uid, target_folder) + raise_on_error(response, "copy_failed") + response = await client.store(uid, "+FLAGS (\\Deleted)") + raise_on_error(response, "delete_failed") + response = await asyncio.wait_for( + client.protocol.expunge(uid, by_uid=True), client.timeout + ) + raise_on_error(response, "expunge_failed") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + await client.close() + + if not hass.services.has_service(DOMAIN, "move"): + hass.services.async_register(DOMAIN, "move", async_move, SERVICE_MOVE_SCHEMA) + + async def async_delete(call: ServiceCall) -> None: + """Process deleting email service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Delete message %s. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(hass, entry_id) + try: + response = await client.store(uid, "+FLAGS (\\Deleted)") + raise_on_error(response, "delete_failed") + response = await asyncio.wait_for( + client.protocol.expunge(uid, by_uid=True), client.timeout + ) + raise_on_error(response, "expunge_failed") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + await client.close() + + if not hass.services.has_service(DOMAIN, "delete"): + hass.services.async_register( + DOMAIN, "delete", async_delete, SERVICE_DELETE_SCHEMA + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator entry.async_on_unload( @@ -67,4 +229,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator ) = hass.data[DOMAIN].pop(entry.entry_id) await coordinator.shutdown() + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, "seen") + hass.services.async_remove(DOMAIN, "move") + hass.services.async_remove(DOMAIN, "delete") return unload_ok diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 7f857ff857f..997bff13534 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -1,4 +1,4 @@ -"""Coordinator for imag integration.""" +"""Coordinator for imap integration.""" from __future__ import annotations @@ -254,6 +254,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): initial = False self._last_message_id = message_id data = { + "entry_id": self.config_entry.entry_id, "server": self.config_entry.data[CONF_SERVER], "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], diff --git a/homeassistant/components/imap/icons.json b/homeassistant/components/imap/icons.json index a4a79aef60e..2e61cf56573 100644 --- a/homeassistant/components/imap/icons.json +++ b/homeassistant/components/imap/icons.json @@ -8,5 +8,10 @@ } } } + }, + "services": { + "seen": "mdi:email-open-outline", + "move": "mdi:email-arrow-right-outline", + "delete": "mdi:trash-can-outline" } } diff --git a/homeassistant/components/imap/services.yaml b/homeassistant/components/imap/services.yaml new file mode 100644 index 00000000000..f0694bfba70 --- /dev/null +++ b/homeassistant/components/imap/services.yaml @@ -0,0 +1,45 @@ +seen: + fields: + entry: + required: true + selector: + config_entry: + integration: "imap" + uid: + required: true + example: "12" + selector: + text: +move: + fields: + entry: + required: true + selector: + config_entry: + integration: "imap" + uid: + required: true + example: "12" + selector: + text: + seen: + selector: + boolean: + target_folder: + required: true + example: "INBOX.Trash" + selector: + text: + +delete: + fields: + entry: + required: true + selector: + config_entry: + integration: "imap" + uid: + example: "12" + required: true + selector: + text: diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index c332e3e8edb..8c06889361c 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -35,6 +35,32 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "exceptions": { + "copy_failed": { + "message": "Copying the message failed with '{error}'." + }, + "delete_failed": { + "message": "Marking the the message for deletion failed with '{error}'." + }, + "expunge_failed": { + "message": "Expungling the the message failed with '{error}'." + }, + "invalid_entry": { + "message": "No valid IMAP entry was found." + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "invalid_folder": { + "message": "[%key:component::imap::config::error::invalid_folder%]" + }, + "imap_server_fail": { + "message": "The IMAP server failed to connect: {error}." + }, + "seen_failed": { + "message": "Marking message as seen failed with '{error}'." + } + }, "options": { "step": { "init": { @@ -64,5 +90,57 @@ "intermediate": "Intermediate ciphers" } } + }, + "services": { + "seen": { + "name": "Mark message as seen", + "description": "Mark an email as seen.", + "fields": { + "entry": { + "name": "Entry", + "description": "The IMAP config entry." + }, + "uid": { + "name": "UID", + "description": "The email identifier (UID)." + } + } + }, + "move": { + "name": "Move message", + "description": "Move an email to a target folder.", + "fields": { + "entry": { + "name": "[%key:component::imap::services::seen::fields::entry::name%]", + "description": "[%key:component::imap::services::seen::fields::entry::description%]" + }, + "seen": { + "name": "Seen", + "description": "Mark the email as seen." + }, + "uid": { + "name": "[%key:component::imap::services::seen::fields::uid::name%]", + "description": "[%key:component::imap::services::seen::fields::uid::description%]" + }, + "target_folder": { + "name": "Target folder", + "description": "The target folder the email should be moved to." + } + } + }, + "delete": { + "name": "Delete message", + "description": "Delete an email.", + "fields": { + "entry": { + "name": "[%key:component::imap::services::seen::fields::entry::name%]", + "description": "[%key:component::imap::services::seen::fields::entry::description%]" + }, + "uid": { + "name": "[%key:component::imap::services::seen::fields::uid::name%]", + "description": "[%key:component::imap::services::seen::fields::uid::description%]" + } + } + } } } diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index 74176efab11..dfe5fa2040f 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,6 +1,6 @@ """Fixtures for imap tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response @@ -62,7 +62,7 @@ async def mock_imap_protocol( imap_pending_idle: bool, imap_login_state: str, imap_select_state: str, -) -> Generator[MagicMock, None]: +) -> AsyncGenerator[MagicMock, None]: """Mock the aioimaplib IMAP protocol handler.""" with patch( @@ -79,7 +79,14 @@ async def mock_imap_protocol( async def close() -> Response: """Mock imap close the selected folder.""" - imap_mock.protocol.state = imap_login_state + return Response("OK", []) + + async def store(uid: str, flags: str) -> Response: + """Mock imap store command.""" + return Response("OK", []) + + async def copy(uid: str, folder: str) -> Response: + """Mock imap store command.""" return Response("OK", []) async def logout() -> Response: @@ -101,12 +108,17 @@ async def mock_imap_protocol( imap_mock.has_pending_idle.return_value = imap_pending_idle imap_mock.protocol = MagicMock() imap_mock.protocol.state = STARTED + imap_mock.protocol.expunge = AsyncMock() + imap_mock.protocol.expunge.return_value = Response("OK", []) imap_mock.has_capability.return_value = imap_has_capability imap_mock.login.side_effect = login imap_mock.close.side_effect = close + imap_mock.copy.side_effect = copy imap_mock.logout.side_effect = logout imap_mock.select.side_effect = select imap_mock.search.return_value = Response(*imap_search) + imap_mock.store.side_effect = store imap_mock.fetch.return_value = Response(*imap_fetch) imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server + imap_mock.timeout = 3 yield imap_mock diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index aba9bd88c44..b0cfb9051a4 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder from homeassistant.components.sensor.const import SensorStateClass from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.util.dt import utcnow from .const import ( @@ -780,3 +781,139 @@ async def test_enforce_polling( mock_imap_protocol.wait_server_push.assert_not_called() else: mock_imap_protocol.assert_has_calls([call.wait_server_push]) + + +@pytest.mark.parametrize( + ("imap_search", "imap_fetch"), + [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config = MOCK_CONFIG.copy() + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["uid"] == "1" + assert data["entry_id"] == config_entry.entry_id + + # Test seen service + data = {"entry": config_entry.entry_id, "uid": "1"} + await hass.services.async_call(DOMAIN, "seen", data, blocking=True) + mock_imap_protocol.store.assert_called_with("1", "+FLAGS (\\Seen)") + mock_imap_protocol.store.reset_mock() + + # Test move service + data = { + "entry": config_entry.entry_id, + "uid": "1", + "seen": True, + "target_folder": "Trash", + } + await hass.services.async_call(DOMAIN, "move", data, blocking=True) + mock_imap_protocol.store.assert_has_calls( + [call("1", "+FLAGS (\\Seen)"), call("1", "+FLAGS (\\Deleted)")] + ) + mock_imap_protocol.copy.assert_called_with("1", "Trash") + mock_imap_protocol.protocol.expunge.assert_called_once() + mock_imap_protocol.store.reset_mock() + mock_imap_protocol.copy.reset_mock() + mock_imap_protocol.protocol.expunge.reset_mock() + + # Test delete service + data = {"entry": config_entry.entry_id, "uid": "1"} + await hass.services.async_call(DOMAIN, "delete", data, blocking=True) + mock_imap_protocol.store.assert_called_with("1", "+FLAGS (\\Deleted)") + mock_imap_protocol.protocol.expunge.assert_called_once() + + # Test with invalid entry_id + data = {"entry": "invalid", "uid": "1"} + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call(DOMAIN, "seen", data, blocking=True) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "invalid_entry" + + # Test processing imap client failures + exceptions = { + "invalid_auth": {"exc": InvalidAuth(), "translation_placeholders": None}, + "invalid_folder": {"exc": InvalidFolder(), "translation_placeholders": None}, + "imap_server_fail": { + "exc": AioImapException("Bla"), + "translation_placeholders": {"error": "Bla"}, + }, + } + for translation_key, attrs in exceptions.items(): + with patch( + "homeassistant.components.imap.connect_to_server", side_effect=attrs["exc"] + ): + data = {"entry": config_entry.entry_id, "uid": "1"} + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call(DOMAIN, "seen", data, blocking=True) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == translation_key + assert ( + exc.value.translation_placeholders == attrs["translation_placeholders"] + ) + + # Test unexpected errors with storing a flag during a service call + service_calls = { + "seen": {"entry": config_entry.entry_id, "uid": "1"}, + "move": { + "entry": config_entry.entry_id, + "uid": "1", + "seen": False, + "target_folder": "Trash", + }, + "delete": {"entry": config_entry.entry_id, "uid": "1"}, + } + store_error_translation_key = { + "seen": "seen_failed", + "move": "copy_failed", + "delete": "delete_failed", + } + for service, data in service_calls.items(): + with ( + pytest.raises(ServiceValidationError) as exc, + patch.object( + mock_imap_protocol, "store", side_effect=AioImapException("Bla") + ), + ): + await hass.services.async_call(DOMAIN, service, data, blocking=True) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "imap_server_fail" + assert exc.value.translation_placeholders == {"error": "Bla"} + # Test with bad responses on store command + with ( + pytest.raises(ServiceValidationError) as exc, + patch.object( + mock_imap_protocol, "store", return_value=Response("BAD", [b"Bla"]) + ), + patch.object( + mock_imap_protocol, "copy", return_value=Response("BAD", [b"Bla"]) + ), + ): + await hass.services.async_call(DOMAIN, service, data, blocking=True) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == store_error_translation_key[service] + assert exc.value.translation_placeholders == {"error": "Bla"}