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:
Rami Mosleh 2023-01-09 12:41:47 +02:00 committed by GitHub
parent c225ed0a1a
commit a0e18051c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 820 additions and 151 deletions

View File

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

View File

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

View File

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

View 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,
)

View 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

View 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()

View 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."""

View File

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

View File

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

View 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."
}
}
}

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

View File

@ -191,6 +191,7 @@ FLOWS = {
"ibeacon",
"icloud",
"ifttt",
"imap",
"inkbird",
"insteon",
"intellifire",

View File

@ -2439,7 +2439,7 @@
"imap": {
"name": "IMAP",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_push"
},
"imap_email_content": {

View File

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

View File

@ -0,0 +1 @@
"""Tests for the imap integration."""

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