ci / lint-test-leaks (pull_request) Canceled after 0s
- IMAP poll (UNSEEN, stdlib imaplib) -> parse (bare address via email.utils) - threading: In-Reply-To -> outbound sent-draft message-id -> proposal - classify: deterministic rules; optional LLM behind provider interface; below-threshold confidence -> needs_review (never guessed) - unthreadable approvals flagged needs_review (suspicious by definition) - merchant_status recorded on proposal detail; proposal lifecycle untouched - reply drafts generated from templates (drafted), NEVER auto-sent - SMTP sends only draft_status=approved (human approve_draft, audited); sent drafts store their Message-ID for future threading - idempotent on Message-ID; DaemonRun wrapper for monitor tests: 9 new (classification rules, threshold->review, threading, idempotence, blocked send without approval, approve->send->audit chain, double approve) — 29 total passing
365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""maild — classify merchant replies, draft responses, gated SMTP.
|
|
|
|
Flow
|
|
----
|
|
1. ``poll``: fetch unread inbound mail (IMAP), parse, thread to the proposal
|
|
via ``In-Reply-To`` of a previously-sent draft, classify (rules + optional
|
|
LLM), store, and create a REPLY DRAFT (never sent automatically).
|
|
2. ``approve_draft``: a human approves a specific draft (audited).
|
|
3. ``send_approved``: SMTP sends *only* drafts in state ``approved``.
|
|
|
|
Classification is conservative: anything below the confidence threshold becomes
|
|
``needs_review`` and no draft is generated.
|
|
|
|
Public signatures match the CLI: ``poll()``, ``approve_draft(id, actor)``,
|
|
``send_approved()``. The ``_*`` variants take an explicit session/fetcher for
|
|
tests and composition.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import email
|
|
import email.policy
|
|
import imaplib
|
|
import smtplib
|
|
from datetime import UTC, datetime
|
|
from email.message import EmailMessage
|
|
|
|
from cayceops.components.maild.classify import classify as _classify_text
|
|
from cayceops.config import Config
|
|
from cayceops.db import session_scope
|
|
from cayceops.models import (
|
|
DaemonRun,
|
|
DraftStatus,
|
|
MailClassification,
|
|
MailDirection,
|
|
MailMessage,
|
|
Proposal,
|
|
audit,
|
|
)
|
|
|
|
# --- IMAP ---------------------------------------------------------------------
|
|
|
|
|
|
def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
|
client = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
|
|
client.login(cfg.imap_user, cfg.imap_password)
|
|
client.select(cfg.imap_mailbox)
|
|
return client
|
|
|
|
|
|
def _fetch_unread(cfg: Config) -> list[bytes]:
|
|
"""Fetch raw unread messages. Returns list of raw RFC822 bytes."""
|
|
client = connect_imap(cfg)
|
|
try:
|
|
_, data = client.search(None, "UNSEEN")
|
|
raw_messages: list[bytes] = []
|
|
for num in data[0].split():
|
|
_, msg_data = client.fetch(num, "(RFC822)")
|
|
raw_messages.append(msg_data[0][1])
|
|
client.store(num, "+FLAGS", "\\Seen")
|
|
return raw_messages
|
|
finally:
|
|
client.logout()
|
|
|
|
|
|
# --- parsing ------------------------------------------------------------------
|
|
|
|
|
|
def parse_raw(raw: bytes) -> dict:
|
|
"""Parse raw RFC822 bytes into our flat dict shape."""
|
|
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
|
body = ""
|
|
if msg.is_multipart():
|
|
for part in msg.walk():
|
|
if part.get_content_type() == "text/plain" and not part.get_filename():
|
|
body = part.get_content()
|
|
break
|
|
else:
|
|
body = msg.get_content()
|
|
reply_to = msg.get("In-Reply-To") or msg.get("References") or ""
|
|
message_id = (msg.get("Message-ID") or "").strip()
|
|
if not message_id:
|
|
message_id = f"<generated-{datetime.now(UTC).timestamp()}@localhost>"
|
|
# store the bare address (envelope-safe); display names stay out of the DB
|
|
from_addr = email.utils.parseaddr(msg.get("From") or "")[1].strip()[:320]
|
|
return {
|
|
"message_id": message_id[:998],
|
|
"in_reply_to": reply_to.split()[-1].strip() if reply_to.strip() else None,
|
|
"from_addr": from_addr,
|
|
"subject": (msg.get("Subject") or "").strip()[:998],
|
|
"body": body[:20000],
|
|
}
|
|
|
|
|
|
# --- proposal threading -------------------------------------------------------
|
|
|
|
|
|
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
|
"""Thread an inbound mail to its proposal via In-Reply-To of sent drafts."""
|
|
irt = parsed.get("in_reply_to")
|
|
if not irt:
|
|
return None
|
|
sent = (
|
|
sess.query(MailMessage)
|
|
.filter(
|
|
MailMessage.direction == MailDirection.OUTBOUND,
|
|
MailMessage.message_id == irt,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if sent is None or sent.proposal_id is None:
|
|
return None
|
|
return sess.get(Proposal, sent.proposal_id)
|
|
|
|
|
|
# --- drafts -------------------------------------------------------------------
|
|
|
|
_DRAFT_TEMPLATES = {
|
|
MailClassification.APPROVAL: (
|
|
"Hello,\n\nThank you for the approval regarding \"{title}\". "
|
|
"We will proceed on our side and follow up with the next steps.\n\nBest regards,\nCayce"
|
|
),
|
|
MailClassification.REJECTION: (
|
|
"Hello,\n\nThank you for considering \"{title}\". "
|
|
"We appreciate the reply and would welcome the chance to revisit in the future.\n\n"
|
|
"Best regards,\nCayce"
|
|
),
|
|
MailClassification.QUESTION: (
|
|
"Hello,\n\nRegarding \"{title}\": {question}\n\n"
|
|
"Could you help us with the details so we can move forward?\n\nBest regards,\nCayce"
|
|
),
|
|
}
|
|
|
|
|
|
def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | None) -> str:
|
|
title = proposal.title if proposal else (parsed.get("subject") or "your message")
|
|
template = _DRAFT_TEMPLATES.get(kind)
|
|
if template is None:
|
|
return ""
|
|
question = (parsed.get("body") or "")[:200].strip() or "we had a question"
|
|
return template.format(title=title[:120], question=question)
|
|
|
|
|
|
# --- inbound pass ---------------------------------------------------------------
|
|
|
|
|
|
def _poll(cfg: Config, sess, fetcher=None) -> dict:
|
|
"""One inbound pass: parse, classify, thread, draft. Returns stats."""
|
|
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
|
|
for raw in (fetcher or _fetch_unread)(cfg):
|
|
parsed = parse_raw(raw)
|
|
if (
|
|
sess.query(MailMessage)
|
|
.filter(MailMessage.message_id == parsed["message_id"])
|
|
.one_or_none()
|
|
):
|
|
stats["skipped"] += 1
|
|
continue
|
|
|
|
proposal = _match_proposal(parsed, sess)
|
|
kind, confidence = _classify_text(f"{parsed['subject']}\n{parsed['body']}", cfg)
|
|
if proposal is None and kind == MailClassification.APPROVAL:
|
|
# an approval we cannot thread is suspicious — human must look
|
|
kind = MailClassification.NEEDS_REVIEW
|
|
confidence = min(confidence, 0.4)
|
|
|
|
record = MailMessage(
|
|
direction=MailDirection.INBOUND,
|
|
message_id=parsed["message_id"],
|
|
in_reply_to=parsed["in_reply_to"],
|
|
from_addr=parsed["from_addr"] or None,
|
|
subject=parsed["subject"] or None,
|
|
body=parsed["body"],
|
|
classification=kind,
|
|
confidence=confidence,
|
|
proposal_id=proposal.id if proposal else None,
|
|
processed_at=datetime.now(UTC),
|
|
)
|
|
sess.add(record)
|
|
sess.flush()
|
|
audit(
|
|
sess,
|
|
actor="daemon:maild",
|
|
action="classify",
|
|
proposal_id=proposal.id if proposal else None,
|
|
detail={"classification": kind.value, "confidence": confidence},
|
|
)
|
|
stats["processed"] += 1
|
|
|
|
if kind == MailClassification.NEEDS_REVIEW:
|
|
stats["needs_review"] += 1
|
|
continue
|
|
|
|
if proposal is not None:
|
|
proposal.detail = {
|
|
**(proposal.detail or {}),
|
|
"merchant_status": kind.value,
|
|
"merchant_reply_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
draft = _build_draft(parsed, kind, proposal)
|
|
if draft:
|
|
record.draft_reply = draft
|
|
record.draft_status = DraftStatus.DRAFTED
|
|
audit(
|
|
sess,
|
|
actor="daemon:maild",
|
|
action="draft_reply",
|
|
proposal_id=proposal.id if proposal else None,
|
|
detail={"mail_id": record.id},
|
|
)
|
|
stats["drafted"] += 1
|
|
return stats
|
|
|
|
|
|
# --- human gate: approve draft -------------------------------------------------
|
|
|
|
|
|
def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
|
"""Human approves a drafted reply. Only then may SMTP send it."""
|
|
record = sess.get(MailMessage, mail_id)
|
|
if record is None or record.direction != MailDirection.INBOUND:
|
|
return 1
|
|
if record.draft_status != DraftStatus.DRAFTED:
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_draft",
|
|
proposal_id=record.proposal_id,
|
|
result="error",
|
|
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
|
)
|
|
return 1
|
|
record.draft_status = DraftStatus.APPROVED
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_draft",
|
|
proposal_id=record.proposal_id,
|
|
detail={"mail_id": record.id},
|
|
)
|
|
return 0
|
|
|
|
|
|
# --- SMTP: only approved drafts ------------------------------------------------
|
|
|
|
|
|
def _smtp_send(cfg: Config, to_addr: str, subject: str, body: str, message_id: str) -> None:
|
|
msg = EmailMessage()
|
|
msg["From"] = cfg.mail_from
|
|
msg["To"] = to_addr
|
|
msg["Subject"] = subject
|
|
msg["Message-ID"] = message_id
|
|
msg.set_content(body)
|
|
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
|
server.starttls()
|
|
server.login(cfg.smtp_user, cfg.smtp_password)
|
|
server.send_message(msg)
|
|
|
|
|
|
def _approved_by(sess, mail_id: int) -> str:
|
|
from cayceops.models import AuditEntry
|
|
|
|
for entry in sess.query(AuditEntry).all():
|
|
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
|
return entry.actor
|
|
return "unknown"
|
|
|
|
|
|
def _send_approved(cfg: Config, sess, sender=None) -> dict:
|
|
"""Send every APPROVED draft. Anything else stays untouched."""
|
|
stats = {"sent": 0, "blocked": 0}
|
|
pending = (
|
|
sess.query(MailMessage)
|
|
.filter(
|
|
MailMessage.direction == MailDirection.INBOUND,
|
|
MailMessage.draft_status == DraftStatus.APPROVED,
|
|
)
|
|
.all()
|
|
)
|
|
for record in pending:
|
|
to_addr = record.from_addr or ""
|
|
if not to_addr:
|
|
record.draft_status = DraftStatus.SKIPPED
|
|
stats["blocked"] += 1
|
|
continue
|
|
message_id = (
|
|
f"<cayce-draft-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
|
)
|
|
(sender or _smtp_send)(
|
|
cfg,
|
|
to_addr,
|
|
f"Re: {record.subject or 'your message'}",
|
|
record.draft_reply or "",
|
|
message_id,
|
|
)
|
|
approved_by = _approved_by(sess, record.id)
|
|
record.draft_status = DraftStatus.SENT
|
|
# store the Message-ID so future replies thread to this draft
|
|
record.message_id = message_id
|
|
audit(
|
|
sess,
|
|
actor="daemon:maild",
|
|
action="send_draft",
|
|
proposal_id=record.proposal_id,
|
|
detail={
|
|
"mail_id": record.id,
|
|
"message_id": message_id,
|
|
"approved_by": approved_by,
|
|
},
|
|
)
|
|
stats["sent"] += 1
|
|
return stats
|
|
|
|
|
|
# --- CLI wrappers (session scope lives here) ------------------------------------
|
|
|
|
|
|
def poll(cfg: Config | None = None) -> int:
|
|
from cayceops.config import load
|
|
from cayceops.db import engine
|
|
|
|
cfg = cfg or load()
|
|
try:
|
|
with session_scope() as sess:
|
|
run_row = DaemonRun(daemon="maild")
|
|
sess.add(run_row)
|
|
sess.flush()
|
|
try:
|
|
stats = _poll(cfg, sess)
|
|
except Exception as exc: # noqa: BLE001 — daemon boundary
|
|
run_row.status = "error"
|
|
run_row.detail = {"error": str(exc)[:300]}
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 1
|
|
run_row.status = "ok"
|
|
run_row.detail = stats
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 0
|
|
finally:
|
|
engine().dispose()
|
|
|
|
|
|
def approve_draft(mail_id: int, actor: str) -> int:
|
|
from cayceops.db import engine
|
|
|
|
try:
|
|
with session_scope() as sess:
|
|
return _approve_draft(mail_id, actor, sess)
|
|
finally:
|
|
engine().dispose()
|
|
|
|
|
|
def send_approved(cfg: Config | None = None) -> int:
|
|
from cayceops.config import load
|
|
from cayceops.db import engine
|
|
|
|
cfg = cfg or load()
|
|
try:
|
|
with session_scope() as sess:
|
|
_send_approved(cfg, sess)
|
|
return 0
|
|
finally:
|
|
engine().dispose()
|