NousResearch/hermes-agent
Email adapter IMAP UID and SMTP TLS fixes
Cluster 991 · gateway. Data as of
Nothing open on GitHub. Fix merged in #1305 on 14 Mar 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #1305. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #1305. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a bug where the email adapter incorrectly uses IMAP sequence numbers instead of UIDs, and STARTTLS skips certificate verification. Two entries appear to be duplicate fix submissions for the same reported issue.
Open issues0
Open PRs0
Fixes on record
-
#1305 fix: email adapter IMAP UID tracking and SMTP TLS verification
files:
gateway/platforms/email.py,tests/gateway/test_email.py,tools/send_message_tool.pyShow the diff
-
gateway/platforms/email.py@@ -22,6 +22,7 @@ import os import re import smtplib +import ssl import uuid from datetime import datetime from email.header import decode_header @@ -212,7 +213,7 @@ async def connect(self) -> bool: 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 @@ async def connect(self) -> bool: 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 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]: 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 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]: 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 @@ def _send_email( 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 @@ def _send_email_with_attachment( 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() -
tests/gateway/test_email.py@@ -797,7 +797,7 @@ def test_connect_success(self): 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 @@ def test_connect_smtp_failure(self): 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 @@ def test_fetch_skips_seen_uids(self): raw_email["Message-ID"] = "<[redacted:email]>" 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 @@ def test_fetch_no_unseen_messages(self): 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 @@ def test_fetch_extracts_sender_name(self): raw_email["Message-ID"] = "<[redacted:email]>" 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 @@ async def mock_dispatch(msg_data): raw_email["Message-ID"] = "<[redacted:email]>" 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 @@ def test_send_email_tool_success(self): 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": "[redacted:email]", -
tools/send_message_tool.py@@ -9,6 +9,7 @@ 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()
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#994 fix: email adapter IMAP UID tracking and SMTP TLS verification · merged via another change →
#1305
Closed issues · 1
- Closed#991Email adapter uses IMAP sequence numbers instead of UIDs, starttls skips cert verification
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #1305
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 14 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.