Merge pull request #1305 from NousResearch/hermes/hermes-2ba57c8a

fix: email adapter IMAP UID tracking and SMTP TLS verification
This commit is contained in:
Teknium 2026-03-14 06:32:35 -07:00 committed by GitHub
commit 63309065b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 43 additions and 17 deletions

View file

@ -22,6 +22,7 @@ import logging
import os import os
import re import re
import smtplib import smtplib
import ssl
import uuid import uuid
from datetime import datetime from datetime import datetime
from email.header import decode_header from email.header import decode_header
@ -212,7 +213,7 @@ class EmailAdapter(BasePlatformAdapter):
imap.login(self._address, self._password) imap.login(self._address, self._password)
# Mark all existing messages as seen so we only process new ones # Mark all existing messages as seen so we only process new ones
imap.select("INBOX") imap.select("INBOX")
status, data = imap.search(None, "ALL") status, data = imap.uid("search", None, "ALL")
if status == "OK" and data[0]: if status == "OK" and data[0]:
for uid in data[0].split(): for uid in data[0].split():
self._seen_uids.add(uid) self._seen_uids.add(uid)
@ -225,7 +226,7 @@ class EmailAdapter(BasePlatformAdapter):
try: try:
# Test SMTP connection # Test SMTP connection
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) 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.login(self._address, self._password)
smtp.quit() smtp.quit()
logger.info("[Email] SMTP connection test passed.") logger.info("[Email] SMTP connection test passed.")
@ -277,7 +278,7 @@ class EmailAdapter(BasePlatformAdapter):
imap.login(self._address, self._password) imap.login(self._address, self._password)
imap.select("INBOX") imap.select("INBOX")
status, data = imap.search(None, "UNSEEN") status, data = imap.uid("search", None, "UNSEEN")
if status != "OK" or not data[0]: if status != "OK" or not data[0]:
imap.logout() imap.logout()
return results return results
@ -287,7 +288,7 @@ class EmailAdapter(BasePlatformAdapter):
continue continue
self._seen_uids.add(uid) self._seen_uids.add(uid)
status, msg_data = imap.fetch(uid, "(RFC822)") status, msg_data = imap.uid("fetch", uid, "(RFC822)")
if status != "OK": if status != "OK":
continue continue
@ -427,7 +428,7 @@ class EmailAdapter(BasePlatformAdapter):
msg.attach(MIMEText(body, "plain", "utf-8")) msg.attach(MIMEText(body, "plain", "utf-8"))
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) 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.login(self._address, self._password)
smtp.send_message(msg) smtp.send_message(msg)
smtp.quit() smtp.quit()
@ -515,7 +516,7 @@ class EmailAdapter(BasePlatformAdapter):
msg.attach(part) msg.attach(part)
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) 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.login(self._address, self._password)
smtp.send_message(msg) smtp.send_message(msg)
smtp.quit() smtp.quit()

View file

@ -797,7 +797,7 @@ class TestConnectDisconnect(unittest.TestCase):
adapter = self._make_adapter() adapter = self._make_adapter()
mock_imap = MagicMock() 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), \ with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \
patch("smtplib.SMTP") as mock_smtp: patch("smtplib.SMTP") as mock_smtp:
@ -831,7 +831,7 @@ class TestConnectDisconnect(unittest.TestCase):
adapter = self._make_adapter() adapter = self._make_adapter()
mock_imap = MagicMock() 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), \ with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \
patch("smtplib.SMTP", side_effect=Exception("SMTP down")): patch("smtplib.SMTP", side_effect=Exception("SMTP down")):
@ -880,8 +880,15 @@ class TestFetchNewMessages(unittest.TestCase):
raw_email["Message-ID"] = "<msg@test.com>" raw_email["Message-ID"] = "<msg@test.com>"
mock_imap = MagicMock() 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): with patch("imaplib.IMAP4_SSL", return_value=mock_imap):
results = adapter._fetch_new_messages() results = adapter._fetch_new_messages()
@ -896,7 +903,7 @@ class TestFetchNewMessages(unittest.TestCase):
adapter = self._make_adapter() adapter = self._make_adapter()
mock_imap = MagicMock() 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): with patch("imaplib.IMAP4_SSL", return_value=mock_imap):
results = adapter._fetch_new_messages() results = adapter._fetch_new_messages()
@ -922,8 +929,15 @@ class TestFetchNewMessages(unittest.TestCase):
raw_email["Message-ID"] = "<msg@test.com>" raw_email["Message-ID"] = "<msg@test.com>"
mock_imap = MagicMock() 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): with patch("imaplib.IMAP4_SSL", return_value=mock_imap):
results = adapter._fetch_new_messages() results = adapter._fetch_new_messages()
@ -966,8 +980,15 @@ class TestPollLoop(unittest.TestCase):
raw_email["Message-ID"] = "<inbox@test.com>" raw_email["Message-ID"] = "<inbox@test.com>"
mock_imap = MagicMock() 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): with patch("imaplib.IMAP4_SSL", return_value=mock_imap):
asyncio.run(adapter._check_inbox()) asyncio.run(adapter._check_inbox())
@ -986,8 +1007,9 @@ class TestSendEmailStandalone(unittest.TestCase):
"EMAIL_SMTP_PORT": "587", "EMAIL_SMTP_PORT": "587",
}) })
def test_send_email_tool_success(self): 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 asyncio
import ssl
from tools.send_message_tool import _send_email from tools.send_message_tool import _send_email
with patch("smtplib.SMTP") as mock_smtp: with patch("smtplib.SMTP") as mock_smtp:
@ -1000,6 +1022,8 @@ class TestSendEmailStandalone(unittest.TestCase):
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertEqual(result["platform"], "email") self.assertEqual(result["platform"], "email")
_, kwargs = mock_server.starttls.call_args
self.assertIsInstance(kwargs["context"], ssl.SSLContext)
@patch.dict(os.environ, { @patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com", "EMAIL_ADDRESS": "hermes@test.com",

View file

@ -9,6 +9,7 @@ import json
import logging import logging
import os import os
import re import re
import ssl
import time import time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -432,7 +433,7 @@ async def _send_email(extra, chat_id, message):
msg["Subject"] = "Hermes Agent" msg["Subject"] = "Hermes Agent"
server = smtplib.SMTP(smtp_host, smtp_port) server = smtplib.SMTP(smtp_host, smtp_port)
server.starttls() server.starttls(context=ssl.create_default_context())
server.login(address, password) server.login(address, password)
server.send_message(msg) server.send_message(msg)
server.quit() server.quit()