Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions homeassistant/components/imap_email_content/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,25 @@ def __init__(self, user, password, server, port, folder, verify_ssl):
self._folder = folder
self._verify_ssl = verify_ssl
self._last_id = None
self._last_message = None
self._unread_ids = deque([])
self.connection = None

@property
def last_id(self) -> int | None:
"""Return last email uid that was processed."""
return self._last_id

@property
def last_unread_id(self) -> int | None:
"""Return last email uid received."""
# We assume the last id in the list is the last unread id
# We cannot know if that is the newest one, because it could arrive later
# https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids
if self._unread_ids:
return int(self._unread_ids[-1])
return self._last_id

def connect(self):
"""Login and setup the connection."""
ssl_context = client_context() if self._verify_ssl else None
Expand Down Expand Up @@ -128,21 +144,21 @@ def read_next(self):
try:
self.connection.select(self._folder, readonly=True)

if not self._unread_ids:
search = f"SINCE {datetime.date.today():%d-%b-%Y}"
if self._last_id is not None:
search = f"UID {self._last_id}:*"

_, data = self.connection.uid("search", None, search)
self._unread_ids = deque(data[0].split())
if self._last_id is None:
# search for today and yesterday
time_from = datetime.datetime.now() - datetime.timedelta(days=1)
search = f"SINCE {time_from:%d-%b-%Y}"
else:
search = f"UID {self._last_id}:*"

_, data = self.connection.uid("search", None, search)
self._unread_ids = deque(data[0].split())
while self._unread_ids:
message_uid = self._unread_ids.popleft()
if self._last_id is None or int(message_uid) > self._last_id:
self._last_id = int(message_uid)
return self._fetch_message(message_uid)

return self._fetch_message(str(self._last_id))
self._last_message = self._fetch_message(message_uid)
return self._last_message

except imaplib.IMAP4.error:
_LOGGER.info("Connection to %s lost, attempting to reconnect", self._server)
Expand Down Expand Up @@ -254,22 +270,30 @@ def get_msg_text(email_message):
def update(self) -> None:
"""Read emails and publish state change."""
email_message = self._email_reader.read_next()

if email_message is None:
self._message = None
self._state_attributes = {}
return

if self.sender_allowed(email_message):
message = EmailContentSensor.get_msg_subject(email_message)

if self._value_template is not None:
message = self.render_template(email_message)

self._message = message
self._state_attributes = {
ATTR_FROM: EmailContentSensor.get_msg_sender(email_message),
ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message),
ATTR_DATE: email_message["Date"],
ATTR_BODY: EmailContentSensor.get_msg_text(email_message),
}
while (
self._last_id is None or self._last_id != self._email_reader.last_unread_id
):
if email_message is None:
self._message = None
self._state_attributes = {}
return

self._last_id = self._email_reader.last_id

if self.sender_allowed(email_message):
message = EmailContentSensor.get_msg_subject(email_message)

if self._value_template is not None:
message = self.render_template(email_message)

self._message = message
self._state_attributes = {
ATTR_FROM: EmailContentSensor.get_msg_sender(email_message),
ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message),
ATTR_DATE: email_message["Date"],
ATTR_BODY: EmailContentSensor.get_msg_text(email_message),
}

Comment on lines +282 to +296
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems like this should only happen if we are going to break out of the loop instead of processing it and than throwing it away when we hit the next one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We do not know if an unseen mail has an allowed sender unless we process it. The current code base uses a custom EmailReader class as backend.
The code in dev needs several update iterations before it reaches the last one.
The improvement here is to iterate the messages until we reach the last one in a single update cycle.

if self._last_id == self._email_reader.last_unread_id:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll have a look at that later today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

self._email_reader.last_id is the last message processed, self._email_reader.last_unread_id is the last message to process. The condition is false when we are at startup and we searched all messages for the last 2 days. There might be more messages that match the IMAP search, but we do not known if the sender matches. When the last message is processed the last state set will be come the new state. If the last message processed is not the last message to process, then we read a new message until we reach the last one. To simulate this, place to matching new message in the mailbox and startup home assistant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Makes sense now that I'm back at my desktop. Apparently I just needed a slightly larger monitor to see that 😉

break
email_message = self._email_reader.read_next()
26 changes: 21 additions & 5 deletions tests/components/imap_email_content/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@
class FakeEMailReader:
"""A test class for sending test emails."""

def __init__(self, messages):
def __init__(self, messages) -> None:
"""Set up the fake email reader."""
self._messages = messages
self.last_id = 0
self.last_unread_id = len(messages)

def add_test_message(self, message):
"""Add a new message."""
self.last_unread_id += 1
self._messages.append(message)

def connect(self):
"""Stay always Connected."""
Expand All @@ -26,6 +33,7 @@ def read_next(self):
"""Get the next email."""
if len(self._messages) == 0:
return None
self.last_id += 1
return self._messages.popleft()


Expand Down Expand Up @@ -146,7 +154,7 @@ async def test_multi_part_only_other_text(hass: HomeAssistant) -> None:


async def test_multiple_emails(hass: HomeAssistant) -> None:
"""Test multiple emails."""
"""Test multiple emails, discarding stale states."""
states = []

test_message1 = email.message.Message()
Expand All @@ -158,9 +166,15 @@ async def test_multiple_emails(hass: HomeAssistant) -> None:
test_message2 = email.message.Message()
test_message2["From"] = "sender@test.com"
test_message2["Subject"] = "Test 2"
test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57)
test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58)
test_message2.set_payload("Test Message 2")

test_message3 = email.message.Message()
test_message3["From"] = "sender@test.com"
test_message3["Subject"] = "Test 3"
test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1)
test_message3.set_payload("Test Message 2")

def state_changed_listener(entity_id, from_s, to_s):
states.append(to_s)

Expand All @@ -178,11 +192,13 @@ def state_changed_listener(entity_id, from_s, to_s):

sensor.async_schedule_update_ha_state(True)
await hass.async_block_till_done()
# Fake a new received message
sensor._email_reader.add_test_message(test_message3)
sensor.async_schedule_update_ha_state(True)
await hass.async_block_till_done()

assert states[0].state == "Test"
assert states[1].state == "Test 2"
assert states[0].state == "Test 2"
assert states[1].state == "Test 3"

assert sensor.extra_state_attributes["body"] == "Test Message 2"

Expand Down