diff --git a/decnet/services/imap.py b/decnet/services/imap.py index 718619f5..e88de5ff 100644 --- a/decnet/services/imap.py +++ b/decnet/services/imap.py @@ -3,10 +3,34 @@ from decnet.services.base import BaseService TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "imap" +# Repo-root default seed dir. When service_cfg["email_seed"] is unset, +# fall back to ``$PROJROOT/bait/`` if that directory exists on the host. +# Operator drops .eml / .json files there to ship a deployment-wide bait +# corpus without per-decky config. Missing dir → no env var, no volume, +# template uses its hardcoded baseline alone. +_PROJ_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_SEED_DIR = _PROJ_ROOT / "bait" _SEED_CONTAINER_PATH = "/var/spool/decnet-emails/seed" +def _resolve_seed_path(service_cfg: dict | None) -> str | None: + """Return the host path to bind-mount, or None. + + Precedence: + 1. service_cfg["email_seed"] if set + truthy. + 2. ``$PROJROOT/bait/`` if it exists. + 3. None. + """ + if service_cfg: + seed = service_cfg.get("email_seed") + if seed: + return str(Path(str(seed)).expanduser().resolve()) + if _DEFAULT_SEED_DIR.is_dir(): + return str(_DEFAULT_SEED_DIR.resolve()) + return None + + class IMAPService(BaseService): name = "imap" ports = [143, 993] @@ -16,6 +40,7 @@ class IMAPService(BaseService): # single .json/.eml. Mounted read-only into the # container; entries concatenate with the hardcoded # bait list (additive to realism-engine output). + # Default fallback: $PROJROOT/bait/ when present. def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { @@ -26,14 +51,12 @@ class IMAPService(BaseService): } if log_target: fragment["environment"]["LOG_TARGET"] = log_target - if service_cfg: - seed = service_cfg.get("email_seed") - if seed: - host_path = str(Path(str(seed)).expanduser().resolve()) - fragment["environment"]["IMAP_EMAIL_SEED"] = _SEED_CONTAINER_PATH - fragment.setdefault("volumes", []).append( - f"{host_path}:{_SEED_CONTAINER_PATH}:ro" - ) + host_path = _resolve_seed_path(service_cfg) + if host_path: + fragment["environment"]["IMAP_EMAIL_SEED"] = _SEED_CONTAINER_PATH + fragment.setdefault("volumes", []).append( + f"{host_path}:{_SEED_CONTAINER_PATH}:ro" + ) return fragment def dockerfile_context(self) -> Path | None: diff --git a/decnet/services/pop3.py b/decnet/services/pop3.py index deb0e838..5071e8de 100644 --- a/decnet/services/pop3.py +++ b/decnet/services/pop3.py @@ -3,10 +3,23 @@ from decnet.services.base import BaseService TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pop3" +# See decnet/services/imap.py for the same default-seed-dir rationale. +_PROJ_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_SEED_DIR = _PROJ_ROOT / "bait" _SEED_CONTAINER_PATH = "/var/spool/decnet-emails/seed" +def _resolve_seed_path(service_cfg: dict | None) -> str | None: + if service_cfg: + seed = service_cfg.get("email_seed") + if seed: + return str(Path(str(seed)).expanduser().resolve()) + if _DEFAULT_SEED_DIR.is_dir(): + return str(_DEFAULT_SEED_DIR.resolve()) + return None + + class POP3Service(BaseService): name = "pop3" ports = [110, 995] @@ -15,6 +28,7 @@ class POP3Service(BaseService): # email_seed: host path to a directory of .eml/.json files OR a # single .json/.eml. Mounted read-only; entries # concatenate with the hardcoded bait list. + # Default fallback: $PROJROOT/bait/ when present. def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { @@ -25,14 +39,12 @@ class POP3Service(BaseService): } if log_target: fragment["environment"]["LOG_TARGET"] = log_target - if service_cfg: - seed = service_cfg.get("email_seed") - if seed: - host_path = str(Path(str(seed)).expanduser().resolve()) - fragment["environment"]["POP3_EMAIL_SEED"] = _SEED_CONTAINER_PATH - fragment.setdefault("volumes", []).append( - f"{host_path}:{_SEED_CONTAINER_PATH}:ro" - ) + host_path = _resolve_seed_path(service_cfg) + if host_path: + fragment["environment"]["POP3_EMAIL_SEED"] = _SEED_CONTAINER_PATH + fragment.setdefault("volumes", []).append( + f"{host_path}:{_SEED_CONTAINER_PATH}:ro" + ) return fragment def dockerfile_context(self) -> Path | None: diff --git a/development/DEBT.md b/development/DEBT.md index 7191a033..6711cfd4 100644 --- a/development/DEBT.md +++ b/development/DEBT.md @@ -112,7 +112,7 @@ Fully refactored to `decnet/web/db/` modular layout: `models.py` (SQLModel schem ### ~~DEBT-026 — IMAP/POP3 bait emails not configurable via service config~~ ✅ RESOLVED **Files:** `templates/imap/server.py`, `templates/pop3/server.py`, `decnet/services/imap.py`, `decnet/services/pop3.py` -Resolved 2026-05-03. `IMAP_EMAIL_SEED` / `POP3_EMAIL_SEED` now accept either a directory (rglob `*.eml` and `*.json`) or a single `.json` / `.eml` file. JSON entries are dicts with required keys `from_addr`, `to_addr`, `subject`, `body` (optional `from_name`, `date`, `flags`); bare-body entries are wrapped into RFC 5322 on load. Loaded entries CONCATENATE with `_BAIT_EMAILS` (additive to the realism-engine emailgen output — the hardcoded baits are no longer replaced). `compose_fragment()` reads `service_cfg["email_seed"]` and bind-mounts the host path read-only at `/var/spool/decnet-emails/seed`. +Resolved 2026-05-03. `IMAP_EMAIL_SEED` / `POP3_EMAIL_SEED` now accept either a directory (rglob `*.eml` and `*.json`) or a single `.json` / `.eml` file. JSON entries are dicts with required keys `from_addr`, `to_addr`, `subject`, `body` (optional `from_name`, `date`, `flags`); bare-body entries are wrapped into RFC 5322 on load. Loaded entries CONCATENATE with `_BAIT_EMAILS` (additive to the realism-engine emailgen output — the hardcoded baits are no longer replaced). `compose_fragment()` reads `service_cfg["email_seed"]` and bind-mounts the host path read-only at `/var/spool/decnet-emails/seed`. When `email_seed` is unset, the compose fragment falls back to `$PROJROOT/bait/` if that directory exists — operators can drop a deployment-wide bait corpus there without touching per-decky config. --- diff --git a/tests/services/test_services.py b/tests/services/test_services.py index fe2ee015..4340846b 100644 --- a/tests/services/test_services.py +++ b/tests/services/test_services.py @@ -365,13 +365,16 @@ def test_telnet_no_cowrie_env_vars(): # IMAP / POP3 email_seed ----------------------------------------------------- -def test_imap_no_email_seed_by_default(): +def test_imap_no_email_seed_when_no_default_and_no_cfg(monkeypatch, tmp_path): + """No service_cfg + missing $PROJROOT/bait → no env, no volume.""" + from decnet.services import imap as imap_svc + monkeypatch.setattr(imap_svc, "_DEFAULT_SEED_DIR", tmp_path / "absent-bait") fragment = _fragment("imap") assert "IMAP_EMAIL_SEED" not in fragment.get("environment", {}) assert "volumes" not in fragment -def test_imap_email_seed_wires_env_and_volume(tmp_path): +def test_imap_email_seed_explicit_cfg_wins(tmp_path): seed_dir = tmp_path / "seed" seed_dir.mkdir() fragment = _fragment("imap", service_cfg={"email_seed": str(seed_dir)}) @@ -382,13 +385,42 @@ def test_imap_email_seed_wires_env_and_volume(tmp_path): assert volumes[0].startswith(str(seed_dir)) -def test_pop3_no_email_seed_by_default(): +def test_imap_default_seed_dir_used_when_present(monkeypatch, tmp_path): + """$PROJROOT/bait/ exists + no service_cfg → mount the default.""" + from decnet.services import imap as imap_svc + default_dir = tmp_path / "bait" + default_dir.mkdir() + monkeypatch.setattr(imap_svc, "_DEFAULT_SEED_DIR", default_dir) + fragment = _fragment("imap") + assert fragment["environment"]["IMAP_EMAIL_SEED"] == "/var/spool/decnet-emails/seed" + volumes = fragment.get("volumes") or [] + assert len(volumes) == 1 + assert volumes[0].startswith(str(default_dir)) + + +def test_imap_explicit_cfg_overrides_default(monkeypatch, tmp_path): + from decnet.services import imap as imap_svc + default_dir = tmp_path / "bait" + default_dir.mkdir() + explicit = tmp_path / "explicit" + explicit.mkdir() + monkeypatch.setattr(imap_svc, "_DEFAULT_SEED_DIR", default_dir) + fragment = _fragment("imap", service_cfg={"email_seed": str(explicit)}) + volumes = fragment.get("volumes") or [] + assert len(volumes) == 1 + assert volumes[0].startswith(str(explicit)) + assert str(default_dir) not in volumes[0] + + +def test_pop3_no_email_seed_when_no_default_and_no_cfg(monkeypatch, tmp_path): + from decnet.services import pop3 as pop3_svc + monkeypatch.setattr(pop3_svc, "_DEFAULT_SEED_DIR", tmp_path / "absent-bait") fragment = _fragment("pop3") assert "POP3_EMAIL_SEED" not in fragment.get("environment", {}) assert "volumes" not in fragment -def test_pop3_email_seed_wires_env_and_volume(tmp_path): +def test_pop3_email_seed_explicit_cfg_wins(tmp_path): seed_file = tmp_path / "seed.json" seed_file.write_text("[]") fragment = _fragment("pop3", service_cfg={"email_seed": str(seed_file)}) @@ -397,3 +429,15 @@ def test_pop3_email_seed_wires_env_and_volume(tmp_path): assert len(volumes) == 1 assert volumes[0].endswith(":/var/spool/decnet-emails/seed:ro") assert volumes[0].startswith(str(seed_file)) + + +def test_pop3_default_seed_dir_used_when_present(monkeypatch, tmp_path): + from decnet.services import pop3 as pop3_svc + default_dir = tmp_path / "bait" + default_dir.mkdir() + monkeypatch.setattr(pop3_svc, "_DEFAULT_SEED_DIR", default_dir) + fragment = _fragment("pop3") + assert fragment["environment"]["POP3_EMAIL_SEED"] == "/var/spool/decnet-emails/seed" + volumes = fragment.get("volumes") or [] + assert len(volumes) == 1 + assert volumes[0].startswith(str(default_dir))