feat(emailgen): Ollama-driven fake email worker for IMAP/POP3 deckies

Second orchestrator worker (decnet emailgen) that drips persona-driven,
threaded, multi-language fake emails into running mail deckies.  Personas
live on Topology.email_personas; topology-wide language_default falls
through to any persona that doesn't pin its own.  Em-dashes are
suppressed at the prompt layer by default and only lifted for personas
explicitly marked uses_llms_heavily — em-dashes are an LLM tell and a
flat corpus of em-dashed mail is a giveaway.

EML delivery writes into /var/spool/decnet-emails/<thread>/<msg>.eml on
the mail decky via docker exec; wiring the IMAP/POP3 templates to read
from that spool (replacing the hardcoded _BAIT_EMAILS) is the next step.
This commit is contained in:
2026-04-26 22:16:19 -04:00
parent 674028d476
commit 3ee55ec341
25 changed files with 2343 additions and 1 deletions

View File

@@ -58,6 +58,8 @@ from .health import (
HealthResponse,
)
from .orchestrator import (
OrchestratorEmail,
OrchestratorEmailsResponse,
OrchestratorEvent,
OrchestratorEventsResponse,
)
@@ -193,6 +195,8 @@ __all__ = [
"ComponentHealth",
"HealthResponse",
# orchestrator
"OrchestratorEmail",
"OrchestratorEmailsResponse",
"OrchestratorEvent",
"OrchestratorEventsResponse",
# logs

View File

@@ -60,3 +60,52 @@ class OrchestratorEventsResponse(BaseModel):
limit: int
offset: int
data: List[dict[str, Any]]
class OrchestratorEmail(SQLModel, table=True):
"""One fake email generated by the ``decnet emailgen`` worker.
Sibling table to :class:`OrchestratorEvent` — kept disjoint because
email rows carry domain-specific fields (subject, message_id,
in_reply_to, language) that have no analogue in the SSH/file events
and would otherwise bloat ``OrchestratorEvent.payload``.
The mail decky's UUID lives in ``mail_decky_uuid`` (the host serving
the IMAP/POP3 mailbox). ``thread_id`` is a worker-side UUID used to
chain replies; ``in_reply_to`` is the parent email's RFC 2822
Message-ID header value (or ``None`` for thread roots).
``payload`` follows the same loose-JSON convention as
:class:`OrchestratorEvent`: ``bytes``, ``generation_ms``, ``model``,
``mannerisms_used``, etc. The worker can extend it without a
migration.
"""
__tablename__ = "orchestrator_emails"
__table_args__ = (
Index("ix_orchestrator_emails_mail_ts", "mail_decky_uuid", "ts"),
Index("ix_orchestrator_emails_thread", "thread_id"),
)
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
ts: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), index=True
)
mail_decky_uuid: str = Field(index=True)
thread_id: str = Field(index=True)
message_id: str = Field(max_length=255)
in_reply_to: Optional[str] = Field(default=None, max_length=255)
sender_email: str = Field(max_length=255, index=True)
recipient_email: str = Field(max_length=255, index=True)
subject: str = Field(max_length=512)
language: str = Field(max_length=8, default="en")
eml_path: str = Field(max_length=1024)
success: bool = Field(default=False, index=True)
payload: str = Field(
sa_column=Column("payload", Text, nullable=False, default="{}")
)
class OrchestratorEmailsResponse(BaseModel):
total: int
limit: int
offset: int
data: List[dict[str, Any]]

View File

@@ -47,6 +47,18 @@ class Topology(SQLModel, table=True):
# running. Drained by the mutator watch loop, which re-pushes via
# AgentClient and clears the flag. NULL for unihost topologies.
needs_resync: bool = Field(default=False, nullable=False)
# JSON-serialised list of EmailPersona dicts consumed by the
# ``decnet emailgen`` worker. Empty list = no fake mailbox owners
# configured for this topology, the worker skips it.
email_personas: str = Field(
sa_column=Column(
"email_personas", _BIG_TEXT, nullable=False, default="[]"
)
)
# ISO 639-1 language code applied to any persona that doesn't override
# ``language`` itself. English by default; ANTI's deployments default
# to "es" by editing this column.
language_default: str = Field(default="en", max_length=8)
class LAN(SQLModel, table=True):

View File

