Add feature to turn off using IMAP-Push on an IMAP server (#96436)

* Add feature to enforce polling an IMAP server

* Add test

* Remove not needed string tweak

* Rename enforce_polling to enable_push

* Push enabled by default
This commit is contained in:
Jan Bouwhuis 2023-07-14 21:26:35 +02:00 committed by GitHub
parent bbc3d0d287
commit 72458b6672
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 85 additions and 14 deletions

View file

@ -14,7 +14,7 @@ from homeassistant.exceptions import (
ConfigEntryNotReady, ConfigEntryNotReady,
) )
from .const import DOMAIN from .const import CONF_ENABLE_PUSH, DOMAIN
from .coordinator import ( from .coordinator import (
ImapPollingDataUpdateCoordinator, ImapPollingDataUpdateCoordinator,
ImapPushDataUpdateCoordinator, ImapPushDataUpdateCoordinator,
@ -39,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator_class: type[ coordinator_class: type[
ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator
] ]
if imap_client.has_capability("IDLE"): enable_push: bool = entry.data.get(CONF_ENABLE_PUSH, True)
if enable_push and imap_client.has_capability("IDLE"):
coordinator_class = ImapPushDataUpdateCoordinator coordinator_class = ImapPushDataUpdateCoordinator
else: else:
coordinator_class = ImapPollingDataUpdateCoordinator coordinator_class = ImapPollingDataUpdateCoordinator

View file

@ -33,6 +33,7 @@ from homeassistant.util.ssl import SSLCipherList
from .const import ( from .const import (
CONF_CHARSET, CONF_CHARSET,
CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_CUSTOM_EVENT_DATA_TEMPLATE,
CONF_ENABLE_PUSH,
CONF_FOLDER, CONF_FOLDER,
CONF_MAX_MESSAGE_SIZE, CONF_MAX_MESSAGE_SIZE,
CONF_SEARCH, CONF_SEARCH,
@ -87,6 +88,7 @@ OPTIONS_SCHEMA_ADVANCED = {
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),
), ),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
} }

View file

@ -11,6 +11,7 @@ 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_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"
CONF_ENABLE_PUSH: Final = "enable_push"
DEFAULT_PORT: Final = 993 DEFAULT_PORT: Final = 993

View file

@ -42,7 +42,8 @@
"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", "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)",
"enable_push": "Enable Push-IMAP if the server supports it. Turn off if Push-IMAP updates are unreliable"
} }
} }
}, },

View file

@ -401,9 +401,9 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("advanced_options", "assert_result"), ("advanced_options", "assert_result"),
[ [
({"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 }}"}, {"custom_event_data_template": "{{ subject }}"},
data_entry_flow.FlowResultType.CREATE_ENTRY, data_entry_flow.FlowResultType.CREATE_ENTRY,
@ -412,6 +412,8 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
{"custom_event_data_template": "{{ invalid_syntax"}, {"custom_event_data_template": "{{ invalid_syntax"},
data_entry_flow.FlowResultType.FORM, data_entry_flow.FlowResultType.FORM,
), ),
({"enable_push": True}, data_entry_flow.FlowResultType.CREATE_ENTRY),
({"enable_push": False}, data_entry_flow.FlowResultType.CREATE_ENTRY),
], ],
ids=[ ids=[
"valid_message_size", "valid_message_size",
@ -419,6 +421,8 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
"invalid_message_size_high", "invalid_message_size_high",
"valid_template", "valid_template",
"invalid_template", "invalid_template",
"enable_push_true",
"enable_push_false",
], ],
) )
async def test_advanced_options_form( async def test_advanced_options_form(
@ -459,7 +463,7 @@ async def test_advanced_options_form(
else: else:
# Check if entry was updated # Check if entry was updated
for key, value in new_config.items(): for key, value in new_config.items():
assert str(entry.data[key]) == value assert 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

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, call, patch
from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response
import pytest import pytest
@ -36,13 +36,17 @@ from tests.common import MockConfigEntry, async_capture_events, async_fire_time_
@pytest.mark.parametrize( @pytest.mark.parametrize(
("cipher_list", "verify_ssl"), ("cipher_list", "verify_ssl", "enable_push"),
[ [
(None, None), (None, None, None),
("python_default", True), ("python_default", True, None),
("python_default", False), ("python_default", False, None),
("modern", True), ("modern", True, None),
("intermediate", True), ("intermediate", True, None),
(None, None, False),
(None, None, True),
("python_default", True, False),
("python_default", False, True),
], ],
) )
@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])
@ -51,6 +55,7 @@ async def test_entry_startup_and_unload(
mock_imap_protocol: MagicMock, mock_imap_protocol: MagicMock,
cipher_list: str | None, cipher_list: str | None,
verify_ssl: bool | None, verify_ssl: bool | None,
enable_push: bool | None,
) -> None: ) -> None:
"""Test imap entry startup and unload with push and polling coordinator and alternate ciphers.""" """Test imap entry startup and unload with push and polling coordinator and alternate ciphers."""
config = MOCK_CONFIG.copy() config = MOCK_CONFIG.copy()
@ -58,6 +63,8 @@ async def test_entry_startup_and_unload(
config["ssl_cipher_list"] = cipher_list config["ssl_cipher_list"] = cipher_list
if verify_ssl is not None: if verify_ssl is not None:
config["verify_ssl"] = verify_ssl config["verify_ssl"] = verify_ssl
if enable_push is not None:
config["enable_push"] = enable_push
config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry = MockConfigEntry(domain=DOMAIN, data=config)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -618,3 +625,58 @@ async def test_custom_template(
assert data["text"] assert data["text"]
assert data["custom"] == result assert data["custom"] == result
assert error in caplog.text if error is not None else True assert error in caplog.text if error is not None else True
@pytest.mark.parametrize(
("imap_search", "imap_fetch"),
[(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)],
)
@pytest.mark.parametrize(
("imap_has_capability", "enable_push", "should_poll"),
[
(True, False, True),
(False, False, True),
(True, True, False),
(False, True, True),
],
ids=["enforce_poll", "poll", "auto_push", "auto_poll"],
)
async def test_enforce_polling(
hass: HomeAssistant,
mock_imap_protocol: MagicMock,
enable_push: bool,
should_poll: True,
) -> None:
"""Test enforce polling."""
event_called = async_capture_events(hass, "imap_content")
config = MOCK_CONFIG.copy()
config["enable_push"] = enable_push
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"
assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT
# 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"]
if should_poll:
mock_imap_protocol.wait_server_push.assert_not_called()
else:
mock_imap_protocol.assert_has_calls([call.wait_server_push])