From fa72f4ff558c72a22d2e66d50da13aa41c252a5a Mon Sep 17 00:00:00 2001 From: Himess Date: Thu, 12 Mar 2026 03:02:51 +0300 Subject: [PATCH 1/3] fix: email adapter IMAP UID tracking and SMTP TLS verification - Use imap.uid() for search and fetch instead of imap.search/fetch. Sequence numbers shift when messages are deleted, causing the adapter to skip new messages or reprocess old ones. UIDs are stable. - Pass ssl.create_default_context() to starttls() so the server certificate is actually verified. Without it smtplib uses ssl._create_stdlib_context() which skips verification. --- gateway/platforms/email.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 3b2db3f6..36d34f98 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -22,6 +22,7 @@ import logging import os import re import smtplib +import ssl import uuid from datetime import datetime from email.header import decode_header @@ -212,7 +213,7 @@ class EmailAdapter(BasePlatformAdapter): imap.login(self._address, self._password) # Mark all existing messages as seen so we only process new ones imap.select("INBOX") - status, data = imap.search(None, "ALL") + status, data = imap.uid("search", None, "ALL") if status == "OK" and data[0]: for uid in data[0].split(): self._seen_uids.add(uid) @@ -225,7 +226,7 @@ class EmailAdapter(BasePlatformAdapter): try: # Test SMTP connection smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) - smtp.starttls() + smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.quit() logger.info("[Email] SMTP connection test passed.") @@ -277,7 +278,7 @@ class EmailAdapter(BasePlatformAdapter): imap.login(self._address, self._password) imap.select("INBOX") - status, data = imap.search(None, "UNSEEN") + status, data = imap.uid("search", None, "UNSEEN") if status != "OK" or not data[0]: imap.logout() return results @@ -287,7 +288,7 @@ class EmailAdapter(BasePlatformAdapter): continue self._seen_uids.add(uid) - status, msg_data = imap.fetch(uid, "(RFC822)") + status, msg_data = imap.uid("fetch", uid, "(RFC822)") if status != "OK": continue @@ -427,7 +428,7 @@ class EmailAdapter(BasePlatformAdapter): msg.attach(MIMEText(body, "plain", "utf-8")) smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) - smtp.starttls() + smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) smtp.quit() @@ -515,7 +516,7 @@ class EmailAdapter(BasePlatformAdapter): msg.attach(part) smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) - smtp.starttls() + smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) smtp.quit() From 344adc72a1b1b9cfae95dc10a82b63aba1ebe33e Mon Sep 17 00:00:00 2001 From: Himess Date: Sat, 14 Mar 2026 13:01:51 +0300 Subject: [PATCH 2/3] fix: update email test mocks to use imap.uid() instead of imap.search/fetch Tests were still mocking imap.search() and imap.fetch() but the implementation was changed to use imap.uid("search", ...) and imap.uid("fetch", ...) for proper UID-based IMAP operations. --- tests/gateway/test_email.py | 39 ++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 5344d369..f5ba8d3c 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -797,7 +797,7 @@ class TestConnectDisconnect(unittest.TestCase): adapter = self._make_adapter() mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b"1 2 3"]) + mock_imap.uid.return_value = ("OK", [b"1 2 3"]) with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \ patch("smtplib.SMTP") as mock_smtp: @@ -831,7 +831,7 @@ class TestConnectDisconnect(unittest.TestCase): adapter = self._make_adapter() mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b""]) + mock_imap.uid.return_value = ("OK", [b""]) with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \ patch("smtplib.SMTP", side_effect=Exception("SMTP down")): @@ -880,8 +880,15 @@ class TestFetchNewMessages(unittest.TestCase): raw_email["Message-ID"] = "" mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b"1 2 3"]) - mock_imap.fetch.return_value = ("OK", [(b"3", raw_email.as_bytes())]) + + def uid_handler(command, *args): + if command == "search": + return ("OK", [b"1 2 3"]) + if command == "fetch": + return ("OK", [(b"3", raw_email.as_bytes())]) + return ("NO", []) + + mock_imap.uid.side_effect = uid_handler with patch("imaplib.IMAP4_SSL", return_value=mock_imap): results = adapter._fetch_new_messages() @@ -896,7 +903,7 @@ class TestFetchNewMessages(unittest.TestCase): adapter = self._make_adapter() mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b""]) + mock_imap.uid.return_value = ("OK", [b""]) with patch("imaplib.IMAP4_SSL", return_value=mock_imap): results = adapter._fetch_new_messages() @@ -922,8 +929,15 @@ class TestFetchNewMessages(unittest.TestCase): raw_email["Message-ID"] = "" mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b"1"]) - mock_imap.fetch.return_value = ("OK", [(b"1", raw_email.as_bytes())]) + + def uid_handler(command, *args): + if command == "search": + return ("OK", [b"1"]) + if command == "fetch": + return ("OK", [(b"1", raw_email.as_bytes())]) + return ("NO", []) + + mock_imap.uid.side_effect = uid_handler with patch("imaplib.IMAP4_SSL", return_value=mock_imap): results = adapter._fetch_new_messages() @@ -966,8 +980,15 @@ class TestPollLoop(unittest.TestCase): raw_email["Message-ID"] = "" mock_imap = MagicMock() - mock_imap.search.return_value = ("OK", [b"1"]) - mock_imap.fetch.return_value = ("OK", [(b"1", raw_email.as_bytes())]) + + def uid_handler(command, *args): + if command == "search": + return ("OK", [b"1"]) + if command == "fetch": + return ("OK", [(b"1", raw_email.as_bytes())]) + return ("NO", []) + + mock_imap.uid.side_effect = uid_handler with patch("imaplib.IMAP4_SSL", return_value=mock_imap): asyncio.run(adapter._check_inbox()) From 71cffbfa4f84c55649610d59dc22a5968ab8654a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 06:31:52 -0700 Subject: [PATCH 3/3] fix: verify SMTP TLS in send_message_tool Add regression coverage for the standalone email send path and pass an explicit default SSL context to STARTTLS for certificate verification, matching the gateway email adapter hardening salvaged from PR #994. --- tests/gateway/test_email.py | 5 ++++- tools/send_message_tool.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index f5ba8d3c..16a418da 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -1007,8 +1007,9 @@ class TestSendEmailStandalone(unittest.TestCase): "EMAIL_SMTP_PORT": "587", }) def test_send_email_tool_success(self): - """_send_email should use SMTP to send.""" + """_send_email should use verified STARTTLS when sending.""" import asyncio + import ssl from tools.send_message_tool import _send_email with patch("smtplib.SMTP") as mock_smtp: @@ -1021,6 +1022,8 @@ class TestSendEmailStandalone(unittest.TestCase): self.assertTrue(result["success"]) self.assertEqual(result["platform"], "email") + _, kwargs = mock_server.starttls.call_args + self.assertIsInstance(kwargs["context"], ssl.SSLContext) @patch.dict(os.environ, { "EMAIL_ADDRESS": "hermes@test.com", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 56ea65f2..537f6335 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -9,6 +9,7 @@ import json import logging import os import re +import ssl import time logger = logging.getLogger(__name__) @@ -432,7 +433,7 @@ async def _send_email(extra, chat_id, message): msg["Subject"] = "Hermes Agent" server = smtplib.SMTP(smtp_host, smtp_port) - server.starttls() + server.starttls(context=ssl.create_default_context()) server.login(address, password) server.send_message(msg) server.quit()