send_email.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """发送邮箱验证码服务(准实时)
  2. 从数据库读取待发送的邮箱验证码,使用阿里云 DirectMail SMTP 发送。
  3. Prerequisites:
  4. - Python >= 3.7
  5. - 配置文件:send.conf(与脚本同目录)
  6. - 必需配置:[smtp] 下的 smtp_username 和 smtp_password
  7. - 必需配置:[database] 下的数据库配置
  8. 运行: python send_email.py
  9. """
  10. import asyncio
  11. import configparser
  12. import os
  13. import sys
  14. import logging
  15. import smtplib
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Optional, Tuple
  19. from email.header import Header
  20. from email.mime.multipart import MIMEMultipart
  21. from email.mime.text import MIMEText
  22. from email.utils import formataddr
  23. from sqlalchemy import select, update
  24. # 加载配置文件
  25. config_file = Path(__file__).parent / "send.conf"
  26. config = configparser.ConfigParser()
  27. if config_file.exists():
  28. config.read(config_file, encoding='utf-8')
  29. else:
  30. raise FileNotFoundError(f"配置文件不存在: {config_file}")
  31. # 导入数据库相关模块(需要从 backend 目录导入)
  32. backend_dir = Path(__file__).parent.parent / "backend"
  33. sys.path.insert(0, str(backend_dir))
  34. from database import DatabaseManager
  35. from models_db import VerificationCode
  36. logger = logging.getLogger(__name__)
  37. # ------- 从配置文件读取参数 -------
  38. # SMTP 配置
  39. SMTP_HOST = config.get("smtp", "smtp_host", fallback="smtpdm.aliyun.com")
  40. SMTP_PORT_SSL = int(config.get("smtp", "smtp_port_ssl", fallback="465"))
  41. SMTP_USERNAME = config.get("smtp", "smtp_username", fallback="").strip()
  42. SMTP_PASSWORD = config.get("smtp", "smtp_password", fallback="").strip()
  43. EMAIL_SUBJECT = config.get("smtp", "email_subject", fallback="Lyrria 注册登录验证码")
  44. # 数据库配置
  45. MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
  46. MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
  47. MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
  48. MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
  49. MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
  50. # 服务配置
  51. POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="2.0"))
  52. # 邮件模板 HTML
  53. EMAIL_BODY_TEMPLATE = """
  54. <!DOCTYPE html>
  55. <html lang="zh-CN">
  56. <head>
  57. <meta charset="UTF-8" />
  58. <title>Lyrria 验证码</title>
  59. <style>
  60. body {{ margin: 0; padding: 0; background: #28272F; font-family: 'Helvetica Neue', Arial, sans-serif; }}
  61. .wrap {{ padding: 32px 16px; }}
  62. .container {{ max-width: 560px; margin: 0 auto; background: #1f1e25; border-radius: 16px; padding: 32px 32px 28px; box-shadow: 0 18px 45px rgba(0,0,0,0.26); color: #e5e7eb; }}
  63. .logo {{ font-size: 18px; font-weight: 800; color: #6b8bff; letter-spacing: 0.3px; }}
  64. .hero {{ margin-top: 12px; text-align: center; }}
  65. .hero img {{ width: 120px; height: auto; filter: drop-shadow(0 10px 18px rgba(0,0,0,0.15)); display: block; margin: 0 auto; }}
  66. .title {{ margin: 18px 0 8px; font-size: 22px; font-weight: 800; color: #f8fafc; }}
  67. .subtitle {{ margin: 0 0 16px; color: #cbd5e1; font-size: 14px; }}
  68. .code-box {{ margin: 16px 0; padding: 16px 20px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; display: inline-block; }}
  69. .code {{ font-size: 32px; font-weight: 800; letter-spacing: 4px; color: #7ea8ff; }}
  70. .desc {{ margin: 10px 0 0; color: #d1d5db; font-size: 14px; line-height: 1.6; }}
  71. .note {{ margin-top: 14px; font-size: 13px; color: #a5b4fc; }}
  72. .footer {{ margin-top: 22px; font-size: 12px; color: #9ca3af; border-top: 1px solid rgba(255,255,255,0.12); padding-top: 12px; }}
  73. </style>
  74. </head>
  75. <body style="margin:0;padding:0;background:#28272F;">
  76. <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#28272F;">
  77. <tr>
  78. <td align="center" style="padding:32px 12px;">
  79. <div class="wrap">
  80. <div class="container">
  81. <div class="logo">Lumi</div>
  82. <div class="hero">
  83. <img src="https://www.lyrria.com/img/Lumi-cap.png" alt="Lumi" />
  84. </div>
  85. <div class="title">您的登录验证码</div>
  86. <div class="subtitle">请在 5 分钟内完成校验,保护您的账号安全。</div>
  87. <div class="code-box">
  88. <div class="code">{code}</div>
  89. </div>
  90. <div class="desc">如非本人操作,请忽略本邮件。验证码勿泄露给他人。</div>
  91. <div class="note">本邮件由系统自动发送,请勿直接回复。</div>
  92. <div class="footer">© {year} Lyrria. All rights reserved.</div>
  93. </div>
  94. </div>
  95. </td>
  96. </tr>
  97. </table>
  98. </body>
  99. </html>
  100. """
  101. def build_message(
  102. sender: str,
  103. recipients: list[str],
  104. subject: str,
  105. body_html: str,
  106. ) -> MIMEMultipart:
  107. """构建 HTML 邮件消息"""
  108. msg = MIMEMultipart("alternative")
  109. msg["Subject"] = Header(subject, "utf-8")
  110. msg["From"] = formataddr(["Lyrria Support", sender])
  111. msg["To"] = ",".join(recipients)
  112. msg.attach(MIMEText(body_html, "html", "utf-8"))
  113. return msg
  114. async def send_email_async(
  115. email: str,
  116. code: str,
  117. smtp_username: str,
  118. smtp_password: str,
  119. ) -> Tuple[bool, Optional[str]]:
  120. """
  121. 发送邮件验证码(异步包装)
  122. Args:
  123. email: 收件人邮箱
  124. code: 验证码
  125. smtp_username: SMTP 用户名
  126. smtp_password: SMTP 密码
  127. Returns:
  128. (是否成功, 错误信息)
  129. """
  130. try:
  131. # 构建邮件内容
  132. body = EMAIL_BODY_TEMPLATE.format(code=code, year=datetime.now().year)
  133. msg = build_message(
  134. sender=smtp_username,
  135. recipients=[email],
  136. subject=EMAIL_SUBJECT,
  137. body_html=body,
  138. )
  139. # 使用 asyncio.to_thread 将同步 SMTP 调用转为异步
  140. def _send():
  141. with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT_SSL) as client:
  142. client.login(smtp_username, smtp_password)
  143. client.sendmail(smtp_username, [email], msg.as_string())
  144. await asyncio.to_thread(_send)
  145. logger.info(f"✅ 邮件发送成功: email={email}, code_id={code[:4]}***")
  146. return True, None
  147. except smtplib.SMTPAuthenticationError as e:
  148. error_msg = f"SMTP authentication error: {e}"
  149. logger.error(f"❌ 邮件发送认证失败: email={email}, {error_msg}")
  150. return False, error_msg
  151. except smtplib.SMTPException as e:
  152. error_msg = f"SMTP error: {e}"
  153. logger.error(f"❌ 邮件发送SMTP错误: email={email}, {error_msg}")
  154. return False, error_msg
  155. except Exception as e:
  156. error_msg = f"Unexpected error: {e}"
  157. logger.error(f"❌ 邮件发送异常: email={email}, {error_msg}")
  158. return False, error_msg
  159. async def process_email_codes(db_manager: DatabaseManager):
  160. """扫描并发送邮箱验证码"""
  161. if not SMTP_USERNAME or not SMTP_PASSWORD:
  162. logger.error("❌ SMTP 配置缺失,请在 send.conf 文件中设置 smtp_username 和 smtp_password")
  163. return 0, 0
  164. async with db_manager.get_session() as session:
  165. # 查询未发送的邮箱验证码
  166. result = await session.execute(
  167. select(VerificationCode)
  168. .where(
  169. VerificationCode.send_type == "email",
  170. VerificationCode.is_sent == False,
  171. VerificationCode.is_valid == True,
  172. VerificationCode.email.isnot(None)
  173. )
  174. .order_by(VerificationCode.created_at.asc())
  175. .limit(50) # 每次处理50条
  176. )
  177. codes = result.scalars().all()
  178. if not codes:
  179. return 0, 0
  180. logger.info(f"📋 找到 {len(codes)} 条待发送的邮箱验证码")
  181. success_count = 0
  182. fail_count = 0
  183. for code_record in codes:
  184. try:
  185. # 发送邮件
  186. success, error_msg = await send_email_async(
  187. code_record.email,
  188. code_record.code,
  189. SMTP_USERNAME,
  190. SMTP_PASSWORD
  191. )
  192. # 🔥 使用显式 UPDATE 语句更新数据库(确保数据正确保存)
  193. now = datetime.now()
  194. code_id = code_record.code_id
  195. if success:
  196. # 成功:更新 is_sent、sent_at 和 metadata
  197. metadata_dict = code_record.metadata_json or {}
  198. metadata_dict["send_status"] = "success"
  199. metadata_dict["send_at"] = now.isoformat()
  200. await session.execute(
  201. update(VerificationCode)
  202. .where(VerificationCode.code_id == code_id)
  203. .values(
  204. is_sent=True,
  205. sent_at=now,
  206. metadata_json=metadata_dict
  207. )
  208. )
  209. await session.commit()
  210. success_count += 1
  211. else:
  212. # 失败:更新 metadata,并将验证码标记为失效,避免重复发送
  213. metadata_dict = code_record.metadata_json or {}
  214. metadata_dict["send_status"] = "failed"
  215. metadata_dict["error"] = error_msg
  216. metadata_dict["send_at"] = now.isoformat()
  217. await session.execute(
  218. update(VerificationCode)
  219. .where(VerificationCode.code_id == code_id)
  220. .values(
  221. is_valid=False,
  222. metadata_json=metadata_dict,
  223. )
  224. )
  225. await session.commit()
  226. fail_count += 1
  227. except Exception as e:
  228. logger.error(f"❌ 处理验证码失败: code_id={code_record.code_id}, error={e}")
  229. try:
  230. # 异常:更新 metadata,且将验证码标记为失效,避免后续重复重试
  231. metadata_dict = code_record.metadata_json or {}
  232. metadata_dict["send_status"] = "error"
  233. metadata_dict["error"] = str(e)
  234. metadata_dict["send_at"] = datetime.now().isoformat()
  235. await session.execute(
  236. update(VerificationCode)
  237. .where(VerificationCode.code_id == code_record.code_id)
  238. .values(
  239. is_valid=False,
  240. metadata_json=metadata_dict,
  241. )
  242. )
  243. await session.commit()
  244. except Exception as update_error:
  245. logger.error(f"❌ 更新错误状态失败: code_id={code_record.code_id}, error={update_error}")
  246. fail_count += 1
  247. if success_count > 0 or fail_count > 0:
  248. logger.info(f"📊 处理完成: 成功 {success_count} 条, 失败 {fail_count} 条")
  249. return success_count, fail_count
  250. async def main_loop():
  251. """主循环:准实时轮询并发送验证码"""
  252. # 如果配置文件中提供了数据库配置,临时设置环境变量
  253. if MYSQL_HOST:
  254. os.environ["MYSQL_HOST"] = MYSQL_HOST
  255. if MYSQL_PORT:
  256. os.environ["MYSQL_PORT"] = MYSQL_PORT
  257. if MYSQL_USER:
  258. os.environ["MYSQL_USER"] = MYSQL_USER
  259. if MYSQL_PASSWORD:
  260. os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
  261. if MYSQL_DATABASE:
  262. os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
  263. db_manager = DatabaseManager()
  264. try:
  265. # 初始化数据库连接
  266. await db_manager.connect()
  267. logger.info("✅ 数据库连接成功")
  268. logger.info(f"🚀 开始准实时发送服务(轮询间隔: {POLL_INTERVAL}秒)...")
  269. # 主循环:持续轮询
  270. while True:
  271. try:
  272. await process_email_codes(db_manager)
  273. except Exception as e:
  274. logger.error(f"❌ 处理验证码时出错: {e}", exc_info=True)
  275. # 等待下次轮询
  276. await asyncio.sleep(POLL_INTERVAL)
  277. except KeyboardInterrupt:
  278. logger.info("🛑 收到中断信号,正在关闭...")
  279. except Exception as e:
  280. logger.error(f"❌ 服务异常: {e}", exc_info=True)
  281. sys.exit(1)
  282. finally:
  283. await db_manager.disconnect()
  284. logger.info("✅ 服务已关闭")
  285. def setup_logging():
  286. """配置日志:正常日志输出到 send_email.log,错误日志输出到 send_email_error.log"""
  287. log_dir = Path(__file__).parent
  288. log_file = log_dir / "send_email.log"
  289. error_log_file = log_dir / "send_email_error.log"
  290. # 创建格式化器
  291. formatter = logging.Formatter(
  292. '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  293. datefmt='%Y-%m-%d %H:%M:%S'
  294. )
  295. # 配置根日志记录器
  296. root_logger = logging.getLogger()
  297. root_logger.setLevel(logging.INFO)
  298. # 清除已有的处理器
  299. root_logger.handlers.clear()
  300. # 1. 控制台处理器(INFO及以上)
  301. console_handler = logging.StreamHandler(sys.stdout)
  302. console_handler.setLevel(logging.INFO)
  303. console_handler.setFormatter(formatter)
  304. root_logger.addHandler(console_handler)
  305. # 2. 文件处理器(所有日志,INFO及以上)
  306. file_handler = logging.FileHandler(log_file, encoding='utf-8')
  307. file_handler.setLevel(logging.INFO)
  308. file_handler.setFormatter(formatter)
  309. root_logger.addHandler(file_handler)
  310. # 3. 错误日志文件处理器(ERROR及以上)
  311. error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
  312. error_file_handler.setLevel(logging.ERROR)
  313. error_file_handler.setFormatter(formatter)
  314. root_logger.addHandler(error_file_handler)
  315. logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
  316. if __name__ == "__main__":
  317. setup_logging()
  318. asyncio.run(main_loop())