diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 3b2db3f6f..36d34f98e 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() diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 5344d369c..16a418da8 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()) @@ -986,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: @@ -1000,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 56ea65f2c..537f6335b 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()