1 """Coordinator for imap integration."""
3 from __future__
import annotations
6 from collections.abc
import Mapping
7 from datetime
import datetime, timedelta
9 from email.header
import decode_header, make_header
10 from email.message
import Message
11 from email.utils
import parseaddr, parsedate_to_datetime
13 from typing
import TYPE_CHECKING, Any
15 from aioimaplib
import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
23 CONTENT_TYPE_TEXT_PLAIN,
27 ConfigEntryAuthFailed,
38 create_no_verify_ssl_context,
43 CONF_CUSTOM_EVENT_DATA_TEMPLATE,
44 CONF_EVENT_MESSAGE_DATA,
46 CONF_MAX_MESSAGE_SIZE,
50 DEFAULT_MAX_MESSAGE_SIZE,
54 from .errors
import InvalidAuth, InvalidFolder
56 _LOGGER = logging.getLogger(__name__)
60 EVENT_IMAP =
"imap_content"
62 MAX_EVENT_DATA_BYTES = 32168
64 DIAGNOSTICS_ATTRIBUTES = [
"date",
"initial"]
68 """Connect to imap server and return client."""
69 ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT)
70 if data.get(CONF_VERIFY_SSL,
True):
74 client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context)
76 "Wait for hello message from server %s on port %s, verify_ssl: %s",
79 data.get(CONF_VERIFY_SSL,
True),
81 await client.wait_hello_from_server()
82 if client.protocol.state == NONAUTH:
84 "Authenticating with %s on server %s",
88 await client.login(data[CONF_USERNAME], data[CONF_PASSWORD])
89 if client.protocol.state
not in {AUTH, SELECTED}:
91 if client.protocol.state == AUTH:
93 "Selecting mail folder %s on server %s",
97 await client.select(data[CONF_FOLDER])
98 if client.protocol.state != SELECTED:
104 """Class to parse an RFC822 email message."""
107 """Initialize IMAP message."""
112 """Try to decode text payloads.
114 Common text encodings are quoted-printable or base64.
115 Falls back to the raw content part if decoding fails.
118 decoded_payload: Any = part.get_payload(decode=
True)
120 assert isinstance(decoded_payload, bytes)
121 content_charset = part.get_content_charset()
or "utf-8"
122 return decoded_payload.decode(content_charset)
125 return str(part.get_payload())
128 def headers(self) -> dict[str, tuple[str, ...]]:
129 """Get the email headers."""
130 header_base: dict[str, tuple[str, ...]] = {}
132 header_instances: tuple[str, ...] = (
str(value),)
133 if header_base.setdefault(key, header_instances) != header_instances:
134 header_base[key] += header_instances
139 """Get the message ID."""
141 for header, value
in self.
email_messageemail_message.items():
142 if header ==
"Message-ID":
147 def date(self) -> datetime | None:
148 """Get the date the email was sent."""
151 if (date_str := self.
email_messageemail_message[
"Date"])
is None:
154 mail_dt_tm = parsedate_to_datetime(date_str)
157 "Parsed date %s is not compliant with rfc2822#section-3.3", date_str
164 """Get the parsed message sender from the email."""
169 """Decode the message subject."""
170 decoded_header = decode_header(self.
email_messageemail_message[
"Subject"]
or "")
171 subject_header = make_header(decoded_header)
172 return str(subject_header)
176 """Get the message text from the email.
178 Will look for text/plain or use/ text/html if not found.
180 message_text: str |
None =
None
181 message_html: str |
None =
None
182 message_untyped_text: str |
None =
None
186 if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN:
187 if message_text
is None:
189 elif part.get_content_type() ==
"text/html":
190 if message_html
is None:
193 part.get_content_type().startswith(
"text")
194 and message_untyped_text
is None
196 message_untyped_text =
str(part.get_payload())
198 if message_text
is not None and message_text.strip():
204 if message_untyped_text:
205 return message_untyped_text
211 """Base class for imap client."""
213 config_entry: ConfigEntry
214 custom_event_template: Template |
None
219 imap_client: IMAP4_SSL,
221 update_interval: timedelta |
None,
223 """Initiate imap client."""
225 self.auth_errors: int = 0
229 self._diagnostics_data: dict[str, Any] = {}
230 self._event_data_keys: list[str] = entry.data.get(
231 CONF_EVENT_MESSAGE_DATA, MESSAGE_DATA_OPTIONS
233 self._max_event_size: int = entry.data.get(
234 CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE
236 _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE)
237 if _custom_event_template
is not None:
243 update_interval=update_interval,
247 """Start coordinator."""
250 """Connect to imap server."""
251 if self.imap_client
is None:
255 """Send a event for the last message if the last message was changed."""
256 response = await self.
imap_clientimap_client.fetch(last_message_uid,
"BODY.PEEK[]")
257 if response.result ==
"OK":
261 if (message_id := message.message_id) == self.
_last_message_id_last_message_id:
266 "server": self.
config_entryconfig_entry.data[CONF_SERVER],
267 "username": self.
config_entryconfig_entry.data[CONF_USERNAME],
268 "search": self.
config_entryconfig_entry.data[CONF_SEARCH],
269 "folder": self.
config_entryconfig_entry.data[CONF_FOLDER],
271 "date": message.date,
272 "sender": message.sender,
273 "subject": message.subject,
274 "uid": last_message_uid,
276 data.update({key: getattr(message, key)
for key
in self._event_data_keys})
280 data, parse_result=
True
283 "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s",
290 except TemplateError
as err:
291 data[
"custom"] =
None
293 "Error rendering IMAP custom template (%s) for msguid %s "
294 "failed with message: %s",
300 data[
"text"] = message.text[: self._max_event_size]
302 if (size := len(
json_bytes(data))) > MAX_EVENT_DATA_BYTES:
304 "Custom imap_content event skipped, size (%s) exceeds "
305 "the maximal event size (%s), sender: %s, subject: %s",
307 MAX_EVENT_DATA_BYTES,
313 self.
hasshass.bus.fire(EVENT_IMAP, data)
315 "Message with id %s (%s) processed, sender: %s, subject: %s, initial: %s",
324 """Fetch last message and messages count."""
327 result, lines = await self.
imap_clientimap_client.search(
329 charset=self.
config_entryconfig_entry.data[CONF_CHARSET],
333 f
"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}"
345 if len(lines) == 1
or not (count := len(message_ids := lines[0].split())):
349 str(message_ids[-1:][0], encoding=self.
config_entryconfig_entry.data[CONF_CHARSET])
355 and last_message_uid
is not None
363 async
def _cleanup(self, log_error: bool =
False) ->
None:
364 """Close resources."""
369 await self.
imap_clientimap_client.stop_wait_server_push()
372 except (AioImapException, TimeoutError):
374 _LOGGER.debug(
"Error while cleaning up imap connection")
379 """Close resources."""
380 await self.
_cleanup_cleanup(log_error=
True)
383 """Update the diagnostics."""
384 self._diagnostics_data.
update(
385 {key: value
for key, value
in data.items()
if key
in DIAGNOSTICS_ATTRIBUTES}
387 custom: Any |
None = data.get(
"custom")
388 self._diagnostics_data[
"custom_template_data_type"] =
str(type(custom))
389 self._diagnostics_data[
"custom_template_result_length"] = (
390 None if custom
is None else len(f
"{custom}")
392 self._diagnostics_data[
"event_time"] = dt_util.now().isoformat()
396 """Return diagnostics info."""
397 return self._diagnostics_data
401 """Class for imap client."""
404 self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
406 """Initiate imap client."""
408 "Connected to server %s using IMAP polling", entry.data[CONF_SERVER]
413 """Update the number of unread emails."""
423 raise UpdateFailed
from ex
424 except InvalidFolder
as ex:
425 _LOGGER.warning(
"Selected mailbox folder is invalid")
429 except InvalidAuth
as ex:
433 _LOGGER.warning(
"Authentication failed, retrying")
436 "Username or password incorrect, starting reauthentication"
440 raise ConfigEntryAuthFailed
from ex
447 """Class for imap client."""
450 self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
452 """Initiate imap client."""
453 _LOGGER.debug(
"Connected to server %s using IMAP push", entry.data[CONF_SERVER])
454 super().
__init__(hass, imap_client, entry,
None)
459 """Update the number of unread emails."""
464 """Start coordinator."""
470 """Wait for data push from server."""
474 except InvalidAuth
as ex:
478 _LOGGER.warning(
"Authentication failed, retrying")
481 "Username or password incorrect, starting reauthentication"
485 await asyncio.sleep(BACKOFF_TIME)
486 except InvalidFolder
as ex:
487 _LOGGER.warning(
"Selected mailbox folder is invalid")
490 await asyncio.sleep(BACKOFF_TIME)
499 await asyncio.sleep(BACKOFF_TIME)
505 idle: asyncio.Future = await self.
imap_clientimap_client.idle_start()
506 await self.
imap_clientimap_client.wait_server_push()
508 async
with asyncio.timeout(10):
511 except (AioImapException, TimeoutError):
513 "Lost %s (will attempt to reconnect after %s s)",
518 await asyncio.sleep(BACKOFF_TIME)
521 """Close resources."""
None shutdown(self, *Any _)
dict[str, Any] diagnostics_data(self)
None _async_process_event(self, str last_message_uid)
None _update_diagnostics(self, dict[str, Any] data)
None __init__(self, HomeAssistant hass, IMAP4_SSL imap_client, ConfigEntry entry, timedelta|None update_interval)
None _cleanup(self, bool log_error=False)
int|None _async_fetch_number_of_messages(self)
None _async_reconnect_if_needed(self)
str _decode_payload(Message part)
dict[str, tuple[str,...]] headers(self)
str|None message_id(self)
None __init__(self, bytes raw_message)
None __init__(self, HomeAssistant hass, IMAP4_SSL imap_client, ConfigEntry entry)
int|None _async_update_data(self)
None __init__(self, HomeAssistant hass, IMAP4_SSL imap_client, ConfigEntry entry)
None shutdown(self, *Any _)
None _async_wait_push_loop(self)
int|None _async_update_data(self)
None async_set_updated_data(self, _DataT data)
None async_set_update_error(self, Exception err)
IMAP4_SSL connect_to_server(Mapping[str, Any] data)
IssData update(pyiss.ISS iss)
ssl.SSLContext client_context(SSLCipherList ssl_cipher_list=SSLCipherList.PYTHON_DEFAULT)
ssl.SSLContext create_no_verify_ssl_context(SSLCipherList ssl_cipher_list=SSLCipherList.PYTHON_DEFAULT)