diff --git a/nerve/config.py b/nerve/config.py index 0422ed1f..0941b214 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1053,11 +1053,12 @@ class ImapMatchConfig: only_matched: bool = False @classmethod + @_coerced def from_dict(cls, d: dict) -> ImapMatchConfig: return cls( - sender_contains=[str(s) for s in d.get("sender_contains", [])], - attachment_contains=[str(s) for s in d.get("attachment_contains", [])], - only_matched=bool(d.get("only_matched", False)), + sender_contains=d.get("sender_contains", []), + attachment_contains=d.get("attachment_contains", []), + only_matched=d.get("only_matched", False), ) @@ -1102,10 +1103,11 @@ class ImapVisionConfig: ) @classmethod + @_coerced def from_dict(cls, d: dict) -> ImapVisionConfig: base = cls() return cls( - enabled=bool(d.get("enabled", base.enabled)), + enabled=d.get("enabled", base.enabled), model=str(d.get("model", base.model)), prompt=str(d.get("prompt", base.prompt)), answer_key=str(d.get("answer_key", base.answer_key)), @@ -1128,12 +1130,17 @@ class ImapAccountConfig: mailbox: str = "INBOX" @classmethod + @_coerced def from_dict(cls, d: dict) -> ImapAccountConfig: + # No key is required here: a half-written account entry must not stop + # the daemon from starting. The registry skips an account whose host + # or username is empty, the same way it skips one with no password. + username = str(d.get("username", "")) return cls( - host=str(d["host"]), - username=str(d["username"]), - label=str(d.get("label") or d["username"].split("@")[0]), - port=int(d.get("port", 993)), + host=str(d.get("host", "")), + username=username, + label=str(d.get("label") or username.split("@")[0]), + port=d.get("port", 993), mailbox=str(d.get("mailbox", "INBOX")), ) @@ -1152,15 +1159,16 @@ class ImapSyncConfig: vision: ImapVisionConfig = field(default_factory=ImapVisionConfig) @classmethod + @_coerced def from_dict(cls, d: dict) -> ImapSyncConfig: return cls( - enabled=bool(d.get("enabled", False)), + enabled=d.get("enabled", False), accounts=[ImapAccountConfig.from_dict(a) for a in d.get("accounts", [])], passwords=dict(d.get("passwords", {})), schedule=str(d.get("schedule", "*/30 * * * *")), - batch_size=int(d.get("batch_size", 20)), - initial_lookback_days=int(d.get("initial_lookback_days", 1)), - condense=bool(d.get("condense", False)), + batch_size=d.get("batch_size", 20), + initial_lookback_days=d.get("initial_lookback_days", 1), + condense=d.get("condense", False), condense_prompt=str(d.get("condense_prompt", "")), match=ImapMatchConfig.from_dict(d.get("match", {})), vision=ImapVisionConfig.from_dict(d.get("vision", {})), diff --git a/nerve/sources/registry.py b/nerve/sources/registry.py index 9197feac..4277d20b 100644 --- a/nerve/sources/registry.py +++ b/nerve/sources/registry.py @@ -106,6 +106,15 @@ def build_source_runners( vision = dataclasses.replace(vision, enabled=False) for account in imap.accounts: + # A half-written entry is dropped here rather than at config load, + # so one bad account cannot stop the daemon from starting. + if not account.host or not account.username: + logger.warning( + "Skipping IMAP account %s: an entry in sync.imap.accounts " + "needs both a host and a username", + account.label or account.host or "", + ) + continue password = imap.passwords.get(account.username, "") if not password: logger.warning( diff --git a/tests/test_imap_source.py b/tests/test_imap_source.py index 38c4ad00..53a0b47c 100644 --- a/tests/test_imap_source.py +++ b/tests/test_imap_source.py @@ -19,7 +19,13 @@ import pytest -from nerve.config import ImapMatchConfig, ImapVisionConfig +from nerve.config import ( + ImapAccountConfig, + ImapMatchConfig, + ImapSyncConfig, + ImapVisionConfig, + NerveConfig, +) from nerve.sources.imap import ( ImapSource, _decode_hdr, @@ -30,6 +36,7 @@ _format, _parse_cursor, ) +from nerve.sources.registry import build_source_runners # 1x1 transparent PNG (valid, tiny) for the image tests. _PNG_BYTES = base64.b64decode( @@ -435,3 +442,65 @@ async def test_mismatched_answer_key_degrades_to_unknown(): summary, _ = await src._build_vision_record(_matched_msg()) assert "unlesbar" in summary + + +# --------------------------------------------------------------------------- +# Config coercion — every value can arrive as a string via ${ENV_VAR} +# --------------------------------------------------------------------------- + +def test_match_rules_survive_a_bare_string(): + """``sender_contains: ${SENDER}`` is one rule, not one rule per character.""" + match = ImapMatchConfig.from_dict({ + "sender_contains": "alerts@bank.com", + "attachment_contains": "scan", + "only_matched": "true", + }) + assert match.sender_contains == ["alerts@bank.com"] + assert match.attachment_contains == ["scan"] + assert match.only_matched is True + + +def test_sync_config_survives_string_scalars(): + """``bool("false")`` is ``True``, so the source must not cast eagerly.""" + sync = ImapSyncConfig.from_dict({ + "enabled": "false", + "batch_size": "50", + "initial_lookback_days": "7", + "condense": "0", + "vision": {"enabled": "false"}, + "accounts": [{"host": "imap.example.com", + "username": "me@example.com", + "port": "993"}], + }) + assert sync.enabled is False + assert sync.batch_size == 50 + assert sync.initial_lookback_days == 7 + assert sync.condense is False + assert sync.vision.enabled is False + assert sync.accounts[0].port == 993 + # label defaults off the username's local part + assert sync.accounts[0].label == "me" + + +def test_account_tolerates_a_half_written_entry(): + """A missing key is dropped later, not raised at config load.""" + account = ImapAccountConfig.from_dict({"port": 143}) + assert account.host == "" + assert account.username == "" + + +@pytest.mark.asyncio +async def test_registry_skips_an_account_missing_its_host(db): + cfg = NerveConfig.from_dict({ + "sync": {"imap": { + "enabled": True, + "accounts": [ + {"username": "me@example.com"}, # no host + {"host": "imap.example.com", "username": "you@example.com"}, + ], + "passwords": {"me@example.com": "x", "you@example.com": "y"}, + }}, + }) + runners = build_source_runners(cfg, db) + labels = [r.source.source_name for r in runners if r.source.source_name.startswith("imap")] + assert labels == ["imap:you"]