282 lines
9.4 KiB
Python
282 lines
9.4 KiB
Python
"""The imap integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
ServiceCall,
|
|
ServiceResponse,
|
|
SupportsResponse,
|
|
callback,
|
|
)
|
|
from homeassistant.exceptions import (
|
|
ConfigEntryAuthFailed,
|
|
ConfigEntryError,
|
|
ConfigEntryNotReady,
|
|
ServiceValidationError,
|
|
)
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from .const import CONF_ENABLE_PUSH, DOMAIN
|
|
from .coordinator import (
|
|
ImapMessage,
|
|
ImapPollingDataUpdateCoordinator,
|
|
ImapPushDataUpdateCoordinator,
|
|
connect_to_server,
|
|
)
|
|
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__)
|
|
|
|
|
|
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
|
|
|
|
|
|
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(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")
|
|
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
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = (
|
|
coordinator_class(hass, imap_client, entry)
|
|
)
|
|
await coordinator.async_config_entry_first_refresh()
|
|
|
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
|
coordinator: (
|
|
ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator
|
|
) = hass.data[DOMAIN].pop(entry.entry_id)
|
|
await coordinator.shutdown()
|
|
return unload_ok
|