"""The imap integration.""" from __future__ import annotations import asyncio from email.message import Message import logging from typing import Any from aioimaplib import IMAP4_SSL, AioImapException, Response import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, callback, ) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, ServiceValidationError, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( ImapDataUpdateCoordinator, ImapMessage, ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, connect_to_server, get_parts, ) from .errors import InvalidAuth, InvalidFolder PLATFORMS: list[Platform] = [Platform.SENSOR] CONF_ENTRY = "entry" CONF_SEEN = "seen" CONF_PART = "part" CONF_UID = "uid" CONF_TARGET_FOLDER = "target_folder" _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _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 SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend( { vol.Required(CONF_PART): cv.string, } ) type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator] async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: """Get IMAP client and connect.""" if (entry := hass.config_entries.async_get_entry(entry_id)) is None or ( entry.state is not ConfigEntryState.LOADED ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_entry", ) 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(hass: HomeAssistant, config: ConfigType) -> bool: """Set up imap services.""" 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() 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() 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() 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") # Index 1 of of the response lines contains the bytearray with the message data message = ImapMessage(response.lines[1]) await client.close() return { "text": message.text, "sender": message.sender, "subject": message.subject, "parts": get_parts(message.email_message), "uid": uid, } hass.services.async_register( DOMAIN, "fetch", async_fetch, SERVICE_FETCH_TEXT_SCHEMA, supports_response=SupportsResponse.ONLY, ) async def async_fetch_part(call: ServiceCall) -> ServiceResponse: """Process fetch email part service and return content.""" @callback def get_message_part(message: Message, part_key: str) -> Message: part: Message | Any = message for index in part_key.split(","): sub_parts = part.get_payload() try: assert isinstance(sub_parts, list) part = sub_parts[int(index)] except (AssertionError, ValueError, IndexError) as exc: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_part_index", ) from exc return part entry_id: str = call.data[CONF_ENTRY] uid: str = call.data[CONF_UID] part_key: str = call.data[CONF_PART] _LOGGER.debug( "Fetch part %s for message %s. Entry: %s", part_key, 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") # Index 1 of of the response lines contains the bytearray with the message data message = ImapMessage(response.lines[1]) await client.close() part_data = get_message_part(message.email_message, part_key) part_data_content = part_data.get_payload(decode=False) try: assert isinstance(part_data_content, str) except AssertionError as exc: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_part_index", ) from exc return { "part_data": part_data_content, "content_type": part_data.get_content_type(), "content_transfer_encoding": part_data.get("Content-Transfer-Encoding"), "filename": part_data.get_filename(), "part": part_key, "uid": uid, } hass.services.async_register( DOMAIN, "fetch_part", async_fetch_part, SERVICE_FETCH_PART_SCHEMA, supports_response=SupportsResponse.ONLY, ) return True async def async_setup_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool: """Set up imap from a config entry.""" try: imap_client: IMAP4_SSL = await connect_to_server(dict(entry.data)) except InvalidAuth as err: raise ConfigEntryAuthFailed from err except InvalidFolder as err: raise ConfigEntryError("Selected mailbox folder is invalid.") from err except (TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err coordinator_class: type[ ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator ] enable_push: bool = entry.data.get(CONF_ENABLE_PUSH, True) if enable_push and imap_client.has_capability("IDLE"): coordinator_class = ImapPushDataUpdateCoordinator else: coordinator_class = ImapPollingDataUpdateCoordinator coordinator: ImapDataUpdateCoordinator = coordinator_class(hass, imap_client, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator = entry.runtime_data await coordinator.shutdown() return unload_ok