delimit-cli 3.13.2 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,975 @@
1
+ """
2
+ Notification helper for delimit_notify and delimit_notify_inbox tools.
3
+
4
+ Supports webhook, Slack, and email channels (outbound).
5
+ Supports impact-based notification routing (LED-233).
6
+ Supports IMAP inbox polling with classification and forwarding (inbound).
7
+ Stores notification history in ~/.delimit/notifications.jsonl.
8
+ Stores inbox routing log in ~/.delimit/inbox_routing.jsonl.
9
+ """
10
+
11
+ import email
12
+ import email.header
13
+ import email.utils
14
+ import imaplib
15
+ import json
16
+ import logging
17
+ import os
18
+ import smtplib
19
+ import urllib.request
20
+ import urllib.error
21
+ from datetime import datetime, timezone
22
+ from email.mime.text import MIMEText
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ try:
27
+ import yaml as _yaml
28
+ except ImportError:
29
+ _yaml = None # type: ignore[assignment]
30
+
31
+ logger = logging.getLogger("delimit.ai.notify")
32
+
33
+ HISTORY_FILE = Path.home() / ".delimit" / "notifications.jsonl"
34
+ INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
35
+
36
+ # ── Inbound email configuration ──────────────────────────────────────
37
+ IMAP_HOST = "mail.spacemail.com"
38
+ IMAP_PORT = 993
39
+ IMAP_USER = "pro@delimit.ai"
40
+ FORWARD_TO = "jamsonsholdings@gmail.com"
41
+
42
+ # Domains/senders whose emails require owner action
43
+ OWNER_ACTION_DOMAINS = {
44
+ "cooperpress.com",
45
+ "github.com",
46
+ "lemon.com",
47
+ "lemonsqueezy.com",
48
+ "namecheap.com",
49
+ "stripe.com",
50
+ "google.com",
51
+ "youtube.com",
52
+ "x.com",
53
+ "twitter.com",
54
+ "npmjs.com",
55
+ "vercel.com",
56
+ "supabase.io",
57
+ "supabase.com",
58
+ "glama.ai",
59
+ "vultr.com",
60
+ "digitalocean.com",
61
+ }
62
+
63
+ OWNER_ACTION_SENDERS = {
64
+ "jamsonsholdings@gmail.com",
65
+ }
66
+
67
+ # Subject patterns that indicate owner-action (compiled once)
68
+ import re as _re
69
+ OWNER_ACTION_SUBJECT_PATTERNS = [
70
+ _re.compile(r"social\s+draft", _re.IGNORECASE),
71
+ _re.compile(r"show\s+hn", _re.IGNORECASE),
72
+ _re.compile(r"approval", _re.IGNORECASE),
73
+ _re.compile(r"action\s+required", _re.IGNORECASE),
74
+ _re.compile(r"reply|respond", _re.IGNORECASE),
75
+ _re.compile(r"invoice", _re.IGNORECASE),
76
+ _re.compile(r"payment", _re.IGNORECASE),
77
+ _re.compile(r"subscription", _re.IGNORECASE),
78
+ ]
79
+
80
+ # Sender patterns that are definitely non-owner (automated/bot)
81
+ NON_OWNER_SENDERS = {
82
+ "noreply@",
83
+ "no-reply@",
84
+ "notifications@",
85
+ "mailer-daemon@",
86
+ "postmaster@",
87
+ "donotreply@",
88
+ }
89
+
90
+
91
+ def _record_notification(entry: Dict[str, Any]) -> None:
92
+ """Append a notification record to the history file."""
93
+ try:
94
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
95
+ with open(HISTORY_FILE, "a", encoding="utf-8") as f:
96
+ f.write(json.dumps(entry) + "\n")
97
+ except OSError as e:
98
+ logger.warning("Failed to record notification: %s", e)
99
+
100
+
101
+ def _post_json(url: str, payload: Dict[str, Any], timeout: int = 10) -> Dict[str, Any]:
102
+ """POST a JSON payload to a URL. Returns status dict."""
103
+ data = json.dumps(payload).encode("utf-8")
104
+ req = urllib.request.Request(
105
+ url,
106
+ data=data,
107
+ headers={"Content-Type": "application/json"},
108
+ method="POST",
109
+ )
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
112
+ return {
113
+ "status_code": resp.status,
114
+ "success": 200 <= resp.status < 300,
115
+ }
116
+ except urllib.error.HTTPError as e:
117
+ return {"status_code": e.code, "success": False, "error": str(e)}
118
+ except urllib.error.URLError as e:
119
+ return {"status_code": 0, "success": False, "error": str(e)}
120
+
121
+
122
+ def send_webhook(
123
+ webhook_url: str,
124
+ message: str,
125
+ event_type: str = "",
126
+ ) -> Dict[str, Any]:
127
+ """Send a generic webhook notification (JSON POST)."""
128
+ if not webhook_url:
129
+ return {"error": "webhook_url is required for webhook channel"}
130
+
131
+ timestamp = datetime.now(timezone.utc).isoformat()
132
+ payload = {
133
+ "event_type": event_type or "delimit_notification",
134
+ "message": message,
135
+ "timestamp": timestamp,
136
+ }
137
+
138
+ result = _post_json(webhook_url, payload)
139
+ record = {
140
+ "channel": "webhook",
141
+ "event_type": event_type,
142
+ "message": message,
143
+ "webhook_url": webhook_url,
144
+ "timestamp": timestamp,
145
+ "success": result.get("success", False),
146
+ }
147
+ _record_notification(record)
148
+
149
+ return {
150
+ "channel": "webhook",
151
+ "delivered": result.get("success", False),
152
+ "status_code": result.get("status_code"),
153
+ "timestamp": timestamp,
154
+ "error": result.get("error"),
155
+ }
156
+
157
+
158
+ def send_slack(
159
+ webhook_url: str,
160
+ message: str,
161
+ event_type: str = "",
162
+ ) -> Dict[str, Any]:
163
+ """Send a Slack notification via incoming webhook."""
164
+ if not webhook_url:
165
+ return {"error": "webhook_url is required for slack channel"}
166
+
167
+ timestamp = datetime.now(timezone.utc).isoformat()
168
+ prefix = f"[{event_type}] " if event_type else ""
169
+ payload = {"text": f"{prefix}{message}"}
170
+
171
+ result = _post_json(webhook_url, payload)
172
+ record = {
173
+ "channel": "slack",
174
+ "event_type": event_type,
175
+ "message": message,
176
+ "webhook_url": webhook_url,
177
+ "timestamp": timestamp,
178
+ "success": result.get("success", False),
179
+ }
180
+ _record_notification(record)
181
+
182
+ return {
183
+ "channel": "slack",
184
+ "delivered": result.get("success", False),
185
+ "status_code": result.get("status_code"),
186
+ "timestamp": timestamp,
187
+ "error": result.get("error"),
188
+ }
189
+
190
+
191
+ def _load_smtp_account(from_account: str) -> Optional[Dict[str, str]]:
192
+ """Load SMTP credentials from smtp-all.json for a given account.
193
+
194
+ Args:
195
+ from_account: Email address key in smtp-all.json (e.g. 'pro@delimit.ai').
196
+
197
+ Returns:
198
+ Dict with host, port, user, pass keys, or None if not found.
199
+ """
200
+ secrets_path = Path.home() / ".delimit" / "secrets" / "smtp-all.json"
201
+ try:
202
+ if not secrets_path.exists():
203
+ return None
204
+ with open(secrets_path, "r", encoding="utf-8") as f:
205
+ accounts = json.load(f)
206
+ if from_account in accounts:
207
+ return accounts[from_account]
208
+ return None
209
+ except (OSError, json.JSONDecodeError) as e:
210
+ logger.warning("Failed to load smtp-all.json: %s", e)
211
+ return None
212
+
213
+
214
+ def send_email(
215
+ to: str = "",
216
+ subject: str = "",
217
+ body: str = "",
218
+ from_account: str = "",
219
+ message: str = "",
220
+ event_type: str = "",
221
+ ) -> Dict[str, Any]:
222
+ """Send an email notification via SMTP.
223
+
224
+ Args:
225
+ to: Recipient email address. Falls back to DELIMIT_SMTP_TO or
226
+ jamsonsholdings@gmail.com.
227
+ subject: Email subject line.
228
+ body: Email body text (preferred). Falls back to 'message' for
229
+ backward compatibility.
230
+ from_account: Sender account key in ~/.delimit/secrets/smtp-all.json
231
+ (e.g. 'pro@delimit.ai', 'admin@wire.report'). If provided, SMTP
232
+ credentials are loaded from that file instead of env vars.
233
+ message: Email body text (legacy parameter, use 'body' instead).
234
+ event_type: Event category for filtering/logging.
235
+
236
+ Credential resolution order:
237
+ 1. from_account lookup in ~/.delimit/secrets/smtp-all.json
238
+ 2. DELIMIT_SMTP_* environment variables
239
+ """
240
+ # body takes precedence, fall back to message for backward compat
241
+ email_body = body or message
242
+
243
+ timestamp = datetime.now(timezone.utc).isoformat()
244
+
245
+ # Try from_account first, then fall back to env vars
246
+ account_creds = _load_smtp_account(from_account) if from_account else None
247
+
248
+ if account_creds:
249
+ smtp_host = account_creds.get("host", "")
250
+ smtp_port = int(account_creds.get("port", 587))
251
+ smtp_user = account_creds.get("user", "")
252
+ smtp_pass = account_creds.get("pass", "")
253
+ smtp_from = from_account
254
+ else:
255
+ smtp_host = os.environ.get("DELIMIT_SMTP_HOST", "")
256
+ smtp_port = int(os.environ.get("DELIMIT_SMTP_PORT", "587"))
257
+ smtp_user = os.environ.get("DELIMIT_SMTP_USER", "")
258
+ smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
259
+ smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
260
+
261
+ smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "jamsonsholdings@gmail.com")
262
+
263
+ if not all([smtp_host, smtp_from, smtp_to]):
264
+ record = {
265
+ "channel": "email",
266
+ "event_type": event_type,
267
+ "to": smtp_to,
268
+ "from": smtp_from,
269
+ "message": email_body,
270
+ "subject": subject,
271
+ "timestamp": timestamp,
272
+ "success": False,
273
+ "reason": "smtp_not_configured",
274
+ }
275
+ _record_notification(record)
276
+ return {
277
+ "channel": "email",
278
+ "delivered": False,
279
+ "timestamp": timestamp,
280
+ "error": "SMTP not configured. Set DELIMIT_SMTP_HOST, DELIMIT_SMTP_FROM, DELIMIT_SMTP_TO environment variables, or use from_account with smtp-all.json.",
281
+ "intent_logged": True,
282
+ }
283
+
284
+ subj = subject or f"Delimit: {event_type or 'Notification'}"
285
+ msg = MIMEText(email_body)
286
+ msg["Subject"] = subj
287
+ msg["From"] = smtp_from
288
+ msg["To"] = smtp_to
289
+
290
+ # Generate a unique Message-ID for threading support (Consensus 116)
291
+ import uuid as _uuid
292
+ domain = smtp_from.split("@", 1)[1] if "@" in smtp_from else "delimit.ai"
293
+ message_id = f"<{_uuid.uuid4().hex}@{domain}>"
294
+ msg["Message-ID"] = message_id
295
+
296
+ try:
297
+ with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
298
+ if smtp_user and smtp_pass:
299
+ server.starttls()
300
+ server.login(smtp_user, smtp_pass)
301
+ server.sendmail(smtp_from, [smtp_to], msg.as_string())
302
+
303
+ record = {
304
+ "channel": "email",
305
+ "event_type": event_type,
306
+ "to": smtp_to,
307
+ "from": smtp_from,
308
+ "subject": subj,
309
+ "message": email_body,
310
+ "timestamp": timestamp,
311
+ "success": True,
312
+ "message_id": message_id,
313
+ }
314
+ _record_notification(record)
315
+
316
+ return {
317
+ "channel": "email",
318
+ "delivered": True,
319
+ "timestamp": timestamp,
320
+ "subject": subj,
321
+ "to": smtp_to,
322
+ "from": smtp_from,
323
+ "message_id": message_id,
324
+ }
325
+ except Exception as e:
326
+ record = {
327
+ "channel": "email",
328
+ "event_type": event_type,
329
+ "to": smtp_to,
330
+ "from": smtp_from,
331
+ "message": email_body,
332
+ "timestamp": timestamp,
333
+ "success": False,
334
+ "error": str(e),
335
+ }
336
+ _record_notification(record)
337
+ return {
338
+ "channel": "email",
339
+ "delivered": False,
340
+ "timestamp": timestamp,
341
+ "error": str(e),
342
+ }
343
+
344
+
345
+ def send_notification(
346
+ channel: str = "webhook",
347
+ message: str = "",
348
+ webhook_url: str = "",
349
+ subject: str = "",
350
+ event_type: str = "",
351
+ to: str = "",
352
+ from_account: str = "",
353
+ ) -> Dict[str, Any]:
354
+ """Route a notification to the appropriate channel."""
355
+ if not message:
356
+ return {"error": "message is required"}
357
+
358
+ if channel == "webhook":
359
+ return send_webhook(webhook_url, message, event_type)
360
+ elif channel == "slack":
361
+ return send_slack(webhook_url, message, event_type)
362
+ elif channel == "email":
363
+ return send_email(
364
+ to=to,
365
+ subject=subject,
366
+ message=message,
367
+ from_account=from_account,
368
+ event_type=event_type,
369
+ )
370
+ else:
371
+ return {"error": f"Unknown channel: {channel}. Supported: webhook, slack, email"}
372
+
373
+
374
+ # ═════════════════════════════════════════════════════════════════════
375
+ # LED-233: Impact-Based Notification Routing
376
+ # ═════════════════════════════════════════════════════════════════════
377
+
378
+ ROUTING_CONFIG_FILE = Path.home() / ".delimit" / "notify_routing.yaml"
379
+
380
+ # Severity aliases — map various input labels to canonical levels
381
+ _SEVERITY_ALIASES: Dict[str, str] = {
382
+ "critical": "critical",
383
+ "breaking": "critical",
384
+ "error": "critical",
385
+ "major": "critical",
386
+ "warning": "warning",
387
+ "non-breaking": "warning",
388
+ "minor": "warning",
389
+ "info": "info",
390
+ "cosmetic": "info",
391
+ "docs": "info",
392
+ "patch": "info",
393
+ "none": "info",
394
+ }
395
+
396
+ DEFAULT_ROUTING_CONFIG: Dict[str, Any] = {
397
+ "routing": {
398
+ "critical": {
399
+ "channels": ["email", "webhook"],
400
+ "email_subject_prefix": "[URGENT]",
401
+ "webhook_priority": "high",
402
+ },
403
+ "warning": {
404
+ "channels": ["webhook"],
405
+ "webhook_priority": "normal",
406
+ },
407
+ "info": {
408
+ "channels": [],
409
+ "digest": True,
410
+ },
411
+ },
412
+ }
413
+
414
+
415
+ def load_routing_config() -> Dict[str, Any]:
416
+ """Load routing config from ~/.delimit/notify_routing.yaml or return defaults.
417
+
418
+ Returns:
419
+ The routing configuration dict with a 'routing' key.
420
+ """
421
+ if ROUTING_CONFIG_FILE.exists():
422
+ try:
423
+ if _yaml is not None:
424
+ with open(ROUTING_CONFIG_FILE, "r", encoding="utf-8") as f:
425
+ config = _yaml.safe_load(f)
426
+ if isinstance(config, dict) and "routing" in config:
427
+ return config
428
+ else:
429
+ # Fallback: try JSON (yaml not installed)
430
+ with open(ROUTING_CONFIG_FILE, "r", encoding="utf-8") as f:
431
+ config = json.load(f)
432
+ if isinstance(config, dict) and "routing" in config:
433
+ return config
434
+ except Exception as e:
435
+ logger.warning("Failed to load routing config from %s: %s", ROUTING_CONFIG_FILE, e)
436
+ return DEFAULT_ROUTING_CONFIG
437
+
438
+
439
+ def save_routing_config(config: Dict[str, Any]) -> Dict[str, Any]:
440
+ """Save routing config to ~/.delimit/notify_routing.yaml.
441
+
442
+ Args:
443
+ config: Full routing config dict (must contain 'routing' key).
444
+
445
+ Returns:
446
+ Status dict with success/error.
447
+ """
448
+ if "routing" not in config:
449
+ return {"error": "Config must contain a 'routing' key."}
450
+ try:
451
+ ROUTING_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
452
+ if _yaml is not None:
453
+ with open(ROUTING_CONFIG_FILE, "w", encoding="utf-8") as f:
454
+ _yaml.dump(config, f, default_flow_style=False)
455
+ else:
456
+ with open(ROUTING_CONFIG_FILE, "w", encoding="utf-8") as f:
457
+ json.dump(config, f, indent=2)
458
+ return {"success": True, "path": str(ROUTING_CONFIG_FILE)}
459
+ except Exception as e:
460
+ return {"error": f"Failed to save routing config: {e}"}
461
+
462
+
463
+ def _classify_severity(change: Dict[str, Any]) -> str:
464
+ """Map a single change dict to a canonical severity level (critical/warning/info).
465
+
466
+ Inspects these keys in order:
467
+ - 'severity' (from lint violations)
468
+ - 'is_breaking' (from diff changes)
469
+ - 'type' (change type string)
470
+ """
471
+ # Direct severity label
472
+ sev = str(change.get("severity", "")).lower()
473
+ if sev in _SEVERITY_ALIASES:
474
+ return _SEVERITY_ALIASES[sev]
475
+
476
+ # Breaking flag from diff engine
477
+ if change.get("is_breaking"):
478
+ return "critical"
479
+
480
+ # Change type heuristic
481
+ ctype = str(change.get("type", "")).lower()
482
+ if "removed" in ctype or "breaking" in ctype:
483
+ return "critical"
484
+ if "added" in ctype or "changed" in ctype:
485
+ return "warning"
486
+
487
+ return "info"
488
+
489
+
490
+ def route_by_impact(
491
+ changes: List[Dict[str, Any]],
492
+ routing_config: Optional[Dict[str, Any]] = None,
493
+ webhook_url: str = "",
494
+ email_to: str = "",
495
+ from_account: str = "",
496
+ dry_run: bool = False,
497
+ ) -> Dict[str, Any]:
498
+ """Route notifications based on change severity.
499
+
500
+ Takes a list of changes (from lint/diff output) and sends notifications
501
+ to the appropriate channels based on severity classification.
502
+
503
+ Args:
504
+ changes: List of change dicts (from lint violations or diff changes).
505
+ routing_config: Custom routing config. Uses saved/default if None.
506
+ webhook_url: Webhook URL for webhook channel delivery.
507
+ email_to: Email recipient for email channel delivery.
508
+ from_account: Sender account key for email delivery.
509
+ dry_run: If True, classify and plan routing but do not send.
510
+
511
+ Returns:
512
+ Dict with routing decisions and delivery results.
513
+ """
514
+ if not changes:
515
+ return {
516
+ "routed": 0,
517
+ "suppressed": 0,
518
+ "decisions": [],
519
+ "notifications_sent": [],
520
+ }
521
+
522
+ config = routing_config or load_routing_config()
523
+ routing_rules = config.get("routing", {})
524
+ timestamp = datetime.now(timezone.utc).isoformat()
525
+
526
+ # Classify all changes by severity
527
+ buckets: Dict[str, List[Dict[str, Any]]] = {
528
+ "critical": [],
529
+ "warning": [],
530
+ "info": [],
531
+ }
532
+ for change in changes:
533
+ severity = _classify_severity(change)
534
+ buckets[severity].append(change)
535
+
536
+ decisions: List[Dict[str, Any]] = []
537
+ notifications_sent: List[Dict[str, Any]] = []
538
+ suppressed_count = 0
539
+
540
+ for severity, items in buckets.items():
541
+ if not items:
542
+ continue
543
+
544
+ rule = routing_rules.get(severity, {})
545
+ channels = rule.get("channels", [])
546
+ is_digest = rule.get("digest", False)
547
+
548
+ if not channels:
549
+ # Suppressed (or digest-only)
550
+ suppressed_count += len(items)
551
+ decisions.append({
552
+ "severity": severity,
553
+ "count": len(items),
554
+ "action": "digest" if is_digest else "suppressed",
555
+ "channels": [],
556
+ })
557
+ continue
558
+
559
+ # Build notification message for this severity bucket
560
+ subject_prefix = rule.get("email_subject_prefix", "")
561
+ webhook_priority = rule.get("webhook_priority", "normal")
562
+
563
+ summary_lines = [f"{len(items)} {severity} change(s) detected:"]
564
+ for item in items[:10]: # Cap detail lines at 10
565
+ msg = item.get("message", item.get("name", item.get("type", "change")))
566
+ path = item.get("path", "")
567
+ summary_lines.append(f" - {msg}" + (f" ({path})" if path else ""))
568
+ if len(items) > 10:
569
+ summary_lines.append(f" ... and {len(items) - 10} more")
570
+ message_body = "\n".join(summary_lines)
571
+
572
+ decision = {
573
+ "severity": severity,
574
+ "count": len(items),
575
+ "action": "notify",
576
+ "channels": list(channels),
577
+ }
578
+ decisions.append(decision)
579
+
580
+ if dry_run:
581
+ continue
582
+
583
+ # Send to each configured channel
584
+ for channel in channels:
585
+ if channel == "email":
586
+ subject = f"{subject_prefix} Delimit: {severity} API changes".strip()
587
+ result = send_email(
588
+ to=email_to,
589
+ subject=subject,
590
+ body=message_body,
591
+ from_account=from_account,
592
+ event_type=f"impact_routing_{severity}",
593
+ )
594
+ notifications_sent.append({
595
+ "channel": "email",
596
+ "severity": severity,
597
+ "delivered": result.get("delivered", False),
598
+ "error": result.get("error"),
599
+ })
600
+ elif channel == "webhook" and webhook_url:
601
+ # Inject priority into the webhook payload
602
+ payload = {
603
+ "event_type": f"delimit_impact_{severity}",
604
+ "message": message_body,
605
+ "priority": webhook_priority,
606
+ "severity": severity,
607
+ "change_count": len(items),
608
+ "timestamp": timestamp,
609
+ }
610
+ post_result = _post_json(webhook_url, payload)
611
+ _record_notification({
612
+ "channel": "webhook",
613
+ "event_type": f"impact_routing_{severity}",
614
+ "message": message_body,
615
+ "webhook_url": webhook_url,
616
+ "priority": webhook_priority,
617
+ "timestamp": timestamp,
618
+ "success": post_result.get("success", False),
619
+ })
620
+ notifications_sent.append({
621
+ "channel": "webhook",
622
+ "severity": severity,
623
+ "priority": webhook_priority,
624
+ "delivered": post_result.get("success", False),
625
+ "error": post_result.get("error"),
626
+ })
627
+ elif channel == "slack" and webhook_url:
628
+ result = send_slack(webhook_url, message_body, f"impact_{severity}")
629
+ notifications_sent.append({
630
+ "channel": "slack",
631
+ "severity": severity,
632
+ "delivered": result.get("delivered", False),
633
+ "error": result.get("error"),
634
+ })
635
+
636
+ return {
637
+ "routed": sum(d["count"] for d in decisions if d["action"] == "notify"),
638
+ "suppressed": suppressed_count,
639
+ "decisions": decisions,
640
+ "notifications_sent": notifications_sent,
641
+ "timestamp": timestamp,
642
+ "dry_run": dry_run,
643
+ }
644
+
645
+
646
+ # ═════════════════════════════════════════════════════════════════════
647
+ # INBOUND EMAIL: IMAP polling, classification, and forwarding
648
+ # ═════════════════════════════════════════════════════════════════════
649
+
650
+ def _record_inbox_routing(entry: Dict[str, Any]) -> None:
651
+ """Append a routing record to the inbox routing log."""
652
+ try:
653
+ INBOX_ROUTING_FILE.parent.mkdir(parents=True, exist_ok=True)
654
+ with open(INBOX_ROUTING_FILE, "a", encoding="utf-8") as f:
655
+ f.write(json.dumps(entry) + "\n")
656
+ except OSError as e:
657
+ logger.warning("Failed to record inbox routing: %s", e)
658
+
659
+
660
+ def _decode_header(raw: str) -> str:
661
+ """Decode an RFC 2047 encoded email header into a plain string."""
662
+ if not raw:
663
+ return ""
664
+ parts = email.header.decode_header(raw)
665
+ decoded = []
666
+ for data, charset in parts:
667
+ if isinstance(data, bytes):
668
+ decoded.append(data.decode(charset or "utf-8", errors="replace"))
669
+ else:
670
+ decoded.append(data)
671
+ return " ".join(decoded)
672
+
673
+
674
+ def _extract_sender_email(from_header: str) -> str:
675
+ """Extract the bare email address from a From header."""
676
+ _, addr = email.utils.parseaddr(from_header)
677
+ return addr.lower()
678
+
679
+
680
+ def _extract_sender_domain(sender_email: str) -> str:
681
+ """Extract domain from an email address."""
682
+ if "@" in sender_email:
683
+ return sender_email.split("@", 1)[1]
684
+ return ""
685
+
686
+
687
+ def classify_email(sender: str, subject: str, from_header: str = "") -> str:
688
+ """Classify an email as 'owner-action' or 'non-owner'.
689
+
690
+ Returns:
691
+ 'owner-action' if the email needs owner attention.
692
+ 'non-owner' if it can stay in the Delimit inbox.
693
+ """
694
+ sender_lower = sender.lower()
695
+ sender_domain = _extract_sender_domain(sender_lower)
696
+
697
+ # Rule 1: from the owner directly
698
+ if sender_lower in OWNER_ACTION_SENDERS:
699
+ return "owner-action"
700
+
701
+ # Rule 2: from a known vendor/partner domain
702
+ if sender_domain in OWNER_ACTION_DOMAINS:
703
+ return "owner-action"
704
+
705
+ # Rule 3: subject matches owner-action patterns
706
+ for pattern in OWNER_ACTION_SUBJECT_PATTERNS:
707
+ if pattern.search(subject):
708
+ return "owner-action"
709
+
710
+ # Rule 4: if sender looks like a real person (not noreply), lean owner-action
711
+ # Only if from_header has a display name that looks personal
712
+ is_noreply = any(sender_lower.startswith(prefix) for prefix in NON_OWNER_SENDERS)
713
+ if not is_noreply and sender_domain and sender_domain not in ("pypi.org",):
714
+ # Check if subject indicates automated content
715
+ automated_keywords = ["unsubscribe", "newsletter", "digest", "weekly roundup",
716
+ "notification", "alert", "automated", "receipt"]
717
+ subject_lower = subject.lower()
718
+ if any(kw in subject_lower for kw in automated_keywords):
719
+ return "non-owner"
720
+ # Personal email from unknown domain - forward to be safe
721
+ return "owner-action"
722
+
723
+ return "non-owner"
724
+
725
+
726
+ def _forward_email(original_msg: email.message.Message, smtp_pass: str) -> bool:
727
+ """Forward an email to the owner via SMTP."""
728
+ subject = _decode_header(original_msg.get("Subject", ""))
729
+ from_header = original_msg.get("From", "")
730
+
731
+ # Build forwarded message
732
+ body_parts = []
733
+ if original_msg.is_multipart():
734
+ for part in original_msg.walk():
735
+ if part.get_content_type() == "text/plain":
736
+ payload = part.get_payload(decode=True)
737
+ if payload:
738
+ body_parts.append(payload.decode("utf-8", errors="replace"))
739
+ else:
740
+ payload = original_msg.get_payload(decode=True)
741
+ if payload:
742
+ body_parts.append(payload.decode("utf-8", errors="replace"))
743
+
744
+ body = "\n".join(body_parts) if body_parts else "(no text content)"
745
+
746
+ fwd_text = (
747
+ f"--- Forwarded from pro@delimit.ai ---\n"
748
+ f"From: {from_header}\n"
749
+ f"Subject: {subject}\n"
750
+ f"Date: {original_msg.get('Date', 'unknown')}\n"
751
+ f"---\n\n"
752
+ f"{body}"
753
+ )
754
+
755
+ fwd_msg = MIMEText(fwd_text)
756
+ fwd_msg["Subject"] = f"[Fwd] {subject}"
757
+ fwd_msg["From"] = IMAP_USER
758
+ fwd_msg["To"] = FORWARD_TO
759
+
760
+ try:
761
+ with smtplib.SMTP(IMAP_HOST, 587, timeout=10) as server:
762
+ server.starttls()
763
+ server.login(IMAP_USER, smtp_pass)
764
+ server.sendmail(IMAP_USER, [FORWARD_TO], fwd_msg.as_string())
765
+ return True
766
+ except Exception as e:
767
+ logger.error("Failed to forward email: %s", e)
768
+ return False
769
+
770
+
771
+ def poll_inbox(
772
+ smtp_pass: str = "",
773
+ limit: int = 20,
774
+ process: bool = True,
775
+ ) -> Dict[str, Any]:
776
+ """Poll the IMAP inbox, classify emails, and optionally forward owner-action items.
777
+
778
+ Args:
779
+ smtp_pass: SMTP/IMAP password for pro@delimit.ai.
780
+ limit: Max number of recent messages to check.
781
+ process: If True, forward owner-action emails and mark as read.
782
+ If False, just report classification (dry run).
783
+
784
+ Returns:
785
+ Summary of inbox state and routing decisions.
786
+ """
787
+ if not smtp_pass:
788
+ smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
789
+ if not smtp_pass:
790
+ return {"error": "IMAP password required. Set DELIMIT_SMTP_PASS or pass smtp_pass."}
791
+
792
+ timestamp = datetime.now(timezone.utc).isoformat()
793
+
794
+ try:
795
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
796
+ imap.login(IMAP_USER, smtp_pass)
797
+ except Exception as e:
798
+ return {"error": f"IMAP connection failed: {e}"}
799
+
800
+ try:
801
+ imap.select("INBOX")
802
+
803
+ # Get UNSEEN messages first, then fall back to recent ALL
804
+ status, unseen_data = imap.search(None, "UNSEEN")
805
+ unseen_ids = unseen_data[0].split() if unseen_data[0] else []
806
+
807
+ # Also get all message IDs for the summary
808
+ status, all_data = imap.search(None, "ALL")
809
+ all_ids = all_data[0].split() if all_data[0] else []
810
+
811
+ # Process unseen messages (up to limit)
812
+ target_ids = unseen_ids[-limit:] if unseen_ids else []
813
+
814
+ results: List[Dict[str, Any]] = []
815
+ forwarded = 0
816
+ skipped = 0
817
+
818
+ for msg_id in target_ids:
819
+ # Fetch without marking as seen (use BODY.PEEK)
820
+ status, data = imap.fetch(msg_id, "(BODY.PEEK[])")
821
+ if status != "OK" or not data or not data[0]:
822
+ continue
823
+
824
+ raw_email = data[0][1]
825
+ msg = email.message_from_bytes(raw_email)
826
+
827
+ from_header = _decode_header(msg.get("From", ""))
828
+ subject = _decode_header(msg.get("Subject", ""))
829
+ date_str = msg.get("Date", "")
830
+ sender_addr = _extract_sender_email(from_header)
831
+
832
+ classification = classify_email(sender_addr, subject, from_header)
833
+
834
+ entry = {
835
+ "msg_id": msg_id.decode(),
836
+ "from": from_header,
837
+ "sender": sender_addr,
838
+ "subject": subject,
839
+ "date": date_str,
840
+ "classification": classification,
841
+ "forwarded": False,
842
+ }
843
+
844
+ if process and classification == "owner-action":
845
+ success = _forward_email(msg, smtp_pass)
846
+ entry["forwarded"] = success
847
+ if success:
848
+ # Mark as seen after successful forward
849
+ imap.store(msg_id, "+FLAGS", "\\Seen")
850
+ forwarded += 1
851
+ else:
852
+ entry["forward_error"] = True
853
+ elif process and classification == "non-owner":
854
+ # Mark as seen (processed, stays in inbox)
855
+ imap.store(msg_id, "+FLAGS", "\\Seen")
856
+ skipped += 1
857
+
858
+ results.append(entry)
859
+ _record_inbox_routing({**entry, "timestamp": timestamp, "process_mode": process})
860
+
861
+ imap.logout()
862
+
863
+ return {
864
+ "timestamp": timestamp,
865
+ "total_messages": len(all_ids),
866
+ "unseen_count": len(unseen_ids),
867
+ "processed": len(results),
868
+ "forwarded_to_owner": forwarded,
869
+ "kept_in_inbox": skipped,
870
+ "dry_run": not process,
871
+ "messages": results,
872
+ }
873
+
874
+ except Exception as e:
875
+ try:
876
+ imap.logout()
877
+ except Exception:
878
+ pass
879
+ return {"error": f"Inbox processing failed: {e}"}
880
+
881
+
882
+ def get_inbox_status(
883
+ smtp_pass: str = "",
884
+ limit: int = 10,
885
+ ) -> Dict[str, Any]:
886
+ """Get inbox status and recent routing history without processing.
887
+
888
+ Args:
889
+ smtp_pass: IMAP password.
890
+ limit: Number of recent messages to show.
891
+
892
+ Returns:
893
+ Inbox summary and recent routing log entries.
894
+ """
895
+ # Get recent routing history from log
896
+ routing_history: List[Dict[str, Any]] = []
897
+ try:
898
+ if INBOX_ROUTING_FILE.exists():
899
+ with open(INBOX_ROUTING_FILE, "r", encoding="utf-8") as f:
900
+ lines = f.readlines()
901
+ for line in lines[-limit:]:
902
+ try:
903
+ routing_history.append(json.loads(line.strip()))
904
+ except json.JSONDecodeError:
905
+ continue
906
+ except OSError:
907
+ pass
908
+
909
+ # Get live inbox state
910
+ if not smtp_pass:
911
+ smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
912
+ if not smtp_pass:
913
+ return {
914
+ "routing_history": routing_history,
915
+ "error": "IMAP password required for live inbox status. Set DELIMIT_SMTP_PASS.",
916
+ }
917
+
918
+ try:
919
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
920
+ imap.login(IMAP_USER, smtp_pass)
921
+ imap.select("INBOX")
922
+
923
+ _, all_data = imap.search(None, "ALL")
924
+ all_ids = all_data[0].split() if all_data[0] else []
925
+
926
+ _, unseen_data = imap.search(None, "UNSEEN")
927
+ unseen_ids = unseen_data[0].split() if unseen_data[0] else []
928
+
929
+ # Preview recent messages
930
+ recent_ids = all_ids[-limit:]
931
+ recent_msgs: List[Dict[str, str]] = []
932
+ for msg_id in recent_ids:
933
+ _, data = imap.fetch(msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] FLAGS)")
934
+ if data and data[0]:
935
+ # Parse flags and headers
936
+ flags_str = ""
937
+ header_bytes = b""
938
+ for part in data:
939
+ if isinstance(part, tuple):
940
+ if b"FLAGS" in part[0]:
941
+ flags_str = part[0].decode(errors="replace")
942
+ header_bytes = part[1]
943
+
944
+ header_text = header_bytes.decode("utf-8", errors="replace")
945
+ tmp_msg = email.message_from_string(header_text)
946
+ from_h = _decode_header(tmp_msg.get("From", ""))
947
+ subj_h = _decode_header(tmp_msg.get("Subject", ""))
948
+ date_h = tmp_msg.get("Date", "")
949
+ sender = _extract_sender_email(from_h)
950
+ cls = classify_email(sender, subj_h, from_h)
951
+ seen = "\\Seen" in flags_str
952
+
953
+ recent_msgs.append({
954
+ "from": from_h,
955
+ "subject": subj_h,
956
+ "date": date_h,
957
+ "classification": cls,
958
+ "seen": seen,
959
+ })
960
+
961
+ imap.logout()
962
+
963
+ return {
964
+ "total_messages": len(all_ids),
965
+ "unseen_count": len(unseen_ids),
966
+ "recent_messages": recent_msgs,
967
+ "routing_history_count": len(routing_history),
968
+ "routing_history": routing_history[-5:],
969
+ }
970
+
971
+ except Exception as e:
972
+ return {
973
+ "routing_history": routing_history,
974
+ "error": f"IMAP connection failed: {e}",
975
+ }