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 __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.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
ConfigEntryError, ConfigEntryError,
ConfigEntryNotReady, ConfigEntryNotReady,
ServiceValidationError,
) )
import homeassistant.helpers.config_validation as cv
from .const import CONF_ENABLE_PUSH, DOMAIN from .const import CONF_ENABLE_PUSH, DOMAIN
from .coordinator import ( from .coordinator import (
@ -23,6 +30,70 @@ from .errors import InvalidAuth, InvalidFolder
PLATFORMS: list[Platform] = [Platform.SENSOR] 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up imap from a config entry.""" """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() 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 hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.async_on_unload( entry.async_on_unload(
@ -67,4 +229,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator
) = hass.data[DOMAIN].pop(entry.entry_id) ) = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.shutdown() 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 return unload_ok

View File

@ -1,4 +1,4 @@
"""Coordinator for imag integration.""" """Coordinator for imap integration."""
from __future__ import annotations from __future__ import annotations
@ -254,6 +254,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
initial = False initial = False
self._last_message_id = message_id self._last_message_id = message_id
data = { data = {
"entry_id": self.config_entry.entry_id,
"server": self.config_entry.data[CONF_SERVER], "server": self.config_entry.data[CONF_SERVER],
"username": self.config_entry.data[CONF_USERNAME], "username": self.config_entry.data[CONF_USERNAME],
"search": self.config_entry.data[CONF_SEARCH], "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%]" "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": { "options": {
"step": { "step": {
"init": { "init": {
@ -64,5 +90,57 @@
"intermediate": "Intermediate ciphers" "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.""" """Fixtures for imap tests."""
from collections.abc import Generator from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response
@ -62,7 +62,7 @@ async def mock_imap_protocol(
imap_pending_idle: bool, imap_pending_idle: bool,
imap_login_state: str, imap_login_state: str,
imap_select_state: str, imap_select_state: str,
) -> Generator[MagicMock, None]: ) -> AsyncGenerator[MagicMock, None]:
"""Mock the aioimaplib IMAP protocol handler.""" """Mock the aioimaplib IMAP protocol handler."""
with patch( with patch(
@ -79,7 +79,14 @@ async def mock_imap_protocol(
async def close() -> Response: async def close() -> Response:
"""Mock imap close the selected folder.""" """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", []) return Response("OK", [])
async def logout() -> Response: 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.has_pending_idle.return_value = imap_pending_idle
imap_mock.protocol = MagicMock() imap_mock.protocol = MagicMock()
imap_mock.protocol.state = STARTED 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.has_capability.return_value = imap_has_capability
imap_mock.login.side_effect = login imap_mock.login.side_effect = login
imap_mock.close.side_effect = close imap_mock.close.side_effect = close
imap_mock.copy.side_effect = copy
imap_mock.logout.side_effect = logout imap_mock.logout.side_effect = logout
imap_mock.select.side_effect = select imap_mock.select.side_effect = select
imap_mock.search.return_value = Response(*imap_search) imap_mock.search.return_value = Response(*imap_search)
imap_mock.store.side_effect = store
imap_mock.fetch.return_value = Response(*imap_fetch) imap_mock.fetch.return_value = Response(*imap_fetch)
imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server
imap_mock.timeout = 3
yield imap_mock 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.components.sensor.const import SensorStateClass
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from .const import ( from .const import (
@ -780,3 +781,139 @@ async def test_enforce_polling(
mock_imap_protocol.wait_server_push.assert_not_called() mock_imap_protocol.wait_server_push.assert_not_called()
else: else:
mock_imap_protocol.assert_has_calls([call.wait_server_push]) 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"}