From 8b5177e989abe8b16165bbecbde8df232288dd47 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 Apr 2024 09:50:28 +0200 Subject: [PATCH] Add IMAP fetch service (#115127) * Add IMAP fetch service * Fix docstr --- homeassistant/components/imap/__init__.py | 46 +++++++++++++- homeassistant/components/imap/icons.json | 3 +- homeassistant/components/imap/services.yaml | 13 ++++ homeassistant/components/imap/strings.json | 17 ++++++ tests/components/imap/test_init.py | 67 ++++++++++++++------- 5 files changed, 121 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 22e32187255..f39a78925c1 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -11,7 +11,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -23,6 +29,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( + ImapMessage, ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, connect_to_server, @@ -56,6 +63,7 @@ SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend( } ) SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: @@ -188,6 +196,42 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.services.async_register(DOMAIN, "delete", async_delete, SERVICE_DELETE_SCHEMA) + async def async_fetch(call: ServiceCall) -> ServiceResponse: + """Process fetch email service and return content.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Fetch text for message %s. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(hass, entry_id) + try: + response = await client.fetch(uid, "BODY.PEEK[]") + 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, "fetch_failed") + message = ImapMessage(response.lines[1]) + await client.close() + return { + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "uid": uid, + } + + hass.services.async_register( + DOMAIN, + "fetch", + async_fetch, + SERVICE_FETCH_TEXT_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/homeassistant/components/imap/icons.json b/homeassistant/components/imap/icons.json index 2e61cf56573..6672f9a4a7f 100644 --- a/homeassistant/components/imap/icons.json +++ b/homeassistant/components/imap/icons.json @@ -12,6 +12,7 @@ "services": { "seen": "mdi:email-open-outline", "move": "mdi:email-arrow-right-outline", - "delete": "mdi:trash-can-outline" + "delete": "mdi:trash-can-outline", + "fetch": "mdi:email-sync-outline" } } diff --git a/homeassistant/components/imap/services.yaml b/homeassistant/components/imap/services.yaml index f0694bfba70..be56eb148da 100644 --- a/homeassistant/components/imap/services.yaml +++ b/homeassistant/components/imap/services.yaml @@ -43,3 +43,16 @@ delete: required: true selector: text: + +fetch: + fields: + entry: + required: true + selector: + config_entry: + integration: "imap" + uid: + required: true + example: "12" + selector: + text: diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 3a20fc244c6..a8413922036 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -45,6 +45,9 @@ "expunge_failed": { "message": "Expunging the message failed with \"{error}\"." }, + "fetch_failed": { + "message": "Fetching the message text failed with \"{error}\"." + }, "invalid_entry": { "message": "No valid IMAP entry was found." }, @@ -92,6 +95,20 @@ } }, "services": { + "fetch": { + "name": "Fetch message", + "description": "Fetch the email message from the server.", + "fields": { + "entry": { + "name": "Entry", + "description": "The IMAP config entry." + }, + "uid": { + "name": "UID", + "description": "The email identifier (UID)." + } + } + }, "seen": { "name": "Mark message as seen", "description": "Mark an email as seen.", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b0cfb9051a4..69c1aaabb2e 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -847,6 +847,17 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N mock_imap_protocol.store.assert_called_with("1", "+FLAGS (\\Deleted)") mock_imap_protocol.protocol.expunge.assert_called_once() + # Test fetch service + data = {"entry": config_entry.entry_id, "uid": "1"} + response = await hass.services.async_call( + DOMAIN, "fetch", data, blocking=True, return_response=True + ) + mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") + assert response["text"] == "Test body\r\n" + assert response["sender"] == "john.doe@example.com" + assert response["subject"] == "Test subject" + assert response["uid"] == "1" + # Test with invalid entry_id data = {"entry": "invalid", "uid": "1"} with pytest.raises(ServiceValidationError) as exc: @@ -877,43 +888,53 @@ async def test_services(hass: HomeAssistant, mock_imap_protocol: MagicMock) -> N ) # 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"}, + service_calls_response = { + "seen": ({"entry": config_entry.entry_id, "uid": "1"}, False), + "move": ( + { + "entry": config_entry.entry_id, + "uid": "1", + "seen": False, + "target_folder": "Trash", + }, + False, + ), + "delete": ({"entry": config_entry.entry_id, "uid": "1"}, False), + "fetch": ({"entry": config_entry.entry_id, "uid": "1"}, True), } - store_error_translation_key = { - "seen": "seen_failed", - "move": "copy_failed", - "delete": "delete_failed", + patch_error_translation_key = { + "seen": ("store", "seen_failed"), + "move": ("copy", "copy_failed"), + "delete": ("store", "delete_failed"), + "fetch": ("fetch", "fetch_failed"), } - for service, data in service_calls.items(): + for service, (data, response) in service_calls_response.items(): with ( pytest.raises(ServiceValidationError) as exc, patch.object( - mock_imap_protocol, "store", side_effect=AioImapException("Bla") + mock_imap_protocol, + patch_error_translation_key[service][0], + side_effect=AioImapException("Bla"), ), ): - await hass.services.async_call(DOMAIN, service, data, blocking=True) + await hass.services.async_call( + DOMAIN, service, data, blocking=True, return_response=response + ) 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 + # Test with bad responses 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"]) + mock_imap_protocol, + patch_error_translation_key[service][0], + return_value=Response("BAD", [b"Bla"]), ), ): - await hass.services.async_call(DOMAIN, service, data, blocking=True) + await hass.services.async_call( + DOMAIN, service, data, blocking=True, return_response=response + ) assert exc.value.translation_domain == DOMAIN - assert exc.value.translation_key == store_error_translation_key[service] + assert exc.value.translation_key == patch_error_translation_key[service][1] assert exc.value.translation_placeholders == {"error": "Bla"}