zcbot/tools/send_email.py

170 lines
6.2 KiB
Python

"""发邮件(host-side,DESIGN §8.5 投递第 2/3 层)。
- `send_email_smtp(...)`:纯函数,读 SMTP_* env 发信。SendEmailTool 与定时任务的
确定性兜底投递(core/scheduler.py notify)共用它。
- `smtp_configured()`:agent_builder 据此决定挂不挂 tool(沿用"有 key 才注册"范式,
§3.4);没配 SMTP 的部署里 agent 看不到一个永远报错的工具。
- `SendEmailTool`:agent 在(定时或交互)run 里调,附件路径强制落 user_root 内防越界。
密钥只在 host 进程读,绝不进沙箱 / run_python。env:
SMTP_HOST SMTP_PORT(默 465) SMTP_USER SMTP_PASSWORD
SMTP_FROM(默 SMTP_USER) SMTP_FROM_NAME(发件人显示名,默"总院科研辅助智能体")
SMTP_TLS(ssl|starttls|none;默按端口:465→ssl 否则 starttls)
"""
from __future__ import annotations
import os
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from pathlib import Path
from typing import Iterable, Optional
from .base import FileOutOfBounds, Tool
_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 单封附件总上限,防把大产物塞爆 SMTP
_MAX_RECIPIENTS = 10
def smtp_configured() -> bool:
"""最小可发信集合是否齐全。"""
return bool(
os.getenv("SMTP_HOST", "").strip()
and os.getenv("SMTP_USER", "").strip()
and os.getenv("SMTP_PASSWORD", "").strip()
)
def _tls_mode(port: int) -> str:
mode = os.getenv("SMTP_TLS", "").strip().lower()
if mode in ("ssl", "starttls", "none"):
return mode
return "ssl" if port == 465 else "starttls"
def send_email_smtp(
to: Iterable[str] | str,
subject: str,
body: str,
attachments: Optional[Iterable[Path]] = None,
*,
timeout: float = 30.0,
) -> None:
"""同步发一封纯文本邮件(可带附件)。失败抛异常,由调用方决定如何处理。
host-side 调用(SendEmailTool 在 to_thread 的 run 线程里;scheduler 在
run_in_executor 里)—— smtplib 是阻塞 IO,不要在 asyncio loop 直接 await。
"""
if not smtp_configured():
raise RuntimeError("SMTP 未配置(需 SMTP_HOST/SMTP_USER/SMTP_PASSWORD)")
host = os.getenv("SMTP_HOST", "").strip()
port = int(os.getenv("SMTP_PORT", "465").strip() or "465")
user = os.getenv("SMTP_USER", "").strip()
password = os.getenv("SMTP_PASSWORD", "").strip()
sender = os.getenv("SMTP_FROM", "").strip() or user
from_name = os.getenv("SMTP_FROM_NAME", "").strip() or "总院科研辅助智能体"
if isinstance(to, str):
to_list = [to]
else:
to_list = list(to)
to_list = [a.strip() for a in to_list if a and a.strip()]
if not to_list:
raise ValueError("收件人为空")
if len(to_list) > _MAX_RECIPIENTS:
raise ValueError(f"收件人过多(上限 {_MAX_RECIPIENTS})")
msg = EmailMessage()
msg["From"] = formataddr((from_name, sender))
msg["To"] = ", ".join(to_list)
msg["Subject"] = subject or "(无主题)"
msg.set_content(body or "")
total = 0
for p in attachments or []:
p = Path(p)
if not p.is_file():
continue
data = p.read_bytes()
total += len(data)
if total > _MAX_ATTACH_BYTES:
raise ValueError(f"附件总大小超过 {_MAX_ATTACH_BYTES // (1024*1024)}MB")
msg.add_attachment(
data, maintype="application", subtype="octet-stream", filename=p.name
)
tls = _tls_mode(port)
if tls == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
smtp.login(user, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
if tls == "starttls":
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
class SendEmailTool(Tool):
name = "send_email"
description = (
"Send an email (plain text, optional file attachments) via the server's configured "
"SMTP account. Use this when the user asks to email a result/report to someone, or when "
"a scheduled task's instruction says to email its output. Attachments are paths inside "
"the working directory (e.g. 'report.docx' or '<wd>/figures/x.png'). Returns a short "
"confirmation or an [Error] line."
)
parameters = {
"type": "object",
"properties": {
"to": {
"type": "array",
"items": {"type": "string"},
"description": "Recipient email address(es).",
},
"subject": {"type": "string", "description": "Email subject line."},
"body": {"type": "string", "description": "Plain-text email body."},
"attachments": {
"type": "array",
"items": {"type": "string"},
"description": "Optional file paths (relative to working dir) to attach.",
},
},
"required": ["to", "subject", "body"],
}
def execute(
self,
to: list[str] | str,
subject: str,
body: str,
attachments: Optional[list[str]] = None,
) -> str:
if isinstance(to, str):
to = [to]
recipients = [a.strip() for a in (to or []) if isinstance(a, str) and a.strip()]
if not recipients:
return "[Error] to 不能为空"
resolved: list[Path] = []
for raw in attachments or []:
if not isinstance(raw, str) or not raw.strip():
continue
# host-side 解析:容器 /workspace 路径翻回宿主 + 强制落 user_root 内(防越界)
try:
p = self._resolve_user_file(raw.strip())
except FileOutOfBounds:
return f"[Error] 附件路径越界(必须在工作目录内): {raw}"
if not p.is_file():
return f"[Error] 附件不存在: {raw}"
resolved.append(p)
try:
send_email_smtp(recipients, subject, body, resolved)
except Exception as e:
return f"[Error] 发送失败: {type(e).__name__}: {e}"
n = f"(含 {len(resolved)} 个附件)" if resolved else ""
return f"[ok] 已发送给 {', '.join(recipients)} {n}".strip()