Java EE 企业应用 E02:邮件提交成功离收件人看到还差几步
SMTP 接受了一封“订单已生成”邮件,然后客户端超时
采购单生成后,系统要给申请人发通知。消费者调用 SMTP 发送,邮件服务器可能已回复成功,但客户端在读回复时断线。消费者只看到 I/O 错误,不知道服务器是否接收;若立刻无限重试,可能送出多封;若把这次记为“已送达”,实际 SMTP 服务器可能根本没有接受。此处有至少四种不同事实:订单业务事务已提交、outbox 任务已被消费者领取、SMTP 服务器接受消息、最终邮箱确实到达并可读。只有最后一项才能叫最终送达,而 Jakarta Mail 的一次发送返回不能保证这一点。
当前累计工程公开的 examples/javaee-enterprise/db/migrations/001-initial.sql 只包含申请、明细和订单;没有通知账本、邮件收件人表、SMTP 资源或本地邮件测试服务。主线第 23、30–31 篇的 outbox 和消费重投是先修条件,不能由健康探针补证。本章没有在同名目录放模拟 SMTP 输出,实验均保持 NOT_RUN。从已有订单和受信租户/主体关联推导通知目标,不允许客户端在 /lab/requests 的请求体里提交任意收件地址,避免把服务器变成群发中继。
为什么需要两本账
同一数据库事务更新订单并写 notification_intent,保存租户、订单 ID、通知类型、模板版本、业务事件 ID 和目标身份 ID。事务失败时两者均不存在;事务成功后消费任务可重试。邮件正文在发送时从允许的字段和固定模板生成,避免出站邮件包含不应跨部门传播的价格或附件。第二本账记录每次发送尝试:任务 ID、尝试号、开始/结束时间、SMTP 对话阶段、服务器接受或失败的分类、远端响应摘要及去标识化的目标地址。业务事件 ID 可以固定为“订单 ID + 邮件类型 + 决策版本”,并在通知任务上设唯一约束;它控制本系统内部重复任务,不保证邮件系统端到端去重。
1 | |
重复投递有两个窗口。若 SMTP 接受后、本地记录接受状态之前进程崩溃,重投可能再发一封;若记录“已发”早于网络发送而进程崩溃,任务被跳过则永久丢失。不能同时用本地数据库事务原子地提交外部 SMTP 副作用,除非协议与资源都提供相应协调机制;普通 Transport.send 并不加入采购数据库的 JTA 事务。一个业务事件 ID 的 Message-ID 可以帮助追踪和部分客户端归并,但邮件服务器不承诺据此去重。业务上若无法接受重复,应增加收件端幂等机制或改变通知渠道/确认协议,而不是把 Message-ID 称为恰好一次。
Jakarta Mail 在哪一刻返回
消费者领取任务必须考虑进程在任何阶段崩溃:领取记录需要租约或可恢复状态,不能只在内存里把任务标成“正在发送”;租约到期由另一工作者接管时可能与仍在运行的老工作者并发发送。发送尝试 ID 与领取序号应持久化,并限定同一任务同时持有的有效租约;由于 SMTP 出站不遵守数据库锁,接管前应检查老实例是否真的结束,无法确认时把状态记为 UNKNOWN,而不是宣称唯一持有者就能实现严格一次投递。任务独立于审批请求的 Servlet 会话,邮件地址从业务认可的身份目录查询;采购员注销并不应使已经提交的通知任务丢失。
发送阶段至少区分连接建立、服务器声明可接收、逐个收件人响应、数据内容提交与最终确认。连接超时可能意味着没有接触服务器;已经写完消息体才超时则完全不同。一次群发有多个收件人时,可能部分收件地址被拒而其他被接收,故实验应先固定单一合成收件人以免掩盖分类,随后才讨论按收件人分账。即使 Transport.send 正常返回,应用也没有看到服务器未来的转发和邮箱入站行为。日志须避免保存整段 SMTP 协议对话中的认证密钥和地址原文,网络抓包也应只在脱敏隔离环境操作。
Jakarta Mail 2.1 定义消息、会话和 Transport 抽象;邮件服务器资源、TLS、认证及连接超时由具体实现与环境配置完成。Transport.sendMessage 执行一次传输尝试:成功只表明提交过程对当前服务器成功,后续网关转发、灰名单、退信、反垃圾、用户阅读都在它的观察边界之外。失败时 MessagingException 以及嵌套原因可提供诊断,但在已发出内容、未收到最后答复时错误不等于“对端肯定未接收”。必须把此种未知结果与明确拒绝码区别开来,保留受限日志后按有界重试/人工复核策略处理。固定测试环境可用本地 SMTP sink,不能用真实客户地址或外部网关验收;sink 捕获只证明该测试 SMTP 接受了哪些邮件。
目标消费方法应由受管执行器触发,通过注入的邮件 Session 构造消息;此处展示出站调用位置,不是现有代码,也未配置 mail/Procurement。收件地址须来自可信用户目录,模板变量需编码、控制换行与邮件头,不能把原始申请备注拼进 Subject。
1 | |
本例省略了地址目录校验、认证与超时配置,因而不能复制进现有工程就宣称安全可用;生产环境还需安全处理邮件头注入、收件人无效、限速、密钥轮换和退信。特别是 orderNumber 必须由内部稳定 ID 推导,不能直接使用用户输入来生成内容。收件地址与邮件正文可能属于个人数据,日志仅保存任务 ID、错误分类和必要的散列/脱敏线索。用独立数据库连接在邮件发送后写账本,不存在对邮件和数据库的“同一事务提交”。
最小故障矩阵
本地 SMTP sink 必须支持可重复的注入条件:按一次请求返回明确错误、在接受内容后关闭连接、在接受前关闭连接、恢复后允许按同一任务再发。每次只改一个条件,保留独立的 sink 接收表而非仅用应用日志证明对方做了什么。若 sink 在 DATA 后没有发最后响应,应用账本只能写“接受与否未知”;若 sink 明确返回失败,再根据故障是否可恢复设置下次尝试时间。禁止无限立即重试:若一分钟内大量订单同时触发未知结果,盲目重试会加重故障并可能重复群发。限定最大尝试数、指数退避和人工处理队列,且保留从业务事件到各次尝试的审计关联。
通知隐私本身也是验收条件。若 A 租户申请的收件人被错误解析为 B 租户账户,邮件一旦出站,数据库回滚也收不回来。选择收件人时带租户与用户关联条件,模板只放最少必要信息,批准/拒绝业务结果从已提交数据库读取;对无有效邮件地址、取消订阅或目标身份被禁用的情况,应按业务规则记录 SKIPPED 或待人工处理,不生成一个地址为空的“成功发送”。对邮件头中的主题、显示名和换行字符做可检查的限制,防止客户端用采购备注注入额外收件人。最终对账应比较订单数、应通知人数、意图数和各状态尝试数,而不是只统计 SMTP 请求量。
正常实验:隔离库生成一张订单及对应 intent,启动本地 SMTP sink、容器和有界消费者,检查订单、intent、尝试账本以及 sink 中的一封邮件字段一致;只能报告“sink 接收一封”,不能写“最终送达”。失败实验一让 sink 明确拒绝收件人,验证账本是 REJECTED、任务按分类安排重试或停机处理且不产生第二张订单;失败实验二在 sink 接受后、应用记录返回前切断响应,验证 UNKNOWN 后重试可能出现两封但两次尝试都有记录,业务订单仍只有一份;失败实验三在同一租户重投消息,验证任务唯一键和状态机不会生成另一个通知意图。即使网络层观测到成功,也应检查 SMTP sink 的独立日志,而不是仅凭 Java 方法返回推断对端状态。恢复时撤销故障注入,记录新尝试号与最终分类,不能修改旧账伪造只有一次发送。
现有工程无邮件任务与 SMTP 端点,也没有满足以上输入的可执行脚本。正常、明确拒绝、回复丢失、重投及恢复均 NOT_RUN。将来执行时在隔离配置下保留服务器返回码、邮件捕获 ID、数据库行和脱敏网络故障时间线;该记录仍不能替最终收件邮箱送达证明。适用范围是业务通知而非正式财务凭证送达保证。
邮件供应商的 250 接受码不是交付凭证。若后来发生退信,必须能以业务事件 ID 和出站消息标识关联原任务,并将其作为不同时间的反馈事件,不能倒改“当时 SMTP 已接受”的历史事实。需要证明实际用户阅读时,仍需另一种明确的用户确认流程;打开追踪像素可能被邮件客户端阻止或预加载,既涉及隐私也不足以充当财务确认。将来从 sink 换为真实服务器,要重新冻结 TLS 校验、证书信任、凭据轮换、域名信誉、吞吐配额和退信接口;本章没有对任何真实地址发邮件,也不从 Jakarta Mail API 推导某个运营商的投递承诺。
还需要定义“通知被跳过”是否业务成功。采购订单生成事务不应该因为某个申请人暂时没有合法邮箱就回滚订单;但通知意图必须保留可追溯的跳过原因和恢复任务,避免页面显示“通知已完成”而账本无记录。地址修改发生在订单提交和发送之间时,应明确取提交时受信目录快照还是发送时最新地址,并记录所用目录版本,避免邮件发到被撤销的地址;处理退信时还须防止一个旧收件地址重新启用后误把历史退信算成当前业务失败。若监管要求交付有可证明的时间界限,仅依靠 SMTP 接受码不够,需要独立的回执与人工升级路径。
可在隔离库中用相同业务键重放消费十次,核对 notification_intent 只有一行、尝试表允许多行且每行有独立分类,SMTP sink 中邮件数可能大于一;这不是测试失败,而是明确展示未知结果造成的重复边界。若产品需求拒绝重复,应将副作用移动到具备服务端幂等键的通知网关并实际测试,不能仅在本地查询“已发送”后就保证外部唯一。一次消费进程启动失败与邮件服务器接受后崩溃要分别注入,否则账本上同是 PENDING,却有不同的真实出站副作用。
账本写入本身也可能失败:SMTP sink 明确已接受邮件、本地数据库暂时不可写,消费者无从持久记录 ACCEPTED。恢复数据库后,同一任务由租约再次领取,需按原业务事件 ID 重试并可能产生第二封;仅靠发送器内存中的“上一封成功”无法穿越进程重启。故障注入应保留 SMTP 捕获时间、数据库不可用区间和重启后的任务状态;不能把“后来账本显示一次成功”当作 SMTP 只收过一封。这个反例也是对“通知恰好一次”断言的直接检验。
两道带答案的练习
练习一: 账本写 SENT 后进程崩溃,没有连 SMTP。订单通知是否“至多一次”且正确?调整写入顺序会消除所有风险吗?
解: 预记 SENT 会漏发;先发后记又会在提交记录前崩溃时重发。应该把已提交的业务任务、发送尝试、ACCEPTED/UNKNOWN 结果分别记录,在 UNKNOWN 窗口允许有界重试并披露重复风险;有最终送达约束时另行设计可核对确认。按故障点检查订单数、任务状态与 sink 邮件数,这里未实际运行。
练习二: 测试 SMTP 收下了邮件,用户说没看到。团队想把消费者状态从 ACCEPTED 改名为 DELIVERED。可以吗?
解: 不可以。SMTP 接受不包含下游邮箱入站与用户阅读;保留接受状态,若业务需要投递事实,应接入可验证回执/退信渠道并建立对应关联 ID 和失败状态,必要时人工确认。即使本地 sink 捕获到字节,也只证实这次隔离测试的接收行为。
规范版本与实际证据
教学基线 JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5;官方规范:Jakarta Mail 2.1、Jakarta Messaging 3.1、Jakarta Transactions 2.0 与 Jakarta EE 11 Platform。Jakarta Mail 定义 Java 邮件 API,不定义外部邮箱阅读确认;消息与本地数据库恢复应分别取证。examples/javaee-enterprise/application/src/main/java/blog/javaee/application/ProcurementUseCases.java 有订单业务入口,但没有邮件消费者;本章没有冻结本地 SMTP sink 的产品版本,也未产生发送记录。





