diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index c26ee51d9bfb6..a718443267cb1 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -566,6 +566,11 @@ class EmailAdapter(BasePlatformAdapter): self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth self._poll_task: Optional[asyncio.Task] = None + # Track the last IMAP fetch attempt so the poll loop can distinguish + # "checked, nothing new" from "the check itself failed" (#80016). + self._last_fetch_failed: bool = False + self._last_fetch_error: str = "" + # Map chat_id (sender email) -> last subject + message-id for threading self._thread_context: Dict[str, Dict[str, str]] = {} @@ -763,6 +768,22 @@ class EmailAdapter(BasePlatformAdapter): # Run IMAP operations in a thread to avoid blocking the event loop loop = asyncio.get_running_loop() messages = await loop.run_in_executor(None, self._fetch_new_messages) + if self._last_fetch_failed: + # The IMAP check itself failed (connect/login/select/search/fetch), + # not just an empty inbox. Surface it through the fatal-error hook + # so the gateway's existing reconnect/backoff/status machinery + # re-establishes the mailbox instead of silently treating every + # failed check as "nothing new" (#80016). The handler runs in a + # detached task (gateway/run.py), so awaiting it from our own poll + # task is safe even though teardown cancels this task. + self._last_fetch_failed = False + self._set_fatal_error( + "email_imap_fetch_failed", + self._last_fetch_error or "IMAP fetch failed", + retryable=True, + ) + await self._notify_fatal_error() + return for msg_data in messages: await self._dispatch_message(msg_data) @@ -862,6 +883,8 @@ class EmailAdapter(BasePlatformAdapter): pass except Exception as e: logger.error("[Email] IMAP fetch error: %s", e) + self._last_fetch_failed = True + self._last_fetch_error = str(e) return results @staticmethod diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 8e46600b04344..f1735f55a6c00 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -581,6 +581,31 @@ class TestPollLoop(unittest.TestCase): self.assertEqual(len(dispatched), 1) self.assertEqual(dispatched[0]["subject"], "Inbox Test") + def test_check_inbox_notifies_fatal_error_on_fetch_failure(self): + """A failed IMAP check must surface through the fatal-error hook so + the gateway's reconnect/backoff machinery learns email is unhealthy + instead of silently treating the failed check as an empty inbox + (#80016).""" + import asyncio + adapter = self._make_adapter() + notified = [] + + async def mock_fatal_handler(adapter): + notified.append(adapter) + + adapter.set_fatal_error_handler(mock_fatal_handler) + + mock_imap = MagicMock() + mock_imap.login.side_effect = Exception("read operation timed out") + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap): + asyncio.run(adapter._check_inbox()) + + self.assertEqual(len(notified), 1) + self.assertEqual(adapter.fatal_error_code, "email_imap_fetch_failed") + self.assertTrue(adapter.fatal_error_retryable) + self.assertIn("read operation timed out", adapter.fatal_error_message) + class TestSendEmailStandalone(unittest.TestCase): """Test the standalone _send_email function in send_message_tool."""