# NousResearch/hermes-agent · Email adapter IMAP/SMTP host resolution and validation before connecting

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #50368. Verify it addresses your case.
- **Recommended:** read PR #50368 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-50368`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #49736: mail adapter fails with \`nodename nor servname\` despite valid IMAP host/credentials

## Prior & competing PRs

- **#50368: fix(email): resolve IMAP/SMTP host from config.extra and validate before connecting (#49736)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/email/adapter.py`, `scripts/release.py`, `tests/gateway/test_email.py`
  - fix hunks:
  - `plugins/platforms/email/adapter.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -120,6 +120,7 @@
         "290859878+synapsesx@users.noreply.github.com": "synapsesx",
         "157689911+itsflownium@users.noreply.github.com": "itsflownium",
         "dirtyren@users.noreply.github.com": "dirtyren",
    +    "[redacted:email]": "devorun",
         "[redacted:email]": "x7peeps",
         "[redacted:email]": "andressommerhoff",
         "[redacted:email]": "x7peeps",
    ```
  - `tests/gateway/test_email.py`

    ```diff
    @@ -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()
    ```
- **#49805: fix(email): resolve IMAP/SMTP host from config and validate before connecting**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `b7f6cb9c8`, `f79e0a706`, `#50368`
- **#49810: fix(email): normalize EMAIL_IMAP_HOST/EMAIL_SMTP_HOST before connect (#49736)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#49736`, `#50368`, `#49805`

## Fix status

- Merged to main in PR #50368 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-21`.
- Subsystem: `gateway`
