{"repo":"NousResearch/hermes-agent","public_id":"991","display_label":"Email adapter IMAP UID and SMTP TLS fixes","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1305,"merged_at":"2026-03-14T13:32:36+00:00"},"resolution":{"state":"resolved","by":"pr-1305"},"freshness":{"state":"stale","last_verified_at":"2026-03-14T13:32:36+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":991,"pr_number":1305,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-14T13:32:36+00:00"}],"members":[{"kind":"issue","number":991,"title":"Email adapter uses IMAP sequence numbers instead of UIDs, starttls skips cert verification","url":null,"state":"closed"},{"kind":"pr","number":994,"title":"fix: email adapter IMAP UID tracking and SMTP TLS verification","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1305","number":1305}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1305,"title":"fix: email adapter IMAP UID tracking and SMTP TLS verification","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","tests/gateway/test_email.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -22,6 +22,7 @@\n import os\n import re\n import smtplib\n+import ssl\n import uuid\n from datetime import datetime\n from email.header import decode_header","@@ -212,7 +213,7 @@ async def connect(self) -> bool:\n             imap.login(self._address, self._password)\n             # Mark all existing messages as seen so we only process new ones\n             imap.select(\"INBOX\")\n-            status, data = imap.search(None, \"ALL\")\n+            status, data = imap.uid(\"search\", None, \"ALL\")\n             if status == \"OK\" and data[0]:\n                 for uid in data[0].split():\n                     self._seen_uids.add(uid)","@@ -225,7 +226,7 @@ async def connect(self) -> bool:\n         try:\n             # Test SMTP connection\n             smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)\n-            smtp.starttls()\n+            smtp.starttls(context=ssl.create_default_context())\n             smtp.login(self._address, self._password)\n             smtp.quit()\n             logger.info(\"[Email] SMTP connection test passed.\")","@@ -277,7 +278,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:\n             imap.login(self._address, self._password)\n             imap.select(\"INBOX\")\n \n-            status, data = imap.search(None, \"UNSEEN\")\n+            status, data = imap.uid(\"search\", None, \"UNSEEN\")\n             if status != \"OK\" or not data[0]:\n                 imap.logout()\n                 return results","@@ -287,7 +288,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:\n                     continue\n                 self._seen_uids.add(uid)\n \n-                status, msg_data = imap.fetch(uid, \"(RFC822)\")\n+                status, msg_data = imap.uid(\"fetch\", uid, \"(RFC822)\")\n                 if status != \"OK\":\n                     continue\n ","@@ -427,7 +428,7 @@ def _send_email(\n         msg.attach(MIMEText(body, \"plain\", \"utf-8\"))\n \n         smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)\n-        smtp.starttls()\n+        smtp.starttls(context=ssl.create_default_context())\n         smtp.login(self._address, self._password)\n         smtp.send_message(msg)\n         smtp.quit()","@@ -515,7 +516,7 @@ def _send_email_with_attachment(\n             msg.attach(part)\n \n         smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)\n-        smtp.starttls()\n+        smtp.starttls(context=ssl.create_default_context())\n         smtp.login(self._address, self._password)\n         smtp.send_message(msg)\n         smtp.quit()"]},{"file":"tests/gateway/test_email.py","hunks":["@@ -797,7 +797,7 @@ def test_connect_success(self):\n         adapter = self._make_adapter()\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"1 2 3\"])\n+        mock_imap.uid.return_value = (\"OK\", [b\"1 2 3\"])\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap), \\\n              patch(\"smtplib.SMTP\") as mock_smtp:","@@ -831,7 +831,7 @@ def test_connect_smtp_failure(self):\n         adapter = self._make_adapter()\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"\"])\n+        mock_imap.uid.return_value = (\"OK\", [b\"\"])\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap), \\\n              patch(\"smtplib.SMTP\", side_effect=Exception(\"SMTP down\")):","@@ -880,8 +880,15 @@ def test_fetch_skips_seen_uids(self):\n         raw_email[\"Message-ID\"] = \"<[redacted:email]>\"\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"1 2 3\"])\n-        mock_imap.fetch.return_value = (\"OK\", [(b\"3\", raw_email.as_bytes())])\n+\n+        def uid_handler(command, *args):\n+            if command == \"search\":\n+                return (\"OK\", [b\"1 2 3\"])\n+            if command == \"fetch\":\n+                return (\"OK\", [(b\"3\", raw_email.as_bytes())])\n+            return (\"NO\", [])\n+\n+        mock_imap.uid.side_effect = uid_handler\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap):\n             results = adapter._fetch_new_messages()","@@ -896,7 +903,7 @@ def test_fetch_no_unseen_messages(self):\n         adapter = self._make_adapter()\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"\"])\n+        mock_imap.uid.return_value = (\"OK\", [b\"\"])\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap):\n             results = adapter._fetch_new_messages()","@@ -922,8 +929,15 @@ def test_fetch_extracts_sender_name(self):\n         raw_email[\"Message-ID\"] = \"<[redacted:email]>\"\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"1\"])\n-        mock_imap.fetch.return_value = (\"OK\", [(b\"1\", raw_email.as_bytes())])\n+\n+        def uid_handler(command, *args):\n+            if command == \"search\":\n+                return (\"OK\", [b\"1\"])\n+            if command == \"fetch\":\n+                return (\"OK\", [(b\"1\", raw_email.as_bytes())])\n+            return (\"NO\", [])\n+\n+        mock_imap.uid.side_effect = uid_handler\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap):\n             results = adapter._fetch_new_messages()","@@ -966,8 +980,15 @@ async def mock_dispatch(msg_data):\n         raw_email[\"Message-ID\"] = \"<[redacted:email]>\"\n \n         mock_imap = MagicMock()\n-        mock_imap.search.return_value = (\"OK\", [b\"1\"])\n-        mock_imap.fetch.return_value = (\"OK\", [(b\"1\", raw_email.as_bytes())])\n+\n+        def uid_handler(command, *args):\n+            if command == \"search\":\n+                return (\"OK\", [b\"1\"])\n+            if command == \"fetch\":\n+                return (\"OK\", [(b\"1\", raw_email.as_bytes())])\n+            return (\"NO\", [])\n+\n+        mock_imap.uid.side_effect = uid_handler\n \n         with patch(\"imaplib.IMAP4_SSL\", return_value=mock_imap):\n             asyncio.run(adapter._check_inbox())","@@ -986,8 +1007,9 @@ class TestSendEmailStandalone(unittest.TestCase):\n         \"EMAIL_SMTP_PORT\": \"587\",\n     })\n     def test_send_email_tool_success(self):\n-        \"\"\"_send_email should use SMTP to send.\"\"\"\n+        \"\"\"_send_email should use verified STARTTLS when sending.\"\"\"\n         import asyncio\n+        import ssl\n         from tools.send_message_tool import _send_email\n \n         with patch(\"smtplib.SMTP\") as mock_smtp:","@@ -1000,6 +1022,8 @@ def test_send_email_tool_success(self):\n \n             self.assertTrue(result[\"success\"])\n             self.assertEqual(result[\"platform\"], \"email\")\n+            _, kwargs = mock_server.starttls.call_args\n+            self.assertIsInstance(kwargs[\"context\"], ssl.SSLContext)\n \n     @patch.dict(os.environ, {\n         \"EMAIL_ADDRESS\": \"[redacted:email]\","]},{"file":"tools/send_message_tool.py","hunks":["@@ -9,6 +9,7 @@\n import logging\n import os\n import re\n+import ssl\n import time\n \n logger = logging.getLogger(__name__)","@@ -432,7 +433,7 @@ async def _send_email(extra, chat_id, message):\n         msg[\"Subject\"] = \"Hermes Agent\"\n \n         server = smtplib.SMTP(smtp_host, smtp_port)\n-        server.starttls()\n+        server.starttls(context=ssl.create_default_context())\n         server.login(address, password)\n         server.send_message(msg)\n         server.quit()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}