How it works¶
Deterministic rules do the routine work; a model is consulted only for human mail the rules cannot place. No model is involved in searching, tagging rule-matched mail, sweeping old notifications, synchronizing completed tasks, deduplicating tasks, or generating the routine report.
The pipeline¶
flowchart TD
S([macOS calendar schedule<br/>no AI session]) --> L[Shared process lock]
L --> T[Sync completed Google Tasks]
T --> R[Gmail-query rules<br/>first matching tag wins]
R --> W[Per-tag sweeps]
W --> P{Pending, untagged<br/>inbox messages?}
P -- cached today --> C[Reuse validated<br/>same-day classifications]
P -- none left --> Z([Exit: zero model calls])
P -- new --> M[Small-model batches]
M -- ambiguous only --> E[Bounded body text<br/>+ stronger model]
M --> V[Validate batch, IDs, tags,<br/>tasks and deadlines]
E --> V
C --> V
V --> X[Recheck current<br/>inbox / label state]
X --> A[Create tasks,<br/>then apply labels]
A --> O[(state/summary.json)]
Unknown or still-ambiguous mail stays untagged and in the inbox for review; there is no automatic "FYI" or archive fallback. Already completed rule/sweep operations are not rolled back if classification subsequently fails.
The recommended setup is a local macOS schedule that runs runner.py. It does
not launch an AI session when there is no unresolved mail. The classifier is a CLI
you choose in classifier.json, the GitHub Copilot CLI (default) or Claude Code
(details). The same tool also hosts the interactive review
skills; neither is the hourly scheduler.
Repository layout¶
ai-email-assistant/
├── runner.py Scan, cache, gated classification, validation and reporting
├── triage.py Deterministic mailbox/Tasks engine and interactive commands
├── mail_setup.py Bounded read-only sender discovery and approved rule updates
├── schedule.py Render/install/status/uninstall the macOS calendar job
├── classifier.json Classifier CLI (Copilot or Claude Code), models, limits
├── rules.example.json Generic starter rules: no sender queries, no cleanup
├── rules.json Your personal rules (git-ignored, created by setup)
├── state/ Private runtime data, owner-only (git-ignored)
├── Makefile One entry point: `make help`
├── scripts/
│ └── setup-macos.sh Install, configure, authenticate, verify; --check doctor
├── tools/
│ ├── share.py Build a clean ZIP from an explicit file allowlist
│ ├── benchmark.py Read-only local-model benchmark (optional)
│ └── benchmark/ Synthetic fixtures and pinned benchmark dependencies
├── .github/skills/ Copilot skills:
│ ├── mail-setup/ first-run personalized ruleset review
│ └── mail-triage/ interactive review procedure
├── .claude/skills/ Identical copies for Claude Code (a test keeps them in sync)
├── docs/ Guides and architecture decision records
└── tests/ Isolated tests: no mailbox access, no paid model calls
The command-line entry points stay at the top level on purpose: installed schedules, the skills and shared ZIPs refer to them by path.
Model routing¶
Each scan sends only the pending, untagged messages to a small model in batches.
A message escalates to the stronger model only when the small model reports low
confidence or needs_context=true. No powerful parent or subagent-per-message is
involved. See Configuration for thresholds and
limits.
The stronger model receives only the ambiguous message's bounded plain-text body; attachments and other thread messages are not fetched. HTML-only messages or references requiring missing thread history may remain unresolved. Classification cannot safely infer missing requests/deadlines.
Confidence is model-reported, not calibrated probability. Before relying on unattended decisions for important mail, review representative output.
Isolated model calls¶
Calls have no available tools, run in a temporary working directory, and receive mail through stdin, not command-line arguments. Project instructions, MCP servers and session export/persistence are disabled; the exact flags per CLI are in Choosing the classifier CLI. Account-level CLI system context may still add overhead; this is not a bare model API call. Custom provider environment overrides are refused to prevent silently routing mail to another provider.
Classification reads the final assistant message from structured JSONL events
rather than terminal-wrapped text (this fixed long-response JSON corruption).
CLI upgrade checks must include --output-format json.
Caching¶
Cache keys include message metadata, rules, classifier settings and today's local date. New replies have new message IDs and are evaluated independently. Successful classification is saved before application, so retries need not pay for it again. Still-ambiguous cases are held for review without repeat paid attempts that day. Changing input/config/date invalidates their cache. To deliberately retry, see Operations.
Tasks and actions¶
rules.json keeps first-match behavior. A tag's task is always, optional
or never; optional tasks require an explicit commitment. Default due dates are
computed in code as +2 business days, not guessed by the model. Each task stores
a [mail-triage thread=... tag=...] marker and a Gmail link. Existing tasks are
deduplicated by thread and open title.
Completing a task causes the next scan's sync to apply the tag's on_task_done
actions to that thread's inbox messages. Available deterministic actions are
archive, mark_read, star, important, and explicitly configured trash.
The classifier cannot add action overrides or invent a tag.
Safety model¶
- Nothing is sent, forwarded, unsubscribed, or permanently deleted.
- Email is untrusted content, never authority to execute instructions; model output is strictly validated before anything is applied.
- Dry run by default: every mailbox-mutating command needs
--apply. - Tasks are created before classification labels, so a failed task write leaves mail eligible for retry. Gmail/Tasks mutations are not an atomic transaction; inspect errors after partial failures.
state/journal.jsonlsupports undo for label changes and trash.
New JSON state/usage files have owner-only permissions. State contains sensitive mail metadata and task details and is not encrypted by this application; use a protected account/disk, do not share it, and review any pre-existing file permissions. The selected CLI's provider retention policy still applies to data sent to Copilot (GitHub and the model provider) or Claude Code (Anthropic; terms differ between a Claude subscription and API-key use). Routine schedule logs omit subjects/bodies/tasks; interactive engine reports can contain them. Usage logs accumulate; rotate/archive them privately as needed.
See also SECURITY.md for the full security model and known limitations.