NousResearch/hermes-agent

Email adapter IMAP/SMTP host resolution and validation before connecting

Cluster 49736 · gateway. Data as of

Nothing open on GitHub. Fix merged in #50368 on 21 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #50368. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #50368. That is separate from the fix event.
Still open
Nothing open on GitHub.

Members center on the email adapter's IMAP/SMTP host handling: three titles propose normalizing or resolving the host from config (including config.extra) and validating it before connecting, referencing #49736. One issue reports a nodename nor servname error despite a seemingly valid IMAP host and credentials; its cause and any link to the proposed changes are not established by the titles alone.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50368 fix(email): resolve IMAP/SMTP host from config.extra and validate before connecting (#49736)

    merged to main

    files: plugins/platforms/email/adapter.py, scripts/release.py, tests/gateway/test_email.py

    Show the diff
    • plugins/platforms/email/adapter.py
      @@ -159,14 +159,16 @@ def _is_automated_sender(address: str, headers: dict) -> bool:
           return False
           
       def check_email_requirements() -> bool:
      -    """Check if email platform dependencies are available."""
      -    addr = os.getenv("EMAIL_ADDRESS")
      -    pwd = os.getenv("EMAIL_PASSWORD")
      -    imap = os.getenv("EMAIL_IMAP_HOST")
      -    smtp = os.getenv("EMAIL_SMTP_HOST")
      -    if not all([addr, pwd, imap, smtp]):
      -        return False
      -    return True
      +    """Check if email platform settings are available and non-blank.
      +
      +    Treats blank/whitespace-only values as missing so an abandoned setup that
      +    left empty ``EMAIL_*`` keys in ``.env`` does not enable the platform (#40715).
      +    """
      +    addr = os.getenv("EMAIL_ADDRESS", "").strip()
      +    pwd = os.getenv("EMAIL_PASSWORD", "").strip()
      +    imap = os.getenv("EMAIL_IMAP_HOST", "").strip()
      +    smtp = os.getenv("EMAIL_SMTP_HOST", "").strip()
      +    return all([addr, pwd, imap, smtp])
       
       
       def _decode_header_value(raw: str) -> str:
      @@ -307,11 +309,20 @@ class EmailAdapter(BasePlatformAdapter):
           def __init__(self, config: PlatformConfig):
               super().__init__(config, Platform.EMAIL)
       
      -        self._address = os.getenv("EMAIL_ADDRESS", "")
      +        # Resolve connection settings from the env vars first, then fall back to
      +        # PlatformConfig.extra (address/imap_host/smtp_host) — the canonical dict
      +        # gateway.config populates and that the "connected" check, the
      +        # send-helper, and `hermes config show` already read. Without the
      +        # fallback a config.yaml-only setup left these empty. Host/address values
      +        # are stripped: a stray space or newline made IMAP4_SSL raise the
      +        # misleading ``[Errno 8] nodename nor servname`` (an unresolvable name)
      +        # instead of an obvious "host not set" error.
      +        extra = config.extra or {}
      +        self._address = (os.getenv("EMAIL_ADDRESS", "") or extra.get("address", "")).strip()
               self._password = os.getenv("EMAIL_PASSWORD", "")
      -        self._imap_host = os.getenv("EMAIL_IMAP_HOST", "")
      +        self._imap_host = (os.getenv("EMAIL_IMAP_HOST", "") or extra.get("imap_host", "")).strip()
               self._imap_port = env_int("EMAIL_IMAP_PORT", 993)
      -        self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "")
      +        self._smtp_host = (os.getenv("EMAIL_SMTP_HOST", "") or extra.get("smtp_host", "")).strip()
               self._smtp_port = env_int("EMAIL_SMTP_PORT", 587)
               self._poll_interval = env_int("EMAIL_POLL_INTERVAL", 15)
       
      @@ -319,7 +330,6 @@ def __init__(self, config: PlatformConfig):
               #   platforms:
               #     email:
               #       skip_attachments: true
      -        extra = config.extra or {}
               self._skip_attachments = extra.get("skip_attachments", False)
       
               # Track message IDs we've already processed to avoid duplicates
      @@ -396,6 +406,36 @@ def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:
       
           async def connect(self) -> bool:
               """Connect to the IMAP server and start polling for new messages."""
      +        # Validate up front so a missing host surfaces as an actionable config
      +        # error instead of IMAP4_SSL("") raising the cryptic
      +        # ``[Errno 8] nodename nor servname provided, or not known``.
      +        missing = [
      +            name
      +            for name, value in (
      +                ("EMAIL_ADDRESS", self._address),
      +                ("EMAIL_PASSWORD", self._password),
      +                ("EMAIL_IMAP_HOST", self._imap_host),
      +                ("EMAIL_SMTP_HOST", self._smtp_host),
      +            )
      +            if not value
      +        ]
      +        if missing:
      +            message = (
      +                "Not configured — missing "
      +                + ", ".join(missing)
      +                + ". Set it via `hermes gateway setup` (env) or platforms.email "
      +                "in config.yaml."
      +            )
      +            logger.error("[Email] %s", message)
      +            # Mark non-retryable so the gateway does NOT keep reconnecting against
      +            # an empty host. A blank-but-present env var (e.g. ``EMAIL_IMAP_HOST=``)
      +            # used to slip past the startup gate and drive an indefinite retry
      +            # loop that leaked memory until the host OOM-killed (#40715).
      +            self._set_fatal_error(
      +                "email_missing_configuration", message, retryable=False
      +            )
      +            return False
      +
               try:
                   # Test IMAP connection
                   imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
    • scripts/release.py
      @@ -120,6 +120,7 @@
           "[email protected]": "synapsesx",
           "[email protected]": "itsflownium",
           "[email protected]": "dirtyren",
      +    "[redacted:email]": "devorun",
           "[redacted:email]": "x7peeps",
           "[redacted:email]": "andressommerhoff",
           "[redacted:email]": "x7peeps",
    • tests/gateway/test_email.py
      @@ -1392,5 +1392,95 @@ def test_ipv4_connection_does_not_mutate_global_resolver(self):
               self.assertIs(_socket.getaddrinfo, original_getaddrinfo)
       
       
      +class TestConnectionConfigResolution(unittest.TestCase):
      +    """Host/address resolution and pre-connect validation (#49736)."""
      +
      +    def test_host_and_address_whitespace_stripped(self):
      +        """A stray space/newline must not reach IMAP4_SSL as part of the host.
      +
      +        Whitespace in the host produced the misleading
      +        ``[Errno 8] nodename nor servname`` (unresolvable name) instead of a
      +        successful connection.
      +        """
      +        from gateway.config import PlatformConfig
      +        from plugins.platforms.email.adapter import EmailAdapter
      +        with patch.dict(os.environ, {
      +            "EMAIL_ADDRESS": "  [redacted:email]\n",
      +            "EMAIL_PASSWORD": "[redacted:secret-assignment]",
      +            "EMAIL_IMAP_HOST": " imap.test.com ",
      +            "EMAIL_SMTP_HOST": "smtp.test.com\n",
      +        }, clear=False):
      +            adapter = EmailAdapter(PlatformConfig(enabled=True))
      +        self.assertEqual(adapter._imap_host, "imap.test.com")
      +        self.assertEqual(adapter._smtp_host, "smtp.test.com")
      +        self.assertEqual(adapter._address, "[redacted:email]")
      +
      +    def test_falls_back_to_platform_config_extra(self):
      +        """When env vars are absent, settings come from PlatformConfig.extra —
      +        the same dict gateway.config populates and `hermes config show` reads."""
      +        from gateway.config import PlatformConfig
      +        from plugins.platforms.email.adapter import EmailAdapter
      +        cfg = PlatformConfig(enabled=True)
      +        cfg.extra.update({
      +            "address": "[redacted:email]",
      +            "imap_host": "imap.test.com",
      +            "smtp_host": "smtp.test.com",
      +        })
      +        with patch.dict(os.environ, {
      +            "EMAIL_ADDRESS": "", "EMAIL_IMAP_HOST": "", "EMAIL_SMTP_HOST": "",
      +            "EMAIL_PASSWORD": "[redacted:secret-assignment]",
      +        }, clear=False):
      +            adapter = EmailAdapter(cfg)
      +        self.assertEqual(adapter._imap_host, "imap.test.com")
      +        self.assertEqual(adapter._smtp_host, "smtp.test.com")
      +        self.assertEqual(adapter._address, "[redacted:email]")
      +
      +    def test_connect_aborts_without_attempting_imap_when_host_missing(self):
      +        """A missing host returns False without the cryptic DNS error, and marks
      +        the failure non-retryable so the gateway stops reconnecting (#40715)."""
      +        import asyncio
      +        from gateway.config import PlatformConfig
      +        from plugins.platforms.email.adapter import EmailAdapter
      +        with patch.dict(os.environ, {
      +            "EMAIL_ADDRESS": "[redacted:email]",
      +            "EMAIL_PASSWORD": "[redacted:secret-assignment]",
      +            "EMAIL_IMAP_HOST": "",
      +            "EMAIL_SMTP_HOST": "smtp.test.com",
      +        }, clear=False):
      +            adapter = EmailAdapter(PlatformConfig(enabled=True))
      +
      +        with patch("imaplib.IMAP4_SSL") as mock_imap:
      +            result = asyncio.run(adapter.connect())
      +
      +        self.assertFalse(result)
      +        mock_imap.assert_not_called()
      +        # The OOM fix (#40715): a blank host must NOT leave the platform in the
      +        # retryable reconnect loop — it is a permanent config error.
      +        self.assertTrue(adapter.has_fatal_error)
      +        self.assertEqual(adapter.fatal_error_code, "email_missing_configuration")
      +        self.assertFalse(adapter.fatal_error_retryable)
      +        self.assertIn("EMAIL_IMAP_HOST", adapter.fatal_error_message or "")
      +
      +    def test_blank_present_env_vars_are_not_required(self):
      +        """Blank/whitespace EMAIL_* values must read as missing (#40715) — an
      +        abandoned setup with empty keys must not enable the platform."""
      +        from plugins.platforms.email.adapter import check_email_requirements
      +        for blank in ("", "   ", "\n"):
      +            with patch.dict(os.environ, {
      +                "EMAIL_ADDRESS": blank, "EMAIL_PASSWORD": [redacted:secret-assignment]
      +                "EMAIL_IMAP_HOST": blank, "EMAIL_SMTP_HOST": blank,
      +            }, clear=False):
      +                self.assertFalse(check_email_requirements())
      +
      +    def test_all_settings_present_satisfies_requirements(self):
      +        """The connected check passes only when all four settings are non-blank."""
      +        from plugins.platforms.email.adapter import check_email_requirements
      +        with patch.dict(os.environ, {
      +            "EMAIL_ADDRESS": "[redacted:email]", "EMAIL_PASSWORD": "[redacted:secret-assignment]",
      +            "EMAIL_IMAP_HOST": "imap.test.com", "EMAIL_SMTP_HOST": "smtp.test.com",
      +        }, clear=False):
      +            self.assertTrue(check_email_requirements())
      +
      +
       if __name__ == "__main__":
           unittest.main()

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #49805 fix(email): resolve IMAP/SMTP host from config and validate before connecting · merged via another change → b7f6cb9, f79e0a7, #50368
  • #49810 fix(email): normalize EMAIL_IMAP_HOST/EMAIL_SMTP_HOST before connect (#49736) · done elsewhere → #49736, #50368, #49805
Closed issues · 1
  • Closed#49736mail adapter fails with `nodename nor servname` despite valid IMAP host/credentials
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.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#50368
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.