Java实战:不发真实邮件,探测邮箱账号真实存在性|SMTP协议探针完整实现
#Java #Netty #SMTP #邮箱探测 #Reactor #Lettuce #邮箱有效性校验为什么需要邮箱探测日常开发中需要校验邮箱的场景不少注册表单校验、客户数据清洗、营销邮件预处理。大部分人第一反应是用正则校验格式但正则只能验字符串写法对不对没法判断这个邮箱在邮件服务器上到底存不存在。举两个例子abc123nonexist-domain-xxx.com格式没问题但域名根本没有邮件服务test123qq.com格式也对但账号可能早就注销了。直接给不存在的邮箱发邮件会产生大量退信影响发件IP信誉。市面上的方案各有短板发真实验证邮件需要用户点链接确认异步流程大批量清洗效率较低第三方校验API有调用费用量大成本高还有限流和数据泄露的顾虑。这套方案适合的场景用户注册前置校验、CRM批量清洗无效邮箱、邮件营销预处理降低退信率、内部数据质量治理。核心思路是利用 SMTP 协议的 RCPT TO 命令做探针——不发送邮件正文只跟邮件服务器握手问一句这个收件人能不能收信。不会产生真实邮件不会落到收件箱速度快可以批量跑。注意这项技术仅限业务内部合法数据校验禁止用于爬虫、骚扰、恶意扫描需遵守网络安全与邮件服务商规范。技术栈与依赖组件版本说明Java17Spring Boot3.xReactor响应式3.xNetty4.xSMTP TCP通信LettuceRedis响应式客户端缓存限流黑名单dnsjavaMX域名DNS解析JacksonJSON序列化缓存对象pom.xml核心依赖?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version4.1.0/version relativePath / /parent groupIdcom.yzjyhp/groupId artifactIdverify-mail/artifactId version0.0.1-SNAPSHOT/version nameverify-mail/name description邮箱有效性校验服务/description properties java.version17/java.version dnsjava.version3.6.5/dnsjava.version jackson.version2.15.2/jackson.version lettuce.version6.2.6.RELEASE/lettuce.version /properties dependencies !-- SpringBoot Web 响应式Web适配Reactor -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- Jakarta Validation 参数校验解决ConstraintViolationException不存在 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- Jackson Json序列化 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version${jackson.version}/version /dependency !-- DNS MX解析 dnsjava -- dependency groupIddnsjava/groupId artifactIddnsjava/artifactId version${dnsjava.version}/version /dependency !-- Redis Lettuce 响应式 -- dependency groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /dependency !-- 日志 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies /projectSMTP 探针的工作流程探针模式下不会发送 DATA 邮件内容握手流程是这样的解析邮箱域名 DNS拿到 MX 邮件交换服务器地址TCP 连接 MX 服务器端口优先 587其次 25发送EHLO your-domain向 SMTP 服务自我介绍判断是否支持STARTTLS扩展支持就升级 TLS 加密通道MAIL FROM:verifyyourbusiness.com设置发件信封发送RCPT TO:targetxxx.com询问目标收件人——这是整个探针的核心命令返回250说明账号存在返回550/551/553说明账号不存在返回4xx是临时失败灰名单、限流不能直接判无效可以重试一次发送QUIT关闭会话不发送邮件正文。整个过程不会投递邮件只是问服务器这个收件人能不能收信。项目模块划分代码按职责拆成了几个模块DNS 解析由DnsMxUtil.java负责解析域名 MX 记录拿到邮件服务器地址。Netty SMTP 通信层有三个 HandlerSmtpPlainHandler.java处理明文连接、EHLO 握手和 STARTTLS 协商SmtpSecureHandler.java在 TLS 就绪后执行 MAIL FROM / RCPT TO 探测用状态机解析 SMTP 应答码SmtpTlsProbe.java封装 TcpClient组装 Netty pipeline对外返回探测结果。业务 VO 枚举层包含EmailVerifyStatus.java定义业务状态EXISTS / NOT_EXISTS / UNKNOWNSmtpSecureHandler.SmtpResult是底层探测结果UnknownReasonEnum.java在 UNKNOWN 时细分失败原因用于监控熔断EmailVerifyResult.java是统一返回对象兼做 Redis 缓存序列化。EmailRedisService.java用 Lettuce Reactive 做非阻塞 Redis 操作负责探测结果缓存、IP 限流计数和黑名单。ProxyPool.java管理多出口 IP 轮换规避 MX 服务器对单 IP 的风控拦截。EmailVerifyService.java是顶层入口串联缓存优先、限流、MX 选择、端口遍历、4xx 重试和结果落缓存的逻辑。执行链路流程图缓存命中缓存未命中限流触发放行MX为空拿到MX列表是否 / 已经重试完毕调用verify(email) 入口Redis查询缓存hitCachetrue直接返回结果从代理池获取出口ProxyIPRedis acquireRateLimit IP限流校验返回状态UNKNOWN原因RATE_LIMIT_BLOCKDnsMxUtil解析域名MX记录返回UNKNOWN原因MX_EMPTY循环尝试端口链(587-25) tryPortChainSmtpTlsProbe建立TCP连接MX服务器SmtpPlainHandler EHLO握手 STARTTLS TLS升级协商SmtpSecureHandler执行MAIL FROM - RCPT TO探测得到底层SmtpResult原始结果mapSmtpResultToBizResult映射业务VO EmailVerifyResult是否4xx临时错误 未重试过?延迟等待后 doRealProbe 执行一次重试写入Redis缓存 setCache返回最终EmailVerifyResult核心代码片段完整源码见上文附件这里选取最关键的几段。顶层入口 EmailVerifyService#verify/** * 对外暴露邮箱验证入口 * 优先读取Redis缓存无缓存执行真实SMTP网络探针探测 */ public MonoEmailVerifyResult verify(String email) { log.info(开始验证邮箱{}, email); // 1.先查缓存命中直接返回标记hitCachetrue return redisService.getCache(email) .flatMap(cache - Mono.just(cache.toBuilder().hitCache(true).build())) // 缓存为空执行真实网络探测 .switchIfEmpty(Mono.defer(() - { log.info(无缓存执行真实探测 email{}, email); return doRealProbe(email, false); })); }RCPT TO 应答判定 SmtpSecureHandler这段是根据 RCPT TO 返回码区分账号状态的核心逻辑case RCPT_TO: // 核心判定分支根据RCPT TO返回码区分账号状态 if (line.startsWith(250)) { // 250 OK服务器承认账号存在 result SmtpResult.EXIST; } else if (line.startsWith(550) || line.startsWith(551) || line.startsWith(553)) { // 标准永久失败账号不存在 result SmtpResult.NOT_EXIST; } else if (line.startsWith(4)) { // 所有4xx应答临时故障允许上层一次延迟重试 result SmtpResult.TEMP_FAIL; } else if (line.startsWith(502)) { // 国内大厂MX常见风控拦截返回502无法判定真实账号状态 if (isDomesticMxTarget(targetEmail)) { rawResp 502_DOMESTIC_BLOCK: rawResp; } result SmtpResult.UNKNOWN; } else { // 其他5xx/非标准应答统一归为无法判定 result SmtpResult.UNKNOWN; } quit(ctx); break;Redis 缓存与限流 EmailRedisService用 Lettuce Reactive 非阻塞 Redis适配 WebFlux 响应式链路。setCache/getCache 做探测结果缓存减少重复网络探测acquireRateLimit 做代理 IP 滑动计数限流避免被封禁addBlacklist / isBlacklisted 做域名和 IP 的黑名单熔断。application.yml 配置spring: redis: host: 127.0.0.1 port: 6379 database: 0 password: verify-mail: # DNS解析超时毫秒 dns-timeout-millis: 3000 # TCP连接超时 connect-timeout-ms: 5000 # 25端口是否跳过STARTTLS skip-start-tls-on25: false # 缓存有效期 分钟 cache-ttl-minute: 30 # IP限流窗口 分钟 rate-limit-window-minute: 5 # 窗口内最大请求数 rate-limit-max: 20 # 单次探测整体超时秒 probe-timeout-second: 10 # 4xx重试延迟毫秒 retry-delay-ms: 800 # 探测端口优先级列表 port-chain: - 587 - 25 # 多出口代理IP池 proxy-ip-list: - 127.0.0.1踩过的坑坑1STARTTLS 升级后 pipeline 没移除明文解码器满屏乱码TLS 握手成功后程序收到大量二进制乱码SMTP 报文解析全部错乱探测清一色返回 UNKNOWN。排查发现执行STARTTLS后链路已经全是 SSL 密文但LineBasedFrameDecoder和StringDecoder还留在 pipeline 里把 TLS 密文当 ASCII 文本解析了。修复方式是在发送 STARTTLS 拿到 220 响应后先移除明文编解码器再插入 SslHandler// 发送STARTTLS拿到220响应之后先移除明文编解码器再插入SslHandler ctx.pipeline().remove(LineBasedFrameDecoder.class); ctx.pipeline().remove(StringDecoder.class); ctx.pipeline().remove(StringEncoder.class); // 最前面插入SslHandler后续流量先走TLS解密 ctx.pipeline().addFirst(new SslHandler(sslContext.newEngine(ctx.alloc()))); state State.TLS_UPGRADING;TLS 握手成功解密之后再把明文解码器加回来处理解密后的 SMTP 文本。坑2Netty 回调重复触发MonoSink 报 already invoked报错MonoSink already invoked下游收到重复数据。原因是 SMTP 会话正常返回、通道关闭、异常捕获这几条路径都会执行回调sink.success 被调了多次。加了一个AtomicBoolean done标记用 compareAndSet 保证回调只执行一次AtomicBoolean done new AtomicBoolean(false); // 回调内部 if(done.compareAndSet(false,true)){ sink.success(res); }坑34xx 临时应答直接判不存在误杀一片大量有效邮箱被判定 NOT_EXISTS。SMTP 协议中4xx是临时错误灰名单、服务器负载高不能等同于账号不存在。底层改为标记TEMP_FAIL上层允许最多一次延迟重试重试仍失败就降级为UNKNOWN不能判不存在。坑4QQ/163 MX 返回 502不是账号问题QQ 邮箱探测经常返回 502这不是账号不存在是服务商风控拦截了出口 IP。处理方式是识别 qq.com、163.com 域名收到 502 时给 rawResp 打上502_DOMESTIC_BLOCK标记上层根据这个标记做代理 IP 轮换和域名熔断而不是直接丢弃结果。坑5忘记处理 Mono.cancelTCP 连接泄漏大批量并发探测时连接数只涨不降。原因是没处理 Mono 的 cancel 信号TcpClient 的 disposable 没释放。加上sink.onDispose(disposable)就好了sink.onDispose(disposable);坑6catch-all 域名没法识别catch-all 域名对任意 RCPT TO 都返回 250没法区分账号真实是否存在。目前代码里预留了catchAllDomain布尔字段后续可以扩展 catch-all 检测逻辑。业务边界这套方案不是 100% 准确。邮件服务商可以开反爬虫风控对陌生 IP 返回误导应答catch-all 域名无论账号是否存在都返回 250灰名单会 4xx 临时拒绝。所以结果里有UNKNOWN状态表示无法判定业务层不能把 UNKNOWN 当成不存在。出口 IP 信誉很重要如果 IP 在反垃圾邮件黑名单上探测会大面积失败建议配多代理 IP 池轮换。再说一次合规问题仅用于自有业务数据校验禁止对陌生域名做大规模扫描。写在最后做邮箱有效性校验的时候SMTP 协议、Netty 网络编程、邮件服务商风控每一层都有坑。如果你也踩过类似的坑欢迎评论区聊聊。觉得有帮助可以点赞收藏方便以后查阅。