Add imap custom event data template (#93423)
* Add imap custom event template * Add template validation
This commit is contained in:
parent
6cd766ef1f
commit
1b5d207984
7 changed files with 155 additions and 23 deletions
|
@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
coordinator_class = ImapPollingDataUpdateCoordinator
|
coordinator_class = ImapPollingDataUpdateCoordinator
|
||||||
|
|
||||||
coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = (
|
coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = (
|
||||||
coordinator_class(hass, imap_client)
|
coordinator_class(hass, imap_client, entry)
|
||||||
)
|
)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
|
|
@ -11,18 +11,24 @@ import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||||
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
SelectSelector,
|
SelectSelector,
|
||||||
SelectSelectorConfig,
|
SelectSelectorConfig,
|
||||||
SelectSelectorMode,
|
SelectSelectorMode,
|
||||||
|
TextSelector,
|
||||||
|
TextSelectorConfig,
|
||||||
|
TextSelectorType,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers.template import Template
|
||||||
from homeassistant.util.ssl import SSLCipherList
|
from homeassistant.util.ssl import SSLCipherList
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CHARSET,
|
CONF_CHARSET,
|
||||||
|
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
|
||||||
CONF_FOLDER,
|
CONF_FOLDER,
|
||||||
CONF_MAX_MESSAGE_SIZE,
|
CONF_MAX_MESSAGE_SIZE,
|
||||||
CONF_SEARCH,
|
CONF_SEARCH,
|
||||||
|
@ -43,6 +49,9 @@ CIPHER_SELECTOR = SelectSelector(
|
||||||
translation_key=CONF_SSL_CIPHER_LIST,
|
translation_key=CONF_SSL_CIPHER_LIST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
TEMPLATE_SELECTOR = TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
|
||||||
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
|
@ -69,14 +78,17 @@ OPTIONS_SCHEMA = vol.Schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
OPTIONS_SCHEMA_ADVANCED = {
|
OPTIONS_SCHEMA_ADVANCED = {
|
||||||
|
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
|
||||||
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
|
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
|
||||||
cv.positive_int,
|
cv.positive_int,
|
||||||
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
|
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
|
||||||
)
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
|
async def validate_input(
|
||||||
|
hass: HomeAssistant, user_input: dict[str, Any]
|
||||||
|
) -> dict[str, str]:
|
||||||
"""Validate user input."""
|
"""Validate user input."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
|
@ -104,6 +116,12 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
|
||||||
errors[CONF_CHARSET] = "invalid_charset"
|
errors[CONF_CHARSET] = "invalid_charset"
|
||||||
else:
|
else:
|
||||||
errors[CONF_SEARCH] = "invalid_search"
|
errors[CONF_SEARCH] = "invalid_search"
|
||||||
|
if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE):
|
||||||
|
try:
|
||||||
|
Template(template, hass=hass).ensure_valid()
|
||||||
|
except TemplateError:
|
||||||
|
errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template"
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
@ -131,7 +149,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
title = user_input[CONF_NAME]
|
title = user_input[CONF_NAME]
|
||||||
if await validate_input(data):
|
if await validate_input(self.hass, data):
|
||||||
raise AbortFlow("cannot_connect")
|
raise AbortFlow("cannot_connect")
|
||||||
return self.async_create_entry(title=title, data=data)
|
return self.async_create_entry(title=title, data=data)
|
||||||
|
|
||||||
|
@ -154,7 +172,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not (errors := await validate_input(user_input)):
|
if not (errors := await validate_input(self.hass, user_input)):
|
||||||
title = user_input[CONF_USERNAME]
|
title = user_input[CONF_USERNAME]
|
||||||
|
|
||||||
return self.async_create_entry(title=title, data=user_input)
|
return self.async_create_entry(title=title, data=user_input)
|
||||||
|
@ -177,7 +195,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
assert self._reauth_entry
|
assert self._reauth_entry
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
user_input = {**self._reauth_entry.data, **user_input}
|
user_input = {**self._reauth_entry.data, **user_input}
|
||||||
if not (errors := await validate_input(user_input)):
|
if not (errors := await validate_input(self.hass, user_input)):
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
self._reauth_entry, data=user_input
|
self._reauth_entry, data=user_input
|
||||||
)
|
)
|
||||||
|
@ -231,7 +249,7 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||||
errors = {"base": err.reason}
|
errors = {"base": err.reason}
|
||||||
else:
|
else:
|
||||||
entry_data.update(user_input)
|
entry_data.update(user_input)
|
||||||
errors = await validate_input(entry_data)
|
errors = await validate_input(self.hass, entry_data)
|
||||||
if not errors:
|
if not errors:
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
self.config_entry, data=entry_data
|
self.config_entry, data=entry_data
|
||||||
|
|
|
@ -9,6 +9,7 @@ CONF_FOLDER: Final = "folder"
|
||||||
CONF_SEARCH: Final = "search"
|
CONF_SEARCH: Final = "search"
|
||||||
CONF_CHARSET: Final = "charset"
|
CONF_CHARSET: Final = "charset"
|
||||||
CONF_MAX_MESSAGE_SIZE = "max_message_size"
|
CONF_MAX_MESSAGE_SIZE = "max_message_size"
|
||||||
|
CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template"
|
||||||
CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list"
|
CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list"
|
||||||
|
|
||||||
DEFAULT_PORT: Final = 993
|
DEFAULT_PORT: Final = 993
|
||||||
|
|
|
@ -19,13 +19,19 @@ from homeassistant.const import (
|
||||||
CONTENT_TYPE_TEXT_PLAIN,
|
CONTENT_TYPE_TEXT_PLAIN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
from homeassistant.exceptions import (
|
||||||
|
ConfigEntryAuthFailed,
|
||||||
|
ConfigEntryError,
|
||||||
|
TemplateError,
|
||||||
|
)
|
||||||
from homeassistant.helpers.json import json_bytes
|
from homeassistant.helpers.json import json_bytes
|
||||||
|
from homeassistant.helpers.template import Template
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from homeassistant.util.ssl import SSLCipherList, client_context
|
from homeassistant.util.ssl import SSLCipherList, client_context
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CHARSET,
|
CONF_CHARSET,
|
||||||
|
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
|
||||||
CONF_FOLDER,
|
CONF_FOLDER,
|
||||||
CONF_MAX_MESSAGE_SIZE,
|
CONF_MAX_MESSAGE_SIZE,
|
||||||
CONF_SEARCH,
|
CONF_SEARCH,
|
||||||
|
@ -145,16 +151,22 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
"""Base class for imap client."""
|
"""Base class for imap client."""
|
||||||
|
|
||||||
config_entry: ConfigEntry
|
config_entry: ConfigEntry
|
||||||
|
custom_event_template: Template | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
imap_client: IMAP4_SSL,
|
imap_client: IMAP4_SSL,
|
||||||
|
entry: ConfigEntry,
|
||||||
update_interval: timedelta | None,
|
update_interval: timedelta | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initiate imap client."""
|
"""Initiate imap client."""
|
||||||
self.imap_client = imap_client
|
self.imap_client = imap_client
|
||||||
self._last_message_id: str | None = None
|
self._last_message_id: str | None = None
|
||||||
|
self.custom_event_template = None
|
||||||
|
_custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE)
|
||||||
|
if _custom_event_template is not None:
|
||||||
|
self.custom_event_template = Template(_custom_event_template, hass=hass)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
|
@ -181,15 +193,36 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
"search": self.config_entry.data[CONF_SEARCH],
|
"search": self.config_entry.data[CONF_SEARCH],
|
||||||
"folder": self.config_entry.data[CONF_FOLDER],
|
"folder": self.config_entry.data[CONF_FOLDER],
|
||||||
"date": message.date,
|
"date": message.date,
|
||||||
"text": message.text[
|
"text": message.text,
|
||||||
: self.config_entry.data.get(
|
|
||||||
CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
"sender": message.sender,
|
"sender": message.sender,
|
||||||
"subject": message.subject,
|
"subject": message.subject,
|
||||||
"headers": message.headers,
|
"headers": message.headers,
|
||||||
}
|
}
|
||||||
|
if self.custom_event_template is not None:
|
||||||
|
try:
|
||||||
|
data["custom"] = self.custom_event_template.async_render(
|
||||||
|
data, parse_result=True
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"imap custom template (%s) for msgid %s rendered to: %s",
|
||||||
|
self.custom_event_template,
|
||||||
|
last_message_id,
|
||||||
|
data["custom"],
|
||||||
|
)
|
||||||
|
except TemplateError as err:
|
||||||
|
data["custom"] = None
|
||||||
|
_LOGGER.error(
|
||||||
|
"Error rendering imap custom template (%s) for msgid %s "
|
||||||
|
"failed with message: %s",
|
||||||
|
self.custom_event_template,
|
||||||
|
last_message_id,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
data["text"] = message.text[
|
||||||
|
: self.config_entry.data.get(
|
||||||
|
CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE
|
||||||
|
)
|
||||||
|
]
|
||||||
if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES:
|
if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Custom imap_content event skipped, size (%s) exceeds "
|
"Custom imap_content event skipped, size (%s) exceeds "
|
||||||
|
@ -203,7 +236,8 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
|
|
||||||
self.hass.bus.fire(EVENT_IMAP, data)
|
self.hass.bus.fire(EVENT_IMAP, data)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Message processed, sender: %s, subject: %s",
|
"Message with id %s processed, sender: %s, subject: %s",
|
||||||
|
last_message_id,
|
||||||
message.sender,
|
message.sender,
|
||||||
message.subject,
|
message.subject,
|
||||||
)
|
)
|
||||||
|
@ -260,9 +294,11 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||||
class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
"""Class for imap client."""
|
"""Class for imap client."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None:
|
def __init__(
|
||||||
|
self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
|
||||||
|
) -> None:
|
||||||
"""Initiate imap client."""
|
"""Initiate imap client."""
|
||||||
super().__init__(hass, imap_client, timedelta(seconds=10))
|
super().__init__(hass, imap_client, entry, timedelta(seconds=10))
|
||||||
|
|
||||||
async def _async_update_data(self) -> int | None:
|
async def _async_update_data(self) -> int | None:
|
||||||
"""Update the number of unread emails."""
|
"""Update the number of unread emails."""
|
||||||
|
@ -291,9 +327,11 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||||
"""Class for imap client."""
|
"""Class for imap client."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None:
|
def __init__(
|
||||||
|
self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
|
||||||
|
) -> None:
|
||||||
"""Initiate imap client."""
|
"""Initiate imap client."""
|
||||||
super().__init__(hass, imap_client, None)
|
super().__init__(hass, imap_client, entry, None)
|
||||||
self._push_wait_task: asyncio.Task[None] | None = None
|
self._push_wait_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
async def _async_update_data(self) -> int | None:
|
async def _async_update_data(self) -> int | None:
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
"data": {
|
"data": {
|
||||||
"folder": "[%key:component::imap::config::step::user::data::folder%]",
|
"folder": "[%key:component::imap::config::step::user::data::folder%]",
|
||||||
"search": "[%key:component::imap::config::step::user::data::search%]",
|
"search": "[%key:component::imap::config::step::user::data::search%]",
|
||||||
|
"custom_event_data_template": "Template to create custom event data",
|
||||||
"max_message_size": "Max message size (2048 < size < 30000)"
|
"max_message_size": "Max message size (2048 < size < 30000)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,7 +51,8 @@
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"invalid_charset": "[%key:component::imap::config::error::invalid_charset%]",
|
"invalid_charset": "[%key:component::imap::config::error::invalid_charset%]",
|
||||||
"invalid_folder": "[%key:component::imap::config::error::invalid_folder%]",
|
"invalid_folder": "[%key:component::imap::config::error::invalid_folder%]",
|
||||||
"invalid_search": "[%key:component::imap::config::error::invalid_search%]"
|
"invalid_search": "[%key:component::imap::config::error::invalid_search%]",
|
||||||
|
"invalid_template": "Invalid template"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|
|
@ -404,6 +404,21 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
|
||||||
({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY),
|
({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY),
|
||||||
({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM),
|
({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM),
|
||||||
({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM),
|
({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM),
|
||||||
|
(
|
||||||
|
{"custom_event_data_template": "{{ subject }}"},
|
||||||
|
data_entry_flow.FlowResultType.CREATE_ENTRY,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"custom_event_data_template": "{{ invalid_syntax"},
|
||||||
|
data_entry_flow.FlowResultType.FORM,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"valid_message_size",
|
||||||
|
"invalid_message_size_low",
|
||||||
|
"invalid_message_size_high",
|
||||||
|
"valid_template",
|
||||||
|
"invalid_template",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_advanced_options_form(
|
async def test_advanced_options_form(
|
||||||
|
@ -438,9 +453,13 @@ async def test_advanced_options_form(
|
||||||
result["flow_id"], new_config
|
result["flow_id"], new_config
|
||||||
)
|
)
|
||||||
assert result2["type"] == assert_result
|
assert result2["type"] == assert_result
|
||||||
# Check if entry was updated
|
|
||||||
for key, value in new_config.items():
|
if result2.get("errors") is not None:
|
||||||
assert str(entry.data[key]) == value
|
assert assert_result == data_entry_flow.FlowResultType.FORM
|
||||||
|
else:
|
||||||
|
# Check if entry was updated
|
||||||
|
for key, value in new_config.items():
|
||||||
|
assert str(entry.data[key]) == value
|
||||||
except vol.MultipleInvalid:
|
except vol.MultipleInvalid:
|
||||||
# Check if form was expected with these options
|
# Check if form was expected with these options
|
||||||
assert assert_result == data_entry_flow.FlowResultType.FORM
|
assert assert_result == data_entry_flow.FlowResultType.FORM
|
||||||
|
|
|
@ -505,3 +505,57 @@ async def test_message_is_truncated(
|
||||||
|
|
||||||
event_data = event_called[0].data
|
event_data = event_called[0].data
|
||||||
assert len(event_data["text"]) == 3
|
assert len(event_data["text"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("imap_search", "imap_fetch"),
|
||||||
|
[(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)],
|
||||||
|
ids=["plain"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("custom_template", "result", "error"),
|
||||||
|
[
|
||||||
|
("{{ subject }}", "Test subject", None),
|
||||||
|
('{{ "@example.com" in sender }}', True, None),
|
||||||
|
("{% bad template }}", None, "Error rendering imap custom template"),
|
||||||
|
],
|
||||||
|
ids=["subject_test", "sender_filter", "template_error"],
|
||||||
|
)
|
||||||
|
async def test_custom_template(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_imap_protocol: MagicMock,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
custom_template: str,
|
||||||
|
result: str | bool | None,
|
||||||
|
error: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Test the custom template event data."""
|
||||||
|
event_called = async_capture_events(hass, "imap_content")
|
||||||
|
|
||||||
|
config = MOCK_CONFIG.copy()
|
||||||
|
config["custom_event_data_template"] = custom_template
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 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["text"]
|
||||||
|
assert data["custom"] == result
|
||||||
|
assert error in caplog.text if error is not None else True
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue