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.
@@ -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
- IMAP_HOST = "mail.spacemail.com"
38
- IMAP_PORT = 993
39
- IMAP_USER = "pro@delimit.ai"
40
- FORWARD_TO = "owner@example.com"
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
- "owner@example.com",
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
- smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "owner@example.com")
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
- msg = MIMEText(email_body)
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
- return send_email(
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