9.8 KiB
9.8 KiB
Mail Agent Specification
Overview
IMAP-based email triage agent with multi-tier escalation. Runs as a service, processes incoming mail, auto-handles obvious cases, escalates uncertain ones.
Architecture
New Mail (IMAP IDLE push)
│
▼
┌─────────────────────────┐
│ L1: Cheap Model │ Fireworks llama-v3p1-8b-instruct
│ - Spam → delete │
│ - Newsletter → archive │
│ - Receipt → archive │
│ - Obvious junk → gone │
│ - Uncertain → L2 │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ L2: James (Opus) │ via Gateway
│ - Review context │
│ - Draft reply? │
│ - Handle or escalate │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ L3: Johan │ Signal notification
│ - Important stuff │
│ - Needs human decision │
└─────────────────────────┘
Accounts (Multi-account support)
First account:
- ID: proton
- Host: 127.0.0.1
- IMAP Port: 1143
- Username: tj@jongsma.me
- Password: BlcMCKtNDfqv0cq1LmGR9g
- TLS: STARTTLS
REST API
Accounts
GET /accounts # List all configured accounts
POST /accounts # Add account
DELETE /accounts/{id} # Remove account
Mailboxes
GET /accounts/{id}/mailboxes # List folders
Messages
GET /accounts/{id}/messages
?folder=INBOX
&unread=true
&from=sender@example.com # Search by sender
&limit=50
&offset=0
GET /accounts/{id}/messages/{uid}?folder=INBOX
# Returns full message with body, attachments list
PATCH /accounts/{id}/messages/{uid}?folder=INBOX
Body: {"seen": true} # Mark as read
Body: {"seen": false} # Mark as unread
Body: {"folder": "Archive"} # Move to folder
DELETE /accounts/{id}/messages/{uid}?folder=INBOX
# Delete message
Actions
POST /accounts/{id}/unsubscribe/{uid}?folder=INBOX
# Find unsubscribe link in email, execute it
Push Events (IMAP IDLE)
GET /accounts/{id}/events?folder=INBOX
# SSE stream, emits on new mail:
# data: {"type": "new", "uid": 123, "from": "...", "subject": "..."}
Triage Pipeline
L1 Triage (Cheap Model)
- Model: Fireworks
accounts/fireworks/models/llama-v3p1-8b-instruct - Cost: ~$0.20/1M tokens (very cheap)
L1 Prompt:
Classify this email. Respond with JSON only.
From: {from}
Subject: {subject}
Preview: {first 500 chars}
Categories:
- spam: Obvious spam, phishing, scams
- newsletter: Marketing, newsletters, promotions
- receipt: Order confirmations, invoices (not shipping)
- shipping: Shipping/delivery updates (picked up, in transit, delivered)
- notification: Automated notifications (GitHub, services)
- personal: From a real person, needs attention
- important: Urgent, financial, legal, medical
- uncertain: Not sure, needs human review
For shipping emails, also extract:
- carrier: UPS, FedEx, USPS, DHL, etc.
- status: ordered, picked_up, in_transit, out_for_delivery, delivered
- item: Brief description of what's being shipped
- expected_date: Expected delivery date (if available)
Response format:
{"category": "...", "confidence": 0.0-1.0, "reason": "brief reason"}
For shipping:
{"category": "shipping", "confidence": 0.9, "reason": "...",
"shipping": {"carrier": "UPS", "status": "picked_up", "item": "E3-1275 Server", "expected_date": "2026-02-03"}}
L1 Actions:
| Category | Confidence > 0.8 | Confidence < 0.8 |
|---|---|---|
| spam | Delete | → L2 |
| newsletter | Archive | → L2 |
| receipt | Archive + label | → L2 |
| shipping | → Dashboard + Archive | → L2 |
| notification | Archive | → L2 |
| personal | → L2 | → L2 |
| important | → L2 + flag | → L2 + flag |
| uncertain | → L2 | → L2 |
Shipping Dashboard Integration
Dashboard API: http://100.123.216.65:9200 (James Dashboard)
When shipping email detected:
- POST to
/api/newswith shipping update - Archive the email
- Track status in local state
Dashboard payload:
POST /api/news
{
"title": "📦 E3-1275 Server",
"body": "Picked up by UPS. Expected Feb 3rd.",
"type": "info",
"source": "shipping",
"url": null
}
Status progression:
ordered→ "Order confirmed"picked_up→ "Picked up by {carrier}"in_transit→ "In transit"out_for_delivery→ "Out for delivery"delivered→ "Delivered ✓"
Auto-cleanup:
- When status =
delivered, set a flag - Next day, DELETE the news item from dashboard
- Track shipments in local JSON:
~/.config/mail-agent/shipments.json
L2 Triage (James/Opus)
- Receives escalated emails via Gateway hook
- Full context review
- Can: archive, delete, draft reply, escalate to Johan
L3 Escalation (Johan)
- Uses existing Clawdbot Signal integration (no hardcoded number needed)
- POST to gateway, let it route to Johan via Signal
- Summary of what needs attention
- Only for actually important stuff
Escalation via Gateway:
POST to gateway → routes to Johan's Signal via existing channel config
Configuration
# config.yaml
server:
host: 127.0.0.1
port: 8025
accounts:
proton:
host: 127.0.0.1
port: 1143
username: tj@jongsma.me
password: BlcMCKtNDfqv0cq1LmGR9g
tls: starttls
folders:
watch: [INBOX]
archive: Archive
spam: Spam
triage:
enabled: true
l1:
provider: fireworks
model: accounts/fireworks/models/llama-v3p1-8b-instruct
api_key: ${FIREWORKS_API_KEY}
l2:
gateway_url: http://localhost:18080 # or however gateway is reached
# Hook mechanism TBD
l3:
gateway_url: http://localhost:18080 # Uses existing Signal integration
shipping:
dashboard_url: http://100.123.216.65:9200
auto_cleanup_days: 1 # Remove from dashboard 1 day after delivered
rules:
always_escalate_from:
- "*@inou.com"
- "*@kaseya.com"
auto_archive_from:
- "*@github.com"
- "noreply@*"
auto_delete_from:
- known-spam-domains.txt
Tech Stack
- Language: Python 3.11+
- Framework: FastAPI
- IMAP: imapclient (IDLE support)
- Async: asyncio + anyio
- LLM: httpx for Fireworks API
Files Structure
mail-agent/
├── SPEC.md # This file
├── README.md # Usage docs
├── config.yaml # Configuration
├── requirements.txt # Dependencies
├── src/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry
│ ├── config.py # Config loading
│ ├── models.py # Pydantic models
│ ├── imap/
│ │ ├── __init__.py
│ │ ├── client.py # IMAP connection
│ │ ├── idle.py # IDLE push handler
│ │ └── parser.py # Email parsing
│ ├── api/
│ │ ├── __init__.py
│ │ ├── accounts.py # Account endpoints
│ │ ├── messages.py # Message endpoints
│ │ └── events.py # SSE endpoint
│ ├── triage/
│ │ ├── __init__.py
│ │ ├── l1.py # L1 cheap model triage
│ │ ├── l2.py # L2 escalation to James
│ │ └── l3.py # L3 escalation to Johan
│ └── actions/
│ ├── __init__.py
│ └── unsubscribe.py # Unsubscribe handler
├── systemd/
│ └── mail-agent.service
└── tests/
└── ...
Systemd Service
[Unit]
Description=Mail Agent
After=network.target protonmail-bridge.service
[Service]
Type=simple
User=johan
WorkingDirectory=/home/johan/dev/mail-agent
ExecStart=/home/johan/dev/mail-agent/.venv/bin/python -m src.main
Restart=always
RestartSec=10
Environment=FIREWORKS_API_KEY=...
[Install]
WantedBy=default.target
Environment Variables
FIREWORKS_API_KEY— For L1 modelMAIL_AGENT_CONFIG— Path to config.yaml (default: ./config.yaml)
Shipment Tracking State
Stored in ~/.config/mail-agent/shipments.json:
{
"shipments": [
{
"id": "ups-1234567890",
"carrier": "UPS",
"item": "E3-1275 Server",
"status": "picked_up",
"expected_date": "2026-02-03",
"dashboard_news_id": "abc123",
"last_updated": "2026-01-30T22:00:00Z",
"delivered_at": null
}
]
}
- On new shipping email: upsert shipment, update dashboard
- On delivered: set
delivered_at, schedule cleanup - Cleanup job: delete from dashboard after 1 day
First Account (Pre-configured)
The Proton Bridge account is already running:
- Service:
systemctl --user status protonmail-bridge - IMAP: 127.0.0.1:1143
- Account: tj@jongsma.me
- Bridge password: BlcMCKtNDfqv0cq1LmGR9g
Open Questions
- Gateway hook mechanism: How does L2/L3 escalation reach James/Johan? POST to gateway? Will check gateway code for webhook/hook endpoint.
Johan's Signal number:RESOLVED: Use existing Clawdbot Signal integration via gatewayFireworks API key:RESOLVED: Available in env
Build Checklist
- Project scaffold
- Config loading
- IMAP client with IDLE
- REST API endpoints
- L1 triage with Fireworks
- Shipping detection + dashboard integration
- Shipment tracking state + auto-cleanup
- L2 escalation hook (gateway)
- L3 escalation via gateway → Signal
- Systemd service
- Test with Proton account
- README documentation