import html import logging from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import aiosmtplib from app.engine.config import settings from app.engine.services.cc_completion import cc_state_for, outbound_e164, outbound_number logger = logging.getLogger(__name__) TAG_LABELS = { "interested": "意向强", "pending": "待确认", "not_interested": "无意向", "": "未评估", } CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"} # 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用) _DEFAULT_SENDER_NAME = "HESUPIPE" def resolve_email_settings(config_data: dict | None) -> dict: """租户 config.email → 发信参数(Task 9 接线; env settings 回退)。 回退规则(语义钉在 test_config_merge: 显式 None = 清空/未设置): - smtp 缺省或显式 None → 整段用 env SMTP_*; - smtp 给了 dict → 段内逐键回退(缺键/空串 → env 对应值, 租户可只改 密码/只换 host); - sender_name 空 → 平台默认(_DEFAULT_SENDER_NAME); - notify_emails 空列表 → env NOTIFY_EMAIL 单元素。 """ email = ((config_data or {}).get("email")) or {} smtp = email.get("smtp") or {} notify = [e for e in (email.get("notify_emails") or []) if e] return { "host": smtp.get("host") or settings.SMTP_HOST, "port": smtp.get("port") or settings.SMTP_PORT, "user": smtp.get("user") or settings.SMTP_USER, "password": smtp.get("password") or settings.SMTP_PASS, "sender_name": email.get("sender_name") or _DEFAULT_SENDER_NAME, "notify_emails": notify or ( [settings.NOTIFY_EMAIL] if settings.NOTIFY_EMAIL else []), } 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"{html.escape(CONTACT_LABELS.get(k, k))}" f"{_esc(v)}" 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"{_esc(k)}" f"{_esc(v)}" for k, v in (customer_profile or {}).items() if not str(k).startswith("_") ) chat_lines = "".join( f"
{'客户' if m.get('role') == 'user' else 'AI'}: {_esc(m.get('content', ''))}
" for m in conversation_messages or [] ) html_body = f"""

🎉 新询盘({html.escape(tag)})

联系方式

{_contact_rows(contacts)}

客户画像

{info_rows}

评估理由

{_esc(reasoning)}

完整对话记录

{chat_lines}

注:邮件为触发时快照,对话可能仍在进行,最新记录请以后台为准。

""" 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"

该联系方式 24h 内已发过完整询盘,客户又开启了新对话," f"详情请查后台。

", "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"

这是本会话第 {count} 次更正(上限 3 次),请以本封为准:

" f"{_contact_rows(body_contacts)}
" f"", "html", "utf-8")) return await _send(msg, cfg)