feat(mail): operator-tunable IMAP/POP3 email seed (DEBT-026)

IMAP_EMAIL_SEED / POP3_EMAIL_SEED accept a directory (rglob *.eml +
*.json) or a single .json/.eml. Loaded entries CONCATENATE with the
hardcoded _BAIT_EMAILS — additive to the realism-engine emailgen
output rather than replacing it. JSON dicts require from_addr /
to_addr / subject / body; bare bodies are wrapped into RFC 5322 on
load. compose_fragment reads service_cfg["email_seed"] and bind-mounts
the host path read-only at /var/spool/decnet-emails/seed.
This commit is contained in:
2026-05-03 02:47:06 -04:00
parent e0b07651fd
commit b88d67794d
8 changed files with 444 additions and 133 deletions

View File

@@ -1,12 +1,19 @@
"""Spool-backed email loading for the POP3 template."""
"""Seed-backed email loading for the POP3 template.
Concat semantics: hardcoded ``_BAIT_EMAILS`` + .eml/.json from the seed
path. Mirrors the IMAP test file.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock, patch
_HARDCODED = 10 # length of templates/pop3/server.py::_BAIT_EMAILS
_EML_TEMPLATE = (
"From: Sender <sender@corp.com>\r\n"
@@ -61,25 +68,54 @@ def _seed(tmp_path: Path, n: int) -> Path:
def test_falls_back_when_seed_unset(tmp_path):
mod = _load_pop3({})
assert len(mod._get_emails()) == 10 # hardcoded fallback
assert len(mod._get_emails()) == _HARDCODED # baseline only
def test_falls_back_when_seed_dir_missing(tmp_path):
mod = _load_pop3({"POP3_EMAIL_SEED": str(tmp_path / "nope")})
assert len(mod._get_emails()) == 10
assert len(mod._get_emails()) == _HARDCODED
def test_loads_emls_from_spool(tmp_path):
def test_seed_concatenates_with_hardcoded(tmp_path):
spool = _seed(tmp_path, n=3)
mod = _load_pop3({"POP3_EMAIL_SEED": str(spool)})
emails = mod._get_emails()
assert len(emails) == 3
# POP3 stores raw RFC 822 strings; verify content round-trips.
assert any("Topic 0" in e for e in emails)
assert all(e.startswith("From:") for e in emails)
# Hardcoded baseline + 3 spooled .eml.
assert len(emails) == _HARDCODED + 3
# Hardcoded entries unchanged at the head.
assert "AWS credentials rotation" in emails[0]
# Seeded entries at the tail.
assert any("Topic 0" in e for e in emails[_HARDCODED:])
assert all(e.startswith("From:") for e in emails[_HARDCODED:])
def test_stat_reflects_spool_size(tmp_path):
def test_json_seed_file_loaded(tmp_path):
seed = tmp_path / "seed.json"
seed.write_text(json.dumps([
{
"from_addr": "ceo@corp.com",
"from_name": "CEO",
"to_addr": "admin@corp.com",
"subject": "Q4 numbers",
"date": "Mon, 27 Apr 2026 09:00:00 +0000",
"body": "Please review attached.",
},
{
# Missing 'subject' — skipped, not fatal.
"from_addr": "ghost@corp.com",
"to_addr": "admin@corp.com",
"body": "no subject",
},
]))
mod = _load_pop3({"POP3_EMAIL_SEED": str(seed)})
emails = mod._get_emails()
assert len(emails) == _HARDCODED + 1
seeded = emails[-1]
assert "Subject: Q4 numbers" in seeded
assert "From: CEO <ceo@corp.com>" in seeded
def test_stat_reflects_concatenated_count(tmp_path):
spool = _seed(tmp_path, n=2)
mod = _load_pop3({"POP3_EMAIL_SEED": str(spool)})
proto = mod.POP3Protocol()
@@ -93,4 +129,5 @@ def test_stat_reflects_spool_size(tmp_path):
written.clear()
proto.data_received(b"STAT\r\n")
out = b"".join(written)
assert out.startswith(b"+OK 2 ")
expected = _HARDCODED + 2
assert out.startswith(f"+OK {expected} ".encode())