""" smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。 替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。 协议:每条消息为完整 JSON,``_route`` 字段指定路由名。 请求示例:: {"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码 {"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费) {"_route": "health"} → 健康检查 响应格式(与 dbv2 / captcha 一致):: {"success": true, "data": {...}} {"success": false, "error": "..."} SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD (__PASSWORD 兼容别名 SMTP_PASSWORD)。 """ import json import os import random import smtplib import threading import time from email.mime.text import MIMEText # 手动加载 .env(支持 KEY=VALUE / KEY: VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号) _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") _env_loaded = 0 if os.path.isfile(_env_path): with open(_env_path, "r") as _f: for _line in _f: _line = _line.strip() if not _line or _line.startswith("#"): continue if _line.startswith("export "): _line = _line[len("export "):].strip() # 支持 KEY=VALUE / KEY: VALUE / KEY VALUE 三种写法 if "=" in _line: _key, _, _val = _line.partition("=") elif ":" in _line: _key, _, _val = _line.partition(":") else: _parts = _line.split(None, 1) _key = _parts[0] _val = _parts[1] if len(_parts) > 1 else "" _key = _key.strip() _val = _val.strip().strip("\"'") if _key and _key not in os.environ: os.environ[_key] = _val _env_loaded += 1 else: print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}") from log_client import logs # pymsg 导入 import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pymsg import MsgServer # noqa: E402 # ── 配置 ──────────────────────────────────────────────────────────────── SMTP_SERVER = os.environ.get("SMTP_SERVER", "") SMTP_PORT = int(os.environ.get("SMTP_PORT", "465")) SMTP_USER = os.environ.get("SMTP_USER", "") # 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名 SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "") CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER DEFAULT_SUBJECT = "DNFS 邮箱验证" # 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义) DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。" code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int} send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) STORE_LOCK = threading.Lock() # ═══════════════════════════════════════════════════════════════════════ # SmtpServer — 主服务类 # ═══════════════════════════════════════════════════════════════════════ class SmtpServer: """基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" def __init__(self, addr="127.0.0.1", port=8008): self.addr = addr self.port = port self._server = MsgServer(addr, port) # 路由表 self._routes = { "send_email": self._handle_send_email, "verify_email": self._handle_verify_email, "health": self._handle_health, } # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: """启动服务(阻塞当前线程)。""" if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): # 只报缺失的 key 名,绝不报值 missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER), ("SMTP_USER", SMTP_USER), ("__PASSWORD", SMTP_PASSWORD)) if not v] logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}" f"(.env 加载 {_env_loaded} 项),send_email 将始终失败") print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") self._server.start(self._dispatch) def stop(self) -> None: """从另一线程停止服务。""" self._server.stop() # ── 消息调度 ------------------------------------------------------- def _dispatch(self, sock, addr, text): """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" try: req = json.loads(text) except json.JSONDecodeError as e: self._reply(sock, False, error=f"JSON 解析失败: {e}") return route = req.pop("_route", "") handler = self._routes.get(route) if handler is None: self._reply(sock, False, error=f"未知路由: {route}") return try: result = handler(req) self._reply(sock, True, data=result) except Exception as e: logs.error("smtp", f"路由 {route} 处理异常: {e}") self._reply(sock, False, error=str(e)) def _reply(self, sock, success, data=None, error=None): resp = {"success": success} if data is not None: resp["data"] = data if error is not None: resp["error"] = error self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) # ═══════════════════════════════════════════════════════════════════ # 路由处理 # ═══════════════════════════════════════════════════════════════════ def _handle_send_email(self, req): """生成 6 位数字验证码,存入内存(TTL 600s),经 SMTP_SSL 发送邮件。 可选字段 subject / content 覆盖默认标题与正文,content 中 {code} 会被替换为验证码。同一邮箱 SEND_INTERVAL 秒内仅允许成功发送一次, 冷却期内的请求直接拒绝且不覆盖已发出的有效验证码。 """ email = str(req.get("email", "")).strip() if not email or "@" not in email: return {"sent": False, "error": "invalid email"} if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]): logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送") return {"sent": False, "error": "SMTP 服务未配置"} # 发送冷却:同一邮箱 SEND_INTERVAL 秒内仅放行一次;发送失败不计入, # 用户可立即重试 with STORE_LOCK: remain = SEND_INTERVAL - (time.time() - send_history.get(email, 0.0)) if remain > 0: wait = int(remain) + 1 logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝") return {"sent": False, "retry_after": wait, "error": f"发送过于频繁,请 {wait} 秒后重试"} code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) subject = str(req.get("subject") or DEFAULT_SUBJECT) content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code) with STORE_LOCK: code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL, 'attempts': 0} # 惰性清理过期项 now = time.time() expired = [k for k, v in code_store.items() if v['expires_at'] < now] for k in expired: del code_store[k] message = MIMEText(content, "plain", "UTF-8") message["From"] = MAIL_FROM message["To"] = email message["Subject"] = subject # server 作用域限定在 try 内;连接失败时不会误调 quit() try: with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server: server.login(SMTP_USER, SMTP_PASSWORD) server.sendmail(SMTP_USER, [email], message.as_string()) except Exception as err: # 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态 with STORE_LOCK: code_store.pop(email, None) logs.error("smtp", f"{email}: 验证码发送失败({err})") return {"sent": False, "error": f"验证码发送失败:{err}"} # 发送成功:记录冷却起点,并惰性清理已失效的冷却记录 with STORE_LOCK: send_history[email] = time.time() stale = [k for k, v in send_history.items() if time.time() - v > SEND_INTERVAL] for k in stale: del send_history[k] logs.info("smtp", f"{email}: 验证码发送成功({code})") return {"sent": True, "ttl": CODE_TTL} def _handle_verify_email(self, req): """校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。 同一验证码连续校验失败达 MAX_VERIFY_ATTEMPTS 次即作废删除(响应带 invalidated 标记),防止对 6 位数字码的在线枚举。 """ email = str(req.get("email", "")).strip() # 兼容旧字段 input_captcha code = str(req.get("code") or req.get("input_captcha") or "").strip() if not email or not code: return {"valid": False, "error": "email and code required"} invalidated = False with STORE_LOCK: entry = code_store.get(email) if entry and entry['expires_at'] < time.time(): del code_store[email] entry = None if entry and entry['code'] == code: # 验证通过:一次性消费,防重放 del code_store[email] valid = True else: valid = False if entry: # 仅对已发出且未过期的验证码计失败次数;达到上限即作废 entry['attempts'] = entry.get('attempts', 0) + 1 if entry['attempts'] >= MAX_VERIFY_ATTEMPTS: del code_store[email] invalidated = True if valid: logs.info("smtp", f"{email}: 验证通过") return {"valid": True} if invalidated: logs.info("smtp", f"{email}: 连续校验失败 {MAX_VERIFY_ATTEMPTS} 次,验证码已作废") return {"valid": False, "invalidated": True, "error": f"验证码连续错误 {MAX_VERIFY_ATTEMPTS} 次,已失效,请重新获取"} logs.info("smtp", f"{email}: 验证失败") return {"valid": False, "error": "invalid or expired code"} def _handle_health(self, _req): return {"status": "ok", "service": "smtp"} # ═══════════════════════════════════════════════════════════════════════ # 入口 # ═══════════════════════════════════════════════════════════════════════ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--port", type=int, default=8008, help="监听端口") args = parser.parse_args() server = SmtpServer(args.addr, args.port) try: server.start() except KeyboardInterrupt: server.stop()