abstractcore 2.9.1__py3-none-any.whl → 2.11.4__py3-none-any.whl

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.
Files changed (85) hide show
  1. abstractcore/__init__.py +7 -27
  2. abstractcore/apps/deepsearch.py +9 -4
  3. abstractcore/apps/extractor.py +33 -100
  4. abstractcore/apps/intent.py +19 -0
  5. abstractcore/apps/judge.py +20 -1
  6. abstractcore/apps/summarizer.py +20 -1
  7. abstractcore/architectures/detection.py +34 -1
  8. abstractcore/architectures/response_postprocessing.py +313 -0
  9. abstractcore/assets/architecture_formats.json +38 -8
  10. abstractcore/assets/model_capabilities.json +882 -160
  11. abstractcore/compression/__init__.py +1 -2
  12. abstractcore/compression/glyph_processor.py +6 -4
  13. abstractcore/config/main.py +52 -20
  14. abstractcore/config/manager.py +390 -12
  15. abstractcore/config/vision_config.py +5 -5
  16. abstractcore/core/interface.py +151 -3
  17. abstractcore/core/session.py +16 -10
  18. abstractcore/download.py +1 -1
  19. abstractcore/embeddings/manager.py +20 -6
  20. abstractcore/endpoint/__init__.py +2 -0
  21. abstractcore/endpoint/app.py +458 -0
  22. abstractcore/mcp/client.py +3 -1
  23. abstractcore/media/__init__.py +52 -17
  24. abstractcore/media/auto_handler.py +42 -22
  25. abstractcore/media/base.py +44 -1
  26. abstractcore/media/capabilities.py +12 -33
  27. abstractcore/media/enrichment.py +105 -0
  28. abstractcore/media/handlers/anthropic_handler.py +19 -28
  29. abstractcore/media/handlers/local_handler.py +124 -70
  30. abstractcore/media/handlers/openai_handler.py +19 -31
  31. abstractcore/media/processors/__init__.py +4 -2
  32. abstractcore/media/processors/audio_processor.py +57 -0
  33. abstractcore/media/processors/office_processor.py +8 -3
  34. abstractcore/media/processors/pdf_processor.py +46 -3
  35. abstractcore/media/processors/text_processor.py +22 -24
  36. abstractcore/media/processors/video_processor.py +58 -0
  37. abstractcore/media/types.py +97 -4
  38. abstractcore/media/utils/image_scaler.py +20 -2
  39. abstractcore/media/utils/video_frames.py +219 -0
  40. abstractcore/media/vision_fallback.py +136 -22
  41. abstractcore/processing/__init__.py +32 -3
  42. abstractcore/processing/basic_deepsearch.py +15 -10
  43. abstractcore/processing/basic_intent.py +3 -2
  44. abstractcore/processing/basic_judge.py +3 -2
  45. abstractcore/processing/basic_summarizer.py +1 -1
  46. abstractcore/providers/__init__.py +3 -1
  47. abstractcore/providers/anthropic_provider.py +95 -8
  48. abstractcore/providers/base.py +1516 -81
  49. abstractcore/providers/huggingface_provider.py +546 -69
  50. abstractcore/providers/lmstudio_provider.py +30 -916
  51. abstractcore/providers/mlx_provider.py +382 -35
  52. abstractcore/providers/model_capabilities.py +5 -1
  53. abstractcore/providers/ollama_provider.py +99 -15
  54. abstractcore/providers/openai_compatible_provider.py +406 -180
  55. abstractcore/providers/openai_provider.py +188 -44
  56. abstractcore/providers/openrouter_provider.py +76 -0
  57. abstractcore/providers/registry.py +61 -5
  58. abstractcore/providers/streaming.py +138 -33
  59. abstractcore/providers/vllm_provider.py +92 -817
  60. abstractcore/server/app.py +478 -28
  61. abstractcore/server/audio_endpoints.py +139 -0
  62. abstractcore/server/vision_endpoints.py +1319 -0
  63. abstractcore/structured/handler.py +316 -41
  64. abstractcore/tools/common_tools.py +5501 -2012
  65. abstractcore/tools/comms_tools.py +1641 -0
  66. abstractcore/tools/core.py +37 -7
  67. abstractcore/tools/handler.py +4 -9
  68. abstractcore/tools/parser.py +49 -2
  69. abstractcore/tools/tag_rewriter.py +2 -1
  70. abstractcore/tools/telegram_tdlib.py +407 -0
  71. abstractcore/tools/telegram_tools.py +261 -0
  72. abstractcore/utils/cli.py +1085 -72
  73. abstractcore/utils/structured_logging.py +29 -8
  74. abstractcore/utils/token_utils.py +2 -0
  75. abstractcore/utils/truncation.py +29 -0
  76. abstractcore/utils/version.py +3 -4
  77. abstractcore/utils/vlm_token_calculator.py +12 -2
  78. abstractcore-2.11.4.dist-info/METADATA +562 -0
  79. abstractcore-2.11.4.dist-info/RECORD +133 -0
  80. {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/WHEEL +1 -1
  81. {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/entry_points.txt +1 -0
  82. abstractcore-2.9.1.dist-info/METADATA +0 -1190
  83. abstractcore-2.9.1.dist-info/RECORD +0 -119
  84. {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/licenses/LICENSE +0 -0
  85. {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1641 @@
1
+ """Communication tools (email + WhatsApp).
2
+
3
+ Design goals:
4
+ - Durable-tool friendly: JSON-safe inputs/outputs; no callables persisted in run state.
5
+ - Secrets-safe by default: resolve credentials from env vars at execution time (avoid ledger leaks).
6
+ - Minimal dependencies: email uses stdlib (imaplib/smtplib/email); WhatsApp uses `requests` (already used by common tools).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta, timezone
13
+ import email
14
+ from email.header import decode_header
15
+ from email.message import EmailMessage, Message
16
+ from email.utils import formatdate, make_msgid
17
+ import imaplib
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import smtplib
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ from abstractcore.tools.core import tool
26
+ from abstractcore.utils.truncation import preview_text
27
+
28
+
29
+ _MONTH_ABBR = (
30
+ "Jan",
31
+ "Feb",
32
+ "Mar",
33
+ "Apr",
34
+ "May",
35
+ "Jun",
36
+ "Jul",
37
+ "Aug",
38
+ "Sep",
39
+ "Oct",
40
+ "Nov",
41
+ "Dec",
42
+ )
43
+
44
+
45
+ def _coerce_str_list(value: Any) -> List[str]:
46
+ if value is None:
47
+ return []
48
+ if isinstance(value, list):
49
+ return [str(v).strip() for v in value if isinstance(v, str) and v.strip()]
50
+ if isinstance(value, tuple):
51
+ return [str(v).strip() for v in value if isinstance(v, str) and v.strip()]
52
+ if isinstance(value, str):
53
+ raw = value.strip()
54
+ if not raw:
55
+ return []
56
+ parts = [p.strip() for p in re.split(r"[;,]+", raw) if p.strip()]
57
+ return parts
58
+ text = str(value).strip()
59
+ return [text] if text else []
60
+
61
+
62
+ def _decode_mime_header(value: Optional[str]) -> str:
63
+ if not isinstance(value, str) or not value.strip():
64
+ return ""
65
+ try:
66
+ chunks = decode_header(value)
67
+ except Exception:
68
+ return value.strip()
69
+
70
+ out: list[str] = []
71
+ for part, charset in chunks:
72
+ if isinstance(part, bytes):
73
+ enc = charset or "utf-8"
74
+ try:
75
+ out.append(part.decode(enc, errors="replace"))
76
+ except Exception:
77
+ out.append(part.decode("utf-8", errors="replace"))
78
+ else:
79
+ out.append(str(part))
80
+ return "".join(out).strip()
81
+
82
+
83
+ def _resolve_required_env(env_var: str, *, label: str) -> Tuple[Optional[str], Optional[str]]:
84
+ """
85
+ Resolve a secret reference.
86
+
87
+ The input is usually an env var *name* (e.g. EMAIL_PASSWORD). For pragmatic operator ergonomics,
88
+ we also accept literal secrets (common when the "env var name" contains characters that shells
89
+ can't export, or when operators intentionally place the secret directly in the reference).
90
+
91
+ Resolution rules:
92
+ 1) If an env var exists with that name, use its value.
93
+ 2) If the reference looks like a conventional env var identifier, fail fast (clear config error).
94
+ 3) Otherwise treat the reference itself as the secret.
95
+ """
96
+ ref = str(env_var or "").strip()
97
+ if not ref:
98
+ return None, f"Missing {label} env var name"
99
+
100
+ value = os.getenv(ref)
101
+ if value is not None and str(value).strip():
102
+ return str(value), None
103
+
104
+ # If it looks like a normal env var name, missing should be an error (don't silently use a name as a password).
105
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ref):
106
+ return None, f"Missing env var {ref} for {label}"
107
+
108
+ # Otherwise: treat the reference itself as the secret.
109
+ return ref, None
110
+
111
+
112
+ def _env_str(name: str) -> Optional[str]:
113
+ v = os.getenv(name)
114
+ if v is None:
115
+ return None
116
+ s = str(v).strip()
117
+ return s if s else None
118
+
119
+
120
+ def _env_bool(name: str) -> Optional[bool]:
121
+ v = _env_str(name)
122
+ if v is None:
123
+ return None
124
+ s = v.strip().lower()
125
+ if s in {"1", "true", "yes", "y", "on"}:
126
+ return True
127
+ if s in {"0", "false", "no", "n", "off"}:
128
+ return False
129
+ return None
130
+
131
+
132
+ def _env_int(name: str) -> Optional[int]:
133
+ v = _env_str(name)
134
+ if v is None:
135
+ return None
136
+ try:
137
+ return int(v)
138
+ except Exception:
139
+ return None
140
+
141
+
142
+ def _load_smtp_defaults() -> Dict[str, Any]:
143
+ """Load default SMTP settings from AbstractCore config + env overrides.
144
+
145
+ Precedence:
146
+ 1) env vars (ABSTRACT_EMAIL_SMTP_*)
147
+ 2) AbstractCore config system (`config.email.*`)
148
+ 3) hardcoded fallbacks
149
+ """
150
+ out: Dict[str, Any] = {
151
+ "smtp_host": "",
152
+ "smtp_port": 587,
153
+ "username": "",
154
+ "password_env_var": "EMAIL_PASSWORD",
155
+ "use_starttls": True,
156
+ "from_email": "",
157
+ "reply_to": "",
158
+ }
159
+
160
+ # AbstractCore config (best-effort).
161
+ try:
162
+ from abstractcore.config.manager import get_config_manager # type: ignore
163
+
164
+ cfg = get_config_manager().config
165
+ email_cfg = getattr(cfg, "email", None)
166
+ if email_cfg is not None:
167
+ out["smtp_host"] = str(getattr(email_cfg, "smtp_host", "") or "")
168
+ try:
169
+ out["smtp_port"] = int(getattr(email_cfg, "smtp_port", 587) or 587)
170
+ except Exception:
171
+ out["smtp_port"] = 587
172
+ out["username"] = str(getattr(email_cfg, "smtp_username", "") or "")
173
+ out["password_env_var"] = str(getattr(email_cfg, "smtp_password_env_var", "") or "") or "EMAIL_PASSWORD"
174
+ out["use_starttls"] = bool(getattr(email_cfg, "smtp_use_starttls", True))
175
+ out["from_email"] = str(getattr(email_cfg, "from_email", "") or "")
176
+ out["reply_to"] = str(getattr(email_cfg, "reply_to", "") or "")
177
+ except Exception:
178
+ pass
179
+
180
+ # Env overrides (framework-native).
181
+ out["smtp_host"] = _env_str("ABSTRACT_EMAIL_SMTP_HOST") or str(out.get("smtp_host") or "")
182
+ out["username"] = _env_str("ABSTRACT_EMAIL_SMTP_USERNAME") or str(out.get("username") or "")
183
+ out["password_env_var"] = _env_str("ABSTRACT_EMAIL_SMTP_PASSWORD_ENV_VAR") or str(out.get("password_env_var") or "EMAIL_PASSWORD")
184
+ out["from_email"] = _env_str("ABSTRACT_EMAIL_FROM") or str(out.get("from_email") or "")
185
+ out["reply_to"] = _env_str("ABSTRACT_EMAIL_REPLY_TO") or str(out.get("reply_to") or "")
186
+
187
+ port = _env_int("ABSTRACT_EMAIL_SMTP_PORT")
188
+ if isinstance(port, int) and port > 0:
189
+ out["smtp_port"] = int(port)
190
+
191
+ starttls = _env_bool("ABSTRACT_EMAIL_SMTP_STARTTLS")
192
+ if isinstance(starttls, bool):
193
+ out["use_starttls"] = bool(starttls)
194
+ else:
195
+ # If STARTTLS isn't explicitly configured, infer a safe default from common ports.
196
+ # - 465 => implicit TLS (SMTP_SSL)
197
+ # - 587/25 => STARTTLS (when supported by the server)
198
+ try:
199
+ port_i = int(out.get("smtp_port") or 587)
200
+ except Exception:
201
+ port_i = 587
202
+ if port_i == 465:
203
+ out["use_starttls"] = False
204
+
205
+ return out
206
+
207
+
208
+ def _load_imap_defaults() -> Dict[str, Any]:
209
+ """Load default IMAP settings from AbstractCore config + env overrides.
210
+
211
+ Precedence:
212
+ 1) env vars (ABSTRACT_EMAIL_IMAP_*)
213
+ 2) AbstractCore config system (`config.email.*`)
214
+ 3) hardcoded fallbacks
215
+ """
216
+ out: Dict[str, Any] = {
217
+ "imap_host": "",
218
+ "imap_port": 993,
219
+ "username": "",
220
+ "password_env_var": "EMAIL_PASSWORD",
221
+ }
222
+
223
+ # AbstractCore config (best-effort).
224
+ try:
225
+ from abstractcore.config.manager import get_config_manager # type: ignore
226
+
227
+ cfg = get_config_manager().config
228
+ email_cfg = getattr(cfg, "email", None)
229
+ if email_cfg is not None:
230
+ out["imap_host"] = str(getattr(email_cfg, "imap_host", "") or "")
231
+ try:
232
+ out["imap_port"] = int(getattr(email_cfg, "imap_port", 993) or 993)
233
+ except Exception:
234
+ out["imap_port"] = 993
235
+ out["username"] = str(getattr(email_cfg, "imap_username", "") or "")
236
+ out["password_env_var"] = str(getattr(email_cfg, "imap_password_env_var", "") or "") or "EMAIL_PASSWORD"
237
+ except Exception:
238
+ pass
239
+
240
+ # Env overrides (framework-native).
241
+ out["imap_host"] = _env_str("ABSTRACT_EMAIL_IMAP_HOST") or str(out.get("imap_host") or "")
242
+ out["username"] = _env_str("ABSTRACT_EMAIL_IMAP_USERNAME") or str(out.get("username") or "")
243
+ out["password_env_var"] = _env_str("ABSTRACT_EMAIL_IMAP_PASSWORD_ENV_VAR") or str(out.get("password_env_var") or "EMAIL_PASSWORD")
244
+
245
+ port = _env_int("ABSTRACT_EMAIL_IMAP_PORT")
246
+ if isinstance(port, int) and port > 0:
247
+ out["imap_port"] = int(port)
248
+
249
+ return out
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class _EmailImapConfig:
254
+ host: str
255
+ port: int
256
+ username: str
257
+ password_env_var: str
258
+ mailbox: str
259
+
260
+
261
+ @dataclass(frozen=True)
262
+ class _EmailSmtpConfig:
263
+ host: str
264
+ port: int
265
+ username: str
266
+ password_env_var: str
267
+ use_starttls: bool
268
+ from_email: str
269
+ reply_to: str
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class _EmailAccountConfig:
274
+ name: str
275
+ allow_read: bool
276
+ allow_send: bool
277
+ imap: Optional[_EmailImapConfig]
278
+ smtp: Optional[_EmailSmtpConfig]
279
+
280
+ def email_address(self) -> str:
281
+ if self.smtp is not None and self.smtp.username:
282
+ return self.smtp.username
283
+ if self.imap is not None and self.imap.username:
284
+ return self.imap.username
285
+ return ""
286
+
287
+ def from_email(self) -> str:
288
+ if self.smtp is None:
289
+ return ""
290
+ return self.smtp.from_email or self.smtp.username
291
+
292
+
293
+ @dataclass(frozen=True)
294
+ class _EmailAccountsConfig:
295
+ accounts: Dict[str, _EmailAccountConfig]
296
+ default_account: Optional[str]
297
+ source: str # "env" | "yaml"
298
+ config_path: Optional[str] = None
299
+
300
+
301
+ def _as_str(value: Any) -> str:
302
+ if value is None:
303
+ return ""
304
+ return str(value).strip()
305
+
306
+
307
+ def _as_bool(value: Any) -> Optional[bool]:
308
+ if value is None:
309
+ return None
310
+ if isinstance(value, bool):
311
+ return value
312
+ if isinstance(value, (int, float)):
313
+ if int(value) == 1:
314
+ return True
315
+ if int(value) == 0:
316
+ return False
317
+ return None
318
+ if isinstance(value, str):
319
+ s = value.strip().lower()
320
+ if s in {"1", "true", "yes", "y", "on"}:
321
+ return True
322
+ if s in {"0", "false", "no", "n", "off"}:
323
+ return False
324
+ return None
325
+
326
+
327
+ def _as_int(value: Any) -> Optional[int]:
328
+ if value is None:
329
+ return None
330
+ if isinstance(value, bool):
331
+ return int(value)
332
+ if isinstance(value, int):
333
+ return value
334
+ if isinstance(value, float):
335
+ return int(value)
336
+ if isinstance(value, str):
337
+ s = value.strip()
338
+ if not s:
339
+ return None
340
+ try:
341
+ return int(s)
342
+ except Exception:
343
+ return None
344
+ try:
345
+ return int(value)
346
+ except Exception:
347
+ return None
348
+
349
+
350
+ _ENV_INTERP_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
351
+
352
+
353
+ def _interpolate_env_in_str(text: str, *, missing: set[str]) -> str:
354
+ raw = str(text or "")
355
+ if "${" not in raw:
356
+ return raw
357
+
358
+ def repl(match: re.Match[str]) -> str:
359
+ name = str(match.group(1) or "").strip()
360
+ default = match.group(2)
361
+ value = os.getenv(name)
362
+ if value is not None and str(value).strip():
363
+ return str(value)
364
+ if default is not None:
365
+ return str(default)
366
+ missing.add(name)
367
+ return ""
368
+
369
+ return _ENV_INTERP_RE.sub(repl, raw)
370
+
371
+
372
+ def _interpolate_env(obj: Any) -> Tuple[Any, set[str]]:
373
+ missing: set[str] = set()
374
+
375
+ def walk(v: Any) -> Any:
376
+ if isinstance(v, str):
377
+ return _interpolate_env_in_str(v, missing=missing)
378
+ if isinstance(v, list):
379
+ return [walk(x) for x in v]
380
+ if isinstance(v, tuple):
381
+ return [walk(x) for x in v]
382
+ if isinstance(v, dict):
383
+ return {k: walk(val) for k, val in v.items()}
384
+ return v
385
+
386
+ return walk(obj), missing
387
+
388
+
389
+ def _parse_email_imap_config(raw: Any) -> Tuple[Optional[_EmailImapConfig], Optional[str]]:
390
+ if raw is None:
391
+ return None, None
392
+ if not isinstance(raw, dict):
393
+ return None, "imap must be a mapping"
394
+
395
+ host = _as_str(raw.get("host") or raw.get("imap_host"))
396
+ username = _as_str(raw.get("username") or raw.get("user"))
397
+ port = _as_int(raw.get("port") or raw.get("imap_port")) or 993
398
+ password_env_var = _as_str(raw.get("password_env_var") or raw.get("passwordEnvVar")) or "EMAIL_PASSWORD"
399
+ mailbox = _as_str(raw.get("mailbox") or raw.get("folder")) or "INBOX"
400
+
401
+ if not host:
402
+ return None, "imap.host is required"
403
+ if not username:
404
+ return None, "imap.username is required"
405
+ if port <= 0:
406
+ return None, "imap.port must be a positive integer"
407
+ if not password_env_var:
408
+ return None, "imap.password_env_var is required"
409
+
410
+ return (
411
+ _EmailImapConfig(
412
+ host=host,
413
+ port=int(port),
414
+ username=username,
415
+ password_env_var=password_env_var,
416
+ mailbox=mailbox,
417
+ ),
418
+ None,
419
+ )
420
+
421
+
422
+ def _parse_email_smtp_config(raw: Any) -> Tuple[Optional[_EmailSmtpConfig], Optional[str]]:
423
+ if raw is None:
424
+ return None, None
425
+ if not isinstance(raw, dict):
426
+ return None, "smtp must be a mapping"
427
+
428
+ host = _as_str(raw.get("host") or raw.get("smtp_host"))
429
+ username = _as_str(raw.get("username") or raw.get("user"))
430
+ port = _as_int(raw.get("port") or raw.get("smtp_port")) or 587
431
+ password_env_var = _as_str(raw.get("password_env_var") or raw.get("passwordEnvVar")) or "EMAIL_PASSWORD"
432
+
433
+ use_starttls = _as_bool(raw.get("use_starttls"))
434
+ if use_starttls is None:
435
+ use_starttls = _as_bool(raw.get("starttls"))
436
+ if use_starttls is None:
437
+ use_starttls = False if int(port) == 465 else True
438
+
439
+ from_email = _as_str(raw.get("from_email") or raw.get("from"))
440
+ reply_to = _as_str(raw.get("reply_to") or raw.get("replyTo"))
441
+
442
+ if not host:
443
+ return None, "smtp.host is required"
444
+ if not username:
445
+ return None, "smtp.username is required"
446
+ if port <= 0:
447
+ return None, "smtp.port must be a positive integer"
448
+ if not password_env_var:
449
+ return None, "smtp.password_env_var is required"
450
+
451
+ return (
452
+ _EmailSmtpConfig(
453
+ host=host,
454
+ port=int(port),
455
+ username=username,
456
+ password_env_var=password_env_var,
457
+ use_starttls=bool(use_starttls),
458
+ from_email=from_email,
459
+ reply_to=reply_to,
460
+ ),
461
+ None,
462
+ )
463
+
464
+
465
+ def _parse_email_account(name: str, raw: Any) -> Tuple[Optional[_EmailAccountConfig], Optional[str]]:
466
+ if not isinstance(raw, dict):
467
+ return None, "account entry must be a mapping"
468
+
469
+ allow_read_raw = _as_bool(raw.get("allow_read"))
470
+ allow_send_raw = _as_bool(raw.get("allow_send"))
471
+
472
+ imap_cfg, imap_err = _parse_email_imap_config(raw.get("imap"))
473
+ if imap_err is not None:
474
+ return None, imap_err
475
+
476
+ smtp_cfg, smtp_err = _parse_email_smtp_config(raw.get("smtp"))
477
+ if smtp_err is not None:
478
+ return None, smtp_err
479
+
480
+ allow_read = bool(imap_cfg is not None) if allow_read_raw is None else bool(allow_read_raw)
481
+ allow_send = bool(smtp_cfg is not None) if allow_send_raw is None else bool(allow_send_raw)
482
+
483
+ if allow_read and imap_cfg is None:
484
+ return None, "allow_read=true but imap is missing"
485
+ if allow_send and smtp_cfg is None:
486
+ return None, "allow_send=true but smtp is missing"
487
+
488
+ return (
489
+ _EmailAccountConfig(
490
+ name=name,
491
+ allow_read=allow_read,
492
+ allow_send=allow_send,
493
+ imap=imap_cfg,
494
+ smtp=smtp_cfg,
495
+ ),
496
+ None,
497
+ )
498
+
499
+
500
+ def _load_email_accounts_from_file(path: str) -> Tuple[Optional[_EmailAccountsConfig], Optional[str]]:
501
+ raw_path = str(path or "").strip()
502
+ if not raw_path:
503
+ return None, "Missing email accounts config path"
504
+
505
+ expanded = os.path.expanduser(os.path.expandvars(raw_path))
506
+ p = Path(expanded)
507
+ if not p.is_file():
508
+ return None, f"ABSTRACT_EMAIL_ACCOUNTS_CONFIG is set but file not found: {expanded}"
509
+
510
+ try:
511
+ text = p.read_text(encoding="utf-8")
512
+ except Exception as e:
513
+ return None, f"Failed to read email accounts config ({expanded}): {e}"
514
+
515
+ try:
516
+ if p.suffix.lower() == ".json":
517
+ data = json.loads(text)
518
+ else:
519
+ try:
520
+ import yaml # type: ignore
521
+ except Exception as e:
522
+ return None, f"PyYAML is required to parse {expanded}: {e}"
523
+ data = yaml.safe_load(text)
524
+ except Exception as e:
525
+ return None, f"Failed to parse email accounts config ({expanded}): {e}"
526
+
527
+ if not isinstance(data, dict):
528
+ return None, "Email accounts config must be a mapping (YAML/JSON object)"
529
+
530
+ data2, missing = _interpolate_env(data)
531
+ if missing:
532
+ missing_list = ", ".join(sorted(missing))
533
+ return None, f"Email accounts config references missing env vars: {missing_list}"
534
+ data = data2
535
+
536
+ accounts_raw = data.get("accounts")
537
+ if not isinstance(accounts_raw, dict) or not accounts_raw:
538
+ return None, "Email accounts config must contain a non-empty 'accounts' mapping"
539
+
540
+ accounts: Dict[str, _EmailAccountConfig] = {}
541
+ errors: list[str] = []
542
+ for k, v in accounts_raw.items():
543
+ name = _as_str(k)
544
+ if not name:
545
+ errors.append("account name must be a non-empty string")
546
+ continue
547
+ acc, err = _parse_email_account(name, v)
548
+ if err is not None:
549
+ errors.append(f"{name}: {err}")
550
+ continue
551
+ if acc is not None:
552
+ accounts[name] = acc
553
+
554
+ if errors:
555
+ return None, "Invalid email accounts config: " + "; ".join(errors)
556
+
557
+ default_from_env = _env_str("ABSTRACT_EMAIL_DEFAULT_ACCOUNT")
558
+ default_from_file = _as_str(data.get("default_account"))
559
+ default_account = (default_from_env or default_from_file or "").strip() or None
560
+ if default_account is None and len(accounts) == 1:
561
+ default_account = next(iter(accounts.keys()))
562
+ if default_account is not None and default_account not in accounts:
563
+ return None, f"Default email account '{default_account}' not found in accounts"
564
+
565
+ return _EmailAccountsConfig(accounts=accounts, default_account=default_account, source="yaml", config_path=str(p)), None
566
+
567
+
568
+ def _load_email_accounts_from_env() -> Tuple[_EmailAccountsConfig, Optional[str]]:
569
+ name = _env_str("ABSTRACT_EMAIL_ACCOUNT_NAME") or "default"
570
+
571
+ smtp_defaults = _load_smtp_defaults()
572
+ smtp_host = str(smtp_defaults.get("smtp_host") or "").strip()
573
+ smtp_user = str(smtp_defaults.get("username") or "").strip()
574
+ smtp_cfg: Optional[_EmailSmtpConfig] = None
575
+ if smtp_host and smtp_user:
576
+ smtp_cfg = _EmailSmtpConfig(
577
+ host=smtp_host,
578
+ port=int(smtp_defaults.get("smtp_port") or 587),
579
+ username=smtp_user,
580
+ password_env_var=str(smtp_defaults.get("password_env_var") or "EMAIL_PASSWORD").strip() or "EMAIL_PASSWORD",
581
+ use_starttls=bool(smtp_defaults.get("use_starttls")),
582
+ from_email=str(smtp_defaults.get("from_email") or "").strip(),
583
+ reply_to=str(smtp_defaults.get("reply_to") or "").strip(),
584
+ )
585
+
586
+ imap_defaults = _load_imap_defaults()
587
+ imap_host = str(imap_defaults.get("imap_host") or "").strip()
588
+ imap_user = str(imap_defaults.get("username") or "").strip()
589
+ imap_cfg: Optional[_EmailImapConfig] = None
590
+ if imap_host and imap_user:
591
+ mailbox = _env_str("ABSTRACT_EMAIL_IMAP_FOLDER") or "INBOX"
592
+ imap_cfg = _EmailImapConfig(
593
+ host=imap_host,
594
+ port=int(imap_defaults.get("imap_port") or 993),
595
+ username=imap_user,
596
+ password_env_var=str(imap_defaults.get("password_env_var") or "EMAIL_PASSWORD").strip() or "EMAIL_PASSWORD",
597
+ mailbox=str(mailbox or "").strip() or "INBOX",
598
+ )
599
+
600
+ accounts: Dict[str, _EmailAccountConfig] = {}
601
+ if smtp_cfg is not None or imap_cfg is not None:
602
+ accounts[name] = _EmailAccountConfig(
603
+ name=name,
604
+ allow_read=bool(imap_cfg is not None),
605
+ allow_send=bool(smtp_cfg is not None),
606
+ imap=imap_cfg,
607
+ smtp=smtp_cfg,
608
+ )
609
+
610
+ default_from_env = _env_str("ABSTRACT_EMAIL_DEFAULT_ACCOUNT")
611
+ default_account = (default_from_env or "").strip() or (name if accounts else None)
612
+ if default_account is not None and default_account not in accounts:
613
+ default_account = name if accounts else None
614
+
615
+ return _EmailAccountsConfig(accounts=accounts, default_account=default_account, source="env", config_path=None), None
616
+
617
+
618
+ def _load_email_accounts_config() -> Tuple[Optional[_EmailAccountsConfig], Optional[str]]:
619
+ path = _env_str("ABSTRACT_EMAIL_ACCOUNTS_CONFIG")
620
+ if path:
621
+ return _load_email_accounts_from_file(path)
622
+ cfg, err = _load_email_accounts_from_env()
623
+ return cfg, err
624
+
625
+
626
+ def _available_email_accounts(cfg: _EmailAccountsConfig) -> str:
627
+ names = sorted(cfg.accounts.keys())
628
+ return ", ".join(names)
629
+
630
+
631
+ def _select_email_account(cfg: _EmailAccountsConfig, requested: Optional[str]) -> Tuple[Optional[_EmailAccountConfig], Optional[str]]:
632
+ if not cfg.accounts:
633
+ return (
634
+ None,
635
+ "No email accounts configured. Set ABSTRACT_EMAIL_ACCOUNTS_CONFIG (YAML) or "
636
+ "ABSTRACT_EMAIL_{IMAP,SMTP}_* env vars.",
637
+ )
638
+
639
+ name = _as_str(requested)
640
+ if not name:
641
+ if cfg.default_account:
642
+ name = cfg.default_account
643
+ elif len(cfg.accounts) == 1:
644
+ name = next(iter(cfg.accounts.keys()))
645
+ else:
646
+ return None, f"Multiple email accounts configured; specify account. Available: {_available_email_accounts(cfg)}"
647
+
648
+ acc = cfg.accounts.get(name)
649
+ if acc is None:
650
+ return None, f"Unknown email account '{name}'. Available: {_available_email_accounts(cfg)}"
651
+ return acc, None
652
+
653
+
654
+ def _select_email_account_for(
655
+ requested: Optional[str], *, capability: str
656
+ ) -> Tuple[Optional[_EmailAccountConfig], Optional[str]]:
657
+ cfg, err = _load_email_accounts_config()
658
+ if err is not None:
659
+ return None, err
660
+ if cfg is None:
661
+ return None, "Email account configuration is unavailable"
662
+
663
+ acc, err2 = _select_email_account(cfg, requested)
664
+ if err2 is not None:
665
+ return None, err2
666
+ if acc is None:
667
+ return None, "Email account selection failed"
668
+
669
+ cap = str(capability or "").strip().lower()
670
+ if cap == "read":
671
+ if not acc.allow_read or acc.imap is None:
672
+ return None, f"Email account '{acc.name}' is not configured for reading (IMAP)."
673
+ elif cap == "send":
674
+ if not acc.allow_send or acc.smtp is None:
675
+ return None, f"Email account '{acc.name}' is not configured for sending (SMTP)."
676
+ else:
677
+ return None, f"Unknown capability: {capability}"
678
+
679
+ return acc, None
680
+
681
+
682
+ def _parse_since(value: Optional[str]) -> Tuple[Optional[datetime], Optional[str]]:
683
+ if value is None:
684
+ return None, None
685
+ raw = str(value).strip()
686
+ if not raw:
687
+ return None, None
688
+
689
+ # Convenience: "7" or "7d" => now - 7 days (UTC).
690
+ m = re.fullmatch(r"(\d+)\s*d?", raw.lower())
691
+ if m:
692
+ days = int(m.group(1))
693
+ return datetime.now(timezone.utc) - timedelta(days=days), None
694
+
695
+ try:
696
+ # Accept ISO 8601. If timezone-naive, treat as UTC to keep behavior deterministic.
697
+ dt = datetime.fromisoformat(raw)
698
+ if dt.tzinfo is None:
699
+ dt = dt.replace(tzinfo=timezone.utc)
700
+ return dt, None
701
+ except Exception:
702
+ return None, "Invalid since datetime; expected ISO8601 (or '7d')"
703
+
704
+
705
+ def _imap_date(dt: datetime) -> str:
706
+ # IMAP expects English month abbreviations.
707
+ month = _MONTH_ABBR[dt.month - 1]
708
+ return f"{dt.day:02d}-{month}-{dt.year:04d}"
709
+
710
+
711
+ def _normalize_imap_flag(flag: str) -> str:
712
+ raw = str(flag or "").strip()
713
+ if not raw:
714
+ return ""
715
+ # Some servers/bridges may double-escape backslash-prefixed flags (e.g. "\\Seen").
716
+ if raw.startswith("\\"):
717
+ return "\\" + raw.lstrip("\\")
718
+ return raw
719
+
720
+
721
+ def _imap_has_seen(flags: List[str]) -> bool:
722
+ for f in flags:
723
+ name = _normalize_imap_flag(f)
724
+ if not name:
725
+ continue
726
+ if name.lstrip("\\").lower() == "seen":
727
+ return True
728
+ return False
729
+
730
+
731
+ def _extract_text_bodies(msg: Message) -> Tuple[str, str]:
732
+ """Return (text/plain, text/html) bodies, best-effort decoded."""
733
+ if msg is None:
734
+ return "", ""
735
+
736
+ text_parts: list[str] = []
737
+ html_parts: list[str] = []
738
+
739
+ def _decode_part(part: Message) -> str:
740
+ payload = part.get_payload(decode=True)
741
+ if payload is None:
742
+ return ""
743
+ charset = part.get_content_charset() or "utf-8"
744
+ try:
745
+ return payload.decode(charset, errors="replace")
746
+ except Exception:
747
+ return payload.decode("utf-8", errors="replace")
748
+
749
+ if msg.is_multipart():
750
+ for part in msg.walk():
751
+ if part.is_multipart():
752
+ continue
753
+ disp = part.get_content_disposition()
754
+ if disp == "attachment":
755
+ continue
756
+ ctype = str(part.get_content_type() or "")
757
+ if ctype == "text/plain":
758
+ text = _decode_part(part).strip()
759
+ if text:
760
+ text_parts.append(text)
761
+ elif ctype == "text/html":
762
+ html = _decode_part(part).strip()
763
+ if html:
764
+ html_parts.append(html)
765
+ else:
766
+ ctype = str(msg.get_content_type() or "")
767
+ if ctype == "text/plain":
768
+ text_parts.append(_decode_part(msg).strip())
769
+ elif ctype == "text/html":
770
+ html_parts.append(_decode_part(msg).strip())
771
+
772
+ return ("\n\n".join([t for t in text_parts if t]).strip(), "\n\n".join([h for h in html_parts if h]).strip())
773
+
774
+
775
+ @tool(
776
+ description="List configured email accounts (what the runtime host has enabled).",
777
+ tags=["comms", "email"],
778
+ when_to_use="Use before reading/sending email to discover which accounts are configured and allowed.",
779
+ examples=[{"description": "List available accounts", "arguments": {}}],
780
+ )
781
+ def list_email_accounts() -> Dict[str, Any]:
782
+ cfg, err = _load_email_accounts_config()
783
+ if err is not None:
784
+ return {"success": False, "error": err}
785
+ if cfg is None:
786
+ return {"success": False, "error": "Email account configuration is unavailable"}
787
+
788
+ accounts: list[Dict[str, Any]] = []
789
+
790
+ def secret_available(secret_ref: str) -> bool:
791
+ ref = str(secret_ref or "").strip()
792
+ if not ref:
793
+ return False
794
+ v = os.getenv(ref)
795
+ if v is not None and str(v).strip():
796
+ return True
797
+ return re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ref) is None
798
+
799
+ for name in sorted(cfg.accounts.keys()):
800
+ acc = cfg.accounts.get(name)
801
+ if acc is None:
802
+ continue
803
+ imap_password_set = False
804
+ smtp_password_set = False
805
+ if acc.imap is not None:
806
+ imap_password_set = secret_available(str(acc.imap.password_env_var or ""))
807
+ if acc.smtp is not None:
808
+ smtp_password_set = secret_available(str(acc.smtp.password_env_var or ""))
809
+ accounts.append(
810
+ {
811
+ "account": acc.name,
812
+ "email": acc.email_address(),
813
+ "from_email": acc.from_email() or None,
814
+ "can_read": bool(acc.allow_read and acc.imap is not None),
815
+ "can_send": bool(acc.allow_send and acc.smtp is not None),
816
+ "imap_password_set": imap_password_set if acc.imap is not None else None,
817
+ "smtp_password_set": smtp_password_set if acc.smtp is not None else None,
818
+ }
819
+ )
820
+
821
+ return {
822
+ "success": True,
823
+ "source": cfg.source,
824
+ "config_path": cfg.config_path,
825
+ "default_account": cfg.default_account,
826
+ "accounts": accounts,
827
+ }
828
+
829
+
830
+ @tool(
831
+ description="Send an email from a configured account (SMTP).",
832
+ tags=["comms", "email"],
833
+ when_to_use=(
834
+ "Use to send an email notification or report. The sender account is restricted to the operator-configured "
835
+ "email accounts (it cannot be overridden by tool arguments)."
836
+ ),
837
+ examples=[
838
+ {
839
+ "description": "Send a simple text email from the default configured account",
840
+ "arguments": {"to": "you@example.com", "subject": "Hello", "body_text": "Hi there!"},
841
+ },
842
+ {
843
+ "description": "Send from a named account (multi-account config)",
844
+ "arguments": {"account": "work", "to": "you@example.com", "subject": "Report", "body_text": "Done."},
845
+ },
846
+ ],
847
+ )
848
+ def send_email(
849
+ to: Any,
850
+ subject: str,
851
+ *,
852
+ account: Optional[str] = None,
853
+ body_text: Optional[str] = None,
854
+ body_html: Optional[str] = None,
855
+ cc: Any = None,
856
+ bcc: Any = None,
857
+ timeout_s: float = 30.0,
858
+ headers: Optional[Dict[str, str]] = None,
859
+ ) -> Dict[str, Any]:
860
+ """Send an email via SMTP using only operator-configured account settings."""
861
+ acc, err = _select_email_account_for(account, capability="send")
862
+ if err is not None:
863
+ return {"success": False, "error": err}
864
+ if acc is None or acc.smtp is None:
865
+ return {"success": False, "error": "SMTP account configuration is unavailable"}
866
+
867
+ smtp_cfg = acc.smtp
868
+
869
+ password, err2 = _resolve_required_env(smtp_cfg.password_env_var, label="SMTP password")
870
+ if err2 is not None:
871
+ return {"success": False, "error": err2, "account": acc.name}
872
+
873
+ subject_s = str(subject or "").strip()
874
+ if not subject_s:
875
+ return {"success": False, "error": "subject is required", "account": acc.name}
876
+
877
+ to_list = _coerce_str_list(to)
878
+ cc_list = _coerce_str_list(cc)
879
+ bcc_list = _coerce_str_list(bcc)
880
+ if not to_list and not cc_list and not bcc_list:
881
+ return {"success": False, "error": "At least one recipient is required (to/cc/bcc)", "account": acc.name}
882
+
883
+ body_text_s = (body_text or "").strip()
884
+ body_html_s = (body_html or "").strip()
885
+ if not body_text_s and not body_html_s:
886
+ return {"success": False, "error": "Provide body_text and/or body_html", "account": acc.name}
887
+
888
+ sender = smtp_cfg.from_email or smtp_cfg.username
889
+ reply_to = smtp_cfg.reply_to.strip() or None
890
+
891
+ msg = EmailMessage()
892
+ msg["Subject"] = subject_s
893
+ msg["From"] = sender
894
+ if to_list:
895
+ msg["To"] = ", ".join(to_list)
896
+ if cc_list:
897
+ msg["Cc"] = ", ".join(cc_list)
898
+ if reply_to:
899
+ msg["Reply-To"] = reply_to
900
+ msg["Date"] = formatdate(localtime=True)
901
+ msg["Message-ID"] = make_msgid()
902
+
903
+ extra_headers = headers if isinstance(headers, dict) else None
904
+ if extra_headers:
905
+ for k, v in extra_headers.items():
906
+ if not isinstance(k, str) or not k.strip():
907
+ continue
908
+ msg[str(k).strip()] = str(v)
909
+
910
+ if body_text_s and body_html_s:
911
+ msg.set_content(body_text_s)
912
+ msg.add_alternative(body_html_s, subtype="html")
913
+ elif body_html_s:
914
+ msg.set_content(body_text_s or "(This email contains HTML content.)")
915
+ msg.add_alternative(body_html_s, subtype="html")
916
+ else:
917
+ msg.set_content(body_text_s)
918
+
919
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
920
+ if timeout <= 0:
921
+ timeout = 30.0
922
+
923
+ try:
924
+ if smtp_cfg.use_starttls:
925
+ client: Any = smtplib.SMTP(smtp_cfg.host, int(smtp_cfg.port), timeout=timeout)
926
+ try:
927
+ client.ehlo()
928
+ client.starttls()
929
+ client.ehlo()
930
+ client.login(smtp_cfg.username, password)
931
+ client.send_message(msg, from_addr=sender, to_addrs=to_list + cc_list + bcc_list)
932
+ finally:
933
+ try:
934
+ client.quit()
935
+ except Exception:
936
+ pass
937
+ else:
938
+ client2: Any = smtplib.SMTP_SSL(smtp_cfg.host, int(smtp_cfg.port), timeout=timeout)
939
+ try:
940
+ client2.login(smtp_cfg.username, password)
941
+ client2.send_message(msg, from_addr=sender, to_addrs=to_list + cc_list + bcc_list)
942
+ finally:
943
+ try:
944
+ client2.quit()
945
+ except Exception:
946
+ pass
947
+
948
+ msg_id = str(msg.get("Message-ID") or "")
949
+ recipients = to_list + cc_list + bcc_list
950
+ rendered = (
951
+ f"Sent email (account={acc.name}) from {sender} to {', '.join(recipients)} "
952
+ f"subject={subject_s!r} message_id={msg_id}"
953
+ )
954
+
955
+ return {
956
+ "success": True,
957
+ "account": acc.name,
958
+ "message_id": msg_id,
959
+ "from": sender,
960
+ "to": list(to_list),
961
+ "cc": list(cc_list),
962
+ "bcc": list(bcc_list),
963
+ "rendered": rendered,
964
+ "smtp": {
965
+ "host": smtp_cfg.host,
966
+ "port": int(smtp_cfg.port),
967
+ "username": smtp_cfg.username,
968
+ "starttls": bool(smtp_cfg.use_starttls),
969
+ },
970
+ }
971
+ except Exception as e:
972
+ return {
973
+ "success": False,
974
+ "account": acc.name,
975
+ "error": str(e),
976
+ "rendered": (
977
+ f"Failed to send email (account={acc.name}) from {sender} to {', '.join(to_list + cc_list + bcc_list)} "
978
+ f"subject={subject_s!r}"
979
+ ),
980
+ "smtp": {
981
+ "host": smtp_cfg.host,
982
+ "port": int(smtp_cfg.port),
983
+ "username": smtp_cfg.username,
984
+ "starttls": bool(smtp_cfg.use_starttls),
985
+ },
986
+ }
987
+
988
+
989
+ @tool(
990
+ description="List recent emails from a configured IMAP mailbox (supports since + read/unread filters).",
991
+ tags=["comms", "email"],
992
+ when_to_use="Use to fetch a digest of recent emails (subject/from/date/flags) for review or routing.",
993
+ examples=[
994
+ {
995
+ "description": "List unread emails from the default configured account (last 7 days)",
996
+ "arguments": {"since": "7d", "status": "unread", "limit": 10},
997
+ },
998
+ {
999
+ "description": "List unread emails from a named account (multi-account config)",
1000
+ "arguments": {"account": "work", "since": "7d", "status": "unread", "limit": 10},
1001
+ },
1002
+ ],
1003
+ )
1004
+ def list_emails(
1005
+ *,
1006
+ account: Optional[str] = None,
1007
+ mailbox: Optional[str] = None,
1008
+ since: Optional[str] = None,
1009
+ status: str = "all",
1010
+ limit: int = 20,
1011
+ timeout_s: float = 30.0,
1012
+ ) -> Dict[str, Any]:
1013
+ """List email headers from an IMAP mailbox using only operator-configured account settings."""
1014
+ acc, err = _select_email_account_for(account, capability="read")
1015
+ if err is not None:
1016
+ return {"success": False, "error": err}
1017
+ if acc is None or acc.imap is None:
1018
+ return {"success": False, "error": "IMAP account configuration is unavailable"}
1019
+
1020
+ imap_cfg = acc.imap
1021
+
1022
+ password, err2 = _resolve_required_env(imap_cfg.password_env_var, label="IMAP password")
1023
+ if err2 is not None:
1024
+ return {"success": False, "error": err2, "account": acc.name}
1025
+
1026
+ mailbox2 = str(mailbox or "").strip() or str(imap_cfg.mailbox or "").strip() or "INBOX"
1027
+
1028
+ try:
1029
+ limit_i = int(limit or 0)
1030
+ except Exception:
1031
+ limit_i = 0
1032
+ if limit_i <= 0:
1033
+ limit_i = 20
1034
+
1035
+ if int(imap_cfg.port) <= 0:
1036
+ return {"success": False, "error": "imap_port must be a positive integer", "account": acc.name}
1037
+
1038
+ dt_since, dt_err = _parse_since(since)
1039
+ if dt_err is not None:
1040
+ return {"success": False, "error": dt_err, "account": acc.name}
1041
+
1042
+ status_norm = str(status or "").strip().lower() or "all"
1043
+ if status_norm not in {"all", "unread", "read"}:
1044
+ return {"success": False, "error": "status must be one of: all, unread, read", "account": acc.name}
1045
+
1046
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
1047
+ if timeout <= 0:
1048
+ timeout = 30.0
1049
+
1050
+ client: Optional[imaplib.IMAP4_SSL] = None
1051
+ try:
1052
+ client = imaplib.IMAP4_SSL(imap_cfg.host, int(imap_cfg.port))
1053
+ try:
1054
+ if getattr(client, "sock", None) is not None:
1055
+ client.sock.settimeout(timeout) # type: ignore[attr-defined]
1056
+ except Exception:
1057
+ pass
1058
+
1059
+ client.login(imap_cfg.username, password)
1060
+ typ, _ = client.select(mailbox2, readonly=True)
1061
+ if typ != "OK":
1062
+ return {"success": False, "error": f"Failed to select mailbox: {mailbox2}", "account": acc.name}
1063
+
1064
+ criteria: list[str] = []
1065
+ if dt_since is not None:
1066
+ criteria.append(f"SINCE { _imap_date(dt_since) }")
1067
+ if status_norm == "unread":
1068
+ criteria.append("UNSEEN")
1069
+ elif status_norm == "read":
1070
+ criteria.append("SEEN")
1071
+ search_query = " ".join(criteria) if criteria else "ALL"
1072
+
1073
+ typ2, data = client.uid("search", None, search_query)
1074
+ if typ2 != "OK" or not data:
1075
+ return {"success": False, "error": "IMAP search failed", "account": acc.name, "mailbox": mailbox2}
1076
+
1077
+ raw_uids = data[0] if isinstance(data, list) and data else b""
1078
+ if not isinstance(raw_uids, (bytes, bytearray)):
1079
+ raw_uids = str(raw_uids).encode("utf-8", errors="replace")
1080
+ uids = [u.decode("utf-8", errors="replace") for u in raw_uids.split() if u]
1081
+
1082
+ # IMAP SEARCH tends to return ascending order; return newest first.
1083
+ uids = list(reversed(uids))[:limit_i]
1084
+
1085
+ messages: list[Dict[str, Any]] = []
1086
+ for uid in uids:
1087
+ typ3, fetched = client.uid(
1088
+ "fetch",
1089
+ uid,
1090
+ "(FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC SUBJECT DATE MESSAGE-ID)])",
1091
+ )
1092
+ if typ3 != "OK" or not fetched:
1093
+ continue
1094
+ # fetched is typically a list of tuples + trailing b')' entry.
1095
+ header_bytes: Optional[bytes] = None
1096
+ flags: list[str] = []
1097
+ size: Optional[int] = None
1098
+ for item in fetched:
1099
+ if not isinstance(item, tuple) or len(item) < 2:
1100
+ continue
1101
+ meta, payload = item[0], item[1]
1102
+ if isinstance(payload, (bytes, bytearray)) and payload:
1103
+ header_bytes = bytes(payload)
1104
+ if isinstance(meta, (bytes, bytearray)):
1105
+ try:
1106
+ flags_bytes = imaplib.ParseFlags(meta)
1107
+ flags = [_normalize_imap_flag(fb.decode("utf-8", errors="replace")) for fb in flags_bytes]
1108
+ except Exception:
1109
+ flags = []
1110
+ m = re.search(rb"RFC822\.SIZE\s+(\d+)", meta)
1111
+ if m:
1112
+ try:
1113
+ size = int(m.group(1))
1114
+ except Exception:
1115
+ size = None
1116
+ if header_bytes is None:
1117
+ continue
1118
+
1119
+ msg = email.message_from_bytes(header_bytes)
1120
+ subject_v = _decode_mime_header(msg.get("Subject"))
1121
+ from_v = _decode_mime_header(msg.get("From"))
1122
+ to_v = _decode_mime_header(msg.get("To"))
1123
+ date_v = _decode_mime_header(msg.get("Date"))
1124
+ message_id = _decode_mime_header(msg.get("Message-ID"))
1125
+
1126
+ messages.append(
1127
+ {
1128
+ "uid": str(uid),
1129
+ "message_id": message_id,
1130
+ "subject": subject_v,
1131
+ "from": from_v,
1132
+ "to": to_v,
1133
+ "date": date_v,
1134
+ "flags": flags,
1135
+ "seen": _imap_has_seen(flags),
1136
+ "size": size,
1137
+ }
1138
+ )
1139
+
1140
+ unread = sum(1 for m in messages if not bool(m.get("seen")))
1141
+ read = len(messages) - unread
1142
+
1143
+ return {
1144
+ "success": True,
1145
+ "account": acc.name,
1146
+ "mailbox": mailbox2,
1147
+ "filter": {"since": dt_since.isoformat() if dt_since else None, "status": status_norm, "limit": limit_i},
1148
+ "counts": {"returned": len(messages), "unread": unread, "read": read},
1149
+ "messages": messages,
1150
+ }
1151
+ except Exception as e:
1152
+ return {"success": False, "account": acc.name, "error": str(e), "mailbox": mailbox2}
1153
+ finally:
1154
+ if client is not None:
1155
+ try:
1156
+ client.logout()
1157
+ except Exception:
1158
+ pass
1159
+
1160
+
1161
+ @tool(
1162
+ description="Read a specific email by IMAP UID (returns decoded subject/headers and text/html body).",
1163
+ tags=["comms", "email"],
1164
+ when_to_use="Use after list_emails to fetch the full content of a specific email.",
1165
+ examples=[
1166
+ {
1167
+ "description": "Read an email by UID from the default configured account",
1168
+ "arguments": {"uid": "12345"},
1169
+ },
1170
+ {"description": "Read an email by UID from a named account", "arguments": {"account": "work", "uid": "12345"}},
1171
+ ],
1172
+ )
1173
+ def read_email(
1174
+ *,
1175
+ uid: str,
1176
+ account: Optional[str] = None,
1177
+ mailbox: Optional[str] = None,
1178
+ timeout_s: float = 30.0,
1179
+ max_body_chars: int = 20000,
1180
+ ) -> Dict[str, Any]:
1181
+ """Read a single email by UID from an IMAP mailbox (best-effort; does not mark as read)."""
1182
+ acc, err = _select_email_account_for(account, capability="read")
1183
+ if err is not None:
1184
+ return {"success": False, "error": err}
1185
+ if acc is None or acc.imap is None:
1186
+ return {"success": False, "error": "IMAP account configuration is unavailable"}
1187
+
1188
+ imap_cfg = acc.imap
1189
+
1190
+ password, err2 = _resolve_required_env(imap_cfg.password_env_var, label="IMAP password")
1191
+ if err2 is not None:
1192
+ return {"success": False, "error": err2, "account": acc.name}
1193
+
1194
+ mailbox2 = str(mailbox or "").strip() or str(imap_cfg.mailbox or "").strip() or "INBOX"
1195
+ uid2 = str(uid or "").strip()
1196
+ if not uid2:
1197
+ return {"success": False, "error": "uid is required", "account": acc.name}
1198
+
1199
+ if int(imap_cfg.port) <= 0:
1200
+ return {"success": False, "error": "imap_port must be a positive integer", "account": acc.name}
1201
+
1202
+ try:
1203
+ max_chars = int(max_body_chars or 0)
1204
+ except Exception:
1205
+ max_chars = 0
1206
+ if max_chars <= 0:
1207
+ max_chars = 20000
1208
+
1209
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
1210
+ if timeout <= 0:
1211
+ timeout = 30.0
1212
+
1213
+ client: Optional[imaplib.IMAP4_SSL] = None
1214
+ try:
1215
+ client = imaplib.IMAP4_SSL(imap_cfg.host, int(imap_cfg.port))
1216
+ try:
1217
+ if getattr(client, "sock", None) is not None:
1218
+ client.sock.settimeout(timeout) # type: ignore[attr-defined]
1219
+ except Exception:
1220
+ pass
1221
+
1222
+ client.login(imap_cfg.username, password)
1223
+ typ, _ = client.select(mailbox2, readonly=True)
1224
+ if typ != "OK":
1225
+ return {"success": False, "error": f"Failed to select mailbox: {mailbox2}", "account": acc.name}
1226
+
1227
+ typ2, fetched = client.uid("fetch", uid2, "(FLAGS BODY.PEEK[])")
1228
+ if typ2 != "OK" or not fetched:
1229
+ return {"success": False, "error": f"Email not found for uid={uid2}", "account": acc.name, "mailbox": mailbox2}
1230
+
1231
+ raw_bytes: Optional[bytes] = None
1232
+ flags: list[str] = []
1233
+ for item in fetched:
1234
+ if not isinstance(item, tuple) or len(item) < 2:
1235
+ continue
1236
+ meta, payload = item[0], item[1]
1237
+ if isinstance(payload, (bytes, bytearray)) and payload:
1238
+ raw_bytes = bytes(payload)
1239
+ if isinstance(meta, (bytes, bytearray)):
1240
+ try:
1241
+ flags_bytes = imaplib.ParseFlags(meta)
1242
+ flags = [_normalize_imap_flag(fb.decode("utf-8", errors="replace")) for fb in flags_bytes]
1243
+ except Exception:
1244
+ flags = []
1245
+
1246
+ if raw_bytes is None:
1247
+ return {"success": False, "error": f"Failed to fetch email bytes for uid={uid2}", "account": acc.name, "mailbox": mailbox2}
1248
+
1249
+ msg = email.message_from_bytes(raw_bytes)
1250
+ subject_v = _decode_mime_header(msg.get("Subject"))
1251
+ from_v = _decode_mime_header(msg.get("From"))
1252
+ to_v = _decode_mime_header(msg.get("To"))
1253
+ cc_v = _decode_mime_header(msg.get("Cc"))
1254
+ date_v = _decode_mime_header(msg.get("Date"))
1255
+ message_id = _decode_mime_header(msg.get("Message-ID"))
1256
+
1257
+ body_text, body_html = _extract_text_bodies(msg)
1258
+ if len(body_text) > max_chars:
1259
+ body_text = body_text[:max_chars] + "…"
1260
+ if len(body_html) > max_chars:
1261
+ body_html = body_html[:max_chars] + "…"
1262
+
1263
+ attachments: list[Dict[str, Any]] = []
1264
+ if msg.is_multipart():
1265
+ for part in msg.walk():
1266
+ if part.is_multipart():
1267
+ continue
1268
+ disp = part.get_content_disposition()
1269
+ filename = part.get_filename()
1270
+ if disp == "attachment" or filename:
1271
+ attachments.append(
1272
+ {
1273
+ "filename": _decode_mime_header(filename),
1274
+ "content_type": str(part.get_content_type() or ""),
1275
+ }
1276
+ )
1277
+
1278
+ return {
1279
+ "success": True,
1280
+ "account": acc.name,
1281
+ "mailbox": mailbox2,
1282
+ "uid": uid2,
1283
+ "message_id": message_id,
1284
+ "subject": subject_v,
1285
+ "from": from_v,
1286
+ "to": to_v,
1287
+ "cc": cc_v,
1288
+ "date": date_v,
1289
+ "flags": flags,
1290
+ "seen": _imap_has_seen(flags),
1291
+ "body_text": body_text,
1292
+ "body_html": body_html,
1293
+ "attachments": attachments,
1294
+ }
1295
+ except Exception as e:
1296
+ return {"success": False, "account": acc.name, "error": str(e), "mailbox": mailbox2, "uid": uid2}
1297
+ finally:
1298
+ if client is not None:
1299
+ try:
1300
+ client.logout()
1301
+ except Exception:
1302
+ pass
1303
+
1304
+
1305
+ def _whatsapp_prefix(value: str) -> str:
1306
+ raw = str(value or "").strip()
1307
+ if not raw:
1308
+ return ""
1309
+ return raw if raw.lower().startswith("whatsapp:") else f"whatsapp:{raw}"
1310
+
1311
+
1312
+ @dataclass(frozen=True)
1313
+ class _TwilioCreds:
1314
+ account_sid: str
1315
+ auth_token: str
1316
+
1317
+
1318
+ def _twilio_creds(*, account_sid_env_var: str, auth_token_env_var: str) -> Tuple[Optional[_TwilioCreds], Optional[str]]:
1319
+ sid, err1 = _resolve_required_env(account_sid_env_var, label="Twilio account SID")
1320
+ if err1 is not None:
1321
+ return None, err1
1322
+ tok, err2 = _resolve_required_env(auth_token_env_var, label="Twilio auth token")
1323
+ if err2 is not None:
1324
+ return None, err2
1325
+ return _TwilioCreds(account_sid=sid, auth_token=tok), None
1326
+
1327
+
1328
+ def _twilio_base_url(account_sid: str) -> str:
1329
+ sid = str(account_sid or "").strip()
1330
+ return f"https://api.twilio.com/2010-04-01/Accounts/{sid}"
1331
+
1332
+
1333
+ @tool(
1334
+ description="Send a WhatsApp message via a provider API (default: Twilio REST).",
1335
+ tags=["comms", "whatsapp"],
1336
+ when_to_use="Use to send a WhatsApp message notification (credentials resolved from env vars).",
1337
+ examples=[
1338
+ {
1339
+ "description": "Send via Twilio WhatsApp",
1340
+ "arguments": {
1341
+ "to": "+15551234567",
1342
+ "from_number": "+15557654321",
1343
+ "body": "Hello from AbstractFramework",
1344
+ },
1345
+ }
1346
+ ],
1347
+ )
1348
+ def send_whatsapp_message(
1349
+ to: str,
1350
+ from_number: str,
1351
+ body: str,
1352
+ *,
1353
+ provider: str = "twilio",
1354
+ account_sid_env_var: str = "TWILIO_ACCOUNT_SID",
1355
+ auth_token_env_var: str = "TWILIO_AUTH_TOKEN",
1356
+ timeout_s: float = 30.0,
1357
+ media_urls: Any = None,
1358
+ ) -> Dict[str, Any]:
1359
+ """Send a WhatsApp message (Twilio-backed v1)."""
1360
+ provider_norm = str(provider or "").strip().lower() or "twilio"
1361
+ if provider_norm != "twilio":
1362
+ return {"success": False, "error": f"Unsupported WhatsApp provider: {provider_norm} (v1 supports: twilio)"}
1363
+
1364
+ creds, err = _twilio_creds(account_sid_env_var=account_sid_env_var, auth_token_env_var=auth_token_env_var)
1365
+ if err is not None:
1366
+ return {"success": False, "error": err}
1367
+
1368
+ to_norm = _whatsapp_prefix(to)
1369
+ from_norm = _whatsapp_prefix(from_number)
1370
+ body_norm = str(body or "").strip()
1371
+ if not to_norm:
1372
+ return {"success": False, "error": "to is required"}
1373
+ if not from_norm:
1374
+ return {"success": False, "error": "from_number is required"}
1375
+ if not body_norm:
1376
+ return {"success": False, "error": "body is required"}
1377
+
1378
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
1379
+ if timeout <= 0:
1380
+ timeout = 30.0
1381
+
1382
+ media_list = _coerce_str_list(media_urls)
1383
+
1384
+ try:
1385
+ import requests # type: ignore
1386
+ except Exception as e:
1387
+ return {"success": False, "error": f"requests is required for WhatsApp tools: {e}"}
1388
+
1389
+ url = f"{_twilio_base_url(creds.account_sid)}/Messages.json"
1390
+ data: Dict[str, Any] = {"To": to_norm, "From": from_norm, "Body": body_norm}
1391
+ # Twilio supports multiple MediaUrl fields (repeated keys); requests supports sequences of tuples.
1392
+ request_data: Any = data
1393
+ if media_list:
1394
+ pairs: list[tuple[str, str]] = [(k, str(v)) for k, v in data.items()]
1395
+ for mu in media_list:
1396
+ pairs.append(("MediaUrl", mu))
1397
+ request_data = pairs
1398
+
1399
+ try:
1400
+ resp = requests.post(url, data=request_data, auth=(creds.account_sid, creds.auth_token), timeout=timeout)
1401
+ except Exception as e:
1402
+ return {"success": False, "error": str(e), "provider": provider_norm}
1403
+
1404
+ try:
1405
+ payload = resp.json()
1406
+ except Exception:
1407
+ payload = {"raw": (resp.text or "").strip()}
1408
+
1409
+ if not getattr(resp, "ok", False):
1410
+ return {
1411
+ "success": False,
1412
+ "error": str(payload.get("message") or payload.get("raw") or f"HTTP {resp.status_code}"),
1413
+ "status_code": int(getattr(resp, "status_code", 0) or 0),
1414
+ "provider": provider_norm,
1415
+ }
1416
+
1417
+ sid = str(payload.get("sid") or "")
1418
+ return {
1419
+ "success": True,
1420
+ "provider": provider_norm,
1421
+ "sid": sid,
1422
+ "status": payload.get("status"),
1423
+ "to": payload.get("to") or to_norm,
1424
+ "from": payload.get("from") or from_norm,
1425
+ }
1426
+
1427
+
1428
+ @tool(
1429
+ description="List recent WhatsApp messages via a provider API (default: Twilio REST).",
1430
+ tags=["comms", "whatsapp"],
1431
+ when_to_use="Use to fetch a digest of recent WhatsApp messages for review (since + direction filters).",
1432
+ examples=[
1433
+ {
1434
+ "description": "List inbound messages since 7 days ago",
1435
+ "arguments": {"since": "7d", "direction": "inbound"},
1436
+ }
1437
+ ],
1438
+ )
1439
+ def list_whatsapp_messages(
1440
+ *,
1441
+ provider: str = "twilio",
1442
+ account_sid_env_var: str = "TWILIO_ACCOUNT_SID",
1443
+ auth_token_env_var: str = "TWILIO_AUTH_TOKEN",
1444
+ to: Optional[str] = None,
1445
+ from_number: Optional[str] = None,
1446
+ since: Optional[str] = None,
1447
+ limit: int = 20,
1448
+ direction: str = "all",
1449
+ timeout_s: float = 30.0,
1450
+ ) -> Dict[str, Any]:
1451
+ """List recent WhatsApp messages (Twilio-backed v1)."""
1452
+ provider_norm = str(provider or "").strip().lower() or "twilio"
1453
+ if provider_norm != "twilio":
1454
+ return {"success": False, "error": f"Unsupported WhatsApp provider: {provider_norm} (v1 supports: twilio)"}
1455
+
1456
+ creds, err = _twilio_creds(account_sid_env_var=account_sid_env_var, auth_token_env_var=auth_token_env_var)
1457
+ if err is not None:
1458
+ return {"success": False, "error": err}
1459
+
1460
+ try:
1461
+ limit_i = int(limit or 0)
1462
+ except Exception:
1463
+ limit_i = 0
1464
+ if limit_i <= 0:
1465
+ limit_i = 20
1466
+
1467
+ dt_since, dt_err = _parse_since(since)
1468
+ if dt_err is not None:
1469
+ return {"success": False, "error": dt_err}
1470
+
1471
+ direction_norm = str(direction or "").strip().lower() or "all"
1472
+ if direction_norm not in {"all", "inbound", "outbound"}:
1473
+ return {"success": False, "error": "direction must be one of: all, inbound, outbound"}
1474
+
1475
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
1476
+ if timeout <= 0:
1477
+ timeout = 30.0
1478
+
1479
+ to_norm = _whatsapp_prefix(to or "") if to else None
1480
+ from_norm = _whatsapp_prefix(from_number or "") if from_number else None
1481
+
1482
+ try:
1483
+ import requests # type: ignore
1484
+ except Exception as e:
1485
+ return {"success": False, "error": f"requests is required for WhatsApp tools: {e}"}
1486
+
1487
+ url = f"{_twilio_base_url(creds.account_sid)}/Messages.json"
1488
+ params: Dict[str, Any] = {"PageSize": limit_i}
1489
+ if to_norm:
1490
+ params["To"] = to_norm
1491
+ if from_norm:
1492
+ params["From"] = from_norm
1493
+ if dt_since is not None:
1494
+ # Twilio supports DateSent> filters (date-only).
1495
+ params["DateSent>"] = dt_since.date().isoformat()
1496
+
1497
+ try:
1498
+ resp = requests.get(url, params=params, auth=(creds.account_sid, creds.auth_token), timeout=timeout)
1499
+ except Exception as e:
1500
+ return {"success": False, "error": str(e), "provider": provider_norm}
1501
+
1502
+ try:
1503
+ payload = resp.json()
1504
+ except Exception:
1505
+ payload = {"raw": (resp.text or "").strip()}
1506
+
1507
+ if not getattr(resp, "ok", False):
1508
+ return {
1509
+ "success": False,
1510
+ "error": str(payload.get("message") or payload.get("raw") or f"HTTP {resp.status_code}"),
1511
+ "status_code": int(getattr(resp, "status_code", 0) or 0),
1512
+ "provider": provider_norm,
1513
+ }
1514
+
1515
+ raw_messages = payload.get("messages")
1516
+ if not isinstance(raw_messages, list):
1517
+ raw_messages = []
1518
+
1519
+ out: list[Dict[str, Any]] = []
1520
+ for m in raw_messages[:limit_i]:
1521
+ if not isinstance(m, dict):
1522
+ continue
1523
+ direction_val = str(m.get("direction") or "")
1524
+ if direction_norm == "inbound" and not direction_val.startswith("inbound"):
1525
+ continue
1526
+ if direction_norm == "outbound" and not direction_val.startswith("outbound"):
1527
+ continue
1528
+
1529
+ body_text = str(m.get("body") or "")
1530
+ body_text = preview_text(body_text, max_chars=500)
1531
+
1532
+ out.append(
1533
+ {
1534
+ "sid": str(m.get("sid") or ""),
1535
+ "status": m.get("status"),
1536
+ "direction": direction_val,
1537
+ "from": m.get("from"),
1538
+ "to": m.get("to"),
1539
+ "date_sent": m.get("date_sent"),
1540
+ "date_created": m.get("date_created"),
1541
+ "body": body_text,
1542
+ }
1543
+ )
1544
+
1545
+ return {
1546
+ "success": True,
1547
+ "provider": provider_norm,
1548
+ "filter": {
1549
+ "since": dt_since.isoformat() if dt_since else None,
1550
+ "direction": direction_norm,
1551
+ "to": to_norm,
1552
+ "from": from_norm,
1553
+ "limit": limit_i,
1554
+ },
1555
+ "messages": out,
1556
+ "counts": {"returned": len(out)},
1557
+ }
1558
+
1559
+
1560
+ @tool(
1561
+ description="Read a specific WhatsApp message by provider message id (default: Twilio SID).",
1562
+ tags=["comms", "whatsapp"],
1563
+ when_to_use="Use after list_whatsapp_messages to fetch full details of one message.",
1564
+ examples=[
1565
+ {"description": "Read a message by SID", "arguments": {"message_id": "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}},
1566
+ ],
1567
+ )
1568
+ def read_whatsapp_message(
1569
+ message_id: str,
1570
+ *,
1571
+ provider: str = "twilio",
1572
+ account_sid_env_var: str = "TWILIO_ACCOUNT_SID",
1573
+ auth_token_env_var: str = "TWILIO_AUTH_TOKEN",
1574
+ timeout_s: float = 30.0,
1575
+ max_body_chars: int = 2000,
1576
+ ) -> Dict[str, Any]:
1577
+ """Read a WhatsApp message by id (Twilio-backed v1)."""
1578
+ provider_norm = str(provider or "").strip().lower() or "twilio"
1579
+ if provider_norm != "twilio":
1580
+ return {"success": False, "error": f"Unsupported WhatsApp provider: {provider_norm} (v1 supports: twilio)"}
1581
+
1582
+ creds, err = _twilio_creds(account_sid_env_var=account_sid_env_var, auth_token_env_var=auth_token_env_var)
1583
+ if err is not None:
1584
+ return {"success": False, "error": err}
1585
+
1586
+ mid = str(message_id or "").strip()
1587
+ if not mid:
1588
+ return {"success": False, "error": "message_id is required"}
1589
+
1590
+ timeout = float(timeout_s) if isinstance(timeout_s, (int, float)) else 30.0
1591
+ if timeout <= 0:
1592
+ timeout = 30.0
1593
+
1594
+ try:
1595
+ max_chars = int(max_body_chars or 0)
1596
+ except Exception:
1597
+ max_chars = 0
1598
+ if max_chars <= 0:
1599
+ max_chars = 2000
1600
+
1601
+ try:
1602
+ import requests # type: ignore
1603
+ except Exception as e:
1604
+ return {"success": False, "error": f"requests is required for WhatsApp tools: {e}"}
1605
+
1606
+ url = f"{_twilio_base_url(creds.account_sid)}/Messages/{mid}.json"
1607
+ try:
1608
+ resp = requests.get(url, auth=(creds.account_sid, creds.auth_token), timeout=timeout)
1609
+ except Exception as e:
1610
+ return {"success": False, "error": str(e), "provider": provider_norm}
1611
+
1612
+ try:
1613
+ payload = resp.json()
1614
+ except Exception:
1615
+ payload = {"raw": (resp.text or "").strip()}
1616
+
1617
+ if not getattr(resp, "ok", False):
1618
+ return {
1619
+ "success": False,
1620
+ "error": str(payload.get("message") or payload.get("raw") or f"HTTP {resp.status_code}"),
1621
+ "status_code": int(getattr(resp, "status_code", 0) or 0),
1622
+ "provider": provider_norm,
1623
+ }
1624
+
1625
+ body_text = str(payload.get("body") or "")
1626
+ if len(body_text) > max_chars:
1627
+ body_text = body_text[:max_chars] + "…"
1628
+
1629
+ return {
1630
+ "success": True,
1631
+ "provider": provider_norm,
1632
+ "sid": str(payload.get("sid") or mid),
1633
+ "status": payload.get("status"),
1634
+ "direction": payload.get("direction"),
1635
+ "from": payload.get("from"),
1636
+ "to": payload.get("to"),
1637
+ "date_sent": payload.get("date_sent"),
1638
+ "date_created": payload.get("date_created"),
1639
+ "body": body_text,
1640
+ "raw": payload,
1641
+ }