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
Changes from all commits
489bb1e
386fc1b
42d567e
fb34733
0e5b5e2
3fc5c00
858e7e8
409aca0
f1c0e01
3b19cde
e731f4a
7619c69
550cb23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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.""" | ||
|
@@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: | |
return client | ||
|
||
|
||
class ImapMessage: | ||
"""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.""" | ||
|
||
|
@@ -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, | ||
|
@@ -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[]") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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( | ||
|
@@ -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.""" | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.