Skip to content

Commit

Permalink
Add imap custom event data template (#93423)
Browse files Browse the repository at this point in the history
* Add imap custom event template

* Add template validation
  • Loading branch information
jbouwh authored May 25, 2023
1 parent 6cd766e commit 1b5d207
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 23 deletions.
2 changes: 1 addition & 1 deletion homeassistant/components/imap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator_class = ImapPollingDataUpdateCoordinator

coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = (
coordinator_class(hass, imap_client)
coordinator_class(hass, imap_client, entry)
)
await coordinator.async_config_entry_first_refresh()

Expand Down
32 changes: 25 additions & 7 deletions homeassistant/components/imap/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,24 @@

from homeassistant import config_entries
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.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.template import Template
from homeassistant.util.ssl import SSLCipherList

from .const import (
CONF_CHARSET,
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
CONF_FOLDER,
CONF_MAX_MESSAGE_SIZE,
CONF_SEARCH,
Expand All @@ -43,6 +49,9 @@
translation_key=CONF_SSL_CIPHER_LIST,
)
)
TEMPLATE_SELECTOR = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
)

CONFIG_SCHEMA = vol.Schema(
{
Expand All @@ -69,14 +78,17 @@
)

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(
cv.positive_int,
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."""
errors = {}

Expand Down Expand Up @@ -104,6 +116,12 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
errors[CONF_CHARSET] = "invalid_charset"
else:
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


Expand Down Expand Up @@ -131,7 +149,7 @@ async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
}
)
title = user_input[CONF_NAME]
if await validate_input(data):
if await validate_input(self.hass, data):
raise AbortFlow("cannot_connect")
return self.async_create_entry(title=title, data=data)

Expand All @@ -154,7 +172,7 @@ async def async_step_user(
}
)

if not (errors := await validate_input(user_input)):
if not (errors := await validate_input(self.hass, user_input)):
title = user_input[CONF_USERNAME]

return self.async_create_entry(title=title, data=user_input)
Expand All @@ -177,7 +195,7 @@ async def async_step_reauth_confirm(
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)):
if not (errors := await validate_input(self.hass, user_input)):
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=user_input
)
Expand Down Expand Up @@ -231,7 +249,7 @@ async def async_step_init(
errors = {"base": err.reason}
else:
entry_data.update(user_input)
errors = await validate_input(entry_data)
errors = await validate_input(self.hass, entry_data)
if not errors:
self.hass.config_entries.async_update_entry(
self.config_entry, data=entry_data
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/imap/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CONF_SEARCH: Final = "search"
CONF_CHARSET: Final = "charset"
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"

DEFAULT_PORT: Final = 993
Expand Down
60 changes: 49 additions & 11 deletions homeassistant/components/imap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@
CONTENT_TYPE_TEXT_PLAIN,
)
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.template import Template
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import SSLCipherList, client_context

from .const import (
CONF_CHARSET,
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
CONF_FOLDER,
CONF_MAX_MESSAGE_SIZE,
CONF_SEARCH,
Expand Down Expand Up @@ -145,16 +151,22 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""

config_entry: ConfigEntry
custom_event_template: Template | None

def __init__(
self,
hass: HomeAssistant,
imap_client: IMAP4_SSL,
entry: ConfigEntry,
update_interval: timedelta | None,
) -> None:
"""Initiate imap client."""
self.imap_client = imap_client
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__(
hass,
_LOGGER,
Expand All @@ -181,15 +193,36 @@ async def _async_process_event(self, last_message_id: str) -> None:
"search": self.config_entry.data[CONF_SEARCH],
"folder": self.config_entry.data[CONF_FOLDER],
"date": message.date,
"text": message.text[
: self.config_entry.data.get(
CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE
)
],
"text": message.text,
"sender": message.sender,
"subject": message.subject,
"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:
_LOGGER.warning(
"Custom imap_content event skipped, size (%s) exceeds "
Expand All @@ -203,7 +236,8 @@ async def _async_process_event(self, last_message_id: str) -> None:

self.hass.bus.fire(EVENT_IMAP, data)
_LOGGER.debug(
"Message processed, sender: %s, subject: %s",
"Message with id %s processed, sender: %s, subject: %s",
last_message_id,
message.sender,
message.subject,
)
Expand Down Expand Up @@ -260,9 +294,11 @@ async def shutdown(self, *_: Any) -> None:
class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""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."""
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:
"""Update the number of unread emails."""
Expand Down Expand Up @@ -291,9 +327,11 @@ async def _async_update_data(self) -> int | None:
class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""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."""
super().__init__(hass, imap_client, None)
super().__init__(hass, imap_client, entry, None)
self._push_wait_task: asyncio.Task[None] | None = None

async def _async_update_data(self) -> int | None:
Expand Down
4 changes: 3 additions & 1 deletion homeassistant/components/imap/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"data": {
"folder": "[%key:component::imap::config::step::user::data::folder%]",
"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)"
}
}
Expand All @@ -50,7 +51,8 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_charset": "[%key:component::imap::config::error::invalid_charset%]",
"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": {
Expand Down
25 changes: 22 additions & 3 deletions tests/components/imap/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "1024"}, 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(
Expand Down Expand Up @@ -438,9 +453,13 @@ async def test_advanced_options_form(
result["flow_id"], new_config
)
assert result2["type"] == assert_result
# Check if entry was updated
for key, value in new_config.items():
assert str(entry.data[key]) == value

if result2.get("errors") is not None:
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:
# Check if form was expected with these options
assert assert_result == data_entry_flow.FlowResultType.FORM
Expand Down
54 changes: 54 additions & 0 deletions tests/components/imap/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,57 @@ async def test_message_is_truncated(

event_data = event_called[0].data
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

0 comments on commit 1b5d207

Please sign in to comment.