Full design for email thread chain feature: envoy/thread-chain-design
triage (mini) → gathering (mini) → working (full). The phase owns the model — no per-iteration model selection.• triage: read email, classify, decide approach, set bundle_key• gathering: load notes, fetch emails by ID, search IMAP — notes-first rule• working: analysis, writing, coding — full model (gpt-5.2-chat-latest)Other phases: summarising, coding, composing, waiting (non-terminal); complete, escalate (terminal)
ONE IMAP connection opened in main() and passed to all functions. Never open additional connections — IMAP sequence numbers are session-specific.Functions taking client: fetch_unread_emails, fetch_emails_by_id, search_emails_imap, execute_actions
Routing table loaded from mail_routing.json in the project root at startup.Two delivery functions:• deliver_via_local_smtp — localhost:25 (pomelo Postfix), no auth/TLS. Used for all internal/critchley.biz addresses.• deliver_via_brevo — smtp-relay.brevo.com:587 with STARTTLS. Used for all external addresses.resolve_delivery(raw) checks LOCAL_SMTP_ADDRESSES dict; if matched, returns local fn + mapped target; otherwise Brevo.
ZoneEdit is the MX for critchley.biz. Routing rules:• john@critchley.biz — specific ZoneEdit alias → Bluewin (Swisscom)• *@critchley.biz — wildcard → Hotmail/Live.com (no individual aliases needed)• envoy@critchley.biz — wildcard → Live.com → PopIt3 → Dovecot IMAP → Orchestrator• envoy_test@critchley.biz — wildcard → Live.com → PopIt3 → responses/new/ (maildir)Local SMTP only works reliably from Virgin Media home network (ISP port 25 not blocked). External addresses should stay on Brevo.
To add a new internal address: edit mail_routing.json — no code change needed.
dispatch_immediate_actions() runs on every iteration: write_notes, delete_notes, send_emails.Terminal actions (move_emails) run only on complete/escalate.CRITICAL: if write_notes fails, send_email still fires — the model doesn't know the write failed. No action success gate yet.
bundle_key: per-task note key set at triage; the orchestrator auto-loads this note at the start of every iteration in the same thread.Gathered notes and emails accumulate across iterations within a run.Continuation emails carry bundle_key + phase + failed_fetches as a JSON attachment for cross-cron-run persistence.
Always normalise before comparing: def _mid(s): return s.strip().strip('<>')Email parser includes <>; LLM output omits them. Without normalisation every fetch appears to fail.
Run with --daemon flag. Two separate IMAP connections:• idle_conn: persistent, dedicated to IMAP IDLE — never used for search/fetch.• work connection: opened fresh by main() for each processing batch.Key functions: run_daemon() (main loop), _daemon_process_pending() (calls main() per batch).IDLE timeout: 1680 s (28 min, safely under 30-min server cutoff). After timeout, reconnects and sends IDLE again.reconnect_delay default: 5 s.Daemon startup also calls _daemon_process_pending() immediately to drain any mail that arrived while it was down.
On every IDLE wake (after idle_done()), daemon compares module mtimes to a baseline snapshot taken at startup.If any .py in the script directory changed: log the change, close idle_conn, call os.execv(sys.executable, sys.argv).os.execv replaces the process image in-place — same PID, pidfile stays valid.Unread mail is left for the new process to pick up immediately on its startup drain.Order matters: check modules BEFORE calling _daemon_process_pending — guarantees mail is always processed with latest code.Helper: _module_mtimes() returns {filepath: mtime} for all .py in script dir.
Log is written to envoy.log in the script directory (previously cron.log). Set by envoy-start.sh.
General-purpose test tool: injects RFC 2822 messages directly into Dovecot IMAP via APPEND.Usage: inject_email.py [subject [body]] [--from ADDR] [--to ADDR] [--folder NAME]Default: envoy_test@ → envoy@, INBOX. No subject = uses built-in default. No body with subject = reads stdin.Triggers IMAP IDLE notification instantly — daemon responds in under a second.