Files
ai-kefu-admin/app/engine/services/email_service.py
T
浅念 71e11c317f refactor: config_views 单入口 + 0语义统一 + FakeAI 收敛(清理单#1#2#3)
- #3 六个消费视图 resolver 收拢 app/platform/config_views(单一定义点):
  resolve_email/push/ai_settings ← 各服务文件, resolve_contact_flags/
  context_seed_map ← conversation, name_to_region_extras ← manager
  __init__ 内联取值(or None 语义原样)——函数体逐字搬入,服务层 import 之,
  旧定义删;Phase 3 编辑器/试金石与该模块对接
- #2 限流 0 语义统一: conn_limiter.try_acquire 的 limit 与 chat_rest 的
  rate_limit_per_hour 取值同款——0 或负 = 回退出厂默认(与姊妹键一致),
  原 conn_limiter 0=全拒改为回退;chat_rest 取值抽 _resolve_rate_limit
  纯函数供锚定
- #1 FakeAI 收敛进 conftest(超集: extract/evaluate/eval_calls),四份拷贝
  删(test_lead_email_trigger/test_cc_integration/test_channel_hygiene/
  test_lead_push_wiring——计划记三份,grep 实有四份;push_wiring 经
  evaluate 参数注入原本地固定返回值,断言零改动)

全量 558 passed(554 + 新增 4: 单入口/内联等价/两处 0 语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:45:42 +08:00

173 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
import logging
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 _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 aiosmtplib.send(
msg, hostname=cfg["host"], port=cfg["port"],
username=cfg["user"], password=cfg["password"],
use_tls=True,
)
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)