资讯详情

邮箱验证码实现全流程:SMTP发送与IMAP自动收取实战解析

📅 2026/10/24 1:22:52 | 华诺云谱 👁 阅读
邮箱验证码实现全流程:SMTP发送与IMAP自动收取实战解析
这段时间我在给一个模拟项目补注册登录流程第十七周的任务排到了邮箱验证码。大概花了整整一天半才把QQ邮箱接收验证码这条链路完全跑通从用户输入QQ邮箱、点击发送验证码到后端调用 SMTP 发出带有验证码的邮件再到用户从邮箱里把验证码取回来完成校验。如果是纯手动业务到这里其实已经结束了但我这个项目还兼顾自动化测试场景需要程序自己去 IMAP 里把邮件验证码抓出来所以又多绕了一段路。后来复盘发现这个功能看着简单实际卡人的地方几乎都在邮件服务商的账号配置和服务端限制上并不是代码本身。比如授权码和登录密码的区别、SMTP 端口选 465 还是 587、验证码邮件莫名进了垃圾箱、发送太频繁被临时封控每一个单独拎出来都不难但串在一起就会让新手原地打转。这篇文章就把我第十七周的完整实现过程、代码、踩坑记录都整理出来给后面要做邮箱接收验证码这块的开发者当个参考。1. 第十七周验证码这条链路我拆成了四段1.1 一次接收验证码到底经历了什么先说清楚QQ邮箱接收验证码这个需求到底在做什么。很多刚接触的人会把发验证码和收验证码混为一谈实际上一个完整的邮箱验证码功能至少经历这么四段第一段用户在前端页面填写 QQ 邮箱地址点击获取验证码前端把邮箱地址提交到后端接口。第二段后端生成一个随机数字验证码存到 Redis 或数据库里设置过期时间然后调用邮件服务商提供的能力把验证码以邮件形式发送到用户填写的 QQ 邮箱。第三段用户登录 QQ 邮箱收信看到邮件后记下验证码回到页面填写。第四段后端把用户提交的验证码和之前存下来的验证码做比对一致则通过验证码作废不一致则拒绝。注意前三段里有个关键点代码本身是不能直接凭空往 QQ 邮箱里塞邮件的必须通过邮件服务商提供的发信协议来请求发送。最常见的就是 SMTP 协议。而后续如果要让程序自动从 QQ 邮箱里读取验证码又得用另一套协议——IMAP。我这篇博客说的接收验证码其实覆盖了用户能收到验证码和程序能读取出验证码两层含义这也是我第十七周实际做的工作范围。1.2 这个功能适合谁以及本文会涉及的工具这个需求在现实里非常常见。最典型的场景是网站注册、找回密码、绑定手机号时的邮箱验证其次是自动化测试、脚本登录、批量注册账号时需要程序自动读取邮箱里的验证码实现全流程自动化。所以这篇记录适合两类人看一类是刚开始写后端接口想给自己的项目加上邮箱验证码能力的开发者另一类是写爬虫或自动化脚本需要像人一样打开邮箱看验证码的开发者。我第十二周的时候写过短信验证码第十七周换成了邮箱验证码。两者流程上很像但邮箱验证码多了一层邮件服务商的配置而且 QQ 邮箱在国内这类邮箱服务里属于比较有代表性的开通方式和限制都值得单开一篇来说。具体技术方案我最后采用的是后端语言Python使用标准库smtplib和imaplib不需要额外安装第三方包。邮件服务QQ 邮箱的 SMTP 和 IMAP 服务分别负责发信和收信。存储Redis保存验证码、过期时间、发送频率标记。邮件解析Python 标准库email配合正则表达式提取验证码。这套方案的好处是依赖极少全 Python 自带思路迁移到 Java 的JavaMail、Node 的nodemailer或 Go 的net/smtp上也完全一样。2. 账号侧先做两件事开 SMTP拿授权码2.1 在哪里开启服务二十分钟能做完很多人写代码之前没先处理邮箱账号结果一跑就报SMTPAuthenticationError第一反应是代码写错了实际是 QQ 邮箱默认根本没开放 SMTP 和 IMAP 服务。要开启也不难。登录 QQ 邮箱网页版之后在设置里找到账户这一栏往下拉能看到一个区域写着POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务里面有几个开关。我这次只用到发信和收信就把SMTP服务和IMAP服务都打开了。如果你只需要发验证码只开 SMTP 服务就够了如果想要程序自动读验证码IMAP 服务也必须开。点击开启之后QQ 邮箱会要求你用手机发一条短信验证身份验证通过后会弹出一串授权码这串授权码必须是这个入口生成的不能自己去猜一个也不能用 QQ 密码登录 SMTP。我当时第一次操作以为授权码就是自己设一个十六位密码折腾了半天才发现是邮件服务商自动生成并展示给你的。2.2 授权码为什么不能拿 QQ 密码来顶替这是邮箱验证码功能里最容易卡住新手的问题。用 SMTP 客户端连接 QQ 邮箱发信时username填完整邮件地址password填的不是 QQ 密码而是授权码。QQ 邮箱做这个设计是出于安全考虑。SMTP 服务只需要发信权限如果直接使用 QQ 密码一旦你的代码或服务器配置泄露别人知道你 QQ 邮箱和密码等于你的整个 QQ 账号都暴露了。授权码是一个独立凭证权限被限定在邮件收发这个范围内不在网页端使用。所以把授权码写在配置里泄露也不至于让 QQ 主账号直接沦陷。我在项目里把授权码放在环境变量里比如.env文件不写死到代码仓库。之前见过有人把授权码直接提交到 Git然后被扫描工具发现邮箱被拿来大量发垃圾邮件这个教训大家务必避开。2.3 顺手把 IMAP 也开掉后面自动收取会用到如果你要做自动化IMAP 服务现在就要一并打开。开的方法和 SMTP 一样在同一个设置页面勾选。IMAP 开放后你的 QQ 邮箱依然可以在网页端正常收信不会有什么明显影响只是允许第三方客户端通过协议读取邮件。另外QQ 邮箱的 IMAP 服务器地址是imap.qq.com默认端口993走 SSL 加密。收信的时候用户名依然填完整 QQ 邮箱地址密码依然是那个授权码。换句话说发信用 SMTP授权码收信用 IMAP授权码同一个授权码可以同时用于多个协议这是 QQ 邮箱官方文档里明确支持的。我在实际项目中用同一个授权码同时接入了发信和收信两条链路目前没有遇到冲突。如果你在别的邮箱服务上操作比如一些海外邮箱服务安全设置更激进可能发信和收信要分别设置应用专属密码那就要按照它的官方说明多申请几个。以 QQ 邮箱为例一个授权码足够完成整个需求。3. 发送侧实现用 SMTP 把验证码送到 QQ 邮箱3.1 为什么我选 Python smtplib以及整体思路到了代码部分。我选择 Python 的smtplib是因为标准库直接支持 SMTP 协议不需要额外装依赖写起来也简单。Java 的JavaMail和 Node 的nodemailer在这类需求里更出名但本质逻辑是一样的指定 SMTP 服务器和端口用账号授权码登录然后提交一封带有主题和正文的邮件。我的整体发送流程是这样从配置读取 SMTP 服务器地址、端口、发件人邮箱、授权码。建立 SSL 连接登录认证。构造邮件设置发件人、收件人、主题、正文。调用 sendmail 发送。发送完成后关闭连接。如果发送过程中出现异常比如网络超时、认证失败、收件人地址格式不对都要在上层接口里捕获并给前端一个明确的提示不能让用户等半天看到 500。3.2 核心发送代码与关键参数解读下面是第十七周我在模拟项目里用的发送函数代码做了一定脱敏处理替换成通用占位符。import smtplib from email.mime.text import MIMEText from email.header import Header import os SMTP_SERVER os.getenv(SMTP_SERVER, smtp.qq.com) SMTP_PORT int(os.getenv(SMTP_PORT, 465)) SMTP_USER os.getenv(SMTP_USER, yournameqq.com) SMTP_CODE os.getenv(SMTP_AUTH_CODE, your16digitauthcode) def send_code_email(to_email: str, code: str): msg MIMEText( f您的验证码是{code}5分钟内有效。若非本人操作请忽略本邮件。, plain, utf-8, ) msg[Subject] Header(【模拟项目】邮箱验证码, utf-8) msg[From] SMTP_USER msg[To] to_email with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as server: server.login(SMTP_USER, SMTP_CODE) server.sendmail(SMTP_USER, [to_email], msg.as_string())有几个参数值得展开讲SMTP_PORT用 465 是因为我使用SMTP_SSL这是 SSL 加密连接QQ 邮箱官方支持。还有一个选择是SMTP加starttls()端口 587。老项目里可能见得多。我建议优先使用 465 的 SSL 方式连接更省心不用额外处理 STARTTLS 升级。from email.header import Header处理中文主题不用的话中文主题在某些客户端会显示成乱码。msg[To]我保留收件人地址这样接收方客户端能正确显示发件人和收件人信息降低被判定为垃圾邮件的概率。with smtplib.SMTP_SSL(...) as server:这种写法会自动管理连接关闭不用手动调quit()即使发送异常也能保证连接被清理。发送频率上来之后每次调用都新建连接会有一点开销但验证码场景本身频率不高没必要维护长连接池。如果你在一个循环里批量发几百封那就应该考虑连接复用而不是每封都重连。3.3 邮件正文怎么写才不容易进垃圾箱第十七周我踩到的第一个大坑是验证码邮件被 QQ 邮箱自己送去了垃圾箱。后来一排查问题出在邮件正文和主题写得过于营销。QQ 邮箱的垃圾邮件拦截策略我们看不见摸不着但从实测看下面这些做法会让邮件更容易进垃圾箱主题里带恭喜免费优惠这类营销词。正文全是 HTML 大图和链接几乎没文字。发件人和收件人地址看起来不匹配。短时间往同一个邮箱发送多封邮件。我后面把主题改成简单的【模拟项目】邮箱验证码正文用纯文本不搞花哨的 HTML只写一行验证码和有效期。这样改了之后验证码基本能稳定进收件箱。这里还有一个细节如果你的系统是给客户做正式产品建议用企业域名邮箱发信并且配置好 SPF、DKIM、DMARC 这些邮件认证记录。这样才能提高邮件送达率。QQ 邮箱个人免费邮箱虽然能发信但发出的信在别的服务商那里可能更容易被当作垃圾邮件。我这个模拟项目不追求高并发送达率用 QQ 邮箱做发件邮箱完全够用。4. 验证码本身生成、存储、校验一个都不能少4.1 生成随机验证码别用 random 硬刚发送邮件之前先得解决验证码怎么生成的问题。这看起来简单但里面有个安全细节值得单独说。我用的是 6 位纯数字验证码。生成方式一开始用的是random.randint(100000, 999999)后来看了一些安全资料觉得不够稳妥因为random模块是伪随机算法基于当前时间等种子在一些高安全要求的场景下验证码存在被预测的风险。虽然邮箱验证码并不像支付密码那样夸张但至少可以做到更规范。于是我在项目里换成了 Python 的secrets模块它是专门用于生成密码学安全随机数的import secrets def generate_code(length: int 6) - str: low 10 ** (length - 1) high 10 ** length - 1 return str(secrets.randbelow(high - low 1) low)这个函数生成 6 位随机数每一位都独立随机返回的是字符串。为什么返回字符串而不是整数因为验证码在用户界面展示和 Redis 里比对时经常要拼字符串直接返回字符串可以避免后续str()转换遗漏。4.2 存储选 Redis过期时间和 key 要设计好验证码生成之后不能只靠邮件里的那封邮件存在服务端必须记一份才能在校验时做比对。我选用 Redis 存储原因很直接验证码需要设置过期时间Redis 的EXPIRE机制天然支持验证码访问频率高Redis 读写性能好而且 Redis 是分布式应用里的常用组件后续服务扩容也容易。我在 Redis 里存的 key 是这样设计的email:code:reset_password:yournameqq.com可以分为三段业务类型 场景 邮箱地址。业务类型字段用来区分注册、找回密码、绑定邮箱这些不同用途防止不同场景的验证码互相串用。value 我存的是验证码本身但也看场景。如果不想让验证码以明文形式出现在 Redis 里可以存验证码的哈希值校验时把用户输入做哈希后比对。我的模拟项目里直接存明文配合过期时间已经够用。如果你想做得更稳可以存哈希。过期时间设置为 5 分钟。太短容易导致用户来不及输完太长又会让验证码暴露窗口变大。5 分钟是行业里比较常见的平衡点。import redis r redis.Redis(hostlocalhost, port6379, db0) def save_code(email: str, code: str, scene: str register, expire: int 300): key femail:code:{scene}:{email.lower()} r.set(key, code, exexpire)这里有个细节存储 key 时我把邮箱地址统一转成了小写。QQ 邮箱地址本身不区分大小写但lstrip和字符串比较时如果不统一很容易在边界测试里出现数据库里有验证码用户死活验证不过的诡异问题。4.3 校验接口的防爆破与一次性逻辑用户填了验证码之后后端校验接口要做这几件事根据用户提交的邮箱、场景拼出 Redis key。检查 key 是否存在。如果存在比对 value 和用户提交的验证码。匹配失败要记录尝试次数超过阈值直接删除验证码。匹配成功要删除 key确保验证码一次性有效。我一开始只做了比对成功就删 key忽略了失败次数限制。结果测试同事发现可以对同一个验证码无限次尝试直到暴力猜中才罢休。6 位数字的空间是一百万暴力尝试成本并不高所以防爆破必须做。我这里用了简单的计数方案def verify_code(email: str, code: str, scene: str register) - bool: key femail:code:{scene}:{email.lower()} saved r.get(key) if saved is None: return False if saved.decode() code: r.delete(key) return True # 记录失败次数连续失败5次删除验证码 fail_key femail:code:fail:{scene}:{email.lower()} count r.incr(fail_key) r.expire(fail_key, 300) if count 5: r.delete(key) r.delete(fail_key) return False这个方案不够优雅但对我的模拟项目来说足够清晰。要想更稳应该把失败次数和验证码放在同一个 Redis key 的哈希里用一种乐观锁来处理并发这里不展开。核心思路是验证码必须限制尝试次数过期必须失效使用后必须作废这三个原则缺一不可。5. 自动接收验证码IMAP 读取到提取的完整流程5.1 为什么需要程序自己读 QQ 邮箱业务上让用户手动输入验证码就够了但我的项目里有一类自动化测试场景测试脚本模拟注册账号需要像真人一样填写验证码这时候程序必须自己从 QQ 邮箱里把验证码取出来。这种需求在真实的自动化测试、机器人流程自动化、第三方登录辅助工具里都很常见。实现方式也很直接通过 IMAP 协议连接到 QQ 邮箱搜索未读邮件解析邮件内容用正则把验证码提取出来。5.2 用 imaplib 连接、搜索未读邮件Python 的imaplib是标准库不需要安装额外包。连接 QQ 邮箱 IMAP 的代码如下import imaplib import email from email.header import decode_header import re IMAP_SERVER imap.qq.com IMAP_PORT 993 def fetch_latest_code(sender_filter: str None): mail imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT) mail.login(SMTP_USER, SMTP_CODE) mail.select(INBOX) # 只搜索未读邮件按发件人过滤可选 if sender_filter: status, messages mail.search(None, UNSEEN, fFROM {sender_filter}) else: status, messages mail.search(None, UNSEEN) if status ! OK or not messages[0]: mail.logout() return None # 取最新一封列表最后一个 msg_ids messages[0].split() latest_id msg_ids[-1] status, msg_data mail.fetch(latest_id, (RFC822)) if status ! OK: mail.logout() return None raw msg_data[0][1] msg email.message_from_bytes(raw) subject decode_mime_header(msg.get(Subject)) body extract_body(msg) # 提取6位验证码 match re.search(r\d{6}, f{subject}\n{body}) if match: code match.group(0) else: code None mail.store(latest_id, FLAGS, \\Seen) mail.logout() return code这段代码里search(None, UNSEEN, ...)是查未读邮件latest_id msg_ids[-1]取的是最新一封。之所以要取最新一封是因为验证码邮件往往和很多其他邮件混在一起我需要的是最近到达的那封。mail.store(latest_id, FLAGS, \\Seen)这行是把邮件标记为已读。在自动化场景里通常要加这步不然每次连接都会重新搜到同一封邮件验证码会被反复读出来。5.3 解析 MIME 邮件和正则提取的细节IMAP 拿到的邮件原始数据是 RFC822 格式需要用email.message_from_bytes解析。正文可能有两种纯文本和 HTML。我在模拟项目里为了简单优先取纯文本部分def extract_body(msg): if msg.is_multipart(): for part in msg.walk(): content_type part.get_content_type() if content_type text/plain: charset part.get_content_charset() or utf-8 return part.get_payload(decodeTrue).decode(charset, errorsignore) else: return msg.get_payload(decodeTrue).decode(utf-8, errorsignore) return 主题解码也要注意中文主题在邮件头里可能是?utf-8?B?...?编码直接读会是乱码所以要用decode_header解def decode_mime_header(value): if not value: return parts decode_header(value) result [] for text, charset in parts: if isinstance(text, bytes): result.append(text.decode(charset or utf-8, errorsignore)) else: result.append(text) return .join(result)正则提取验证码时我写了\d{6}这种贪婪匹配有可能匹配到邮件正文里的其他 6 位数字比如 QQ 号、订单号。更稳的做法是把搜索目标限定在验证码后面pattern re.compile(r(?:验证码|code|CODE)[:\s]*(\d{6})) match pattern.search(text)不过要注意QQ 邮箱会在邮件页脚加一些签名、推广内容里面也可能出现 6 位数字所以提取验证码之前先只提取我自己的邮件正文部分或者用发件人过滤减少误匹配。6. 第十七周踩过的坑按排查顺序整理6.1 授权码当成登录密码第一层就瘫了第一个坑是我自己挖的。第一次写连接代码时我习惯性地把 QQ 邮箱的密码填进login()函数里结果返回 535 认证失败。报错信息是Authentication failed。排查过程其实很简单先看配置再看代码。我对照了 QQ 邮箱设置页面发现要开启 SMTP 服务之后系统才给授权码而授权码是在开启服务那个流程里自动生成的。当时我以为授权码就是随便设置的十六位字符串其实不是。这个坑最典型的特征就是SMTPAuthenticationError但你的账号密码看起来完全没问题。遇到这种报错优先去邮箱设置页面重新检查授权码是否复制完整。授权码里有大写字母和数字复制时很容易漏字符我建议直接用设置页面的复制按钮不要手打。6.2 验证码邮件进垃圾箱而不是收件箱第二个坑是邮件进了垃圾箱。第一次发信成功后我在网页端等了好久没看到邮件差点以为是发信失败。后来下意识点开垃圾箱发现邮件安静地躺在里面。这事的排查路线是先看邮件头里的发信来源确认发件人设置是否正确。再看主题和正文有没有营销关键词。最后检查是否短时间重复发送触发了频率限制。我最终的解决方案就是前面说的把邮件正文改成纯文本主题固定为【模拟项目】邮箱验证码发送间隔控制在 60 秒以上。这样改完之后测试了 20 次有 19 次都直接进收件箱剩下的 1 次可能是网络或者运气问题重发就正常了。如果是生产环境建议你注册一个专门的发信域名配置 SPF/DKIM/DMARC 这些邮件认证。个人 QQ 邮箱发验证码在小规模场景下够用大规模发信被拒收的概率会明显升高。6.3 发信频率限制接口突然返回需要验证码第三个坑更隐蔽。某次测试脚本循环发了几十封验证码某个节点之后QQ 邮箱突然不接收我的连接了接口报错提示需要验证网页端登录时也要求额外验证身份。这是典型的频率触顶行为。QQ 邮箱对单账号单小时的发信量有隐性限制虽然官方文档不会把具体上限写得很严谨但实测连续发几十封后就会被临时限制。如果遇到这种情况能做的应急措施是检查是否同一个邮箱地址被频繁发送接口侧增加发送间隔限制。如果必须批量测试准备多个发件邮箱轮换。给发送链路加失败重试但重试次数别太多不然反而扩大限制。我目前的模拟项目在发送接口层面做了 60 秒重复发送限制同一个邮箱 60 秒内只能发送一次超过就返回发送过于频繁。这既保护了用户邮箱不被轰炸也降低了自己被邮件服务商限制的概率。6.4 自动收取时邮件已经读完/超时的处理最后一个坑是自动化收信时遇到的。IMAP 的UNSEEN搜索默认只找未读邮件但测试脚本运行过一次之后邮件被标记为已读第二次再跑就搜不到了验证码返回None。这时候要么在测试前手动清理邮箱要么调整自动收信的逻辑不只看未读而是直接取某一封符合主题的最近邮件。我调整后的做法是搜索条件改成FROM 发件人 主题后缀取时间最近的一封不再依赖未读状态。比如status, messages mail.search(None, fFROM yournameqq.com SUBJECT 模拟项目)这样做的好处是即使上一轮测试忘了清理邮件也不影响下一轮读取。坏处是可能误读到旧验证码所以要在业务逻辑上保证验证码是一次性的并且校验时会和 Redis 里的新验证码比对旧的自然匹配不上。另外 IMAP 连接经常会因为长时间空闲被服务端断开所以每封邮件读取之间如果间隔太久建议重新连接或者在异常处理里捕捉超时连接错误重连一次。我在脚本里加了简单的重试逻辑遇到断连就重新登录实测稳定很多。最后再分享一点第十七周复盘后的体会邮箱验证码这个功能如果只看一段发送代码会觉得也就那样但真正落地时账号配置、安全存储、频率限制、自动收取这些环节任何一个处理不到位都会在某个意想不到的节点冒出来咬你一口。尤其是 QQ 邮箱这种国内邮件服务商很多限制不会明确写在文档里只能靠实测去摸规律。我的建议是先开 SMTP/IMAP拿到授权码再写发送和收取代码最后补上验证码的过期、次数、一次性校验按照这个顺序推进能少走很多弯路。如果后面有人想把短信验证码也一起接进来这套流程的核心设计思路是通用的改改发送通道就能复用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