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