Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement imap_content event for imap integration #90242

Merged
merged 13 commits into from Mar 28, 2023
3 changes: 0 additions & 3 deletions .coveragerc
Expand Up @@ -518,9 +518,6 @@ omit =
homeassistant/components/ifttt/alarm_control_panel.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/*
homeassistant/components/insteon/binary_sensor.py
Expand Down
111 changes: 108 additions & 3 deletions homeassistant/components/imap/coordinator.py
Expand Up @@ -4,14 +4,20 @@
import asyncio
from collections.abc import Mapping
from datetime import timedelta
import email
import logging
from typing import Any

from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException
import async_timeout

from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONTENT_TYPE_TEXT_PLAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
Expand All @@ -23,6 +29,8 @@

BACKOFF_TIME = 10

EVENT_IMAP = "imap_content"


async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL:
"""Connect to imap server and return client."""
Expand All @@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL:
return client


class ImapMessage:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also borderline protocol details.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, this is mainly inherrited from the imap_email_content integration.

"""Class to parse an RFC822 email message."""

def __init__(self, raw_message: bytes) -> None:
"""Initialize IMAP message."""
self.email_message = email.message_from_bytes(raw_message)

@property
def headers(self) -> dict[str, tuple[str,]]:
"""Get the email headers."""
header_base: dict[str, tuple[str,]] = {}
for key, value in self.email_message.items():
header: tuple[str,] = (str(value),)
if header_base.setdefault(key, header) != header:
header_base[key] += header # type: ignore[assignment]
return header_base

@property
def sender(self) -> str:
"""Get the parsed message sender from the email."""
return str(email.utils.parseaddr(self.email_message["From"])[1])

@property
def subject(self) -> str:
"""Decode the message subject."""
decoded_header = email.header.decode_header(self.email_message["Subject"])
header = email.header.make_header(decoded_header)
return str(header)

@property
def text(self) -> str:
"""Get the message text from the email.

Will look for text/plain or use text/html if not found.
"""
message_text = None
message_html = None
message_untyped_text = None

for part in self.email_message.walk():
if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN:
if message_text is None:
message_text = part.get_payload()
elif part.get_content_type() == "text/html":
if message_html is None:
message_html = part.get_payload()
elif (
part.get_content_type().startswith("text")
and message_untyped_text is None
):
message_untyped_text = part.get_payload()

if message_text is not None:
return message_text

if message_html is not None:
return message_html

if message_untyped_text is not None:
return message_untyped_text

return self.email_message.get_payload()


class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""

Expand All @@ -50,6 +122,7 @@ def __init__(
) -> None:
"""Initiate imap client."""
self.imap_client = imap_client
self._last_message_id: str | None = None
super().__init__(
hass,
_LOGGER,
Expand All @@ -65,8 +138,30 @@ async def _async_reconnect_if_needed(self) -> None:
if self.imap_client is None:
self.imap_client = await connect_to_server(self.config_entry.data)

async def _async_process_event(self, last_message_id: str) -> None:
"""Send a event for the last message if the last message was changed."""
response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit annoying that we need to handle protocol details like this. I didn't find any library that does this better though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but at least it is async, better than using the native python library (like used for imap_email_content).

if response.result == "OK":
message = ImapMessage(response.lines[1])
data = {
"server": self.config_entry.data[CONF_SERVER],
"username": self.config_entry.data[CONF_USERNAME],
"search": self.config_entry.data[CONF_SEARCH],
"folder": self.config_entry.data[CONF_FOLDER],
"text": message.text,
"sender": message.sender,
"subject": message.subject,
"headers": message.headers,
}
self.hass.bus.fire(EVENT_IMAP, data)
_LOGGER.debug(
"Message processed, sender: %s, subject: %s",
message.sender,
message.subject,
)

async def _async_fetch_number_of_messages(self) -> int | None:
"""Fetch number of messages."""
"""Fetch last message and messages count."""
await self._async_reconnect_if_needed()
await self.imap_client.noop()
result, lines = await self.imap_client.search(
Expand All @@ -77,7 +172,17 @@ async def _async_fetch_number_of_messages(self) -> int | None:
raise UpdateFailed(
f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}"
)
return len(lines[0].split())
count: int = len(message_ids := lines[0].split())
last_message_id = (
str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET])
if count
else None
)
if count and last_message_id is not None:
self._last_message_id = last_message_id
await self._async_process_event(last_message_id)

return count

async def _cleanup(self, log_error: bool = False) -> None:
"""Close resources."""
Expand Down
102 changes: 100 additions & 2 deletions tests/components/imap/conftest.py
@@ -1,9 +1,13 @@
"""Test the iamp config flow."""
"""Fixtures for imap tests."""

from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response
import pytest

from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN


@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
Expand All @@ -12,3 +16,97 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"homeassistant.components.imap.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry


@pytest.fixture
def imap_has_capability() -> bool:
"""Fixture to set the imap capabilities."""
return True


@pytest.fixture
def imap_login_state() -> str:
"""Fixture to set the imap state after login."""
return AUTH


@pytest.fixture
def imap_select_state() -> str:
"""Fixture to set the imap capabilities."""
return SELECTED


@pytest.fixture
def imap_search() -> tuple[str, list[bytes]]:
"""Fixture to set the imap search response."""
return EMPTY_SEARCH_RESPONSE


@pytest.fixture
def imap_fetch() -> tuple[str, list[bytes | bytearray]]:
"""Fixture to set the imap fetch response."""
return TEST_FETCH_RESPONSE_TEXT_PLAIN


@pytest.fixture
def imap_pending_idle() -> bool:
"""Fixture to set the imap pending idle feature."""
return True


@pytest.fixture
async def mock_imap_protocol(
imap_search: tuple[str, list[bytes]],
imap_fetch: tuple[str, list[bytes | bytearray]],
imap_has_capability: bool,
imap_pending_idle: bool,
imap_login_state: str,
imap_select_state: str,
) -> Generator[MagicMock, None]:
"""Mock the aioimaplib IMAP protocol handler."""

with patch(
"homeassistant.components.imap.coordinator.IMAP4_SSL", autospec=True
) as imap_mock:
imap_mock = imap_mock.return_value

async def login(user: str, password: str) -> Response:
"""Mock imap login."""
imap_mock.protocol.state = imap_login_state
if imap_login_state != AUTH:
return Response("BAD", [])
return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"])

async def close() -> Response:
"""Mock imap close the selected folder."""
imap_mock.protocol.state = imap_login_state
return Response("OK", [])

async def logout() -> Response:
"""Mock imap logout."""
imap_mock.protocol.state = LOGOUT
return Response("OK", [])

async def select(mailbox: str = "INBOX") -> Response:
"""Mock imap folder select."""
imap_mock.protocol.state = imap_select_state
if imap_login_state != SELECTED:
return Response("BAD", [])
return Response("OK", [])

async def wait_hello_from_server() -> None:
"""Mock wait for hello."""
imap_mock.protocol.state = NONAUTH

imap_mock.has_pending_idle.return_value = imap_pending_idle
imap_mock.protocol = MagicMock()
imap_mock.protocol.state = STARTED
imap_mock.has_capability.return_value = imap_has_capability
imap_mock.login.side_effect = login
imap_mock.close.side_effect = close
imap_mock.logout.side_effect = logout
imap_mock.select.side_effect = select
imap_mock.search.return_value = Response(*imap_search)
imap_mock.fetch.return_value = Response(*imap_fetch)
imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server
yield imap_mock