feat(mail): default email_seed → \$PROJROOT/bait/ when unset
When service_cfg["email_seed"] is absent, compose_fragment now falls back to $PROJROOT/bait/ if that directory exists on the host. Lets operators drop a deployment-wide bait corpus into one place without threading email_seed through every decky's config. Missing dir keeps old no-op behavior.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user