diff --git a/config.yaml b/config.yaml index 3de412f..0589295 100644 --- a/config.yaml +++ b/config.yaml @@ -56,4 +56,4 @@ triage: base_url: https://api.fireworks.ai/inference/v1 api_key: ${FIREWORKS_API_KEY} model: accounts/fireworks/models/kimi-k2p5 - dashboard_url: http://localhost:9202/api/alerts + dashboard_url: http://localhost:9200/api/news diff --git a/specs/outbound-follow-up-tracker.md b/specs/outbound-follow-up-tracker.md new file mode 100644 index 0000000..c3179dd --- /dev/null +++ b/specs/outbound-follow-up-tracker.md @@ -0,0 +1,148 @@ +# Outbound Email Follow-Up Tracker + +*Spec — February 14, 2026* +*Status: Parked, ready to build* + +--- + +## Problem + +Johan sends emails that require responses. If no reply comes, nobody notices until he manually remembers to check. Conversations also move to phone/WhatsApp/in-person — so "no email reply" doesn't always mean "not handled." + +## Solution + +Triage outgoing emails the same way we triage incoming: K2.5 reads each sent email and decides if it expects a reply. If yes, track it with a deadline. If the deadline passes with no reply, push one alert to Fully. Dismiss via Fully or auto-resolve on inbound reply. + +## Architecture + +Everything lives in MC. Fully is just the display layer. + +### IMAP Changes + +Add Sent folder polling to existing connectors: +- `tj_jongsma_me` → poll `Sent` alongside `INBOX` +- `johan_jongsma_me` → poll `Sent` alongside `INBOX` + +Tag outgoing messages with `direction: "outbound"` in MC's message store (inbound messages get `direction: "inbound"` or remain untagged for backwards compat). + +### Schema: `follow_ups` table (orchestration.db) + +```sql +CREATE TABLE follow_ups ( + id TEXT PRIMARY KEY, -- MC message ID of the outbound email + to_address TEXT NOT NULL, -- recipient email + to_name TEXT, -- recipient display name + subject TEXT, -- email subject + sent_at TEXT NOT NULL, -- ISO timestamp + deadline_hours INTEGER DEFAULT 48, -- expected response window + expects_reply INTEGER DEFAULT 1, -- 1=yes, 0=no (triage decided no) + reply_received INTEGER DEFAULT 0, -- auto-set on inbound match + reply_message_id TEXT, -- MC message ID of the inbound reply + alerted_at TEXT, -- timestamp when Fully alert was pushed (NULL = not yet) + dismissed INTEGER DEFAULT 0, -- set via Fully callback + dismissed_at TEXT, -- timestamp + created_at TEXT DEFAULT (datetime('now')) +); +``` + +### Triage (K2.5) + +Same triage engine, second pass for outbound messages. Prompt addition: + +``` +For OUTBOUND emails, determine: +1. Does this email expect a reply? (question, request, proposal, scheduling = yes. FYI, acknowledgment, thank you = no) +2. How urgent is the expected reply? + - urgent: 24h (time-sensitive, medical, financial) + - normal: 48h (business correspondence, requests) + - low: 7d (informational, low-priority asks) +3. Output: { "expects_reply": true/false, "deadline_hours": 24|48|168 } +``` + +### Alert Flow + +**Check runs every hour (cron or heartbeat):** + +``` +SELECT * FROM follow_ups +WHERE expects_reply = 1 + AND reply_received = 0 + AND dismissed = 0 + AND alerted_at IS NULL + AND datetime(sent_at, '+' || deadline_hours || ' hours') < datetime('now') +``` + +For each result: +1. POST to Fully: `{ "message": "🟠 No reply from {to_name} re: {subject} — sent {N} days ago", "priority": "warning" }` +2. SET `alerted_at = now()` +3. **Never alert again for this follow-up** + +### Auto-Resolve + +When an inbound email arrives, check if sender matches any active follow-up: + +``` +UPDATE follow_ups +SET reply_received = 1, reply_message_id = ? +WHERE to_address = ? + AND reply_received = 0 + AND dismissed = 0 +ORDER BY sent_at DESC +LIMIT 1 +``` + +Match by email address. Thread matching (In-Reply-To header) is better but address matching covers 90% of cases and is simpler. + +### Fully Dismiss Callback + +When a follow-up alert is dismissed on Fully (long-press done or ×): + +Alert dashboard POSTs to MC: +``` +POST /api/follow-ups/{id}/dismiss +``` + +MC sets `dismissed = 1, dismissed_at = now()`. + +This requires: +1. Follow-up alerts include the MC follow-up ID in their metadata +2. Alert dashboard `removeAlert` / done handler checks for follow-up ID and calls MC + +### Auto-Expire + +Follow-ups that were alerted but not dismissed auto-expire after 14 days: + +``` +UPDATE follow_ups SET dismissed = 1 +WHERE alerted_at IS NOT NULL + AND dismissed = 0 + AND datetime(alerted_at, '+14 days') < datetime('now') +``` + +Runs on the same hourly check. + +## Edge Cases + +- **Reply via WhatsApp/phone:** Johan dismisses the Fully alert. That's the signal. +- **Multiple emails to same person:** Each gets its own follow-up. Inbound reply resolves the most recent one. +- **CC/BCC recipients:** Only track the TO address. CC'd people rarely owe a reply. +- **Auto-replies/OOO:** Don't count as a real reply. Triage can detect these. +- **Johan replies again (bump):** If Johan sends a second email to the same person on the same thread, reset the deadline on the existing follow-up. + +## Implementation Order + +1. Add Sent folder to IMAP connectors +2. Add `direction` field to MC message model +3. Create `follow_ups` table +4. Add outbound triage prompt to K2.5 +5. Add follow-up check to hourly cron / heartbeat +6. Add auto-resolve on inbound match +7. Add dismiss callback endpoint in MC +8. Wire alert dashboard to call MC on dismiss +9. Add auto-expire cleanup + +## Not In Scope (Yet) + +- WhatsApp outbound tracking (could add later, same pattern) +- Calendar meeting follow-ups (different trigger, same tracker) +- Priority escalation (if 2x deadline passes, escalate from 🟠 to 🔴)