
上周三早上八点刚过值班群里跳出来一句注册验证码收不到了后面跟着几张用户投诉截图。我第一反应是网关挂了结果日志里干干净净接口返回全是 200只是mailSender.send()那一行耗时从平时的 300 毫秒飙到了 8 秒然后抛了个MailAuthenticationException。顺着往上翻发现前一天晚上有人改过application-prod.yml把spring.mail.password从授权码改成了邮箱的登录密码——看起来是一模一样的密码但实际完全不是一回事。这件事之后我把整套SpringBoot 配置 Email 发送功能的链路从头理了一遍从spring-boot-starter-mail的自动配置、SMTP 握手参数到模板渲染、异步线程池、失败重试和排错路径全部重新过了一遍。这篇就把这套东西完整写出来包含可以直接抄的配置和代码也包含那几个我花了整晚才定位到的地方。不管你是第一次在项目里接邮件功能还是已经用了两三年但一直没搞清楚 465 和 587 到底差在哪下面的内容应该都能对上你的某些具体问题。1. spring-boot-starter-mail 到底帮你做了哪些事很多人把 starter 当成一个导了包就能用的开关其实它在背后做了三件挺关键的事理解清楚这三件事后面配置出错的时候你才能知道该去哪儿找原因。1.1 自动配置塞进容器的那个 JavaMailSender只要 classpath 下存在spring-boot-starter-mail并且配置了spring.mail.hostMailSenderAutoConfiguration就会往容器里注册一个JavaMailSenderImpl实例Bean 名称叫mailSender。它做的事情非常朴素读取spring.mail.*下的 host、port、username、password、protocol、default-encoding 等字段再把你写在spring.mail.properties下面的键值对原封不动塞进java.util.Properties最后交给Session.getInstance()构造出 JavaMail 的Session。也就是说spring.mail.properties里的内容是不做任何加工直接透传给 JavaMail 的所以官方文档里那些mail.smtp.xxx属性你在 Spring Boot 里一样能用只是要多加一层mail.smtp.前缀的嵌套写法。这里有个容易踩的点JavaMailSenderImpl是单例的Session也是共享的。意味着你没法在运行期给不同的租户用不同的发件账号——如果业务上真需要多账号发信别在同一个JavaMailSender上想办法直接自己 new 几个JavaMailSenderImpl实例放进 Map 里按 key 取比改自动配置干净得多。Configuration public class MailSenderConfig { Bean Primary public JavaMailSender primaryMailSender(MailProperties props) { JavaMailSenderImpl sender new JavaMailSenderImpl(); sender.setHost(props.getHost()); sender.setPort(props.getPort()); sender.setUsername(props.getUsername()); sender.setPassword(props.getPassword()); sender.setDefaultEncoding(UTF-8); sender.setJavaMailProperties(props.getProperties()); return sender; } }上面这种写法基本就是把自动配置抄了一遍除非你要做多账号否则没必要。但如果你的项目里同时存在两个JavaMailSender类型的 Bean注入点必须用Qualifier指定名字否则启动直接报NoUniqueBeanDefinitionException——这类问题在引入第三方 SDK 时特别容易冒出来。1.2 Spring Boot 3 之后包名全变了这一条是升级项目时最容易被卡住的地方。Spring Boot 2.x 时代spring-boot-starter-mail传递下来的是com.sun.mail:jakarta.mail1.6.x虽然 artifact 名叫 jakarta但包名仍然是javax.mail.*。到了 Spring Boot 3.0底层换成了jakarta.mail-api2.x 加org.eclipse.angus:angus-mail包名统一变成jakarta.mail.*。结果就是你从网上抄的一段import javax.mail.internet.MimeMessage;在 Boot 3 项目里根本编译不过。这不是你抄错了是版本对不上。判断方法很简单看pom.xml里 Spring Boot 的版本号2.x 用javax.mail3.x 用jakarta.mail两者不能混。我见过有人在 Boot 3 项目里手动把javax.mail-api加回来想兼容一下老代码最后导致 classpath 下同时存在两套 API运行时抛ClassCastException排查成本极高。1.3 除了 starter 你还需要什么纯发文本邮件一个 starter 就够了。但只要涉及 HTML 模板就得多引一个模板引擎Thymeleaf 或者 FreeMarker 都行。我一般选 Thymeleaf原因是它的语法在邮件场景里够用而且Context传变量的方式和 Web 层一致不用记两套 API。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency如果你的项目本来就是个纯后端服务、压根没引 Web starter那引 Thymeleaf 会顺带把spring-boot-starter-web的一堆东西拉进来包体积会变大。这种情况下用 FreeMarker 更轻一点或者干脆把模板渲染成静态字符串存起来用简单的字符串替换虽然土但省依赖。2. 配置项在 SMTP 握手时对应的到底是哪一步application.yml里那几行看着简单但每写错一个字段报出来的异常完全不一样。我习惯把 SMTP 连接过程拆成建连 → 加密协商 → 身份认证 → 投递四个阶段配置项分别对应前三个阶段出问题的时候按阶段对号入座比盲猜快得多。2.1 host 和 port 决定建连protocol 决定用哪套实现spring.mail.host是 SMTP 服务器地址port是端口这两个填错了会直接ConnectException或者SocketTimeoutException异常信息里会带上具体的地址和端口很好认。protocol一般不写默认就是smtp只有在极少数需要走 SMTPS 显式协议的场景才改成smtps。真正需要留意的是端口和加密方式的绑定关系。常见的三种组合是这样的端口加密方式需要开启的配置备注465隐式 SSLmail.smtp.ssl.enabletrue连接建立时立刻加密587STARTTLSmail.smtp.starttls.enabletrue先明文建连再升级加密25通常不加密都不开多数服务商已限制对外访问这张表里的坑在于465 端口配starttls.enabletrue是不会成功的因为 465 一上来就是 TLS 握手客户端如果按明文协议去打招呼服务器会直接断开。反过来 587 配ssl.enabletrue也一样失败。这两种错配报的异常有时候都是MessagingException: Could not connect to SMTP host光看报错看不出区别必须回头看一眼端口和开关是不是成对。2.2 auth 和 trust 这两个开关的实际含义mail.smtp.authtrue表示我要做身份认证绝大部分服务商都要求开。但如果你的服务器在内网、走的是不需要认证的中继那这个开关关掉反而更省事。mail.smtp.ssl.trust是另一个经常被乱用的配置。它的作用是信任哪些主机的证书很多人图省事直接写*表示信任所有。这在测试环境无所谓生产环境写*等于关掉了证书校验中间人一抓一个准。正确的做法是填具体的 SMTP 主机名或者干脆不配——只要服务器的证书链完整、系统时间正确默认就是能验过的。提示如果只在测试环境遇到PKIX path building failed先确认是不是缺根证书或者 JDK 版本太老实在绕不过去再考虑在测试配置里放宽别把这个设置带进生产。2.3 三个 timeout 不配线程会被拖死JavaMail 有三组超时参数单位都是毫秒默认值分别是connectiontimeout无限、timeout无限、writetimeout无限。听起来无限很安全实际上是最危险的默认值——SMTP 服务器偶尔会因为反垃圾策略把连接挂起而不返回任何数据客户端就一直阻塞在那里。如果是同步发送这个请求线程就废了。我一般的配置是建连 5 秒、读 10 秒、写 10 秒spring: mail: host: smtp.example.com port: 465 username: no-replyexample.com password: ${MAIL_PASSWORD} default-encoding: UTF-8 properties: mail: smtp: auth: true ssl: enable: true starttls: enable: false connectiontimeout: 5000 timeout: 10000 writetimeout: 10000建连超时给短一点是因为连接阶段失败通常是网络或地址问题重试也没意义早点失败早点进降级逻辑。读写超时给到 10 秒是因为大附件上传确实需要时间给太短会导致大邮件永远发不出去。2.4 password 里填的是授权码不是登录密码这就是开头那次事故的根因。现在绝大多数邮箱服务商在开启 SMTP 服务时都会要求你生成一个独立的客户端授权码/专用密码而不是直接用网页登录的那个密码。两者的区别在于登录密码走的是网页的认证通道授权码走的是 SMTP/IMAP 这类客户端协议通道。填错了报的是535 Error: authentication failed或者501 Authentication failed异常信息里不会告诉你你填错了类型只会说认证失败非常误导人。我的习惯是把密码从配置文件里拿出来走环境变量或配置中心一来避免密码进 Git二来方便轮换。授权码一旦泄露别人可以拿你的域名大量发信后果不用我多说。3. 四种邮件形态的代码差异代码层面其实就两个类SimpleMailMessage和MimeMessageHelper。前者只能发纯文本后者能发一切。很多人不管什么场景都直接用后者其实发个纯文本通知用SimpleMailMessage更快也更省心代码量差不了几行但省掉了 multipart 编码的开销。3.1 SimpleMailMessage 的适用边界SimpleMailMessage message new SimpleMailMessage(); message.setFrom(no-replyexample.com); message.setTo(someoneexample.com); message.setSubject(您的验证码); message.setText(验证码 code 5 分钟内有效。); mailSender.send(message); // 批量收件人 message.setTo(new String[]{aexample.com, bexample.com});这个类的限制很明确不能带附件、不能带内联图片、不能发 HTML。好处是它内部不做 MIME 编码发送速度快日志也清晰。短信提醒、系统告警、纯文本通知这类场景用它完全够。有一个细节要注意setFrom()的地址必须和spring.mail.username一致或者至少是服务商允许的别名。如果发件人和认证账号不一致很多服务商会返回554或者550 Sender address rejected。这个问题我在两三个项目里都遇到过症状是换了个发件人名字就发不出去了。3.2 MimeMessageHelper 的 multipart 开关MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, true, UTF-8); helper.setFrom(no-replyexample.com); helper.setTo(someoneexample.com); helper.setSubject(月度报表); helper.setText(h1报表已生成/h1p请查收附件。/p, true); helper.addAttachment(报表.xlsx, new File(/data/export/report.xlsx)); mailSender.send(message);第二个构造参数multipart决定是否启用 MIME 多部分消息。只要你需要附件或者内联资源就必须传true。传false再去调addAttachment会直接抛IllegalStateException。第三个参数是编码不传的话在部分环境下中文主题会变成一串问号。setText的第二个参数html也很关键传true才会按 HTML 渲染传false的话你写的h1会原样显示在邮件正文里——这个错误我第一次写邮件功能的时候就犯过测试同事截了张图发给我正文里赫然躺着h1你好/h1。3.3 内联图片靠 cid 引用不是外链邮件里的图片有两种放法外链 URL 和 内联嵌入。外链的问题在于很多邮件客户端默认不加载远程图片用户看到的是一个个空白框。内联嵌入则是把图片作为 MIME 的一个 part 塞进邮件体用 Content-ID 引用。MimeMessageHelper helper new MimeMessageHelper(message, true, UTF-8); helper.setText(p欢迎加入/pimg srccid:logo width120/, true); helper.addInline(logo, new ClassPathResource(static/logo.png));addInline的第一个参数就是你 HTML 里cid:后面的标识两边必须完全一致大小写敏感。写完发一封给自己如果图片显示成破图先检查 cid 是不是对得上这是最常见的错误。内联图片有个副作用图片体积会直接算进邮件总大小。如果图片没压缩过一封邮件轻松超过 1MB某些收件方会直接拒收。我一般会把邮件用的图片单独过一个压缩流程控制单张在 50KB 以内。3.4 附件名的中文编码中文附件的文件名乱码几乎是必踩的坑。MimeMessageHelper.addAttachment内部会调MimeUtility.encodeWord处理文件名但它依赖你传给MimeMessageHelper构造器的编码。如果构造时用的是默认编码、而系统默认编码是 GBK 之类客户端解出来就是乱码。稳妥的做法是在构造时显式指定 UTF-8同时在 application 配置里固定spring.mail.default-encoding: UTF-8MimeMessageHelper helper new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()); helper.addAttachment(2024年7月对账单.xlsx, resource);另外别用new File(...)直接指一个可能不存在的大文件一旦文件路径有问题异常会抛在addAttachment阶段而不是发送阶段报错信息还特别含糊。我习惯先判断文件存在和大小再做发送。4. 把 HTML 正文从 Java 字符串里挪出去一开始很多人都是直接在 Java 里拼 HTML 字符串写着写着这个方法就变成三四百行改一个文案要重新编译发版前端同事看了直摇头。邮件正文本质上是视图就该交给模板引擎。4.1 Thymeleaf 渲染邮件的完整调用链Service public class MailTemplateService { private final TemplateEngine templateEngine; public MailTemplateService(TemplateEngine templateEngine) { this.templateEngine templateEngine; } public String renderVerifyMail(String code, String username) { Context context new Context(Locale.CHINA); context.setVariable(code, code); context.setVariable(username, username); context.setVariable(expireMinutes, 5); return templateEngine.process(email/verify-code, context); } }模板文件放在src/main/resources/templates/email/verify-code.htmlThymeleaf 默认就能扫到。渲染出来的结果是一个完整 HTML 字符串直接传给MimeMessageHelper.setText(html, true)。这里有个需要留意的点TemplateEngine和SpringTemplateEngine是两个不同的 Bean。注入TemplateEngine接口类型时Spring 会拿到自动配置的那个SpringTemplateEngine实例它已经配好了ClassLoaderTemplateResolver前缀是classpath:/templates/后缀是.html。如果你自己又定义了一个TemplateEngine的 Bean注入就会歧义这时候得用Qualifier(emailTemplateEngine)之类的方式区分。4.2 邮件模板的写法约束邮件模板和网页模板的约束完全不一样。网页上你能用 flex、grid、外部 CSS邮件里一大半客户端不支持。我的经验是把样式全部内联到 style 属性上布局用 table 嵌套宽度控制在 600px 以内。Thymeleaf 的th:inlinecss在邮件里基本用不上别浪费时间去试。变量注入要考虑转义。如果用户名里带或者直接拼进 HTML 会破坏结构。Thymeleaf 的th:text默认会做 HTML 转义th:utext不转义。邮件正文里凡是用到用户输入的地方一律用th:text除非你百分之百确定那段内容是系统生成的。还有一个容易忽略的地方邮件模板不能依赖任何外部资源加载。你在模板里写link relstylesheet href/css/mail.css在本地测试的时候可能因为浏览器缓存看着正常但邮件客户端拉不到这个地址用户看到的就是一坨没有样式的纯文本。4.3 模板缓存与热更新的取舍Thymeleaf 默认在生产环境开启模板缓存好处是渲染快坏处是改了模板不重启不生效。开发期把spring.thymeleaf.cache设成 false改完刷新就能看到效果生产环境保持 true别为了方便改文案把它关掉缓存关掉之后每次渲染都要读文件、解析模板量大时性能差距很明显。如果业务上确实需要频繁调整邮件文案、又不想发版可以考虑把模板内容存到数据库或者配置中心渲染时用StringTemplateResolver动态解析。这套方案我做过一次代价是要自己管理模板版本和缓存失效不到万不得已不建议上。5. 同步发送拖垮接口异步、线程池与重试邮件发送是一个外部依赖调用耗时完全不受你控制。同样一次send()网络好的时候 200 毫秒SMTP 服务器繁忙的时候能到十几秒。把这种调用放在 HTTP 请求的主线程里等于把系统的稳定性交给了别人的服务器。5.1 一次 send 的耗时到底花在哪我把一次发送拆开测过大致是这么分布的TCP 建连加 TLS 握手 100 到 300 毫秒如果没开连接池每次都要重来SMTP 协议握手和认证 100 到 200 毫秒传输正文和附件按体积算1MB 左右的附件 500 毫秒到 2 秒不等最后QUIT关闭连接。加起来一个不带附件的纯文本邮件稳定在 300 到 800 毫秒之间。这个数字单看不大但注册接口如果每次都同步发一封验证码邮件在并发上来之后线程池很快被占满接口 RT 直接从 50 毫秒涨到 1 秒以上。这不是邮件慢是你的线程在等别人的服务器。5.2 自定义线程池而不是用默认的在方法上加个Async就完事是很多教程的做法。问题是如果不指定执行器Spring 会去找容器里的TaskExecutor找不到就退回到SimpleAsyncTaskExecutor——这个实现每次调用都新建一个线程没有池化没有队列高并发下线程数会直接冲上去。用在邮件这种低频场景可能看不出问题但一旦遇上批量发送服务很可能被自己的线程拖垮。Configuration public class MailAsyncConfig { Bean(mailExecutor) public ThreadPoolTaskExecutor mailExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(mail-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }核心线程 4 个是因为发邮件本身是 IO 密集型不需要太多线程多了反而增加 SMTP 服务端的压力容易触发频率限制。队列给 200 是因为邮件允许一定延迟堆一点没关系。拒绝策略我选的是CallerRunsPolicy队列满了就让提交任务的线程自己执行——这虽然会拖慢调用方但至少不会丢任务也不会因为AbortPolicy直接抛异常打断业务主流程。setWaitForTasksToCompleteOnShutdown(true)加setAwaitTerminationSeconds(30)是给优雅停机用的。不加的话服务重启时队列里没发完的邮件直接丢失而且不会留下任何记录。Service public class MailService { Async(mailExecutor) public void sendAsync(MailTask task) { try { doSend(task); } catch (Exception e) { log.error(邮件发送失败, taskId{}, task.getId(), e); } } }注意Async方法必须是 public 且通过代理调用在同一个类里自己调自己是不生效的这个坑几乎所有用 Spring 异步的人都踩过。要验证也简单在方法里打一行线程名看是不是mail-开头的。5.3 哪些异常值得重试异步之后失败是静默的必须自己补重试。但不是所有异常都该重试判断错了会浪费大量配额。异常类型是否重试原因MailAuthenticationException否账号密码问题重试一百次也一样MailSendException连接超时是网络抖动隔几秒重试大概率成功MailSendException550/554否被服务端拒绝通常是内容或发件人问题MailParseException否消息构造有问题重试无意义MailException未知谨慎记录日志后重试一次观察用 Spring Retry 的话可以这样写Retryable( retryFor MailSendException.class, noRetryFor MailAuthenticationException.class, maxAttempts 3, backoff Backoff(delay 2000, multiplier 2) ) public void doSend(MailTask task) { ... }退避时间用 2 秒起、每次翻倍第三次在 8 秒左右整体控制在 15 秒内。再长的话用户那边验证码早就过期了重试成功也没意义。5.4 失败之后的兜底记录重试全部失败之后这封邮件就真的没了。如果业务上有要求比如订单通知必须送达的得把失败记录落库配一个定时任务做补偿。补偿的时候要加一条限制同一封邮件最多补偿 3 次超过就标记为终态否则一旦对方邮箱永久失效这个任务会无限循环下去。我在实际项目里的做法是建一张mail_send_log表记录 bizId、收件人、模板标识、状态、失败原因、重试次数。这条记录在发送前插入发送成功后更新状态异步那边挂了也有据可查。有了这张表运营侧还能统计出这个月验证码邮件触达率 98.7%比什么都说不清强得多。6. 排错实录四类报错的定位路径配置和代码都写对了不代表一定发得出去实际环境里报错五花八门。下面这四类是我遇到频率最高的按看什么、怎么验证、改哪里的顺序整理照着走基本能定位。6.1 535 认证失败报错长这样javax.mail.AuthenticationFailedException: 535 Error: authentication failed第一件事确认密码填的是授权码不是登录密码。这是最高频的原因占了我遇到的这类问题的一大半。去邮箱后台的客户端授权或者安全设置里重新生成一个替换掉配置里的值先排除这一条。第二件事确认 username 是完整的邮箱地址不是前缀。有些服务商两者都收有些不收写全总是对的。第三件事确认这个账号已经开启了 SMTP 服务。很多企业邮箱默认是关闭的需要在管理后台手动开关。如果三条都没问题就在本地用命令行跑一遍 SMTP 握手把邮件客户端这一层剥掉# 465 端口用 openssl 直连测试 openssl s_client -connect smtp.example.com:465 -crlf能连上并且能正常返回 banner说明网络和端口没问题问题在账号或代码连不上就是网络层的锅跟代码无关。6.2 554 和 550 被拒com.sun.mail.smtp.SMTPSendFailedException: 554 5.7.1 Message rejected这两个码都属于服务端主动拒绝但原因差别很大550一般是收件人地址不存在或者发件人不在允许列表里554更多是内容被判定为垃圾邮件或者触发了发信频率限制发件人和认证账号不一致是最常见的550诱因。比如你用no-replyexample.com认证却在setFrom里写了serviceexample.com如果这个别名没在服务商后台配过就会被拒。554则要回头看看内容。主题全是感叹号、正文里塞了一堆短链接、收件人列表特别长又用了明文To这些都是典型特征。批量发送时务必用Bcc而不是To既能避免收件人互相看到也能降低被判垃圾邮件的概率。6.3 连接超时和半开连接javax.mail.MessagingException: Could not connect to SMTP host: smtp.example.com, port: 465 Caused by: java.net.SocketTimeoutException: connect timed out看到这个先分两种情况是完全连不上还是连上了但没响应。完全连不上大概率是防火墙或者安全组没放行出站端口。云环境的机器默认可能只放行 80/443465 和 587 需要单独加规则。这个在容器化部署时特别容易忘因为构建镜像那台机器和运行环境的出站策略可能不一样。连上了但没响应多半是服务端把你挂起了。这时候如果配置里没设timeout线程就会一直等。加了超时之后至少能快速失败进重试。还有一种更隐蔽的情况连接复用导致的半开连接。JavaMail 默认每次发送都新建连接不存在这个问题但如果开启了mail.smtp.connectionpooltrue池里的连接可能已经被服务端断开而客户端不知道发送时会抛MessagingException。遇到这种情况可以调小mail.smtp.connectionpooltimeout让空闲连接早点被清理。6.4 主题、附件名、正文乱码乱码分三种处理方式不一样现象原因处理主题变成?UTF-8?B?...?或一堆问号编码没指定或者客户端解码异常检查MimeMessageHelper构造时的编码参数附件名乱码文件名编码与传输编码不匹配显式指定 UTF-8或用MimeUtility.encodeWord预处理正文中的中文显示为方块正文编码与实际字节不符配置default-encoding: UTF-8并确认模板文件本身是 UTF-8第三种最容易被忽略模板文件保存的时候用了 GBKIDE 里看着正常渲染出来就是乱码。检查方法是直接用文本编辑器另存为 UTF-8或者在application.yml里强制指定spring: thymeleaf: encoding: UTF-8顺便提一句MimeMessageHelper的编码参数和spring.mail.default-encoding是两回事前者管的是 MIME part 的编码声明后者管的是JavaMailSenderImpl的默认字符集。两个都设成 UTF-8 最省心。7. 上线前还得补的几件事功能能跑通和能上线是两码事。下面这几点在本地开发时完全感受不到一到生产就会暴露。7.1 发件人地址和认证账号要保持一致前面提过一次这里再强调一遍因为它的后果比想象中严重。发件人和认证账号不一致时有的服务商直接拒收有的服务商接受但会改写发件人用户收到的邮件里 From 字段变成了认证账号的地址。这会带来两个问题一是回复邮件回到了错误的地址二是 SPF/DKIM 校验可能不通过邮件直接进垃圾箱。如果你的业务确实需要用不同的显示名称正确做法是setFrom传完整地址的同时设置个人名称helper.setFrom(no-replyexample.com, 某某服务平台);这个重载方法会把显示名做 MIME 编码中文不会乱码。别自己拼某某平台 no-replyexample.com字符串格式稍有偏差就会被解析成非法地址。7.2 收件人列表、密送与频率控制批量发送时setTo(String[])传一个数组看起来很方便但实际上所有收件人都能互相看到彼此的邮箱地址这是明确的隐私问题。正确做法是setBcchelper.setTo(no-replyexample.com); // 名义收件人 helper.setBcc(bccList.toArray(new String[0]));频率控制是另一个必须做的事。企业邮箱普遍有每日发信上限和单位时间内的频率限制超过之后返回 550 或者干脆静默丢弃。批量通知类邮件一定要分批发送每批之间留出间隔别指望一次循环发完。我一般会在发送服务里加一个简单的令牌桶限流把速率控制在服务商限制的 70% 左右留点余量。7.3 日志里别留密码和完整邮箱排查问题的时候日志越详细越好但有两类信息不能进日志密码/授权码和完整的收件人邮箱。密码进日志的风险不用解释。收件人邮箱属于个人信息日志里打全量地址在很多合规要求下是不允许的。我的做法是打码处理private String maskEmail(String email) { int at email.indexOf(); if (at 1) return *** email.substring(at); return email.charAt(0) *** email.substring(at); }a***example.com这个粒度足够定位问题又不至于泄露完整信息。异常堆栈里如果带了Session的调试信息记得把mail.debug关掉——生产环境开着 debug 会把整个 SMTP 交互过程包括认证信息打到日志里。7.4 让发送结果可统计、可告警最后一件事也是最容易被跳过的一件把发送结果指标化。至少要能回答三个问题今天发了多少封、成功多少、失败的原因分布是什么。有了这三个数字你才能判断用户反馈收不到验证码到底是个例还是系统性问题。如果项目里有 Micrometer加几个 Counter 就够了meterRegistry.counter(mail.send, result, success).increment(); meterRegistry.counter(mail.send, result, failure, type, auth).increment();没有监控体系的话退而求其次在mail_send_log表上建个按天聚合的查询配一个每天早上跑的定时任务失败率超过 5% 就往值班群推一条消息。这个成本很低但能让你在用户投诉之前就知道出事了。我个人在实际操作中的体会是邮件功能的技术难度其实不高真正的成本全在出错之后怎么快速定位和失败了怎么兜住这两件事上。SMTP 是个很老的协议报错信息又普遍不友好同样的异常背后可能是四五种完全不同的原因。所以与其在功能开发阶段追求写得多优雅不如把超时、重试、日志、落库这几件事一次性做扎实后面能省下大量半夜爬起来查日志的时间。另外一个小技巧新接入一个邮件服务商时先写一个能发纯文本的 Hello World跑通了再往上加 HTML、附件和异步别一上来就全套堆上去出了问题根本分不清是哪一层的事。