@@ -952,3 +952,57 @@ class BaseRepository(ABC):
unbounded growth without paying the cost on every write.
"""
raise NotImplementedError
async def record_orchestrator_email(self, data: dict[str, Any]) -> str:
"""Insert one orchestrator-generated email row, returning its uuid."""
raise NotImplementedError
async def list_orchestrator_emails(
self,
limit: int = 100,
offset: int = 0,
*,
mail_decky_uuid: Optional[str] = None,
thread_id: Optional[str] = None,
since_ts: Optional[Any] = None,
) -> list[dict[str, Any]]:
"""Paginated orchestrator emails newest-first.
Optional filters narrow to a single mail decky or to one thread,
used by the dashboard's mailbox-inspector view.
"""
raise NotImplementedError
async def count_orchestrator_emails(
self,
*,
mail_decky_uuid: Optional[str] = None,
) -> int:
"""Total orchestrator-email rows, optionally filtered by mail decky."""
raise NotImplementedError
async def list_orchestrator_email_threads(
self,
mail_decky_uuid: str,
sender_email: str,
recipient_email: str,
*,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Open threads between *sender_email* and *recipient_email* on
*mail_decky_uuid*, newest-first.
Used by the emailgen scheduler to decide whether to start a new
thread or reply on an existing one. Each entry is one row's
worth of dict — the worker only needs ``thread_id`` and the most
recent ``message_id`` / ``subject`` to build the reply.
"""
raise NotImplementedError
async def prune_orchestrator_emails(self, per_decky_cap: int = 10000) -> int:
"""Trim per-``mail_decky_uuid`` rows to a cap. Returns deleted count.
Mirrors :meth:`prune_orchestrator_events`; emailgen worker calls
this on a periodic tick.
"""
raise NotImplementedError

View File

@@ -51,6 +51,7 @@ from decnet.web.db.models import (
TopologyEdge,
TopologyStatusEvent,
TopologyMutation,
OrchestratorEmail,
OrchestratorEvent,
WebhookSubscription,
)
@@ -3003,3 +3004,113 @@ class SQLModelRepository(BaseRepository):
deleted += res.rowcount or 0
await session.commit()
return deleted
# ---------------------------------------------------------- emailgen
async def record_orchestrator_email(self, data: dict[str, Any]) -> str:
payload = data.get("payload")
if isinstance(payload, (dict, list)):
data = {**data, "payload": json.dumps(payload)}
async with self._session() as session:
row = OrchestratorEmail(**data)
session.add(row)
await session.commit()
await session.refresh(row)
return row.uuid
async def list_orchestrator_emails(
self,
limit: int = 100,
offset: int = 0,
*,
mail_decky_uuid: Optional[str] = None,
thread_id: Optional[str] = None,
since_ts: Optional[datetime] = None,
) -> list[dict[str, Any]]:
async with self._session() as session:
stmt = select(OrchestratorEmail)
if mail_decky_uuid is not None:
stmt = stmt.where(
OrchestratorEmail.mail_decky_uuid == mail_decky_uuid
)
if thread_id is not None:
stmt = stmt.where(OrchestratorEmail.thread_id == thread_id)
if since_ts is not None:
stmt = stmt.where(OrchestratorEmail.ts >= since_ts)
stmt = (
stmt.order_by(desc(OrchestratorEmail.ts))
.offset(offset)
.limit(limit)
)
result = await session.execute(stmt)
return [r.model_dump(mode="json") for r in result.scalars().all()]
async def count_orchestrator_emails(
self,
*,
mail_decky_uuid: Optional[str] = None,
) -> int:
stmt = select(func.count()).select_from(OrchestratorEmail)
if mail_decky_uuid is not None:
stmt = stmt.where(OrchestratorEmail.mail_decky_uuid == mail_decky_uuid)
async with self._session() as session:
result = await session.execute(stmt)
return result.scalar() or 0
async def list_orchestrator_email_threads(
self,
mail_decky_uuid: str,
sender_email: str,
recipient_email: str,
*,
limit: int = 50,
) -> list[dict[str, Any]]:
# Most-recent row per (sender, recipient) pair under this mail decky.
# The scheduler only needs the latest message_id/subject/thread_id to
# construct a reply; older rows in the same thread aren't relevant
# for the "do we reply or start fresh" decision.
async with self._session() as session:
stmt = (
select(OrchestratorEmail)
.where(
OrchestratorEmail.mail_decky_uuid == mail_decky_uuid,
or_(
(OrchestratorEmail.sender_email == sender_email)
& (OrchestratorEmail.recipient_email == recipient_email),
(OrchestratorEmail.sender_email == recipient_email)
& (OrchestratorEmail.recipient_email == sender_email),
),
OrchestratorEmail.success.is_(True),
)
.order_by(desc(OrchestratorEmail.ts))
.limit(limit)
)
result = await session.execute(stmt)
return [r.model_dump(mode="json") for r in result.scalars().all()]
async def prune_orchestrator_emails(self, per_decky_cap: int = 10000) -> int:
"""Trim per-mail-decky rows to *per_decky_cap*, oldest-first."""
deleted = 0
async with self._session() as session:
decky_rows = await session.execute(
select(OrchestratorEmail.mail_decky_uuid).distinct()
)
for (mail_uuid,) in decky_rows.all():
keep = await session.execute(
select(OrchestratorEmail.uuid)
.where(OrchestratorEmail.mail_decky_uuid == mail_uuid)
.order_by(desc(OrchestratorEmail.ts))
.limit(per_decky_cap)
)
keep_uuids = [u for (u,) in keep.all()]
if not keep_uuids:
continue
from sqlalchemy import delete as _delete
stmt = _delete(OrchestratorEmail).where(
OrchestratorEmail.mail_decky_uuid == mail_uuid,
OrchestratorEmail.uuid.notin_(keep_uuids),
)
res = await session.execute(stmt)
deleted += res.rowcount or 0
await session.commit()
return deleted