mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add config flow to imap (#74623)
* Add config flow to imap fix coverage fix config_flows.py * move coordinator to seperate file, remove name key * update intrgations.json * update requirements_all.txt * fix importing issue_registry * Address comments * Improve handling exceptions on intial connection * exit loop tasks properly * fix timeout * revert async_timeout * Improve entity update handling * ensure we wait for idle to finish * fix typing * Update deprecation period Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
c225ed0a1a
commit
a0e18051c7
@ -561,6 +561,8 @@ omit =
|
||||
homeassistant/components/ifttt/const.py
|
||||
homeassistant/components/iglo/light.py
|
||||
homeassistant/components/ihc/*
|
||||
homeassistant/components/imap/__init__.py
|
||||
homeassistant/components/imap/coordinator.py
|
||||
homeassistant/components/imap/sensor.py
|
||||
homeassistant/components/imap_email_content/sensor.py
|
||||
homeassistant/components/incomfort/*
|
||||
|
@ -537,6 +537,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/image_processing/ @home-assistant/core
|
||||
/homeassistant/components/image_upload/ @home-assistant/core
|
||||
/tests/components/image_upload/ @home-assistant/core
|
||||
/homeassistant/components/imap/ @engrbm87
|
||||
/tests/components/imap/ @engrbm87
|
||||
/homeassistant/components/incomfort/ @zxdavb
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
|
@ -1 +1,54 @@
|
||||
"""The imap component."""
|
||||
"""The imap integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioimaplib import IMAP4_SSL, AioImapException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ImapDataUpdateCoordinator, connect_to_server
|
||||
from .errors import InvalidAuth, InvalidFolder
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
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 (asyncio.TimeoutError, AioImapException) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = ImapDataUpdateCoordinator(hass, imap_client)
|
||||
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: ImapDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await coordinator.shutdown()
|
||||
return unload_ok
|
||||
|
136
homeassistant/components/imap/config_flow.py
Normal file
136
homeassistant/components/imap/config_flow.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Config flow for imap integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aioimaplib import AioImapException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_CHARSET,
|
||||
CONF_FOLDER,
|
||||
CONF_SEARCH,
|
||||
CONF_SERVER,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import connect_to_server
|
||||
from .errors import InvalidAuth, InvalidFolder
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SERVER): str,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_CHARSET, default="utf-8"): str,
|
||||
vol.Optional(CONF_FOLDER, default="INBOX"): str,
|
||||
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate user input."""
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
imap_client = await connect_to_server(user_input)
|
||||
result, lines = await imap_client.search(
|
||||
user_input[CONF_SEARCH],
|
||||
charset=user_input[CONF_CHARSET],
|
||||
)
|
||||
|
||||
except InvalidAuth:
|
||||
errors[CONF_USERNAME] = errors[CONF_PASSWORD] = "invalid_auth"
|
||||
except InvalidFolder:
|
||||
errors[CONF_FOLDER] = "invalid_folder"
|
||||
except (asyncio.TimeoutError, AioImapException, ConnectionRefusedError):
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
if result != "OK":
|
||||
if "The specified charset is not supported" in lines[0].decode("utf-8"):
|
||||
errors[CONF_CHARSET] = "invalid_charset"
|
||||
else:
|
||||
errors[CONF_SEARCH] = "invalid_search"
|
||||
return errors
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for imap."""
|
||||
|
||||
VERSION = 1
|
||||
_reauth_entry: config_entries.ConfigEntry | None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_FOLDER: user_input[CONF_FOLDER],
|
||||
CONF_SEARCH: user_input[CONF_SEARCH],
|
||||
}
|
||||
)
|
||||
|
||||
if not (errors := await validate_input(user_input)):
|
||||
# To be removed when YAML import is removed
|
||||
title = user_input.get(CONF_NAME, user_input[CONF_USERNAME])
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
return await self.async_step_user(import_config)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
errors = {}
|
||||
assert self._reauth_entry
|
||||
if user_input is not None:
|
||||
user_input = {**self._reauth_entry.data, **user_input}
|
||||
if not (errors := await validate_input(user_input)):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._reauth_entry, data=user_input
|
||||
)
|
||||
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
description_placeholders={
|
||||
CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME]
|
||||
},
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
12
homeassistant/components/imap/const.py
Normal file
12
homeassistant/components/imap/const.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Constants for the imap integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "imap"
|
||||
|
||||
CONF_SERVER: Final = "server"
|
||||
CONF_FOLDER: Final = "folder"
|
||||
CONF_SEARCH: Final = "search"
|
||||
CONF_CHARSET: Final = "charset"
|
||||
|
||||
DEFAULT_PORT: Final = 993
|
104
homeassistant/components/imap/coordinator.py
Normal file
104
homeassistant/components/imap/coordinator.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Coordinator for imag integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN
|
||||
from .errors import InvalidAuth, InvalidFolder
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL:
|
||||
"""Connect to imap server and return client."""
|
||||
client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT])
|
||||
await client.wait_hello_from_server()
|
||||
await client.login(data[CONF_USERNAME], data[CONF_PASSWORD])
|
||||
if client.protocol.state != AUTH:
|
||||
raise InvalidAuth
|
||||
await client.select(data[CONF_FOLDER])
|
||||
if client.protocol.state != SELECTED:
|
||||
raise InvalidFolder
|
||||
return client
|
||||
|
||||
|
||||
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]):
|
||||
"""Class for imap client."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None:
|
||||
"""Initiate imap client."""
|
||||
self.hass = hass
|
||||
self.imap_client = imap_client
|
||||
self.support_push = imap_client.has_capability("IDLE")
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=10) if not self.support_push else None,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> int:
|
||||
"""Update the number of unread emails."""
|
||||
try:
|
||||
if self.imap_client is None:
|
||||
self.imap_client = await connect_to_server(self.config_entry.data)
|
||||
except (AioImapException, asyncio.TimeoutError) as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
return await self.refresh_email_count()
|
||||
|
||||
async def refresh_email_count(self) -> int:
|
||||
"""Check the number of found emails."""
|
||||
try:
|
||||
await self.imap_client.noop()
|
||||
result, lines = await self.imap_client.search(
|
||||
self.config_entry.data[CONF_SEARCH],
|
||||
charset=self.config_entry.data[CONF_CHARSET],
|
||||
)
|
||||
except (AioImapException, asyncio.TimeoutError) as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
if result != "OK":
|
||||
raise UpdateFailed(
|
||||
f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}"
|
||||
)
|
||||
if self.support_push:
|
||||
self.hass.async_create_task(self.async_wait_server_push())
|
||||
return len(lines[0].split())
|
||||
|
||||
async def async_wait_server_push(self) -> None:
|
||||
"""Wait for data push from server."""
|
||||
try:
|
||||
idle: asyncio.Future = await self.imap_client.idle_start()
|
||||
await self.imap_client.wait_server_push()
|
||||
self.imap_client.idle_done()
|
||||
async with async_timeout.timeout(10):
|
||||
await idle
|
||||
|
||||
except (AioImapException, asyncio.TimeoutError):
|
||||
_LOGGER.warning(
|
||||
"Lost %s (will attempt to reconnect)",
|
||||
self.config_entry.data[CONF_SERVER],
|
||||
)
|
||||
self.imap_client = None
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def shutdown(self, *_) -> None:
|
||||
"""Close resources."""
|
||||
if self.imap_client:
|
||||
await self.imap_client.stop_wait_server_push()
|
||||
await self.imap_client.logout()
|
11
homeassistant/components/imap/errors.py
Normal file
11
homeassistant/components/imap/errors.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Exceptions raised by IMAP integration."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Raise exception for invalid credentials."""
|
||||
|
||||
|
||||
class InvalidFolder(HomeAssistantError):
|
||||
"""Raise exception for invalid folder."""
|
@ -1,9 +1,11 @@
|
||||
{
|
||||
"domain": "imap",
|
||||
"name": "IMAP",
|
||||
"config_flow": true,
|
||||
"dependencies": ["repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/imap",
|
||||
"requirements": ["aioimaplib==1.0.1"],
|
||||
"codeowners": [],
|
||||
"codeowners": ["@engrbm87"],
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioimaplib"]
|
||||
}
|
||||
|
@ -1,37 +1,29 @@
|
||||
"""IMAP sensor support."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aioimaplib import IMAP4_SSL, AioImapException
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_SERVER = "server"
|
||||
CONF_FOLDER = "folder"
|
||||
CONF_SEARCH = "search"
|
||||
CONF_CHARSET = "charset"
|
||||
|
||||
DEFAULT_PORT = 993
|
||||
|
||||
ICON = "mdi:email-outline"
|
||||
from . import ImapDataUpdateCoordinator
|
||||
from .const import (
|
||||
CONF_CHARSET,
|
||||
CONF_FOLDER,
|
||||
CONF_SEARCH,
|
||||
CONF_SERVER,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@ -54,139 +46,60 @@ async def async_setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the IMAP platform."""
|
||||
sensor = ImapSensor(
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD),
|
||||
config.get(CONF_SERVER),
|
||||
config.get(CONF_PORT),
|
||||
config.get(CONF_CHARSET),
|
||||
config.get(CONF_FOLDER),
|
||||
config.get(CONF_SEARCH),
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml",
|
||||
breaks_in_ha_version="2023.4.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
)
|
||||
if not await sensor.connection():
|
||||
raise PlatformNotReady
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown)
|
||||
async_add_entities([sensor], True)
|
||||
|
||||
|
||||
class ImapSensor(SensorEntity):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the Imap sensor."""
|
||||
|
||||
coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities([ImapSensor(coordinator)])
|
||||
|
||||
|
||||
class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of an IMAP sensor."""
|
||||
|
||||
def __init__(self, name, user, password, server, port, charset, folder, search):
|
||||
_attr_icon = "mdi:email-outline"
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ImapDataUpdateCoordinator) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._name = name or user
|
||||
self._user = user
|
||||
self._password = password
|
||||
self._server = server
|
||||
self._port = port
|
||||
self._charset = charset
|
||||
self._folder = folder
|
||||
self._email_count = None
|
||||
self._search = search
|
||||
self._connection = None
|
||||
self._does_push = None
|
||||
self._idle_loop_task = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when an entity is about to be added to Home Assistant."""
|
||||
if not self.should_poll:
|
||||
self._idle_loop_task = self.hass.loop.create_task(self.idle_loop())
|
||||
super().__init__(coordinator)
|
||||
# To be removed when YAML import is removed
|
||||
if CONF_NAME in coordinator.config_entry.data:
|
||||
self._attr_name = coordinator.config_entry.data[CONF_NAME]
|
||||
self._attr_has_entity_name = False
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=f"IMAP ({coordinator.config_entry.data[CONF_USERNAME]})",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> int:
|
||||
"""Return the number of emails found."""
|
||||
return self._email_count
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the availability of the device."""
|
||||
return self._connection is not None
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return if polling is needed."""
|
||||
return not self._does_push
|
||||
|
||||
async def connection(self):
|
||||
"""Return a connection to the server, establishing it if necessary."""
|
||||
if self._connection is None:
|
||||
try:
|
||||
self._connection = IMAP4_SSL(self._server, self._port)
|
||||
await self._connection.wait_hello_from_server()
|
||||
await self._connection.login(self._user, self._password)
|
||||
await self._connection.select(self._folder)
|
||||
self._does_push = self._connection.has_capability("IDLE")
|
||||
except (AioImapException, asyncio.TimeoutError):
|
||||
self._connection = None
|
||||
|
||||
return self._connection
|
||||
|
||||
async def idle_loop(self):
|
||||
"""Wait for data pushed from server."""
|
||||
while True:
|
||||
try:
|
||||
if await self.connection():
|
||||
await self.refresh_email_count()
|
||||
self.async_write_ha_state()
|
||||
|
||||
idle = await self._connection.idle_start()
|
||||
await self._connection.wait_server_push()
|
||||
self._connection.idle_done()
|
||||
async with async_timeout.timeout(10):
|
||||
await idle
|
||||
else:
|
||||
self.async_write_ha_state()
|
||||
except (AioImapException, asyncio.TimeoutError):
|
||||
self.disconnected()
|
||||
return self.coordinator.data
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Periodic polling of state."""
|
||||
try:
|
||||
if await self.connection():
|
||||
await self.refresh_email_count()
|
||||
except (AioImapException, asyncio.TimeoutError):
|
||||
self.disconnected()
|
||||
|
||||
async def refresh_email_count(self):
|
||||
"""Check the number of found emails."""
|
||||
if self._connection:
|
||||
await self._connection.noop()
|
||||
result, lines = await self._connection.search(
|
||||
self._search, charset=self._charset
|
||||
)
|
||||
|
||||
if result == "OK":
|
||||
self._email_count = len(lines[0].split())
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Can't parse IMAP server response to search '%s': %s / %s",
|
||||
self._search,
|
||||
result,
|
||||
lines[0],
|
||||
)
|
||||
|
||||
def disconnected(self):
|
||||
"""Forget the connection after it was lost."""
|
||||
_LOGGER.warning("Lost %s (will attempt to reconnect)", self._server)
|
||||
self._connection = None
|
||||
|
||||
async def shutdown(self, *_):
|
||||
"""Close resources."""
|
||||
if self._connection:
|
||||
if self._connection.has_pending_idle():
|
||||
self._connection.idle_done()
|
||||
await self._connection.logout()
|
||||
if self._idle_loop_task:
|
||||
self._idle_loop_task.cancel()
|
||||
"""Check for idle state before updating."""
|
||||
if not await self.coordinator.imap_client.stop_wait_server_push():
|
||||
await super().async_update()
|
||||
|
40
homeassistant/components/imap/strings.json
Normal file
40
homeassistant/components/imap/strings.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"server": "Server",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"charset": "Character set",
|
||||
"folder": "Folder",
|
||||
"search": "IMAP search"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The password for {username} is invalid.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_charset": "The specified charset is not supported",
|
||||
"invalid_search": "The selected search is invalid"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The IMAP YAML configuration is being removed",
|
||||
"description": "Configuring IMAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the IMAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
40
homeassistant/components/imap/translations/en.json
Normal file
40
homeassistant/components/imap/translations/en.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"invalid_charset": "The specified charset is not supported",
|
||||
"invalid_search": "The selected search is invalid"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "The password for {username} is invalid.",
|
||||
"title": "Reauthenticate Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"charset": "Character set",
|
||||
"folder": "Folder",
|
||||
"password": "Password",
|
||||
"port": "Port",
|
||||
"search": "IMAP search",
|
||||
"server": "Server",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring IMAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the IMAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The IMAP YAML configuration is being removed"
|
||||
}
|
||||
}
|
||||
}
|
@ -191,6 +191,7 @@ FLOWS = {
|
||||
"ibeacon",
|
||||
"icloud",
|
||||
"ifttt",
|
||||
"imap",
|
||||
"inkbird",
|
||||
"insteon",
|
||||
"intellifire",
|
||||
|
@ -2439,7 +2439,7 @@
|
||||
"imap": {
|
||||
"name": "IMAP",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"imap_email_content": {
|
||||
|
@ -170,6 +170,9 @@ aiohttp_cors==0.7.0
|
||||
# homeassistant.components.hue
|
||||
aiohue==4.5.0
|
||||
|
||||
# homeassistant.components.imap
|
||||
aioimaplib==1.0.1
|
||||
|
||||
# homeassistant.components.apache_kafka
|
||||
aiokafka==0.7.2
|
||||
|
||||
|
1
tests/components/imap/__init__.py
Normal file
1
tests/components/imap/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the imap integration."""
|
349
tests/components/imap/test_config_flow.py
Normal file
349
tests/components/imap/test_config_flow.py
Normal file
@ -0,0 +1,349 @@
|
||||
"""Test the imap config flow."""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from aioimaplib import AioImapException
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.imap.const import (
|
||||
CONF_CHARSET,
|
||||
CONF_FOLDER,
|
||||
CONF_SEARCH,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_CONFIG = {
|
||||
"username": "email@email.com",
|
||||
"password": "password",
|
||||
"server": "imap.server.com",
|
||||
"port": 993,
|
||||
"charset": "utf-8",
|
||||
"folder": "INBOX",
|
||||
"search": "UnSeen UnDeleted",
|
||||
}
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server"
|
||||
) as mock_client, patch(
|
||||
"homeassistant.components.imap.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
mock_client.return_value.search.return_value = (
|
||||
"OK",
|
||||
[b""],
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "email@email.com"
|
||||
assert result2["data"] == MOCK_CONFIG
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import_flow_success(hass: HomeAssistant) -> None:
|
||||
"""Test a successful import of yaml."""
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server"
|
||||
) as mock_client, patch(
|
||||
"homeassistant.components.imap.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
mock_client.return_value.search.return_value = (
|
||||
"OK",
|
||||
[b""],
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
"name": "IMAP",
|
||||
"username": "email@email.com",
|
||||
"password": "password",
|
||||
"server": "imap.server.com",
|
||||
"port": 993,
|
||||
"charset": "utf-8",
|
||||
"folder": "INBOX",
|
||||
"search": "UnSeen UnDeleted",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "IMAP"
|
||||
assert result2["data"] == {
|
||||
"name": "IMAP",
|
||||
"username": "email@email.com",
|
||||
"password": "password",
|
||||
"server": "imap.server.com",
|
||||
"port": 993,
|
||||
"charset": "utf-8",
|
||||
"folder": "INBOX",
|
||||
"search": "UnSeen UnDeleted",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_entry_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test aborting if the entry is already configured."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"username": "email@email.com",
|
||||
"password": "password",
|
||||
"server": "imap.server.com",
|
||||
"port": 993,
|
||||
"charset": "utf-8",
|
||||
"folder": "INBOX",
|
||||
"search": "UnSeen UnDeleted",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == FlowResultType.ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server",
|
||||
side_effect=InvalidAuth,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {
|
||||
CONF_USERNAME: "invalid_auth",
|
||||
CONF_PASSWORD: "invalid_auth",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[asyncio.TimeoutError, AioImapException("")],
|
||||
)
|
||||
async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server",
|
||||
side_effect=exc,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_invalid_charset(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid charset."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server"
|
||||
) as mock_client:
|
||||
mock_client.return_value.search.return_value = (
|
||||
"NO",
|
||||
[b"The specified charset is not supported"],
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {CONF_CHARSET: "invalid_charset"}
|
||||
|
||||
|
||||
async def test_form_invalid_folder(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid folder selection."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server",
|
||||
side_effect=InvalidFolder,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {CONF_FOLDER: "invalid_folder"}
|
||||
|
||||
|
||||
async def test_form_invalid_search(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid search."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server"
|
||||
) as mock_client:
|
||||
mock_client.return_value.search.return_value = (
|
||||
"BAD",
|
||||
[b"Invalid search"],
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], MOCK_CONFIG
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {CONF_SEARCH: "invalid_search"}
|
||||
|
||||
|
||||
async def test_reauth_success(hass: HomeAssistant) -> None:
|
||||
"""Test we can reauth."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
},
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"] == {CONF_USERNAME: "email@email.com"}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server"
|
||||
) as mock_client, patch(
|
||||
"homeassistant.components.imap.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
mock_client.return_value.search.return_value = (
|
||||
"OK",
|
||||
[b""],
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.ABORT
|
||||
assert result2["reason"] == "reauth_successful"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_failed(hass: HomeAssistant) -> None:
|
||||
"""Test we can reauth."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
},
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server",
|
||||
side_effect=InvalidAuth,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "test-wrong-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {
|
||||
CONF_USERNAME: "invalid_auth",
|
||||
CONF_PASSWORD: "invalid_auth",
|
||||
}
|
||||
|
||||
|
||||
async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None:
|
||||
"""Test we can reauth."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
},
|
||||
data=MOCK_CONFIG,
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.imap.config_flow.connect_to_server",
|
||||
side_effect=asyncio.TimeoutError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "test-wrong-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
Loading…
x
Reference in New Issue
Block a user