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
This commit is contained in:
Jan Bouwhuis 2024-04-02 23:05:05 +02:00 committed by GitHub
parent 906d3198e3
commit 83b56ab005
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 450 additions and 6 deletions

View File

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

View File

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

View File

@ -8,5 +8,10 @@
}
}
}
},
"services": {
"seen": "mdi:email-open-outline",
"move": "mdi:email-arrow-right-outline",
"delete": "mdi:trash-can-outline"
}
}

View File

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

View File

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

View File

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

View File

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