delimit-cli 4.0.2 → 4.0.4
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/README.md +9 -242
- package/bin/delimit-cli.js +352 -4
- package/bin/delimit-setup.js +37 -6
- package/gateway/ai/agent_dispatch.py +34 -2
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/ledger_manager.py +13 -3
- package/gateway/ai/ledger_propose.py +240 -0
- package/gateway/ai/loop_engine.py +175 -372
- package/gateway/ai/notify.py +700 -13
- package/gateway/ai/reddit_proxy.py +106 -0
- package/gateway/ai/reddit_scanner.py +34 -0
- package/gateway/ai/server.py +343 -81
- package/gateway/ai/siem_streaming.py +290 -0
- package/gateway/ai/social_daemon.py +189 -0
- package/gateway/ai/swarm.py +434 -0
- package/lib/continuity-resolver.js +325 -0
- package/lib/cross-model-hooks.js +214 -2
- package/lib/delimit-template.js +5 -0
- package/lib/session-shell.js +655 -0
- package/lib/session-worker.js +479 -0
- package/package.json +1 -1
- package/scripts/security-check.sh +12 -0
package/gateway/ai/notify.py
CHANGED
|
@@ -23,21 +23,81 @@ from email.mime.text import MIMEText
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
|
+
import threading
|
|
27
|
+
import time as _time
|
|
28
|
+
|
|
26
29
|
try:
|
|
27
30
|
import yaml as _yaml
|
|
28
31
|
except ImportError:
|
|
29
32
|
_yaml = None # type: ignore[assignment]
|
|
30
33
|
|
|
34
|
+
# ── Email Throttle (Consensus 123: Storm Prevention) ────────────────
|
|
35
|
+
# - Max 5 immediate emails per hour
|
|
36
|
+
# - Non-P0 notifications batch into 15-minute digests
|
|
37
|
+
# - P0 always sends immediately (bypasses throttle)
|
|
38
|
+
_email_throttle_lock = threading.Lock()
|
|
39
|
+
_email_send_times: list = [] # timestamps of recent sends
|
|
40
|
+
_email_digest_queue: list = [] # batched non-urgent emails
|
|
41
|
+
_EMAIL_MAX_PER_HOUR = 5
|
|
42
|
+
_EMAIL_DIGEST_INTERVAL = 900 # 15 minutes
|
|
43
|
+
_last_digest_flush = 0.0
|
|
44
|
+
|
|
31
45
|
logger = logging.getLogger("delimit.ai.notify")
|
|
32
46
|
|
|
33
47
|
HISTORY_FILE = Path.home() / ".delimit" / "notifications.jsonl"
|
|
34
48
|
INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
|
|
49
|
+
OWNER_ACTIONS_FILE = Path.home() / ".delimit" / "owner_actions.jsonl"
|
|
50
|
+
|
|
51
|
+
def _load_json_file(path: Path) -> Dict[str, Any]:
|
|
52
|
+
try:
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return {}
|
|
55
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
56
|
+
data = json.load(f)
|
|
57
|
+
return data if isinstance(data, dict) else {}
|
|
58
|
+
except (OSError, json.JSONDecodeError):
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_secret_value(*names: str) -> str:
|
|
63
|
+
"""Load a secret value from ~/.delimit/secrets/<NAME>.json files."""
|
|
64
|
+
secrets_dir = Path.home() / ".delimit" / "secrets"
|
|
65
|
+
for name in names:
|
|
66
|
+
data = _load_json_file(secrets_dir / f"{name}.json")
|
|
67
|
+
value = data.get("value") or data.get("token") or data.get("access_token") or ""
|
|
68
|
+
if value:
|
|
69
|
+
return str(value)
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_inbound_email_config() -> Dict[str, str]:
|
|
74
|
+
secrets_dir = Path.home() / ".delimit" / "secrets"
|
|
75
|
+
smtp_accounts = _load_json_file(secrets_dir / "smtp-all.json")
|
|
76
|
+
defaults = smtp_accounts.get("_defaults", {}) if isinstance(smtp_accounts.get("_defaults"), dict) else {}
|
|
77
|
+
account_name = str(defaults.get("from_account") or "pro@delimit.ai")
|
|
78
|
+
account = smtp_accounts.get(account_name, {}) if isinstance(smtp_accounts.get(account_name), dict) else {}
|
|
79
|
+
forward_cfg = _load_json_file(secrets_dir / "forward-to.json")
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"imap_host": str(account.get("host") or ""),
|
|
83
|
+
"imap_port": str(account.get("imap_port") or "993"),
|
|
84
|
+
"imap_user": str(account.get("user") or account_name or ""),
|
|
85
|
+
"forward_to": str(
|
|
86
|
+
os.environ.get("DELIMIT_FORWARD_TO", "")
|
|
87
|
+
or forward_cfg.get("value")
|
|
88
|
+
or forward_cfg.get("to")
|
|
89
|
+
or defaults.get("to")
|
|
90
|
+
or ""
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
35
94
|
|
|
36
95
|
# ── Inbound email configuration ──────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
96
|
+
_INBOUND_CFG = _load_inbound_email_config()
|
|
97
|
+
IMAP_HOST = os.environ.get("DELIMIT_IMAP_HOST", "") or _INBOUND_CFG.get("imap_host", "")
|
|
98
|
+
IMAP_PORT = int(os.environ.get("DELIMIT_IMAP_PORT", "") or _INBOUND_CFG.get("imap_port", "993"))
|
|
99
|
+
IMAP_USER = os.environ.get("DELIMIT_IMAP_USER", "") or _INBOUND_CFG.get("imap_user", "")
|
|
100
|
+
FORWARD_TO = _INBOUND_CFG.get("forward_to", "")
|
|
41
101
|
|
|
42
102
|
# Domains/senders whose emails require owner action
|
|
43
103
|
OWNER_ACTION_DOMAINS = {
|
|
@@ -60,9 +120,9 @@ OWNER_ACTION_DOMAINS = {
|
|
|
60
120
|
"digitalocean.com",
|
|
61
121
|
}
|
|
62
122
|
|
|
63
|
-
OWNER_ACTION_SENDERS =
|
|
64
|
-
|
|
65
|
-
|
|
123
|
+
OWNER_ACTION_SENDERS = set(
|
|
124
|
+
filter(None, [os.environ.get("DELIMIT_OWNER_EMAIL", "")])
|
|
125
|
+
)
|
|
66
126
|
|
|
67
127
|
# Subject patterns that indicate owner-action (compiled once)
|
|
68
128
|
import re as _re
|
|
@@ -98,6 +158,21 @@ def _record_notification(entry: Dict[str, Any]) -> None:
|
|
|
98
158
|
logger.warning("Failed to record notification: %s", e)
|
|
99
159
|
|
|
100
160
|
|
|
161
|
+
def record_owner_action(entry: Dict[str, Any]) -> None:
|
|
162
|
+
"""Append an owner-action record for dashboard and async fanout."""
|
|
163
|
+
try:
|
|
164
|
+
OWNER_ACTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
payload = {
|
|
166
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
167
|
+
"status": "open",
|
|
168
|
+
**entry,
|
|
169
|
+
}
|
|
170
|
+
with open(OWNER_ACTIONS_FILE, "a", encoding="utf-8") as f:
|
|
171
|
+
f.write(json.dumps(payload) + "\n")
|
|
172
|
+
except OSError as e:
|
|
173
|
+
logger.warning("Failed to record owner action: %s", e)
|
|
174
|
+
|
|
175
|
+
|
|
101
176
|
def _post_json(url: str, payload: Dict[str, Any], timeout: int = 10) -> Dict[str, Any]:
|
|
102
177
|
"""POST a JSON payload to a URL. Returns status dict."""
|
|
103
178
|
data = json.dumps(payload).encode("utf-8")
|
|
@@ -188,6 +263,43 @@ def send_slack(
|
|
|
188
263
|
}
|
|
189
264
|
|
|
190
265
|
|
|
266
|
+
def send_telegram(
|
|
267
|
+
message: str,
|
|
268
|
+
event_type: str = "",
|
|
269
|
+
bot_token: str = "",
|
|
270
|
+
chat_id: str = "",
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
"""Send a Telegram message via bot API."""
|
|
273
|
+
bot_token = bot_token or os.environ.get("DELIMIT_TELEGRAM_BOT_TOKEN", "") or _load_secret_value("DELIMIT_TELEGRAM_BOT_TOKEN", "TELEGRAM_MONITOR_BOT_TOKEN")
|
|
274
|
+
chat_id = chat_id or os.environ.get("DELIMIT_TELEGRAM_CHAT_ID", "") or _load_secret_value("DELIMIT_TELEGRAM_CHAT_ID", "TELEGRAM_MONITOR_CHAT_ID")
|
|
275
|
+
if not bot_token or not chat_id:
|
|
276
|
+
return {"error": "telegram bot token and chat id are required"}
|
|
277
|
+
|
|
278
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
279
|
+
prefix = f"[{event_type}] " if event_type else ""
|
|
280
|
+
payload = {
|
|
281
|
+
"chat_id": chat_id,
|
|
282
|
+
"text": f"{prefix}{message}",
|
|
283
|
+
"disable_web_page_preview": False,
|
|
284
|
+
}
|
|
285
|
+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
286
|
+
result = _post_json(url, payload)
|
|
287
|
+
_record_notification({
|
|
288
|
+
"channel": "telegram",
|
|
289
|
+
"event_type": event_type,
|
|
290
|
+
"message": message,
|
|
291
|
+
"timestamp": timestamp,
|
|
292
|
+
"success": result.get("success", False),
|
|
293
|
+
})
|
|
294
|
+
return {
|
|
295
|
+
"channel": "telegram",
|
|
296
|
+
"delivered": result.get("success", False),
|
|
297
|
+
"status_code": result.get("status_code"),
|
|
298
|
+
"timestamp": timestamp,
|
|
299
|
+
"error": result.get("error"),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
191
303
|
def _load_smtp_account(from_account: str) -> Optional[Dict[str, str]]:
|
|
192
304
|
"""Load SMTP credentials from smtp-all.json for a given account.
|
|
193
305
|
|
|
@@ -211,6 +323,381 @@ def _load_smtp_account(from_account: str) -> Optional[Dict[str, str]]:
|
|
|
211
323
|
return None
|
|
212
324
|
|
|
213
325
|
|
|
326
|
+
def _flush_email_digest():
|
|
327
|
+
"""Send all queued non-urgent emails as a single digest."""
|
|
328
|
+
global _email_digest_queue
|
|
329
|
+
if not _email_digest_queue:
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
items = list(_email_digest_queue)
|
|
333
|
+
_email_digest_queue.clear()
|
|
334
|
+
|
|
335
|
+
digest_body = f"Delimit Notification Digest ({len(items)} items)\n{'=' * 50}\n\n"
|
|
336
|
+
for i, item in enumerate(items, 1):
|
|
337
|
+
digest_body += f"--- [{i}] {item.get('subject', 'No subject')} ---\n"
|
|
338
|
+
digest_body += f"{item.get('body', '')[:500]}\n\n"
|
|
339
|
+
|
|
340
|
+
# Send digest as a single email (bypasses throttle since it IS the flush)
|
|
341
|
+
_email_send_times.append(_time.time())
|
|
342
|
+
digest_subject = f"[DIGEST] {len(items)} Delimit notifications"
|
|
343
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
344
|
+
try:
|
|
345
|
+
from_acct = items[0].get("from_account", "") if items else ""
|
|
346
|
+
smtp_to = items[0].get("to", "") if items else ""
|
|
347
|
+
if not smtp_to:
|
|
348
|
+
defaults = _load_smtp_account("_defaults") or {}
|
|
349
|
+
smtp_to = str(defaults.get("to") or os.environ.get("DELIMIT_SMTP_TO", ""))
|
|
350
|
+
delivered = _send_smtp_direct(
|
|
351
|
+
to=smtp_to,
|
|
352
|
+
subject=digest_subject,
|
|
353
|
+
body=digest_body,
|
|
354
|
+
from_account=from_acct,
|
|
355
|
+
)
|
|
356
|
+
_record_notification({
|
|
357
|
+
"channel": "email",
|
|
358
|
+
"event_type": "digest",
|
|
359
|
+
"to": smtp_to,
|
|
360
|
+
"from": from_acct or "pro@delimit.ai",
|
|
361
|
+
"subject": digest_subject,
|
|
362
|
+
"message": digest_body,
|
|
363
|
+
"timestamp": timestamp,
|
|
364
|
+
"success": delivered,
|
|
365
|
+
"items": len(items),
|
|
366
|
+
})
|
|
367
|
+
if delivered:
|
|
368
|
+
logger.info("Flushed email digest: %d items", len(items))
|
|
369
|
+
else:
|
|
370
|
+
logger.warning("Digest flush failed: no SMTP recipient or SMTP send returned false")
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.warning("Digest flush failed: %s", e)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _send_smtp_direct(to: str, subject: str, body: str, from_account: str = "") -> bool:
|
|
376
|
+
"""Low-level SMTP send — used by digest flush and direct sends."""
|
|
377
|
+
if not from_account:
|
|
378
|
+
defaults = _load_smtp_account("_defaults")
|
|
379
|
+
if defaults and defaults.get("from_account"):
|
|
380
|
+
from_account = str(defaults["from_account"])
|
|
381
|
+
|
|
382
|
+
acct = _load_smtp_account(from_account) if from_account else None
|
|
383
|
+
smtp_host = (acct or {}).get("host", os.environ.get("DELIMIT_SMTP_HOST", "smtp.gmail.com"))
|
|
384
|
+
smtp_port = int((acct or {}).get("port", os.environ.get("DELIMIT_SMTP_PORT", "587")))
|
|
385
|
+
smtp_user = (acct or {}).get("user", os.environ.get("DELIMIT_SMTP_USER", ""))
|
|
386
|
+
smtp_pass = (acct or {}).get("pass", os.environ.get("DELIMIT_SMTP_PASS", ""))
|
|
387
|
+
smtp_from = (acct or {}).get("from", smtp_user)
|
|
388
|
+
|
|
389
|
+
if not smtp_pass:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
393
|
+
msg["Subject"] = subject
|
|
394
|
+
msg["From"] = smtp_from
|
|
395
|
+
msg["To"] = to
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
|
|
399
|
+
server.starttls()
|
|
400
|
+
server.login(smtp_user, smtp_pass)
|
|
401
|
+
server.sendmail(smtp_from, [to], msg.as_string())
|
|
402
|
+
return True
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error("SMTP send failed: %s", e)
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _render_html_email(subject: str, body: str, event_type: str) -> str:
|
|
409
|
+
"""Render a professional HTML email from a plain-text body.
|
|
410
|
+
|
|
411
|
+
Converts markdown-like patterns to HTML:
|
|
412
|
+
- Lines starting with "---" become <hr>
|
|
413
|
+
- Lines with ALL CAPS become section headers
|
|
414
|
+
- Lines starting with "- " become list items
|
|
415
|
+
- URLs become clickable links
|
|
416
|
+
- Draft text in quotes gets styled as blockquotes
|
|
417
|
+
- "approve/reject" instructions get styled as action buttons
|
|
418
|
+
"""
|
|
419
|
+
import re
|
|
420
|
+
import html as _html
|
|
421
|
+
|
|
422
|
+
# Determine accent color from event type
|
|
423
|
+
color_map = {
|
|
424
|
+
"social_draft": "#7C3AED", # purple — approval needed
|
|
425
|
+
"outreach": "#7C3AED",
|
|
426
|
+
"deploy": "#059669", # green — deploy/success
|
|
427
|
+
"gate_failure": "#DC2626", # red — failure/alert
|
|
428
|
+
"digest": "#2563EB", # blue — informational
|
|
429
|
+
"info": "#2563EB",
|
|
430
|
+
}
|
|
431
|
+
accent = color_map.get(event_type, "#7C3AED")
|
|
432
|
+
|
|
433
|
+
# Parse subject for badge
|
|
434
|
+
badge = ""
|
|
435
|
+
badge_match = re.match(r'\[([A-Z]+)\]', subject)
|
|
436
|
+
if badge_match:
|
|
437
|
+
badge = badge_match.group(1)
|
|
438
|
+
|
|
439
|
+
def _render_copy_block(label: str, text: str) -> str:
|
|
440
|
+
escaped_text = _html.escape(text.strip("\n"))
|
|
441
|
+
escaped_label = _html.escape(label)
|
|
442
|
+
return (
|
|
443
|
+
f'<div style="margin:14px 0">'
|
|
444
|
+
f'<div style="background:{accent};color:white;padding:8px 12px;'
|
|
445
|
+
f'border-radius:8px 8px 0 0;font-size:12px;font-weight:700;letter-spacing:0.3px">'
|
|
446
|
+
f'{escaped_label}</div>'
|
|
447
|
+
f'<div style="border:1px solid #D1D5DB;border-top:none;border-radius:0 0 8px 8px;'
|
|
448
|
+
f'background:#F9FAFB;padding:12px">'
|
|
449
|
+
f'<div style="font-size:11px;color:#6B7280;margin-bottom:8px">'
|
|
450
|
+
f'Tap and hold inside this block to copy.</div>'
|
|
451
|
+
f'<pre style="margin:0;white-space:pre-wrap;word-break:break-word;'
|
|
452
|
+
f'font:13px/1.55 SFMono-Regular,Consolas,Monaco,monospace;color:#111827">'
|
|
453
|
+
f'{escaped_text}</pre>'
|
|
454
|
+
f'</div>'
|
|
455
|
+
f'</div>'
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Convert body lines to HTML
|
|
459
|
+
lines = body.split("\n")
|
|
460
|
+
html_lines = []
|
|
461
|
+
in_list = False
|
|
462
|
+
active_copy_label = None
|
|
463
|
+
active_copy_lines = []
|
|
464
|
+
|
|
465
|
+
for line in lines:
|
|
466
|
+
stripped = line.strip()
|
|
467
|
+
|
|
468
|
+
if stripped.startswith("--- COPY BELOW THIS LINE ---"):
|
|
469
|
+
if in_list:
|
|
470
|
+
html_lines.append("</ul>")
|
|
471
|
+
in_list = False
|
|
472
|
+
active_copy_label = "Manual Post Text"
|
|
473
|
+
active_copy_lines = []
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
if stripped.startswith("--- TITLE (paste in title field) ---"):
|
|
477
|
+
if in_list:
|
|
478
|
+
html_lines.append("</ul>")
|
|
479
|
+
in_list = False
|
|
480
|
+
active_copy_label = "Post Title"
|
|
481
|
+
active_copy_lines = []
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
if stripped.startswith("--- BODY (paste in body field) ---"):
|
|
485
|
+
if active_copy_label and active_copy_lines:
|
|
486
|
+
html_lines.append(_render_copy_block(active_copy_label, "\n".join(active_copy_lines)))
|
|
487
|
+
if in_list:
|
|
488
|
+
html_lines.append("</ul>")
|
|
489
|
+
in_list = False
|
|
490
|
+
active_copy_label = "Post Body"
|
|
491
|
+
active_copy_lines = []
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
if stripped.startswith("--- SOURCE POST TITLE ---"):
|
|
495
|
+
if active_copy_label and active_copy_lines:
|
|
496
|
+
html_lines.append(_render_copy_block(active_copy_label, "\n".join(active_copy_lines)))
|
|
497
|
+
if in_list:
|
|
498
|
+
html_lines.append("</ul>")
|
|
499
|
+
in_list = False
|
|
500
|
+
active_copy_label = "Source Post Title"
|
|
501
|
+
active_copy_lines = []
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
if stripped.startswith("--- SOURCE POST BODY ---"):
|
|
505
|
+
if active_copy_label and active_copy_lines:
|
|
506
|
+
html_lines.append(_render_copy_block(active_copy_label, "\n".join(active_copy_lines)))
|
|
507
|
+
if in_list:
|
|
508
|
+
html_lines.append("</ul>")
|
|
509
|
+
in_list = False
|
|
510
|
+
active_copy_label = "Source Post Body"
|
|
511
|
+
active_copy_lines = []
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
if stripped == "--- END ---" and active_copy_label is not None:
|
|
515
|
+
html_lines.append(_render_copy_block(active_copy_label, "\n".join(active_copy_lines)))
|
|
516
|
+
active_copy_label = None
|
|
517
|
+
active_copy_lines = []
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
if active_copy_label is not None:
|
|
521
|
+
active_copy_lines.append(line)
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
if not stripped:
|
|
525
|
+
if in_list:
|
|
526
|
+
html_lines.append("</ul>")
|
|
527
|
+
in_list = False
|
|
528
|
+
html_lines.append("<br>")
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
# Draft block headers: "--- Draft <id> (platform) ---"
|
|
532
|
+
if stripped.startswith("--- Draft ") and stripped.endswith("---"):
|
|
533
|
+
if in_list:
|
|
534
|
+
html_lines.append("</ul>")
|
|
535
|
+
in_list = False
|
|
536
|
+
draft_label = _html.escape(stripped.strip("- ").strip())
|
|
537
|
+
html_lines.append(
|
|
538
|
+
f'<div style="background:{accent};color:white;padding:8px 14px;'
|
|
539
|
+
f'border-radius:6px 6px 0 0;margin-top:16px;font-size:13px;font-weight:700">'
|
|
540
|
+
f'{draft_label}</div>'
|
|
541
|
+
f'<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-top:none;'
|
|
542
|
+
f'border-radius:0 0 6px 6px;padding:12px 14px;margin-bottom:4px">'
|
|
543
|
+
)
|
|
544
|
+
# The next lines until the next "---" or "To approve" will be inside this box
|
|
545
|
+
# We'll close it when we hit the next separator
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
# Horizontal rules / separators
|
|
549
|
+
if stripped.startswith("---") or stripped.startswith("==="):
|
|
550
|
+
if in_list:
|
|
551
|
+
html_lines.append("</ul>")
|
|
552
|
+
in_list = False
|
|
553
|
+
# Close any open draft box
|
|
554
|
+
html_lines.append('</div>')
|
|
555
|
+
html_lines.append(f'<hr style="border:none;border-top:1px solid #E5E7EB;margin:16px 0">')
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
# Section headers (ALL CAPS lines or lines ending with colon that are short)
|
|
559
|
+
if (stripped.isupper() and len(stripped) > 3 and len(stripped) < 60) or \
|
|
560
|
+
(stripped.endswith(":") and len(stripped) < 50 and stripped[:-1].replace(" ", "").replace("-", "").isalpha()):
|
|
561
|
+
if in_list:
|
|
562
|
+
html_lines.append("</ul>")
|
|
563
|
+
in_list = False
|
|
564
|
+
html_lines.append(
|
|
565
|
+
f'<h3 style="color:{accent};font-size:13px;font-weight:700;'
|
|
566
|
+
f'text-transform:uppercase;letter-spacing:0.5px;margin:20px 0 8px 0;'
|
|
567
|
+
f'border-bottom:2px solid {accent};padding-bottom:4px">'
|
|
568
|
+
f'{_html.escape(stripped)}</h3>'
|
|
569
|
+
)
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
# List items
|
|
573
|
+
if stripped.startswith("- ") or stripped.startswith("* "):
|
|
574
|
+
if not in_list:
|
|
575
|
+
html_lines.append('<ul style="margin:4px 0;padding-left:20px">')
|
|
576
|
+
in_list = True
|
|
577
|
+
item = _html.escape(stripped[2:])
|
|
578
|
+
# Bold draft IDs
|
|
579
|
+
item = re.sub(r'([0-9a-f]{12})', r'<code style="background:#F3F4F6;padding:1px 4px;border-radius:3px;font-size:12px">\1</code>', item)
|
|
580
|
+
html_lines.append(f'<li style="margin:4px 0;color:#374151">{item}</li>')
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
if in_list:
|
|
584
|
+
html_lines.append("</ul>")
|
|
585
|
+
in_list = False
|
|
586
|
+
|
|
587
|
+
escaped = _html.escape(stripped)
|
|
588
|
+
|
|
589
|
+
# Convert URLs to clickable links
|
|
590
|
+
escaped = re.sub(
|
|
591
|
+
r'(https?://[^\s<>&"]+)',
|
|
592
|
+
r'<a href="\1" style="color:{};text-decoration:underline">\1</a>'.format(accent),
|
|
593
|
+
escaped,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Style quoted draft text
|
|
597
|
+
if escaped.startswith('"') and escaped.endswith('"'):
|
|
598
|
+
html_lines.append(
|
|
599
|
+
f'<blockquote style="border-left:3px solid {accent};margin:8px 0;'
|
|
600
|
+
f'padding:8px 12px;background:#F9FAFB;color:#374151;font-style:italic">'
|
|
601
|
+
f'{escaped}</blockquote>'
|
|
602
|
+
)
|
|
603
|
+
continue
|
|
604
|
+
|
|
605
|
+
# Style approve/reject instructions as action callouts
|
|
606
|
+
if any(kw in stripped.lower() for kw in ("to approve", "reply with", "reply \"approve")):
|
|
607
|
+
html_lines.append(
|
|
608
|
+
f'<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;'
|
|
609
|
+
f'padding:10px 14px;margin:12px 0;font-weight:600;color:#166534">'
|
|
610
|
+
f'{escaped}</div>'
|
|
611
|
+
)
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
if any(kw in stripped.lower() for kw in ("to reject", "reply \"reject")):
|
|
615
|
+
html_lines.append(
|
|
616
|
+
f'<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;'
|
|
617
|
+
f'padding:10px 14px;margin:12px 0;color:#991B1B">'
|
|
618
|
+
f'{escaped}</div>'
|
|
619
|
+
)
|
|
620
|
+
continue
|
|
621
|
+
|
|
622
|
+
# Protocol warnings
|
|
623
|
+
if "PROTOCOL WARNING" in stripped:
|
|
624
|
+
html_lines.append(
|
|
625
|
+
f'<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:6px;'
|
|
626
|
+
f'padding:10px 14px;margin:12px 0;color:#92400E;font-size:12px">'
|
|
627
|
+
f'{escaped}</div>'
|
|
628
|
+
)
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
# Regular paragraph
|
|
632
|
+
html_lines.append(f'<p style="margin:6px 0;color:#374151;line-height:1.5">{escaped}</p>')
|
|
633
|
+
|
|
634
|
+
if in_list:
|
|
635
|
+
html_lines.append("</ul>")
|
|
636
|
+
|
|
637
|
+
body_html = "\n".join(html_lines)
|
|
638
|
+
|
|
639
|
+
# Build full HTML email
|
|
640
|
+
badge_html = ""
|
|
641
|
+
if badge:
|
|
642
|
+
badge_colors = {
|
|
643
|
+
"APPROVE": ("#7C3AED", "#EDE9FE"),
|
|
644
|
+
"ACTION": ("#D97706", "#FEF3C7"),
|
|
645
|
+
"ALERT": ("#DC2626", "#FEE2E2"),
|
|
646
|
+
"URGENT": ("#DC2626", "#FEE2E2"),
|
|
647
|
+
"GATE": ("#DC2626", "#FEE2E2"),
|
|
648
|
+
"DIGEST": ("#2563EB", "#DBEAFE"),
|
|
649
|
+
"INFO": ("#6B7280", "#F3F4F6"),
|
|
650
|
+
"OUTREACH": ("#7C3AED", "#EDE9FE"),
|
|
651
|
+
"DEPLOY": ("#059669", "#D1FAE5"),
|
|
652
|
+
}
|
|
653
|
+
fg, bg = badge_colors.get(badge, ("#6B7280", "#F3F4F6"))
|
|
654
|
+
badge_html = (
|
|
655
|
+
f'<span style="display:inline-block;background:{bg};color:{fg};'
|
|
656
|
+
f'font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;'
|
|
657
|
+
f'letter-spacing:0.5px;margin-bottom:8px">{badge}</span><br>'
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Clean subject for display (remove bracket prefix)
|
|
661
|
+
display_subject = re.sub(r'^\[[A-Z]+\]\s*', '', subject)
|
|
662
|
+
|
|
663
|
+
return f"""<!DOCTYPE html>
|
|
664
|
+
<html>
|
|
665
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
666
|
+
<body style="margin:0;padding:0;background:#F9FAFB;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
667
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F9FAFB;padding:20px 0">
|
|
668
|
+
<tr><td align="center">
|
|
669
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background:#FFFFFF;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.1);overflow:hidden">
|
|
670
|
+
|
|
671
|
+
<!-- Header bar -->
|
|
672
|
+
<tr><td style="background:{accent};padding:16px 24px">
|
|
673
|
+
<table width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
674
|
+
<td><span style="color:white;font-size:14px;font-weight:700;letter-spacing:0.5px">DELIMIT</span></td>
|
|
675
|
+
<td align="right"><span style="color:rgba(255,255,255,0.8);font-size:11px">{event_type or 'notification'}</span></td>
|
|
676
|
+
</tr></table>
|
|
677
|
+
</td></tr>
|
|
678
|
+
|
|
679
|
+
<!-- Body -->
|
|
680
|
+
<tr><td style="padding:24px">
|
|
681
|
+
{badge_html}
|
|
682
|
+
<h2 style="margin:0 0 16px 0;color:#111827;font-size:18px;font-weight:600;line-height:1.3">{_html.escape(display_subject)}</h2>
|
|
683
|
+
{body_html}
|
|
684
|
+
</td></tr>
|
|
685
|
+
|
|
686
|
+
<!-- Footer -->
|
|
687
|
+
<tr><td style="background:#F9FAFB;padding:12px 24px;border-top:1px solid #E5E7EB">
|
|
688
|
+
<table width="100%" cellpadding="0" cellspacing="0"><tr>
|
|
689
|
+
<td><span style="color:#9CA3AF;font-size:11px">Sent by Delimit governance layer</span></td>
|
|
690
|
+
<td align="right"><a href="https://delimit.ai" style="color:#9CA3AF;font-size:11px;text-decoration:none">delimit.ai</a></td>
|
|
691
|
+
</tr></table>
|
|
692
|
+
</td></tr>
|
|
693
|
+
|
|
694
|
+
</table>
|
|
695
|
+
</td></tr>
|
|
696
|
+
</table>
|
|
697
|
+
</body>
|
|
698
|
+
</html>"""
|
|
699
|
+
|
|
700
|
+
|
|
214
701
|
def send_email(
|
|
215
702
|
to: str = "",
|
|
216
703
|
subject: str = "",
|
|
@@ -241,8 +728,46 @@ def send_email(
|
|
|
241
728
|
email_body = body or message
|
|
242
729
|
|
|
243
730
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
731
|
+
event_key = (event_type or "").lower()
|
|
732
|
+
force_digest = event_key in ("social_draft", "outreach")
|
|
733
|
+
|
|
734
|
+
# ── Throttle: batch non-urgent, always send P0/immediate ──
|
|
735
|
+
is_urgent = (not force_digest) and any(tag in event_key + (subject or "").lower()
|
|
736
|
+
for tag in ("p0", "urgent", "alert", "critical", "social_draft", "approve",
|
|
737
|
+
"founder_directive", "gate_failure"))
|
|
738
|
+
|
|
739
|
+
global _last_digest_flush
|
|
740
|
+
with _email_throttle_lock:
|
|
741
|
+
now = _time.time()
|
|
742
|
+
# Prune sends older than 1 hour
|
|
743
|
+
_email_send_times[:] = [t for t in _email_send_times if now - t < 3600]
|
|
744
|
+
|
|
745
|
+
if force_digest or (not is_urgent and len(_email_send_times) >= _EMAIL_MAX_PER_HOUR):
|
|
746
|
+
# Queue for digest instead of sending immediately
|
|
747
|
+
_email_digest_queue.append({
|
|
748
|
+
"to": to, "subject": subject, "body": email_body,
|
|
749
|
+
"from_account": from_account, "event_type": event_type,
|
|
750
|
+
"timestamp": timestamp,
|
|
751
|
+
})
|
|
752
|
+
# Flush digest every 15 minutes
|
|
753
|
+
if now - _last_digest_flush >= _EMAIL_DIGEST_INTERVAL and _email_digest_queue:
|
|
754
|
+
_flush_email_digest()
|
|
755
|
+
_last_digest_flush = now
|
|
756
|
+
return {
|
|
757
|
+
"channel": "email",
|
|
758
|
+
"delivered": False,
|
|
759
|
+
"queued": True,
|
|
760
|
+
"reason": "Batched for digest." if force_digest else f"Throttled ({len(_email_send_times)}/{_EMAIL_MAX_PER_HOUR} per hour). Batched for digest.",
|
|
761
|
+
"queue_size": len(_email_digest_queue),
|
|
762
|
+
"timestamp": timestamp,
|
|
763
|
+
}
|
|
764
|
+
_email_send_times.append(now)
|
|
244
765
|
|
|
245
|
-
# Try from_account first, then fall back to env vars
|
|
766
|
+
# Try from_account first, then _defaults, then fall back to env vars
|
|
767
|
+
if not from_account:
|
|
768
|
+
defaults = _load_smtp_account("_defaults")
|
|
769
|
+
if defaults and defaults.get("from_account"):
|
|
770
|
+
from_account = defaults["from_account"]
|
|
246
771
|
account_creds = _load_smtp_account(from_account) if from_account else None
|
|
247
772
|
|
|
248
773
|
if account_creds:
|
|
@@ -250,7 +775,7 @@ def send_email(
|
|
|
250
775
|
smtp_port = int(account_creds.get("port", 587))
|
|
251
776
|
smtp_user = account_creds.get("user", "")
|
|
252
777
|
smtp_pass = account_creds.get("pass", "")
|
|
253
|
-
smtp_from = from_account
|
|
778
|
+
smtp_from = account_creds.get("from_alias", from_account)
|
|
254
779
|
else:
|
|
255
780
|
smtp_host = os.environ.get("DELIMIT_SMTP_HOST", "")
|
|
256
781
|
smtp_port = int(os.environ.get("DELIMIT_SMTP_PORT", "587"))
|
|
@@ -258,7 +783,12 @@ def send_email(
|
|
|
258
783
|
smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
|
|
259
784
|
smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
|
|
260
785
|
|
|
261
|
-
|
|
786
|
+
# Resolve recipient: explicit > env var > smtp-all.json _defaults
|
|
787
|
+
smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "")
|
|
788
|
+
if not smtp_to:
|
|
789
|
+
defaults = _load_smtp_account("_defaults")
|
|
790
|
+
if defaults:
|
|
791
|
+
smtp_to = defaults.get("to", "")
|
|
262
792
|
|
|
263
793
|
if not all([smtp_host, smtp_from, smtp_to]):
|
|
264
794
|
record = {
|
|
@@ -282,7 +812,8 @@ def send_email(
|
|
|
282
812
|
}
|
|
283
813
|
|
|
284
814
|
subj = subject or f"Delimit: {event_type or 'Notification'}"
|
|
285
|
-
|
|
815
|
+
html_body = _render_html_email(subj, email_body, event_type)
|
|
816
|
+
msg = MIMEText(html_body, "html")
|
|
286
817
|
msg["Subject"] = subj
|
|
287
818
|
msg["From"] = smtp_from
|
|
288
819
|
msg["To"] = smtp_to
|
|
@@ -342,6 +873,149 @@ def send_email(
|
|
|
342
873
|
}
|
|
343
874
|
|
|
344
875
|
|
|
876
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
877
|
+
# Email Protocol — enforced server-side, model-agnostic
|
|
878
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
879
|
+
# Every email must be self-contained and actionable on mobile.
|
|
880
|
+
# The protocol validates required sections per event_type and rejects
|
|
881
|
+
# or fixes emails that don't meet the standard.
|
|
882
|
+
|
|
883
|
+
# Subject line MUST start with one of these brackets:
|
|
884
|
+
_VALID_SUBJECT_PREFIXES = (
|
|
885
|
+
"[APPROVE]", "[ACTION]", "[INFO]", "[ALERT]", "[DIGEST]",
|
|
886
|
+
"[URGENT]", "[OUTREACH]", "[DEPLOY]", "[GATE]",
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
# Required sections per event_type. Each is a (header, description) tuple.
|
|
890
|
+
_EMAIL_PROTOCOL: Dict[str, List[tuple]] = {
|
|
891
|
+
"social_draft": [
|
|
892
|
+
("THREAD CONTEXT", "subreddit/platform, post topic, engagement stats"),
|
|
893
|
+
("DRAFT", "the full draft text, not just an ID"),
|
|
894
|
+
("TO APPROVE", "reply instructions with draft_id"),
|
|
895
|
+
],
|
|
896
|
+
"outreach": [
|
|
897
|
+
("TARGETS FOUND", "list with platform, title/snippet, URL"),
|
|
898
|
+
("DRAFTS", "full draft text for each, with draft_id"),
|
|
899
|
+
("TO APPROVE", "reply instructions"),
|
|
900
|
+
],
|
|
901
|
+
"deploy": [
|
|
902
|
+
("WHAT CHANGED", "summary of changes being deployed"),
|
|
903
|
+
("GATES PASSED", "test, security, lint results"),
|
|
904
|
+
("TO APPROVE", "reply instructions or auto-proceed note"),
|
|
905
|
+
],
|
|
906
|
+
"gate_failure": [
|
|
907
|
+
("WHAT FAILED", "which gate and why"),
|
|
908
|
+
("IMPACT", "what is blocked"),
|
|
909
|
+
("TO FIX", "next steps"),
|
|
910
|
+
],
|
|
911
|
+
"digest": [
|
|
912
|
+
("COMPLETED", "what was done since last digest"),
|
|
913
|
+
("PENDING YOUR ACTION", "items needing founder response"),
|
|
914
|
+
],
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _load_drafts_by_ids(draft_ids: list) -> list:
|
|
919
|
+
"""Load draft entries from social_drafts.jsonl matching the given IDs."""
|
|
920
|
+
drafts_file = Path.home() / ".delimit" / "social_drafts.jsonl"
|
|
921
|
+
if not drafts_file.exists():
|
|
922
|
+
return []
|
|
923
|
+
results = []
|
|
924
|
+
id_set = set(draft_ids)
|
|
925
|
+
try:
|
|
926
|
+
for line in drafts_file.read_text().splitlines():
|
|
927
|
+
if not line.strip():
|
|
928
|
+
continue
|
|
929
|
+
try:
|
|
930
|
+
d = json.loads(line)
|
|
931
|
+
if d.get("draft_id") in id_set and d.get("status") == "pending":
|
|
932
|
+
results.append(d)
|
|
933
|
+
except (json.JSONDecodeError, ValueError):
|
|
934
|
+
continue
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
return results
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _enforce_email_protocol(subject: str, message: str, event_type: str) -> tuple:
|
|
941
|
+
"""Validate and fix email against the protocol. Returns (subject, message, warnings)."""
|
|
942
|
+
warnings = []
|
|
943
|
+
|
|
944
|
+
# 1. Subject must have a valid prefix bracket
|
|
945
|
+
if not any(subject.startswith(p) for p in _VALID_SUBJECT_PREFIXES):
|
|
946
|
+
# Try to infer from event_type
|
|
947
|
+
prefix_map = {
|
|
948
|
+
"social_draft": "[APPROVE]",
|
|
949
|
+
"outreach": "[OUTREACH]",
|
|
950
|
+
"deploy": "[DEPLOY]",
|
|
951
|
+
"gate_failure": "[ALERT]",
|
|
952
|
+
"digest": "[DIGEST]",
|
|
953
|
+
"info": "[INFO]",
|
|
954
|
+
}
|
|
955
|
+
prefix = prefix_map.get(event_type, "[INFO]")
|
|
956
|
+
subject = f"{prefix} {subject}"
|
|
957
|
+
warnings.append(f"Subject prefix added: {prefix}")
|
|
958
|
+
|
|
959
|
+
# 2. Check required sections for this event_type
|
|
960
|
+
required = _EMAIL_PROTOCOL.get(event_type, [])
|
|
961
|
+
msg_upper = message.upper()
|
|
962
|
+
missing = []
|
|
963
|
+
for header, desc in required:
|
|
964
|
+
# Check if the section header appears (case-insensitive, with or without colon)
|
|
965
|
+
if header.upper() not in msg_upper:
|
|
966
|
+
missing.append(f"{header} ({desc})")
|
|
967
|
+
|
|
968
|
+
if missing:
|
|
969
|
+
# Append a protocol warning to the email body so the founder sees what's missing
|
|
970
|
+
message += "\n\n" + "=" * 40
|
|
971
|
+
message += "\nPROTOCOL WARNING — Missing required sections:"
|
|
972
|
+
for m in missing:
|
|
973
|
+
message += f"\n - {m}"
|
|
974
|
+
message += "\n\nThis email may not be fully actionable. The sending model"
|
|
975
|
+
message += "\nskipped required context. Check drafts via delimit_social_approve."
|
|
976
|
+
warnings.append(f"Missing sections: {', '.join(m.split(' (')[0] for m in missing)}")
|
|
977
|
+
|
|
978
|
+
# 3. Outreach/social_draft emails — auto-inject full draft text from social_drafts.jsonl
|
|
979
|
+
if event_type in ("social_draft", "outreach"):
|
|
980
|
+
import re
|
|
981
|
+
draft_ids = re.findall(r'[0-9a-f]{12}', message)
|
|
982
|
+
if draft_ids:
|
|
983
|
+
drafts = _load_drafts_by_ids(draft_ids)
|
|
984
|
+
if drafts:
|
|
985
|
+
message += "\n\n" + "=" * 40
|
|
986
|
+
message += "\nCOPY-READY DRAFTS"
|
|
987
|
+
message += "\n" + "=" * 40
|
|
988
|
+
for d in drafts:
|
|
989
|
+
did = d.get("draft_id", "")
|
|
990
|
+
text = d.get("text", "")
|
|
991
|
+
platform = d.get("platform", "")
|
|
992
|
+
ctx = d.get("context", "")
|
|
993
|
+
thread_url = d.get("thread_url", "")
|
|
994
|
+
reply_to_id = d.get("reply_to_id", "")
|
|
995
|
+
# Try to extract URL from context if thread_url is empty
|
|
996
|
+
if not thread_url and ctx:
|
|
997
|
+
url_match = re.search(r'https?://[^\s]+', ctx)
|
|
998
|
+
if url_match:
|
|
999
|
+
thread_url = url_match.group(0)
|
|
1000
|
+
message += f"\n\n--- Draft {did} ({platform}) ---"
|
|
1001
|
+
message += f"\nWHERE: {platform}"
|
|
1002
|
+
where_link = thread_url or (f"https://x.com/i/status/{reply_to_id}" if reply_to_id else "")
|
|
1003
|
+
if where_link:
|
|
1004
|
+
message += f"\nLINK: {where_link}"
|
|
1005
|
+
if ctx:
|
|
1006
|
+
message += f"\nWHY: {ctx}"
|
|
1007
|
+
message += f"\nWHAT:\nManual Post Text\n{text}"
|
|
1008
|
+
message += f"\n\nTo approve: reply \"approve {did}\""
|
|
1009
|
+
message += "\n\n" + "=" * 40
|
|
1010
|
+
warnings.append(f"Auto-injected {len(drafts)} draft texts from social_drafts.jsonl")
|
|
1011
|
+
|
|
1012
|
+
# 4. Always append the standard footer
|
|
1013
|
+
if "delimit.ai" not in message.lower() and "Delimit" not in message:
|
|
1014
|
+
message += "\n\n---\nSent by Delimit governance layer"
|
|
1015
|
+
|
|
1016
|
+
return subject, message, warnings
|
|
1017
|
+
|
|
1018
|
+
|
|
345
1019
|
def send_notification(
|
|
346
1020
|
channel: str = "webhook",
|
|
347
1021
|
message: str = "",
|
|
@@ -355,20 +1029,30 @@ def send_notification(
|
|
|
355
1029
|
if not message:
|
|
356
1030
|
return {"error": "message is required"}
|
|
357
1031
|
|
|
1032
|
+
# Enforce email protocol for all email notifications
|
|
1033
|
+
protocol_warnings = []
|
|
1034
|
+
if channel == "email":
|
|
1035
|
+
subject, message, protocol_warnings = _enforce_email_protocol(subject, message, event_type)
|
|
1036
|
+
|
|
358
1037
|
if channel == "webhook":
|
|
359
1038
|
return send_webhook(webhook_url, message, event_type)
|
|
360
1039
|
elif channel == "slack":
|
|
361
1040
|
return send_slack(webhook_url, message, event_type)
|
|
362
1041
|
elif channel == "email":
|
|
363
|
-
|
|
1042
|
+
result = send_email(
|
|
364
1043
|
to=to,
|
|
365
1044
|
subject=subject,
|
|
366
1045
|
message=message,
|
|
367
1046
|
from_account=from_account,
|
|
368
1047
|
event_type=event_type,
|
|
369
1048
|
)
|
|
1049
|
+
if protocol_warnings:
|
|
1050
|
+
result["protocol_warnings"] = protocol_warnings
|
|
1051
|
+
return result
|
|
1052
|
+
elif channel == "telegram":
|
|
1053
|
+
return send_telegram(message=message, event_type=event_type)
|
|
370
1054
|
else:
|
|
371
|
-
return {"error": f"Unknown channel: {channel}. Supported: webhook, slack, email"}
|
|
1055
|
+
return {"error": f"Unknown channel: {channel}. Supported: webhook, slack, email, telegram"}
|
|
372
1056
|
|
|
373
1057
|
|
|
374
1058
|
# ═════════════════════════════════════════════════════════════════════
|
|
@@ -786,6 +1470,9 @@ def poll_inbox(
|
|
|
786
1470
|
"""
|
|
787
1471
|
if not smtp_pass:
|
|
788
1472
|
smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
|
|
1473
|
+
if not smtp_pass and IMAP_USER:
|
|
1474
|
+
account = _load_smtp_account(IMAP_USER)
|
|
1475
|
+
smtp_pass = str((account or {}).get("pass") or (account or {}).get("password") or "")
|
|
789
1476
|
if not smtp_pass:
|
|
790
1477
|
return {"error": "IMAP password required. Set DELIMIT_SMTP_PASS or pass smtp_pass."}
|
|
791
1478
|
|