send_outreach_emails.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. #!/usr/bin/env python3
  2. import argparse
  3. import html
  4. import json
  5. import mimetypes
  6. import re
  7. import smtplib
  8. import ssl
  9. import uuid
  10. from email.message import EmailMessage
  11. from email.utils import formatdate, make_msgid
  12. from datetime import datetime
  13. from pathlib import Path
  14. try:
  15. from common.artifact_manager import resolve_artifact_path
  16. except ImportError: # pragma: no cover - supports direct CLI execution
  17. import sys
  18. sys.path.append(str(Path(__file__).resolve().parents[1]))
  19. from common.artifact_manager import resolve_artifact_path
  20. IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
  21. STRONG_PHRASES = [
  22. "Wuling Overseas Business Department",
  23. "SGMW / Wuling",
  24. "30 million vehicles",
  25. "1 million vehicles",
  26. "60+ countries",
  27. "200+ overseas sales and service outlets",
  28. "BYD",
  29. "Chery",
  30. "more practical vehicle models, broader customer scenarios, and clearer visible profit margins for dealers",
  31. "20-30 minute online meeting",
  32. "20–30 minute online meeting",
  33. ]
  34. def extract_inline_images(markdown_body, base_dirs=None):
  35. images = []
  36. base_dirs = [Path(item) for item in (base_dirs or []) if item]
  37. def replace(match):
  38. alt = match.group(1).strip() or "image"
  39. raw_path = match.group(2).strip().strip('"')
  40. path = Path(raw_path)
  41. if not path.is_absolute():
  42. for base_dir in base_dirs:
  43. candidate = (base_dir / raw_path).resolve()
  44. if candidate.exists():
  45. path = candidate
  46. break
  47. cid = uuid.uuid4().hex
  48. images.append({"alt": alt, "path": path, "cid": cid})
  49. return f"[[INLINE_IMAGE:{len(images) - 1}]]"
  50. return IMAGE_PATTERN.sub(replace, markdown_body), images
  51. def render_inline_markdown(text):
  52. escaped = html.escape(text)
  53. escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
  54. escaped = re.sub(
  55. r"\[([^\]]+)\]\((mailto:[^)]+)\)",
  56. r'<a href="\2">\1</a>',
  57. escaped,
  58. )
  59. escaped = re.sub(
  60. r"\[([^\]]+)\]\((https?://[^)]+)\)",
  61. r'<a href="\2">\1</a>',
  62. escaped,
  63. )
  64. escaped = re.sub(
  65. r"(Email:\s*)(?!<a)([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
  66. r'\1<a href="mailto:\2">\2</a>',
  67. escaped,
  68. )
  69. escaped = re.sub(
  70. r"(Website:\s*)(?!<a)(https?://[^\s<]+)",
  71. r'\1<a href="\2">\2</a>',
  72. escaped,
  73. )
  74. return escaped
  75. def markdownish_to_html(body, base_dirs=None):
  76. body_without_images, images = extract_inline_images(body, base_dirs=base_dirs)
  77. lines = [line.rstrip() for line in body_without_images.replace("\r\n", "\n").split("\n")]
  78. html_blocks = []
  79. in_list = False
  80. def close_list():
  81. nonlocal in_list
  82. if in_list:
  83. html_blocks.append("</ul>")
  84. in_list = False
  85. for line in lines:
  86. stripped = line.strip()
  87. if not stripped:
  88. close_list()
  89. continue
  90. if stripped.startswith("[[INLINE_IMAGE:"):
  91. close_list()
  92. index = int(re.search(r"\d+", stripped).group(0))
  93. image = images[index]
  94. alt = html.escape(image["alt"])
  95. html_blocks.append(
  96. f'<div class="image-wrap" style="margin:10px 0 16px 0;">'
  97. f'<img src="cid:{image["cid"]}" alt="{alt}" width="300" '
  98. f'style="display:block;width:300px;max-width:300px;height:auto;border:0;outline:none;text-decoration:none;">'
  99. f'</div>'
  100. )
  101. continue
  102. if stripped.startswith("- "):
  103. if not in_list:
  104. html_blocks.append('<ul style="margin:4px 0 14px 23px;padding:0;">')
  105. in_list = True
  106. html_blocks.append(
  107. f'<li style="margin:0 0 7px 0;padding-left:3px;">{render_inline_markdown(stripped[2:].strip())}</li>'
  108. )
  109. continue
  110. close_list()
  111. css_class = "signature" if stripped in {"Senior Business Development Manager", "Wuling Overseas Business Department"} or stripped.startswith(("WhatsApp", "Email:", "Website:")) else ""
  112. margin = "0 0 3px 0" if css_class else "0 0 10px 0"
  113. if stripped.startswith("We can offer our partners"):
  114. margin = "24px 0 10px 0"
  115. if stripped == "Best regards,":
  116. margin = "24px 0 10px 0"
  117. html_blocks.append(
  118. f'<p class="{css_class}" style="margin:{margin};">{render_inline_markdown(stripped)}</p>'
  119. )
  120. close_list()
  121. styles = """
  122. .email-shell {
  123. margin: 0;
  124. padding: 0;
  125. color: #1f2937;
  126. font-family: Arial, Helvetica, sans-serif;
  127. font-size: 12px;
  128. line-height: 1.55;
  129. }
  130. .email-shell p {
  131. margin: 0 0 10px 0;
  132. }
  133. .email-shell strong {
  134. font-weight: 700;
  135. color: #111827;
  136. }
  137. .email-shell .signature {
  138. margin: 0 0 3px 0;
  139. }
  140. .email-shell .image-wrap {
  141. margin: 10px 0 16px 0;
  142. }
  143. .email-shell img {
  144. display: block;
  145. width: 300px;
  146. max-width: 300px;
  147. height: auto;
  148. border: 0;
  149. outline: none;
  150. text-decoration: none;
  151. }
  152. .email-shell a {
  153. color: #2563eb;
  154. text-decoration: underline;
  155. }
  156. """
  157. html_body = "\n".join(html_blocks)
  158. return f"""<!doctype html>
  159. <html>
  160. <body>
  161. <style>{styles}</style>
  162. <div class="email-shell" style="margin:0;padding:0;color:#1f2937;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.55;">
  163. {html_body}
  164. </div>
  165. </body>
  166. </html>""", images
  167. def plain_text_without_markdown_images(body):
  168. text = IMAGE_PATTERN.sub("", body)
  169. text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
  170. text = re.sub(r"\[([^\]]+)\]\((?:mailto:)?([^\)]+)\)", r"\1", text)
  171. return text.strip() + "\n"
  172. def attach_inline_images(message, images):
  173. html_part = message.get_payload()[1]
  174. for image in images:
  175. path = image["path"]
  176. if not path.exists():
  177. raise FileNotFoundError(f"图片文件不存在:{path}")
  178. content_type, _ = mimetypes.guess_type(str(path))
  179. if not content_type or not content_type.startswith("image/"):
  180. content_type = "image/png"
  181. maintype, subtype = content_type.split("/", 1)
  182. html_part.add_related(path.read_bytes(), maintype=maintype, subtype=subtype, cid=f"<{image['cid']}>", disposition="inline")
  183. def build_message(sender, sender_name, to, subject, body, base_dirs=None):
  184. message = EmailMessage()
  185. from_value = sender
  186. if sender_name:
  187. from_value = f"{sender_name} <{sender}>"
  188. message["From"] = from_value
  189. message["To"] = to
  190. message["Subject"] = subject
  191. message["Date"] = formatdate(localtime=True)
  192. message["Message-ID"] = make_msgid(domain="huatu.hk")
  193. message["Reply-To"] = sender
  194. message.set_content(plain_text_without_markdown_images(body))
  195. html_body, images = markdownish_to_html(body, base_dirs=base_dirs)
  196. message.add_alternative(html_body, subtype="html")
  197. attach_inline_images(message, images)
  198. return message
  199. def main():
  200. parser = argparse.ArgumentParser(description="发送已预览确认的经销商建联 HTML 邮件。")
  201. parser.add_argument("--input", required=True, help="prepare 脚本生成的 JSON 预览文件")
  202. parser.add_argument("--smtp-host", required=True, help="SMTP 主机,例如 smtp.qq.com")
  203. parser.add_argument("--smtp-port", type=int, default=465, help="SMTP SSL 端口,默认 465")
  204. parser.add_argument("--sender", required=True, help="发件邮箱")
  205. parser.add_argument("--auth-code", required=True, help="SMTP 授权码;只用于本次连接,不写入日志")
  206. parser.add_argument("--sender-name", default="Chris Chen", help="Email From display name. Official email identity is fixed to Chris Chen.")
  207. parser.add_argument("--confirm-send", action="store_true", help="必须显式提供才会发送")
  208. parser.add_argument("--sent-log", default="", help="发送日志 JSONL 路径;默认写到预览文件旁边,不写入 skill 包")
  209. parser.add_argument("--run-id", default="", help="Run ID; default send log is stored under runs/YYYYMMDD/<run_id>/")
  210. args = parser.parse_args()
  211. if not args.confirm_send:
  212. raise SystemExit("未提供 --confirm-send,已停止发送。")
  213. input_path = Path(args.input)
  214. preview = json.loads(input_path.read_text(encoding="utf-8"))
  215. emails = preview.get("emails", [])
  216. if not emails:
  217. raise SystemExit("预览文件中没有可发送邮件。")
  218. template_value = preview.get("source", {}).get("template", "")
  219. template_path = Path(template_value) if template_value else None
  220. skill_root = Path(__file__).resolve().parents[2]
  221. base_dirs = [input_path.parent, Path.cwd(), skill_root]
  222. if template_path:
  223. if template_path.is_absolute():
  224. base_dirs.append(template_path.parent)
  225. else:
  226. base_dirs.append((Path.cwd() / template_path).parent)
  227. base_dirs.append((skill_root / template_path).parent)
  228. if args.sent_log:
  229. sent_log_path = Path(args.sent_log).expanduser()
  230. if not sent_log_path.is_absolute():
  231. sent_log_path = Path.cwd() / sent_log_path
  232. else:
  233. if "runs" in input_path.parts:
  234. sent_log_path = input_path.with_name("send-log.jsonl")
  235. else:
  236. sent_log_path = resolve_artifact_path(
  237. "",
  238. kind="email_send",
  239. default_name="send-log.jsonl",
  240. run_id=args.run_id or input_path.stem,
  241. )
  242. context = ssl.create_default_context()
  243. results = []
  244. with smtplib.SMTP_SSL(args.smtp_host, args.smtp_port, context=context) as server:
  245. server.login(args.sender, args.auth_code)
  246. for item in emails:
  247. try:
  248. message = build_message(
  249. sender=args.sender,
  250. sender_name=args.sender_name,
  251. to=item["to"],
  252. subject=item["subject"],
  253. body=item["body"],
  254. base_dirs=base_dirs,
  255. )
  256. server.send_message(message)
  257. sent_result = {"to": item["to"], "dealer_name": item.get("dealer_name", ""), "status": "sent"}
  258. results.append(sent_result)
  259. log_entry = {
  260. "sent_at": datetime.now().isoformat(timespec="seconds"),
  261. "to": item["to"],
  262. "dealer_name": item.get("dealer_name", ""),
  263. "subject": item.get("subject", ""),
  264. "sender": args.sender,
  265. }
  266. sent_log_path.parent.mkdir(parents=True, exist_ok=True)
  267. with sent_log_path.open("a", encoding="utf-8") as log_file:
  268. log_file.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
  269. except Exception as exc:
  270. results.append({
  271. "to": item["to"],
  272. "dealer_name": item.get("dealer_name", ""),
  273. "status": "failed",
  274. "error": str(exc),
  275. })
  276. print(json.dumps({"sent": sum(1 for r in results if r["status"] == "sent"), "results": results}, ensure_ascii=False, indent=2))
  277. if __name__ == "__main__":
  278. main()