email_service.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. """
  2. 邮件服务
  3. 提供邮箱验证码发送功能,基于 SMTP 协议
  4. """
  5. import os
  6. import random
  7. import logging
  8. import smtplib
  9. from email.mime.text import MIMEText
  10. from email.mime.multipart import MIMEMultipart
  11. from typing import Optional
  12. logger = logging.getLogger(__name__)
  13. # 邮件配置(从环境变量读取)
  14. SMTP_HOST = os.getenv("SMTP_HOST", "smtp.qq.com")
  15. SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
  16. SMTP_USER = os.getenv("SMTP_USER", "")
  17. SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") # QQ邮箱授权码
  18. SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "智创空间")
  19. SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "true").lower() == "true"
  20. # 验证码有效期(秒)
  21. EMAIL_CODE_TTL = 300 # 5分钟
  22. # 同一邮箱发送间隔(秒)
  23. EMAIL_SEND_INTERVAL = 60
  24. def _send_email(to_email: str, subject: str, html_content: str) -> bool:
  25. """发送邮件,返回是否成功"""
  26. if not SMTP_USER or not SMTP_PASSWORD:
  27. logger.warning("未配置 SMTP_USER 或 SMTP_PASSWORD,邮件发送跳过(开发模式)")
  28. return True # 开发模式下直接返回成功
  29. try:
  30. msg = MIMEMultipart("alternative")
  31. msg["Subject"] = subject
  32. msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_USER}>"
  33. msg["To"] = to_email
  34. part = MIMEText(html_content, "html", "utf-8")
  35. msg.attach(part)
  36. if SMTP_USE_SSL:
  37. server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10)
  38. else:
  39. server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10)
  40. server.starttls()
  41. server.login(SMTP_USER, SMTP_PASSWORD)
  42. server.sendmail(SMTP_USER, [to_email], msg.as_string())
  43. server.quit()
  44. logger.info(f"邮件发送成功: to={to_email}, subject={subject}")
  45. return True
  46. except Exception as e:
  47. logger.error(f"邮件发送失败: to={to_email}, error={e}")
  48. return False
  49. def _build_code_email(code: str, scene: str) -> tuple[str, str]:
  50. """构建验证码邮件内容,返回 (subject, html)"""
  51. scene_map = {
  52. "register": "注册",
  53. "login": "登录",
  54. "reset_password": "重置密码",
  55. }
  56. scene_name = scene_map.get(scene, "验证")
  57. subject = f"【智创空间】{scene_name}验证码"
  58. html = f"""
  59. <div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px; background: #f9fafb; border-radius: 12px;">
  60. <h2 style="color: #1d4ed8; margin-bottom: 8px;">智创空间</h2>
  61. <p style="color: #374151; font-size: 15px;">您正在进行<strong>{scene_name}</strong>操作,验证码为:</p>
  62. <div style="background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;">
  63. <span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #1d4ed8;">{code}</span>
  64. </div>
  65. <p style="color: #6b7280; font-size: 13px;">验证码有效期 <strong>5 分钟</strong>,请勿泄露给他人。</p>
  66. <p style="color: #9ca3af; font-size: 12px; margin-top: 24px;">如非本人操作,请忽略此邮件。</p>
  67. </div>
  68. """
  69. return subject, html
  70. class EmailCodeService:
  71. """邮箱验证码服务(基于 Redis)"""
  72. REDIS_PREFIX_CODE = "email:code:"
  73. REDIS_PREFIX_INTERVAL = "email:interval:"
  74. def __init__(self):
  75. from app.core.redis import redis_manager
  76. self._redis_manager = redis_manager
  77. @property
  78. def redis(self):
  79. return self._redis_manager.get_client()
  80. async def send_code(self, email: str, scene: str = "register") -> tuple[bool, str]:
  81. """
  82. 发送邮箱验证码
  83. 返回 (success, message)
  84. """
  85. if not self.redis:
  86. return False, "邮件服务暂不可用"
  87. # 检查发送间隔
  88. interval_key = f"{self.REDIS_PREFIX_INTERVAL}{email}"
  89. if await self.redis.exists(interval_key):
  90. ttl = await self.redis.ttl(interval_key)
  91. return False, f"请 {ttl} 秒后再试"
  92. # 生成6位验证码
  93. code = str(random.randint(100000, 999999))
  94. # 发送邮件
  95. subject, html = _build_code_email(code, scene)
  96. ok = _send_email(email, subject, html)
  97. if not ok:
  98. return False, "邮件发送失败,请稍后重试"
  99. # 存储验证码
  100. code_key = f"{self.REDIS_PREFIX_CODE}{email}"
  101. await self.redis.setex(code_key, EMAIL_CODE_TTL, code)
  102. # 设置发送间隔
  103. await self.redis.setex(interval_key, EMAIL_SEND_INTERVAL, "1")
  104. logger.info(f"邮箱验证码已发送: email={email}, scene={scene}, code={code}")
  105. return True, "验证码已发送"
  106. async def verify_code(self, email: str, code: str, delete_after: bool = True) -> bool:
  107. """验证邮箱验证码,验证成功后默认删除"""
  108. if not self.redis:
  109. logger.warning(f"Redis 不可用,邮箱验证码验证失败: email={email}")
  110. return False
  111. code_key = f"{self.REDIS_PREFIX_CODE}{email}"
  112. stored = await self.redis.get(code_key)
  113. logger.info(f"邮箱验证码校验: email={email}, input={code}, stored={stored}")
  114. if stored and stored == code:
  115. if delete_after:
  116. await self.redis.delete(code_key)
  117. return True
  118. return False
  119. # 全局单例
  120. email_code_service = EmailCodeService()