delimit-cli 3.13.2 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,623 @@
1
+ """
2
+ Inbox polling daemon for Delimit's email governance system.
3
+
4
+ Polls pro@delimit.ai via IMAP every 5 minutes, auto-classifies emails,
5
+ forwards owner-action items, and handles draft approval via email replies.
6
+
7
+ Consensus 116: Standalone daemon, fresh IMAP connections, 10-minute cancel window.
8
+
9
+ Can run as:
10
+ - Standalone script: python inbox_daemon.py
11
+ - MCP tool: delimit_inbox_daemon(action="start"|"stop"|"status")
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import threading
19
+ import time
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ logger = logging.getLogger("delimit.ai.inbox_daemon")
25
+
26
+ # ── Configuration ────────────────────────────────────────────────────
27
+ POLL_INTERVAL = int(os.environ.get("DELIMIT_INBOX_POLL_INTERVAL", "300")) # seconds
28
+ CANCEL_WINDOW = 600 # 10 minutes in seconds
29
+ MAX_CONSECUTIVE_FAILURES = 3
30
+
31
+ ALERTS_DIR = Path.home() / ".delimit" / "alerts"
32
+ ALERT_FILE = ALERTS_DIR / "inbox_daemon.json"
33
+ ROUTING_LOG = Path.home() / ".delimit" / "inbox_routing.jsonl"
34
+
35
+ # Approval keywords (case-insensitive, must be standalone words)
36
+ APPROVAL_KEYWORDS = [
37
+ "approved",
38
+ "approve",
39
+ "yes",
40
+ "go ahead",
41
+ "lgtm",
42
+ "looks good",
43
+ "ship it",
44
+ "post it",
45
+ ]
46
+
47
+ CANCEL_KEYWORDS = [
48
+ "cancel",
49
+ "stop",
50
+ "abort",
51
+ "don't post",
52
+ "do not post",
53
+ "hold",
54
+ ]
55
+
56
+ # Regex to extract 12-char hex draft ID from subject lines
57
+ DRAFT_ID_PATTERN = re.compile(r"\b([0-9a-f]{12})\b", re.IGNORECASE)
58
+
59
+
60
+ # ── Daemon State ─────────────────────────────────────────────────────
61
+
62
+ class InboxDaemonState:
63
+ """Thread-safe state for the inbox polling daemon."""
64
+
65
+ def __init__(self):
66
+ self.running = False
67
+ self.last_poll: Optional[str] = None
68
+ self.items_processed: int = 0
69
+ self.items_forwarded: int = 0
70
+ self.approvals_detected: int = 0
71
+ self.consecutive_failures: int = 0
72
+ self.total_polls: int = 0
73
+ self.stopped_reason: Optional[str] = None
74
+ self._lock = threading.Lock()
75
+ self._thread: Optional[threading.Thread] = None
76
+ self._stop_event = threading.Event()
77
+ # Pending approvals: draft_id -> {approved_at, message_id, draft_record}
78
+ self._pending_approvals: Dict[str, Dict[str, Any]] = {}
79
+
80
+ def to_dict(self) -> Dict[str, Any]:
81
+ with self._lock:
82
+ return {
83
+ "running": self.running,
84
+ "last_poll": self.last_poll,
85
+ "items_processed": self.items_processed,
86
+ "items_forwarded": self.items_forwarded,
87
+ "approvals_detected": self.approvals_detected,
88
+ "consecutive_failures": self.consecutive_failures,
89
+ "total_polls": self.total_polls,
90
+ "stopped_reason": self.stopped_reason,
91
+ "pending_approvals": list(self._pending_approvals.keys()),
92
+ "poll_interval_seconds": POLL_INTERVAL,
93
+ }
94
+
95
+ def record_success(self, processed: int, forwarded: int):
96
+ with self._lock:
97
+ self.consecutive_failures = 0
98
+ self.items_processed += processed
99
+ self.items_forwarded += forwarded
100
+ self.total_polls += 1
101
+ self.last_poll = datetime.now(timezone.utc).isoformat()
102
+
103
+ def record_failure(self) -> int:
104
+ with self._lock:
105
+ self.consecutive_failures += 1
106
+ self.total_polls += 1
107
+ self.last_poll = datetime.now(timezone.utc).isoformat()
108
+ return self.consecutive_failures
109
+
110
+ def add_pending_approval(self, draft_id: str, info: Dict[str, Any]):
111
+ with self._lock:
112
+ self._pending_approvals[draft_id] = info
113
+ self.approvals_detected += 1
114
+
115
+ def remove_pending_approval(self, draft_id: str) -> Optional[Dict[str, Any]]:
116
+ with self._lock:
117
+ return self._pending_approvals.pop(draft_id, None)
118
+
119
+ def get_pending_approvals(self) -> Dict[str, Dict[str, Any]]:
120
+ with self._lock:
121
+ return dict(self._pending_approvals)
122
+
123
+
124
+ # Singleton state
125
+ _daemon_state = InboxDaemonState()
126
+
127
+
128
+ # ── Logging ──────────────────────────────────────────────────────────
129
+
130
+ def _log_routing(entry: Dict[str, Any]) -> None:
131
+ """Append a routing decision to the audit trail."""
132
+ try:
133
+ ROUTING_LOG.parent.mkdir(parents=True, exist_ok=True)
134
+ entry["daemon"] = True
135
+ entry["timestamp"] = datetime.now(timezone.utc).isoformat()
136
+ with open(ROUTING_LOG, "a", encoding="utf-8") as f:
137
+ f.write(json.dumps(entry) + "\n")
138
+ except OSError as e:
139
+ logger.warning("Failed to write routing log: %s", e)
140
+
141
+
142
+ def _write_alert(reason: str, failure_count: int) -> None:
143
+ """Write an alert file for SessionStart to pick up."""
144
+ try:
145
+ ALERTS_DIR.mkdir(parents=True, exist_ok=True)
146
+ alert = {
147
+ "alert": "inbox_daemon_stopped",
148
+ "reason": reason,
149
+ "failure_count": failure_count,
150
+ "timestamp": datetime.now(timezone.utc).isoformat(),
151
+ }
152
+ with open(ALERT_FILE, "w", encoding="utf-8") as f:
153
+ json.dump(alert, f, indent=2)
154
+ logger.error("Inbox daemon alert written: %s", reason)
155
+ except OSError as e:
156
+ logger.error("Failed to write alert file: %s", e)
157
+
158
+
159
+ # ── Approval Detection ───────────────────────────────────────────────
160
+
161
+ def detect_approval_keywords(text: str) -> bool:
162
+ """Check if text contains approval keywords."""
163
+ if not text:
164
+ return False
165
+ text_lower = text.lower().strip()
166
+ for keyword in APPROVAL_KEYWORDS:
167
+ if keyword in text_lower:
168
+ return True
169
+ return False
170
+
171
+
172
+ def detect_cancel_keywords(text: str) -> bool:
173
+ """Check if text contains cancellation keywords."""
174
+ if not text:
175
+ return False
176
+ text_lower = text.lower().strip()
177
+ for keyword in CANCEL_KEYWORDS:
178
+ if keyword in text_lower:
179
+ return True
180
+ return False
181
+
182
+
183
+ def _get_all_drafts() -> list:
184
+ """Load all drafts via ai.social module. Separate function for clean mocking."""
185
+ import ai.social
186
+ return ai.social._load_all_drafts()
187
+
188
+
189
+ def _approve_draft(draft_id: str) -> dict:
190
+ """Approve and post a draft via ai.social module. Separate function for clean mocking."""
191
+ import ai.social
192
+ return ai.social.approve_draft(draft_id)
193
+
194
+
195
+ def match_draft_by_headers(
196
+ in_reply_to: str,
197
+ references: str,
198
+ subject: str,
199
+ ) -> Optional[str]:
200
+ """Match an inbound email to a draft via headers or subject line.
201
+
202
+ Primary: In-Reply-To / References header matching against stored Message-IDs.
203
+ Fallback: Extract 12-char hex draft ID from subject line.
204
+
205
+ Returns draft_id if matched, None otherwise.
206
+ """
207
+ all_drafts = _get_all_drafts()
208
+
209
+ # Primary: match In-Reply-To or References against stored notification_message_id
210
+ if in_reply_to or references:
211
+ header_ids = set()
212
+ if in_reply_to:
213
+ header_ids.add(in_reply_to.strip().strip("<>"))
214
+ if references:
215
+ for ref in references.split():
216
+ header_ids.add(ref.strip().strip("<>"))
217
+
218
+ for draft in all_drafts:
219
+ stored_mid = draft.get("notification_message_id", "")
220
+ if stored_mid and stored_mid.strip("<>") in header_ids:
221
+ if draft.get("status") in ("pending", "approved-pending"):
222
+ return draft.get("draft_id")
223
+
224
+ # Fallback: extract draft ID from subject
225
+ # Strip Re:/Fwd: prefixes
226
+ clean_subject = re.sub(r"^(Re|Fwd|Fw)\s*:\s*", "", subject, flags=re.IGNORECASE).strip()
227
+ match = DRAFT_ID_PATTERN.search(clean_subject)
228
+ if match:
229
+ candidate_id = match.group(1).lower()
230
+ for draft in all_drafts:
231
+ if draft.get("draft_id") == candidate_id:
232
+ if draft.get("status") in ("pending", "approved-pending"):
233
+ return candidate_id
234
+
235
+ return None
236
+
237
+
238
+ def mark_draft_status(draft_id: str, status: str) -> bool:
239
+ """Update a draft's status without posting it.
240
+
241
+ Used for approved-pending, cancelled states.
242
+ """
243
+ import ai.social
244
+
245
+ all_drafts = ai.social._load_all_drafts()
246
+ for draft in all_drafts:
247
+ if draft.get("draft_id") == draft_id:
248
+ draft["status"] = status
249
+ draft[f"{status.replace('-', '_')}_at"] = datetime.now(timezone.utc).isoformat()
250
+ ai.social._rewrite_drafts(all_drafts)
251
+ return True
252
+ return False
253
+
254
+
255
+ # ── Email Body Extraction ────────────────────────────────────────────
256
+
257
+ def _extract_body(msg) -> str:
258
+ """Extract plain text body from an email message."""
259
+ import email as _email
260
+
261
+ if msg.is_multipart():
262
+ for part in msg.walk():
263
+ if part.get_content_type() == "text/plain":
264
+ payload = part.get_payload(decode=True)
265
+ if payload:
266
+ return payload.decode("utf-8", errors="replace")
267
+ else:
268
+ payload = msg.get_payload(decode=True)
269
+ if payload:
270
+ return payload.decode("utf-8", errors="replace")
271
+ return ""
272
+
273
+
274
+ # ── Core Polling Logic ───────────────────────────────────────────────
275
+
276
+ def poll_once() -> Dict[str, Any]:
277
+ """Execute a single poll cycle.
278
+
279
+ 1. Connect to IMAP, fetch unseen messages
280
+ 2. Classify each message
281
+ 3. Check for approval/cancel replies
282
+ 4. Forward owner-action emails
283
+ 5. Process pending approval windows
284
+
285
+ Returns summary of actions taken.
286
+ """
287
+ import email as _email
288
+ import email.header
289
+ import email.utils
290
+ import imaplib
291
+
292
+ from ai.notify import (
293
+ IMAP_HOST, IMAP_PORT, IMAP_USER, FORWARD_TO,
294
+ classify_email, _extract_sender_email, _decode_header,
295
+ _forward_email, send_email, _record_inbox_routing,
296
+ )
297
+
298
+ smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
299
+ if not smtp_pass:
300
+ return {"error": "DELIMIT_SMTP_PASS not set"}
301
+
302
+ # Fresh IMAP connection each poll (consensus: no connection pooling)
303
+ try:
304
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
305
+ imap.login(IMAP_USER, smtp_pass)
306
+ except Exception as e:
307
+ failures = _daemon_state.record_failure()
308
+ _log_routing({"event": "imap_failure", "error": str(e), "consecutive": failures})
309
+ if failures >= MAX_CONSECUTIVE_FAILURES:
310
+ reason = f"3 consecutive IMAP failures. Last: {e}"
311
+ _daemon_state.stopped_reason = reason
312
+ _daemon_state.running = False
313
+ _daemon_state._stop_event.set()
314
+ _write_alert(reason, failures)
315
+ return {"error": f"IMAP connection failed: {e}", "consecutive_failures": failures}
316
+
317
+ try:
318
+ imap.select("INBOX")
319
+ _, unseen_data = imap.search(None, "UNSEEN")
320
+ unseen_ids = unseen_data[0].split() if unseen_data[0] else []
321
+
322
+ processed = 0
323
+ forwarded = 0
324
+ approvals = []
325
+ cancels = []
326
+
327
+ for msg_id in unseen_ids[-20:]: # Cap at 20 per cycle
328
+ _, data = imap.fetch(msg_id, "(BODY.PEEK[])")
329
+ if not data or not data[0]:
330
+ continue
331
+
332
+ raw_email = data[0][1]
333
+ msg = _email.message_from_bytes(raw_email)
334
+
335
+ from_header = _decode_header(msg.get("From", ""))
336
+ subject = _decode_header(msg.get("Subject", ""))
337
+ sender_addr = _extract_sender_email(from_header)
338
+ in_reply_to = msg.get("In-Reply-To", "")
339
+ references = msg.get("References", "")
340
+ body_text = _extract_body(msg)
341
+
342
+ classification = classify_email(sender_addr, subject, from_header)
343
+
344
+ # Check for approval/cancel replies
345
+ draft_id = match_draft_by_headers(in_reply_to, references, subject)
346
+
347
+ action_taken = "classified"
348
+
349
+ if draft_id and detect_cancel_keywords(body_text):
350
+ # Cancel a pending approval
351
+ pending = _daemon_state.remove_pending_approval(draft_id)
352
+ if pending:
353
+ mark_draft_status(draft_id, "cancelled")
354
+ send_email(
355
+ to=FORWARD_TO,
356
+ subject=f"Draft {draft_id} CANCELLED",
357
+ body=f"Draft {draft_id} was cancelled via email reply. It will NOT be posted.",
358
+ from_account="pro@delimit.ai",
359
+ event_type="draft_cancelled",
360
+ )
361
+ action_taken = "cancel_received"
362
+ cancels.append(draft_id)
363
+ else:
364
+ action_taken = "cancel_no_pending"
365
+ # Mark as seen
366
+ imap.store(msg_id, "+FLAGS", "\\Seen")
367
+
368
+ elif draft_id and detect_approval_keywords(body_text):
369
+ # Both draft match AND approval keyword required (consensus)
370
+ mark_draft_status(draft_id, "approved-pending")
371
+ _daemon_state.add_pending_approval(draft_id, {
372
+ "approved_at": datetime.now(timezone.utc).isoformat(),
373
+ "approved_at_ts": time.time(),
374
+ })
375
+ # Send cancel-window notification
376
+ send_email(
377
+ to=FORWARD_TO,
378
+ subject=f"Draft {draft_id} approved - posting in 10 minutes",
379
+ body=(
380
+ f"Draft {draft_id} has been approved via email reply.\n\n"
381
+ f"It will be posted in 10 minutes.\n\n"
382
+ f"Reply CANCEL to this email to stop the post."
383
+ ),
384
+ from_account="pro@delimit.ai",
385
+ event_type="draft_approval_window",
386
+ )
387
+ action_taken = "approval_detected"
388
+ approvals.append(draft_id)
389
+ # Mark as seen
390
+ imap.store(msg_id, "+FLAGS", "\\Seen")
391
+
392
+ elif classification == "owner-action":
393
+ success = _forward_email(msg, smtp_pass)
394
+ if success:
395
+ imap.store(msg_id, "+FLAGS", "\\Seen")
396
+ forwarded += 1
397
+ action_taken = "forwarded" if success else "forward_failed"
398
+
399
+ else:
400
+ # Non-owner, mark as seen
401
+ imap.store(msg_id, "+FLAGS", "\\Seen")
402
+ action_taken = "non_owner_archived"
403
+
404
+ processed += 1
405
+ _log_routing({
406
+ "event": "email_processed",
407
+ "from": from_header,
408
+ "sender": sender_addr,
409
+ "subject": subject,
410
+ "classification": classification,
411
+ "draft_match": draft_id,
412
+ "action": action_taken,
413
+ })
414
+
415
+ imap.logout()
416
+
417
+ # Process pending approval windows (post drafts past the cancel window)
418
+ posted_drafts = _process_pending_approvals()
419
+
420
+ _daemon_state.record_success(processed, forwarded)
421
+
422
+ return {
423
+ "processed": processed,
424
+ "forwarded": forwarded,
425
+ "approvals_detected": approvals,
426
+ "cancels_received": cancels,
427
+ "drafts_posted": posted_drafts,
428
+ "unseen_count": len(unseen_ids),
429
+ }
430
+
431
+ except Exception as e:
432
+ try:
433
+ imap.logout()
434
+ except Exception:
435
+ pass
436
+ failures = _daemon_state.record_failure()
437
+ _log_routing({"event": "poll_error", "error": str(e), "consecutive": failures})
438
+ return {"error": f"Poll failed: {e}", "consecutive_failures": failures}
439
+
440
+
441
+ def _process_pending_approvals() -> List[str]:
442
+ """Check pending approvals and post any past the cancel window."""
443
+
444
+ posted = []
445
+ now = time.time()
446
+ pending = _daemon_state.get_pending_approvals()
447
+
448
+ for draft_id, info in pending.items():
449
+ approved_ts = info.get("approved_at_ts", 0)
450
+ if now - approved_ts >= CANCEL_WINDOW:
451
+ # Cancel window expired, post it
452
+ _daemon_state.remove_pending_approval(draft_id)
453
+ # Update status to approved so approve_draft can post it
454
+ mark_draft_status(draft_id, "pending") # Reset to pending for approve_draft
455
+ result = _approve_draft(draft_id)
456
+ if "error" not in result:
457
+ posted.append(draft_id)
458
+ _log_routing({
459
+ "event": "draft_auto_posted",
460
+ "draft_id": draft_id,
461
+ "post_result": result,
462
+ })
463
+ else:
464
+ _log_routing({
465
+ "event": "draft_post_failed",
466
+ "draft_id": draft_id,
467
+ "error": result.get("error"),
468
+ })
469
+
470
+ return posted
471
+
472
+
473
+ # ── Daemon Loop ──────────────────────────────────────────────────────
474
+
475
+ def _daemon_loop() -> None:
476
+ """Main polling loop. Runs until stop event is set."""
477
+ logger.info("Inbox daemon started. Polling every %d seconds.", POLL_INTERVAL)
478
+ _log_routing({"event": "daemon_started", "poll_interval": POLL_INTERVAL})
479
+
480
+ while not _daemon_state._stop_event.is_set():
481
+ try:
482
+ result = poll_once()
483
+ if "error" in result:
484
+ logger.warning("Poll error: %s", result["error"])
485
+ else:
486
+ logger.info(
487
+ "Poll complete: %d processed, %d forwarded",
488
+ result.get("processed", 0),
489
+ result.get("forwarded", 0),
490
+ )
491
+ except Exception as e:
492
+ logger.error("Unexpected error in daemon loop: %s", e)
493
+ failures = _daemon_state.record_failure()
494
+ if failures >= MAX_CONSECUTIVE_FAILURES:
495
+ reason = f"3 consecutive failures in loop. Last: {e}"
496
+ _daemon_state.stopped_reason = reason
497
+ _write_alert(reason, failures)
498
+ break
499
+
500
+ # Wait for stop event or poll interval
501
+ _daemon_state._stop_event.wait(timeout=POLL_INTERVAL)
502
+
503
+ _daemon_state.running = False
504
+ _log_routing({"event": "daemon_stopped", "reason": _daemon_state.stopped_reason or "manual_stop"})
505
+ logger.info("Inbox daemon stopped.")
506
+
507
+
508
+ def start_daemon() -> Dict[str, Any]:
509
+ """Start the inbox polling daemon in a background thread."""
510
+ if _daemon_state.running:
511
+ return {"status": "already_running", **_daemon_state.to_dict()}
512
+
513
+ _daemon_state.running = True
514
+ _daemon_state.stopped_reason = None
515
+ _daemon_state.consecutive_failures = 0
516
+ _daemon_state._stop_event.clear()
517
+
518
+ thread = threading.Thread(target=_daemon_loop, name="inbox-daemon", daemon=True)
519
+ _daemon_state._thread = thread
520
+ thread.start()
521
+
522
+ return {"status": "started", **_daemon_state.to_dict()}
523
+
524
+
525
+ def stop_daemon() -> Dict[str, Any]:
526
+ """Stop the inbox polling daemon."""
527
+ if not _daemon_state.running:
528
+ return {"status": "not_running", **_daemon_state.to_dict()}
529
+
530
+ _daemon_state._stop_event.set()
531
+ _daemon_state.stopped_reason = "manual_stop"
532
+ # Give the thread a moment to finish
533
+ if _daemon_state._thread:
534
+ _daemon_state._thread.join(timeout=5)
535
+ _daemon_state.running = False
536
+
537
+ return {"status": "stopped", **_daemon_state.to_dict()}
538
+
539
+
540
+ def get_daemon_status() -> Dict[str, Any]:
541
+ """Get current daemon status."""
542
+ result = _daemon_state.to_dict()
543
+
544
+ # Check for alert file
545
+ if ALERT_FILE.exists():
546
+ try:
547
+ with open(ALERT_FILE, "r") as f:
548
+ result["alert"] = json.load(f)
549
+ except (OSError, json.JSONDecodeError):
550
+ pass
551
+
552
+ return result
553
+
554
+
555
+ # ── Standalone Entry Point ───────────────────────────────────────────
556
+
557
+ def main():
558
+ """Run the daemon as a standalone process (for systemd).
559
+
560
+ Supports argparse flags:
561
+ --interval N Override poll interval in seconds (default: DELIMIT_INBOX_POLL_INTERVAL or 300)
562
+ --once Run a single poll cycle and exit (useful for cron or testing)
563
+ """
564
+ import argparse
565
+
566
+ parser = argparse.ArgumentParser(
567
+ description="Delimit inbox polling daemon — email governance for pro@delimit.ai",
568
+ )
569
+ parser.add_argument(
570
+ "--interval",
571
+ type=int,
572
+ default=None,
573
+ help="Poll interval in seconds (default: DELIMIT_INBOX_POLL_INTERVAL env or 300)",
574
+ )
575
+ parser.add_argument(
576
+ "--once",
577
+ action="store_true",
578
+ help="Run a single poll cycle and exit",
579
+ )
580
+ args = parser.parse_args()
581
+
582
+ logging.basicConfig(
583
+ level=logging.INFO,
584
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
585
+ )
586
+
587
+ if not os.environ.get("DELIMIT_SMTP_PASS"):
588
+ logger.error("DELIMIT_SMTP_PASS environment variable is required.")
589
+ raise SystemExit(1)
590
+
591
+ # Override poll interval if --interval flag provided
592
+ global POLL_INTERVAL
593
+ if args.interval is not None:
594
+ POLL_INTERVAL = args.interval
595
+ logger.info("Poll interval overridden to %d seconds via --interval flag.", POLL_INTERVAL)
596
+
597
+ if args.once:
598
+ logger.info("Running single poll cycle (--once mode)")
599
+ result = poll_once()
600
+ if "error" in result:
601
+ logger.error("Poll failed: %s", result["error"])
602
+ raise SystemExit(1)
603
+ logger.info(
604
+ "Poll complete: %d processed, %d forwarded",
605
+ result.get("processed", 0),
606
+ result.get("forwarded", 0),
607
+ )
608
+ return
609
+
610
+ logger.info("Starting Delimit inbox polling daemon (standalone mode)")
611
+ _daemon_state.running = True
612
+ _daemon_state._stop_event.clear()
613
+
614
+ try:
615
+ _daemon_loop()
616
+ except KeyboardInterrupt:
617
+ logger.info("Interrupted. Shutting down.")
618
+ _daemon_state._stop_event.set()
619
+ _daemon_state.running = False
620
+
621
+
622
+ if __name__ == "__main__":
623
+ main()