kontor-cli is a local Python CLI that manages a work mailbox through
himalaya, DavMail (the EWS-to-IMAP bridge), deterministic rules, and an
OpenAI-compatible LLM fallback. It classifies messages and moves them into a
controlled folder taxonomy. It never deletes email.
- macOS or Linux with Git
- Python 3.12 or newer
- uv
- himalaya 1.0 or newer (
brew install himalayaon macOS) - DavMail running locally; the example
configuration uses IMAP
localhost:1110, SMTPlocalhost:1025, and HTTP proxylocalhost:3128 - An OpenAI-compatible API endpoint and key for messages that do not match a deterministic rule
This is the recommended first run. It validates the local tools and connection, then previews only the inbox without moving messages.
git clone https://github.com/r3dlex/cli-kontor.git
cd cli-kontor
uv sync --frozen
cp config.example.yaml config.yaml
# Edit config.yaml with your account, DavMail, and LLM settings, then start DavMail.
uv run kontor-cli check-config
uv run kontor-cli dry-run --phase realtimeSuccessful configuration prints:
Config OK — all prerequisites satisfied.
The dry run emits JSON log records such as [DRY-RUN] Would move email ... and
ends with Phase 'realtime' complete: {...}. No mailbox message is moved and no
Asana task is created during a dry run. Even during a process dry run, fallback
classification sends the email's sender, subject, and date to the configured LLM.
Optional triage is separate and agent-driven: triage reads candidate bodies
locally and prints them for the agent; it does not call the configured LLM or
write to Asana. Only triage-create --no-dry-run can write an Asana task.
Classification can also write local decision logs under rules/evolved/. Those
logs include the email ID, subject, and sender. They are gitignored, but treat
these files as sensitive: restrict access, retain them only for an active review
or audit need, and delete them locally when that review or audit need ends.
- Preview first:
dry-run --phase ...andprocess --phase ... --dry-runuse the same mailbox pipeline while suppressing mailbox moves. The process commands never invoke triage or Asana. - Process commands mutate the mailbox by moving messages. They never delete
messages;
delete_email()always raisesDeleteNotSupportedError. classifyevaluates deterministic YAML and Python rules only; it does not call the LLM fallback used byprocess.classify --email-id ...prints the resulting rules-only target (or the4_Infodefault) and does not move the message.triageis also preview-only.config.yamlis gitignored. Keep mailbox and API credentials out of commits.- Re-running a phase is supported. Messages already in their target folder are skipped, and realtime only scans the current inbox. Always repeat a dry run after changing configuration or rules.
After reviewing dry-run output, run the narrowest mutating phase that fits:
uv run kontor-cli process --phase realtime # Move classified inbox messages
uv run kontor-cli process --phase rebuild # Re-evaluate messages in fixed scan folders
uv run kontor-cli process --phase heal # Repair violations in fixed scan foldersUseful read-only or guarded commands:
uv run kontor-cli classify --email-id <id> # Print one message's target folder
uv run kontor-cli triage # List agent-triage candidates and context
uv run kontor-cli triage-create --email-id <id> --category nudging # Preview a task
uv run kontor-cli dry-run --phase rebuild # Preview the broad historical pass
uv run kontor-cli dry-run --phase heal # Preview invariant repairs
uv run kontor-cli process --phase heal --rules-freezerebuild and heal scan only the fixed SCAN_FOLDERS list in the pipeline;
they do not discover arbitrary valid taxonomy folders. In particular, a valid
MGT_, PRJ_, or EXT_ folder that is absent from that list is not scanned.
--rules-freeze writes a timestamped snapshot of evolved-rule metadata before
the heal run. Use it when a reviewed heal run should retain that audit point.
Enable triage.enabled, configure triage.owner_email, and provide the Asana
workspace and four category project GIDs shown in config.example.yaml.
triage is read-only and prints each candidate's body, eligibility reason,
canonical folder, decisive-sender hint, and configured owner. The agent should
create a task only when the message requires that owner's input or action.
triage-create defaults to an offline, no-write preview. Pass --deadline YYYY-MM-DD when the message supplies a due date; the dashed form is required,
and malformed or compact dates fail before mailbox access. Pass --no-dry-run
only after reviewing the preview. The real-write path validates every configured
Asana project before mailbox access and exits nonzero on validation, dedup-query,
or task-creation API failures. Neither triage command moves or deletes email.
Update the checkout without rewriting local history, synchronize exactly the locked dependencies, and repeat the safety checks:
git pull --ff-only
uv sync --frozen
uv run kontor-cli check-config
uv run kontor-cli dry-run --phase realtimeKeep local config.yaml and evolved-rule logs when updating. If the example
configuration changes, compare it with your local file instead of overwriting
credentials. A rerun re-reads the mailbox and current rules; it does not resume
an old in-memory scan.
Config error: Config file not found: copyconfig.example.yamltoconfig.yaml, or pass--config /path/to/config.yamlafter the command name.himalaya error: confirmhimalaya --versionworks and satisfieshimalaya.versionin the config.DavMail error: start DavMail and confirm the configured host and IMAP port are reachable.check-configprobes the IMAP endpoint.- No
[DRY-RUN]records: messages already in the target folder are skipped. The final phase summary still reports how many messages were scanned. - LLM failures: verify
llm.base_url,llm.api_key, andllm.model. YAML and Python rules run before the LLM fallback.
Run uv run kontor-cli --help or uv run kontor-cli <command> --help for the
source-backed command reference.
Rules are evaluated in this order:
- YAML DSL in
rules/rules.d/*.yaml - Python rules in
rules/rules.py - Natural-language rule context with the LLM fallback
The resulting classification passes through the folder policy, which applies the taxonomy and archive age rule before any move.
INBOX
├─ 0_Action ← Requires your action
├─ 1_Management/MGT_<Topic>
├─ 2_Projects/PRJ_<Domain>_<Initiative>_<Scope>
├─ 3_External/EXT_<Company>_<Topic>
├─ 4_Info ← Newsletters, announcements
├─ 9_System ← CI/CD, security alerts
└─ Archive/ ← Emails >6 months old (mirrors structure)
uv sync --frozen
uv run pytest tests/unit/ -v --tb=short
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/ --ignore-missing-imports
bash scripts/validate-rules.sh
bash scripts/archgate.sh structural .rules.tsSee CONTRIBUTING.md for the TDD and pull-request workflow, CONTEXT.md for domain language, and docs/adr/0001-email-move-only.md for the move-only safety decision.
This repo follows the v3 AI-SDLC layout. See .ai/matrix.json, .memory/human-override/, and docs/architecture/adr/. Modules at r3dlex/skills/ai-sdlc-init/modules/.
This repository follows the AI-SDLC methodology. See AGENTS.md, RULES.md, PLANS.md, .ai/workflows/repo-workflow.md, and .ai/workflows/repo-workflow.json.