v0.1 wave 2C (step 8): email digest scheduler

- digest.py: DigestScheduler with daily 06:00 PT loop
- SmtpConfig env-driven (CRAFTING_SMTP_*)
- notify.on event filter respected per project
- GET /digests/{date} + POST /admin/digest/run-now (dry_run flag)
- migration 006: digest_runs (idempotency via UNIQUE(date, project_name))
- text + HTML email bodies; matches spec's worked example
- Server lifespan integration; gracefully disables if SMTP not configured
- tests/test_digest.py: 8 tests (aggregation / filter / smtp mock / idempotency / endpoint)

Patch-drafted line is a placeholder until wave 3 / step 9 ships.

Spec: memory/spec-crafting-table.md
This commit is contained in:
Kayos 2026-04-29 08:32:56 -07:00
parent 2e16ec886d
commit 98306ca2e0
8 changed files with 1095 additions and 3 deletions

View file

@ -112,6 +112,21 @@ MIGRATIONS: list[tuple[str, str]] = [
CREATE INDEX IF NOT EXISTS idx_findings_fingerprint ON findings(fingerprint);
""",
),
(
"006_digest_runs",
"""
CREATE TABLE IF NOT EXISTS digest_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
project_name TEXT NOT NULL,
sent_at INTEGER NOT NULL,
recipient_count INTEGER NOT NULL,
job_count INTEGER NOT NULL,
UNIQUE(date, project_name)
);
CREATE INDEX IF NOT EXISTS idx_digest_runs_date ON digest_runs(date);
""",
),
]
# fmt: on
@ -489,6 +504,52 @@ class DB:
).fetchall()
return [dict(r) for r in rows]
# ---------- digest runs --------------------------------------------------
def record_digest_run(
self,
*,
date: str,
project_name: str,
sent_at: int,
recipient_count: int,
job_count: int,
) -> bool:
"""Record an attempted/successful digest send. UNIQUE(date, project_name)
enforces idempotency a second call for the same date+project is a
no-op (returns False)."""
with self._conn() as c:
cur = c.execute(
"""
INSERT OR IGNORE INTO digest_runs
(date, project_name, sent_at, recipient_count, job_count)
VALUES (?, ?, ?, ?, ?)
""",
(date, project_name, sent_at, recipient_count, job_count),
)
return cur.rowcount == 1
def digest_run_exists(self, date: str, project_name: str) -> bool:
with self._conn() as c:
row = c.execute(
"SELECT 1 FROM digest_runs WHERE date=? AND project_name=?",
(date, project_name),
).fetchone()
return row is not None
def list_digest_runs(self, date: str | None = None) -> list[dict]:
with self._conn() as c:
if date is None:
rows = c.execute(
"SELECT * FROM digest_runs ORDER BY date DESC, project_name"
).fetchall()
else:
rows = c.execute(
"SELECT * FROM digest_runs WHERE date=? ORDER BY project_name",
(date,),
).fetchall()
return [dict(r) for r in rows]
# ---------- async wrappers ----------------------------------------------
async def arun(self, fn, *args, **kwargs):