上周三早上八点刚过,值班群里跳出来一句"注册验证码收不到了",后面跟着几张用户投诉截图。我第一反应是网关挂了,结果日志里干干净净,接口返回全是 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.host,MailSenderAutoConfiguration就会往容器里注册一个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.mail,3.x 用jakarta.mail,两者不能混。我见过有人在 Boot 3 项目里手动把javax.mail-api加回来,想"兼容一下老代码",最后导致 classpath 下同时存在两套 API,运行时抛ClassCastException,排查成本极高。
1.3 除了 starter 你还需要什么
纯发文本邮件,一个 starter 就够了。但只要涉及 HTML 模板,就得多引一个模板引擎,Thymeleaf 或者 FreeMarker 都行。我一般选 Thymeleaf,原因是它的语法在邮件场景里够用,而且Context传变量的方式和 Web 层一致,不用记两套 API。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 | 隐式 SSL | mail.smtp.ssl.enable=true | 连接建立时立刻加密 |
| 587 | STARTTLS | mail.smtp.starttls.enable=true | 先明文建连,再升级加密 |
| 25 | 通常不加密 | 都不开 | 多数服务商已限制对外访问 |
这张表里的坑在于:465 端口配starttls.enable=true是不会成功的,因为 465 一上来就是 TLS 握手,客户端如果按明文协议去打招呼,服务器会直接断开。反过来 587 配ssl.enable=true也一样失败。这两种错配报的异常有时候都是MessagingException: Could not connect to SMTP host,光看报错看不出区别,必须回头看一眼端口和开关是不是成对。
2.2 auth 和 trust 这两个开关的实际含义
mail.smtp.auth=true表示"我要做身份认证",绝大部分服务商都要求开。但如果你的服务器在内网、走的是不需要认证的中继,那这个开关关掉反而更省事。
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-reply@example.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-reply@example.com"); message.setTo("someone@example.com"); message.setSubject("您的验证码"); message.setText("验证码:" + code + ",5 分钟内有效。"); mailSender.send(message); // 批量收件人 message.setTo(new String[]{"a@example.com", "b@example.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-reply@example.com"); helper.setTo("someone@example.com"); helper.setSubject("月度报表"); helper.setText("<h1>报表已生成</h1><p>请查收附件。</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>欢迎加入</p><img src='cid:logo' width='120'/>", 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-8:
MimeMessageHelper 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.html,Thymeleaf 默认就能扫到。渲染出来的结果是一个完整 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:inline="css"在邮件里基本用不上,别浪费时间去试。
变量注入要考虑转义。如果用户名里带<或者&,直接拼进 HTML 会破坏结构。Thymeleaf 的th:text默认会做 HTML 转义,th:utext不转义。邮件正文里凡是用到用户输入的地方,一律用th:text,除非你百分之百确定那段内容是系统生成的。
还有一个容易忽略的地方:邮件模板不能依赖任何外部资源加载。你在模板里写<link rel="stylesheet" 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(连接超时) | 是 | 网络抖动,隔几秒重试大概率成功 |
MailSendException(550/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-reply@example.com认证,却在setFrom里写了service@example.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/443,465 和 587 需要单独加规则。这个在容器化部署时特别容易忘,因为构建镜像那台机器和运行环境的出站策略可能不一样。
连上了但没响应,多半是服务端把你挂起了。这时候如果配置里没设timeout,线程就会一直等。加了超时之后,至少能快速失败进重试。
还有一种更隐蔽的情况:连接复用导致的"半开连接"。JavaMail 默认每次发送都新建连接,不存在这个问题;但如果开启了mail.smtp.connectionpool=true,池里的连接可能已经被服务端断开而客户端不知道,发送时会抛MessagingException。遇到这种情况可以调小mail.smtp.connectionpooltimeout,让空闲连接早点被清理。
6.4 主题、附件名、正文乱码
乱码分三种,处理方式不一样:
| 现象 | 原因 | 处理 |
|---|---|---|
主题变成=?UTF-8?B?...?=或一堆问号 | 编码没指定,或者客户端解码异常 | 检查MimeMessageHelper构造时的编码参数 |
| 附件名乱码 | 文件名编码与传输编码不匹配 | 显式指定 UTF-8,或用MimeUtility.encodeWord预处理 |
| 正文中的中文显示为方块 | 正文编码与实际字节不符 | 配置default-encoding: UTF-8并确认模板文件本身是 UTF-8 |
第三种最容易被忽略:模板文件保存的时候用了 GBK,IDE 里看着正常,渲染出来就是乱码。检查方法是直接用文本编辑器另存为 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-reply@example.com", "某某服务平台");这个重载方法会把显示名做 MIME 编码,中文不会乱码。别自己拼"某某平台 <no-reply@example.com>"字符串,格式稍有偏差就会被解析成非法地址。
7.2 收件人列表、密送与频率控制
批量发送时,setTo(String[])传一个数组看起来很方便,但实际上所有收件人都能互相看到彼此的邮箱地址,这是明确的隐私问题。正确做法是setBcc:
helper.setTo("no-reply@example.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、附件和异步,别一上来就全套堆上去,出了问题根本分不清是哪一层的事。