bf923d9cf7
- 后端 POST /tenants/{slug}/email/test:resolve_email_settings 当前配置
真发测试邮件(主题 [all-kefu] SMTP 测试邮件+租户名+UTC+8 时间);
回退后 host/user 仍空 → 400;成败如实返回 {sent, error},密钥不进响应
- 后端 POST /tenants/{slug}/push/test:连通性探测——与 _do_post 同款
连接形态 POST 空 messages 批量(红线:绝不携带线索数据);不可达
reason 分类 dns/timeout/connect(Windows 裸 ConnectError 文案兜底),
auth_ok 三态(2xx 真 / 401·403 假 / 其余 null),单次无重试
- email_service 抽 _smtp_send 共用连接逻辑(_send 契约不变)+
send_test_email(成败+错误串如实返回,不走吞异常 bool)
- 前端 CredsPane:邮件/推送/AI 三组凭据表单(数值/邮箱前端预检 +
后端 400 双层);密钥哨兵交互(__SET__ 占位符/不动保存保持库值/
清除按钮显式清空可撤销);SMTP 测试与推送探测三态内联展示
- 测试:pytest 新增 18(email/test 7 + push/test 11),全量 595 通过;
vitest 新增 20(model 哨兵往返/预检边界 + API 调用形态),107 通过
Co-Authored-By: Claude Code <noreply@anthropic.com>
213 lines
8.8 KiB
Python
213 lines
8.8 KiB
Python
import html
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
|
||
import aiosmtplib
|
||
|
||
from app.engine.services.cc_completion import cc_state_for, outbound_e164, outbound_number
|
||
# Task 10: 发信参数 resolver 收拢至 config_views(单一定义点)
|
||
from app.platform.config_views import resolve_email_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TAG_LABELS = {
|
||
"interested": "意向强", "pending": "待确认",
|
||
"not_interested": "无意向", "": "未评估",
|
||
}
|
||
CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"}
|
||
|
||
|
||
def _esc(v) -> str:
|
||
return html.escape(str(v)) if v else ""
|
||
|
||
|
||
def _flat(v) -> str:
|
||
"""主题行用:折叠空白(堵换行注入头部的通道)+ 限长。"""
|
||
return " ".join(str(v).split())[:60]
|
||
|
||
|
||
def _contact_rows(contacts: dict) -> str:
|
||
return "".join(
|
||
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{html.escape(CONTACT_LABELS.get(k, k))}</td>"
|
||
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
|
||
for k, v in (contacts or {}).items() if v
|
||
)
|
||
|
||
|
||
def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]:
|
||
"""Task 6(spec §5)出站双视图:_cc 感知的正文/主题两套联系方式值。
|
||
|
||
正文视图(body):phone/whatsapp 有态 → e164 + 按 confirmed 标注;
|
||
无 e164 → 裸号 +(缺国家区号);canon 不匹配的残留态在 cc_state_for
|
||
内判 None 回退原文。主题视图(subj):同状态一律纯 e164 无标注
|
||
(主题拥挤)。其余渠道(email/wechat)原样。
|
||
"""
|
||
body: dict = {}
|
||
subj: dict = {}
|
||
for k, v in (contacts or {}).items():
|
||
if not v:
|
||
continue
|
||
if k in ("phone", "whatsapp"):
|
||
state = cc_state_for(cc_info, k, str(v))
|
||
body[k] = outbound_number(str(v), state)
|
||
subj[k] = outbound_e164(str(v), state)
|
||
else:
|
||
body[k] = str(v)
|
||
subj[k] = str(v)
|
||
return body, subj
|
||
|
||
|
||
async def _smtp_send(msg: MIMEMultipart, cfg: dict) -> None:
|
||
"""底层连接发送(Phase 3 Task 7c 从 _send 抽出): 直调 aiosmtplib,
|
||
失败抛原始异常——需要错误串的调用方(配置中心测试邮件 send_test_email)
|
||
复用同一连接逻辑;_send 在此之上包"吞异常返 bool"契约,行为不变。"""
|
||
await aiosmtplib.send(
|
||
msg, hostname=cfg["host"], port=cfg["port"],
|
||
username=cfg["user"], password=cfg["password"],
|
||
use_tls=True,
|
||
)
|
||
|
||
|
||
async def _send(msg: MIMEMultipart, cfg: dict) -> bool:
|
||
if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]):
|
||
logger.warning("SMTP not configured, skipping email")
|
||
return False
|
||
try:
|
||
await _smtp_send(msg, cfg)
|
||
logger.info("Lead email sent to %s", cfg["notify_emails"])
|
||
return True
|
||
except Exception:
|
||
logger.exception("Failed to send lead email")
|
||
return False
|
||
|
||
|
||
async def send_lead_email(
|
||
customer_profile: dict,
|
||
contact_info: dict,
|
||
reasoning: str,
|
||
conversation_messages: list[dict],
|
||
intent_level: str = "",
|
||
cc_info: dict | None = None,
|
||
config_data: dict | None = None,
|
||
) -> bool:
|
||
"""cc_info = raw_info["_cc"](Task 6, spec §5):正文联系方式行渲染
|
||
补全号+标注、主题行号码用 e164 无标注;缺省(无 _cc/显式 + 号)行为不变。
|
||
config_data = 租户配置(Task 9):邮件参数经 resolve_email_settings 解析,
|
||
None 走 env settings(平台默认)。"""
|
||
tag = TAG_LABELS.get(intent_level, "未评估")
|
||
contacts, subj_contacts = _cc_views(contact_info, cc_info)
|
||
country = (customer_profile or {}).get("目标国家", "")
|
||
if not country:
|
||
country = (customer_profile or {}).get("target_country", "未知")
|
||
|
||
msg = MIMEMultipart("alternative")
|
||
contact_parts = [
|
||
f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
|
||
]
|
||
subject = f"[新询盘·{tag}] {_flat(country)}"
|
||
if contact_parts:
|
||
subject += " | " + ", ".join(contact_parts)
|
||
cfg = resolve_email_settings(config_data)
|
||
msg["Subject"] = subject
|
||
msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>"
|
||
msg["To"] = ", ".join(cfg["notify_emails"])
|
||
|
||
info_rows = "".join(
|
||
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{_esc(k)}</td>"
|
||
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
|
||
for k, v in (customer_profile or {}).items() if not str(k).startswith("_")
|
||
)
|
||
chat_lines = "".join(
|
||
f"<div style='margin:4px 0;'><b>{'客户' if m.get('role') == 'user' else 'AI'}:</b> {_esc(m.get('content', ''))}</div>"
|
||
for m in conversation_messages or []
|
||
)
|
||
|
||
html_body = f"""
|
||
<html><body style="font-family:sans-serif;color:#333;">
|
||
<h2>🎉 新询盘({html.escape(tag)})</h2>
|
||
<h3>联系方式</h3>
|
||
<table style="border-collapse:collapse;border:1px solid #ddd;">{_contact_rows(contacts)}</table>
|
||
<h3>客户画像</h3>
|
||
<table style="border-collapse:collapse;border:1px solid #ddd;">{info_rows}</table>
|
||
<h3>评估理由</h3><p>{_esc(reasoning)}</p>
|
||
<h3>完整对话记录</h3>
|
||
<div style="background:#f5f5f5;padding:12px;border-radius:6px;">{chat_lines}</div>
|
||
<p style="color:#999;font-size:12px;">注:邮件为触发时快照,对话可能仍在进行,最新记录请以后台为准。</p>
|
||
</body></html>
|
||
"""
|
||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||
return await _send(msg, cfg)
|
||
|
||
|
||
async def send_returning_lead_email(
|
||
contact_info: dict, cc_info: dict | None = None,
|
||
config_data: dict | None = None,
|
||
) -> bool:
|
||
"""D5: 24h 内同联系方式再访,只发一行轻提醒。主题行号码走 e164(Task 6)。"""
|
||
_body, subj_contacts = _cc_views(contact_info, cc_info)
|
||
contact_parts = [
|
||
f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
|
||
]
|
||
cfg = resolve_email_settings(config_data)
|
||
msg = MIMEMultipart("alternative")
|
||
msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访"
|
||
msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>"
|
||
msg["To"] = ", ".join(cfg["notify_emails"])
|
||
msg.attach(MIMEText(
|
||
f"<html><body><p>该联系方式 24h 内已发过完整询盘,客户又开启了新对话,"
|
||
f"详情请查后台。</p></body></html>", "html", "utf-8"))
|
||
return await _send(msg, cfg)
|
||
|
||
|
||
async def send_correction_email(
|
||
contact_info: dict, count: int = 1, cc_info: dict | None = None,
|
||
config_data: dict | None = None,
|
||
) -> bool:
|
||
"""D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。
|
||
正文联系方式行渲染补全号+标注(Task 6, spec §5 更正路径)。"""
|
||
body_contacts, _subj = _cc_views(contact_info, cc_info)
|
||
cfg = resolve_email_settings(config_data)
|
||
msg = MIMEMultipart("alternative")
|
||
msg["Subject"] = f"[询盘·更正{count}] 客户联系方式已更新(以本封为准)"
|
||
msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>"
|
||
msg["To"] = ", ".join(cfg["notify_emails"])
|
||
msg.attach(MIMEText(
|
||
f"<html><body><p>这是本会话第 {count} 次更正(上限 3 次),请以本封为准:</p>"
|
||
f"<table>{_contact_rows(body_contacts)}</table>"
|
||
f"</body></html>", "html", "utf-8"))
|
||
return await _send(msg, cfg)
|
||
|
||
|
||
async def send_test_email(
|
||
to: str, tenant_name: str = "",
|
||
config_data: dict | None = None,
|
||
) -> tuple[bool, str | None]:
|
||
"""配置中心测试邮件(Phase 3 Task 7c): 按当前生效邮件配置真发一封到
|
||
指定收件人——主题/正文钉契约("[all-kefu] SMTP 测试邮件"+租户名+时间,
|
||
时间与 lead_push_service 同款 +08:00 口径)。
|
||
|
||
与 _send 的吞异常 bool 契约解耦: 成败与错误串如实返回(探测端点直传
|
||
前端,凭据对不对让收件箱与错误串说话);连接逻辑复用 _smtp_send。
|
||
"SMTP 未配置"的 400 判定在端点层(本函数假定 cfg 已可用)。"""
|
||
cfg = resolve_email_settings(config_data)
|
||
now = datetime.now(timezone(timedelta(hours=8)))
|
||
msg = MIMEMultipart("alternative")
|
||
msg["Subject"] = "[all-kefu] SMTP 测试邮件"
|
||
msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>"
|
||
msg["To"] = to
|
||
msg.attach(MIMEText(
|
||
f"<html><body>"
|
||
f"<p>这是一封 SMTP 配置测试邮件——收到本邮件说明该租户的邮件配置"
|
||
f"可以正常发信。</p>"
|
||
f"<p>租户:{_esc(tenant_name)}</p>"
|
||
f"<p>时间:{now.strftime('%Y-%m-%d %H:%M:%S')}(UTC+8)</p>"
|
||
f"</body></html>", "html", "utf-8"))
|
||
try:
|
||
await _smtp_send(msg, cfg)
|
||
return True, None
|
||
except Exception as exc:
|
||
logger.exception("Test email to %s failed", to)
|
||
return False, repr(exc)
|