Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand Down Expand Up @@ -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)),
Expand All @@ -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")),
)

Expand All @@ -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", {})),
Expand Down
9 changes: 9 additions & 0 deletions nerve/sources/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<unnamed>",
)
continue
password = imap.passwords.get(account.username, "")
if not password:
logger.warning(
Expand Down
71 changes: 70 additions & 1 deletion tests/test_imap_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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"]
Loading