Add IMAP fetch service (#115127)

* Add IMAP fetch service

* Fix docstr
This commit is contained in:
Jan Bouwhuis 2024-04-08 09:50:28 +02:00 committed by GitHub
parent 1cace9a609
commit 8b5177e989
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 121 additions and 25 deletions

View file

@ -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

View file

@ -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"
}
}

View file

@ -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:

View file

@ -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.",

View file

@ -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"}