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
3 changed files with 43 additions and 17 deletions

View File

@@ -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()

View File

@@ -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"] = "<msg@test.com>"
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"] = "<msg@test.com>"
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"] = "<inbox@test.com>"
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",

View File

@@ -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()