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.
- abstractcore/__init__.py +7 -27
- abstractcore/apps/deepsearch.py +9 -4
- abstractcore/apps/extractor.py +33 -100
- abstractcore/apps/intent.py +19 -0
- abstractcore/apps/judge.py +20 -1
- abstractcore/apps/summarizer.py +20 -1
- abstractcore/architectures/detection.py +34 -1
- abstractcore/architectures/response_postprocessing.py +313 -0
- abstractcore/assets/architecture_formats.json +38 -8
- abstractcore/assets/model_capabilities.json +882 -160
- abstractcore/compression/__init__.py +1 -2
- abstractcore/compression/glyph_processor.py +6 -4
- abstractcore/config/main.py +52 -20
- abstractcore/config/manager.py +390 -12
- abstractcore/config/vision_config.py +5 -5
- abstractcore/core/interface.py +151 -3
- abstractcore/core/session.py +16 -10
- abstractcore/download.py +1 -1
- abstractcore/embeddings/manager.py +20 -6
- abstractcore/endpoint/__init__.py +2 -0
- abstractcore/endpoint/app.py +458 -0
- abstractcore/mcp/client.py +3 -1
- abstractcore/media/__init__.py +52 -17
- abstractcore/media/auto_handler.py +42 -22
- abstractcore/media/base.py +44 -1
- abstractcore/media/capabilities.py +12 -33
- abstractcore/media/enrichment.py +105 -0
- abstractcore/media/handlers/anthropic_handler.py +19 -28
- abstractcore/media/handlers/local_handler.py +124 -70
- abstractcore/media/handlers/openai_handler.py +19 -31
- abstractcore/media/processors/__init__.py +4 -2
- abstractcore/media/processors/audio_processor.py +57 -0
- abstractcore/media/processors/office_processor.py +8 -3
- abstractcore/media/processors/pdf_processor.py +46 -3
- abstractcore/media/processors/text_processor.py +22 -24
- abstractcore/media/processors/video_processor.py +58 -0
- abstractcore/media/types.py +97 -4
- abstractcore/media/utils/image_scaler.py +20 -2
- abstractcore/media/utils/video_frames.py +219 -0
- abstractcore/media/vision_fallback.py +136 -22
- abstractcore/processing/__init__.py +32 -3
- abstractcore/processing/basic_deepsearch.py +15 -10
- abstractcore/processing/basic_intent.py +3 -2
- abstractcore/processing/basic_judge.py +3 -2
- abstractcore/processing/basic_summarizer.py +1 -1
- abstractcore/providers/__init__.py +3 -1
- abstractcore/providers/anthropic_provider.py +95 -8
- abstractcore/providers/base.py +1516 -81
- abstractcore/providers/huggingface_provider.py +546 -69
- abstractcore/providers/lmstudio_provider.py +30 -916
- abstractcore/providers/mlx_provider.py +382 -35
- abstractcore/providers/model_capabilities.py +5 -1
- abstractcore/providers/ollama_provider.py +99 -15
- abstractcore/providers/openai_compatible_provider.py +406 -180
- abstractcore/providers/openai_provider.py +188 -44
- abstractcore/providers/openrouter_provider.py +76 -0
- abstractcore/providers/registry.py +61 -5
- abstractcore/providers/streaming.py +138 -33
- abstractcore/providers/vllm_provider.py +92 -817
- abstractcore/server/app.py +478 -28
- abstractcore/server/audio_endpoints.py +139 -0
- abstractcore/server/vision_endpoints.py +1319 -0
- abstractcore/structured/handler.py +316 -41
- abstractcore/tools/common_tools.py +5501 -2012
- abstractcore/tools/comms_tools.py +1641 -0
- abstractcore/tools/core.py +37 -7
- abstractcore/tools/handler.py +4 -9
- abstractcore/tools/parser.py +49 -2
- abstractcore/tools/tag_rewriter.py +2 -1
- abstractcore/tools/telegram_tdlib.py +407 -0
- abstractcore/tools/telegram_tools.py +261 -0
- abstractcore/utils/cli.py +1085 -72
- abstractcore/utils/structured_logging.py +29 -8
- abstractcore/utils/token_utils.py +2 -0
- abstractcore/utils/truncation.py +29 -0
- abstractcore/utils/version.py +3 -4
- abstractcore/utils/vlm_token_calculator.py +12 -2
- abstractcore-2.11.4.dist-info/METADATA +562 -0
- abstractcore-2.11.4.dist-info/RECORD +133 -0
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/WHEEL +1 -1
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/entry_points.txt +1 -0
- abstractcore-2.9.1.dist-info/METADATA +0 -1190
- abstractcore-2.9.1.dist-info/RECORD +0 -119
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.4.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
}
|