diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 76eb8e46f53..97432b91054 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta import email import logging from typing import Any @@ -62,6 +62,19 @@ class ImapMessage: header_base[key] += header # type: ignore[assignment] return header_base + @property + def date(self) -> datetime | None: + """Get the date the email was sent.""" + # See https://www.rfc-editor.org/rfc/rfc2822#section-3.3 + date_str: str | None + if (date_str := self.email_message["Date"]) is None: + return None + # In some cases a timezone or comment is added in parenthesis after the date + # We want to strip that part to avoid parsing errors + return datetime.strptime( + date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" + ) + @property def sender(self) -> str: """Get the parsed message sender from the email.""" @@ -148,6 +161,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "date": message.date, "text": message.text, "sender": message.sender, "subject": message.subject, diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 68fab7d38cb..7c774527b31 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -1,6 +1,11 @@ """Constants for tests imap integration.""" -TEST_MESSAGE = ( + +DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" +DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" + +TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" @@ -8,13 +13,18 @@ TEST_MESSAGE = ( b"Received: from localhost (localhost [127.0.0.1])\r\n\t" b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +) +TEST_MESSAGE_HEADERS2 = ( b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" ) +TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 +TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 + TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" TEST_CONTENT_BINARY = ( @@ -88,6 +98,31 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_INVALID_DATE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + + TEST_FETCH_RESPONSE_TEXT_OTHER = ( "OK", [ diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ec9058830dd..fdcf37b76ba 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,6 @@ """Test the imap entry initialization.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -17,10 +17,12 @@ from .const import ( BAD_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_INVALID_DATE, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, TEST_SEARCH_RESPONSE, ) from .test_config_flow import MOCK_CONFIG @@ -66,20 +68,31 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - "imap_fetch", + ("imap_fetch", "valid_date"), [ - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_BINARY, + (TEST_FETCH_RESPONSE_TEXT_BARE, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), + (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True), + (TEST_FETCH_RESPONSE_HTML, True), + (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_BINARY, True), + ], + ids=[ + "bare", + "plain", + "plain_alt", + "invalid_date", + "other", + "html", + "multipart", + "binary", ], - ids=["bare", "plain", "other", "html", "multipart", "binary"], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -106,6 +119,12 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert ( + valid_date + and isinstance(data["date"], datetime) + or not valid_date + and data["date"] is None + ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"])