接手某内部系统的第二周,我被邮件模块整破防了。这个用 JavaMail 实现的邮件发送模块,支撑着订单通知、告警提醒、报表推送各种场景,但所有 HTML 格式邮件的模板全靠字符串拼接。新来的运营同事想改一句话,得排期让我改代码;想调整某个按钮颜色,又是一轮打包部署。每次看那段几百行的 buildHtml 方法,我都觉得这不是在维护代码,而是在考古。
后来我花了两个晚上,用 JavaMail + Thymeleaf 模板引擎把这个模块重写了。模板从 Java 字符串挪到独立的 HTML 文件里,业务数据通过上下文对象注入,邮件呈现实时预览、方便调试,改版也不用动 Java 代码。这篇文章把完整的实现思路、关键代码和落地时踩过的坑都梳理一遍,适合刚接手邮件发送模块的开发者,以及那些还在用字符串拼接 HTML 邮件、想切换到模板方案的同学参考。
1. 从字符串拼接邮件说起:为什么一定要引入模板引擎
1.1 邮件内容和代码耦合的尴尬
先说我看到的那段历史代码长什么样。核心逻辑大概是这样的:
StringBuilder html = new StringBuilder(); html.append("<div style=\"font-family: Arial, sans-serif;\">"); html.append("<h2>尊敬的 ").append(userName).append(",您好:</h2>"); html.append("<p>您的订单 <b>").append(orderNo).append("</b> 已发货。</p>"); if (remark != null) { html.append("<p>备注:").append(remark).append("</p>"); } html.append("<a href=\"").append(detailUrl).append("\">点击查看详情</a>"); html.append("</div>");这段代码的问题,相信大家一眼就能看出来:
第一,可维护性极差。邮件模板本质上属于展示层,现在和业务判断、数据组装全揉在一个方法里。产品经理想改一句话,我得先读懂这段字符串拼接逻辑,再小心翼翼地去动引号和加号,一个转义字符写错,线上邮件就直接花屏。
第二,HTML 转义很容易翻车。如果 userName 是用户在前台输入的昵称,内容是<script>alert(1)</script>,直接拼进 HTML 里,轻则样式错乱,重则造成邮件内容注入,收件人打开邮件看到各种异常展示,甚至被钓鱼链接欺骗。正规做法是对用户内容做 HTML 转义,但在字符串拼接的场景里,人肉转义漏一次就是事故。
第三,改版成本高。运营说"邮件顶部加个 banner 图",前端切好图,后端要把图片标签拼进 Java 字符串里;运营说"双十一换个促销横幅",又得走一遍改代码、编译、部署的流程。明明只是改个展示层的东西,却每次都牵动整个服务发布。
1.2 JavaMail 与 Thymeleaf 组合能解决什么
把 JavaMail 和 Thymeleaf 组合起来,本质上是把"发邮件"这件事拆成两个边界清晰的部分:
- JavaMail 负责传输:建 Session、组装 MimeMessage、走 SMTP 协议把邮件交到收件方邮箱服务器。它不关心邮件长什么样、里边的数据从哪来。
- Thymeleaf 负责渲染:把模板文件和后端传入的数据模型合并,生成一段完整的 HTML 字符串。它不关心邮件怎么发出去,只关心模板语法和数据填充。
这样做的好处非常直接。模板文件独立放在src/main/resources/templates/mail/目录下,前端同事直接打开就是一个带默认数据的静态页面,所见即所得;后端只需要往Context里塞数据然后调用一次渲染方法,拿到 HTML 字符串交给 JavaMail 发送。改文案、改样式、加板块,都不需要动 Java 代码。
从技术原理上说,Thymeleaf 的渲染结果本质就是字符串处理,它和 JavaMail 的MimeMessageHelper.setText(html, true)方法无缝衔接。后面整个核心链路就是围绕"模板字符串 -> HTML 邮件内容 -> MimeMessage"这条主线展开的。
2. 选型对比:Thymeleaf 凭什么胜任邮件模板
2.1 主流模板引擎在邮件场景的取舍
在 Java 生态里,能用来生成 HTML 的模板引擎不少,常见的有 FreeMarker、Velocity(及其后继者)、Thymeleaf。我在做技术选型时简单对比过,下面这个表格基本能说明问题:
| 引擎 | 模板语法风格 | Spring Boot 契合度 | 邮件场景特点 | 现状 |
|---|---|---|---|---|
| Velocity | #if#foreach指令风格 | 一般,需要额外配置 | 传统方案,老项目常见 | 更新缓慢,社区趋冷 |
| FreeMarker | <#if><#list>标签风格 | 较好 | 支持好,写起来略显攻击性 | 稳定,仍有很多项目在用 |
| Thymeleaf | th:text属性风格 | 默认集成,开箱即用 | 自然模板,可直接浏览器预览 | 官方活跃,与 Spring 生态深度绑定 |
核心考量是 Spring Boot 场景下的整合成本。spring-boot-starter-thymeleaf引入之后,Spring Boot 自动配置好SpringTemplateEngine和模板解析器,模板文件默认放在classpath:/templates/下,几乎零配置就能跑起来。而 FreeMarker 虽然也挺好,但多一个配置环节,语法对后端开发者来说更像是"在 HTML 里写标签",不如 Thymeleaf 那种自然属性直观。
2.2 Thymeleaf 的"自然模板"特性对邮件意味着什么
Thymeleaf 有一个很独特的设计,叫自然模板。简单说,模板文件本身就是一个合法的静态 HTML 页面,所有动态内容通过th:*属性标注。
<p th:text="'尊敬的 ' + ${userName} + ':'">尊敬的 客户:</p>用浏览器直接打开这个文件,不经过 Thymeleaf 渲染,你看到的是"尊敬的 客户:";经过渲染后,看到的是"尊敬的 张三:"。也就是说,模板在没有后端数据的情况下依然可以预览,这对邮件场景来说太宝贵了。
邮件模板的审阅往往涉及运营、产品、前端多方协作,他们大多不熟悉 Java,也不方便启动整个后端服务。用 Thymeleaf 之后,直接把 HTML 文件发给运营同事,什么数据都不用准备,浏览器打开就能看到大致布局和默认文案;渲染后的效果,最多就是默认示例数据替换成真实数据而已。这个特性是其他标签风格模板引擎做不到的。
另外,邮件模板还有一个独有的诉求:模板里经常要写大量兼容邮件客户端的<table>布局和内联样式。Thymeleaf 对原生 HTML 标签完全透明,不管多复杂的 table 嵌套、style 内联它都能原样输出,只渲染带th:*属性的节点。这一点也让我最终选了它。
3. 核心链路落地:从模板文件到 MimeMessage 的全过程
3.1 依赖引入和 SMTP 参数:先让 Hello World 发出去
我用的是 Spring Boot 项目,依赖方面其实就两个 starter,省去很多手工装配的麻烦:
<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>spring-boot-starter-mail内部封装了 JavaMail API,并且自动配置出JavaMailSender这个 Bean;spring-boot-starter-thymeleaf则自动配置出SpringTemplateEngine。两个 Bean 直接注入 Service 就能用。
然后是application.yml里的 SMTP 配置。我拿公司内部邮箱系统举例,参数基本是这几项:
spring: mail: host: smtp.example.com port: 587 username: it_service@example.com password: your-smtp-password default-encoding: UTF-8 properties: mail: smtp: auth: true starttls: enable: true timeout: 5000 connectiontimeout: 5000 writetimeout: 5000这里的port建议用 587 而不是 25。25 端口是传统 SMTP 端口,很多云服务器默认封禁,而且明文传输。587 配合 STARTTLS 是主流邮件服务商推荐的提交端口,安全性和可达性都更好。timeout系列参数也建议加上,不然默认 TCP 超时时间很长,邮件服务不可用时接口会卡很久。
3.2 模板渲染:Thymeleaf 的 Context 与表达式
模板文件我放在src/main/resources/templates/mail/order-notify.html。这个路径是 Spring Boot 自动配置的默认模板根目录,不需要额外指定解析器。
<!DOCTYPE html> <html lang="zh" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>订单发货通知</title> </head> <body> <p th:text="'尊敬的 ' + ${userName} + ':'">尊敬的 客户:</p> <p>您的订单 <strong th:text="${orderNo}">订单号</strong> 已发货。</p> <p th:text="'预计送达时间:' + ${deliveryTime}">预计送达时间:待定</p> <p th:if="${remark != null}" th:text="${remark}">备注信息</p> <a th:href="${detailUrl}" href="https://example.com/order/detail">点击查看详情</a> </body> </html>注意一个细节:HTML 文件里我写了默认内容作为兜底。即使后端万一渲染失败,或者有人直接用浏览器打开模板,看到的也是一份结构完整的示例页面。这是 Thymeleaf 自然模板语法的优势,在邮件模板协作场景里特别实用。
渲染的核心代码如下:
@Service public class MailService { private final JavaMailSender mailSender; private final SpringTemplateEngine templateEngine; public MailService(JavaMailSender mailSender, SpringTemplateEngine templateEngine) { this.mailSender = mailSender; this.templateEngine = templateEngine; } public void sendOrderNotify(String to, OrderNotifyData data) throws MessagingException { Context context = new Context(); context.setVariable("userName", data.getUserName()); context.setVariable("orderNo", data.getOrderNo()); context.setVariable("deliveryTime", data.getDeliveryTime()); context.setVariable("remark", data.getRemark()); context.setVariable("detailUrl", data.getDetailUrl()); String html = templateEngine.process("mail/order-notify", context); MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom("it_service@example.com"); helper.setTo(to); helper.setSubject("您的订单已发货"); helper.setText(html, true); mailSender.send(message); } }这里用的是org.thymeleaf.context.Context而不是WebContext。原因很简单:邮件渲染不依赖 HTTP 请求对象,不是在处理用户请求的 Web 上下文,直接用一个普通Context塞变量就够了。
3.3 组装 MimeMessage:真正容易翻车的环节
很多第一次接触 JavaMail 的同学容易在MimeMessageHelper上栽跟头。先说一个最容易忽略的细节:helper.setText(html, true)的第二个布尔参数表示"内容是 HTML"。
如果不传这个true,JavaMail 会把内容当作纯文本text/plain发送。收件人打开邮件,看到的不是渲染好的页面,而是一堆带着尖括号的 HTML 源码。这个参数设置了之后,MimeMessageHelper才会在 MIME 头里写入Content-Type: text/html;charset=UTF-8,邮件客户端才会按 HTML 解析正文。
第二个细节是new MimeMessageHelper(message, true, "UTF-8")构造器的第一个布尔值true,它表示"这是一个 multipart 邮件"。如果只发纯 HTML 文本,不设也行;但只要后面要发附件、要内嵌图片,就一定要传true。这个参数的本质是告诉 Helper:允许构建multipart/mixed或者multipart/related的 MIME 结构。我建议无论有没有附件,统一传true,避免以后加附件时忘了改构造器。
第三个容易被忽略的细节是helper.setFrom("it_service@example.com")。有些 SMTP 服务商会校验发件人地址必须和认证账号一致,不一致会直接报错Sender address rejected。如果遇到这种情况,把发件人地址改成 SMTP 认证的账号即可。另外,如果要显示一个更友好的发件人名称,可以这样写:
helper.setFrom("it_service@example.com", "某系统通知");这里第二个参数的显示名会经过邮件编码处理,收件人那边看到是"某系统通知"而不是一堆乱码。前提是构造器里的字符集已经指定为 UTF-8。
整个链路走通之后,我看到的效果是:模板文件独立管理,数据通过 Context 注入,JavaMail 负责传输,改动文案不再需要碰 Java 代码。这个体验和之前字符串拼接完全不是一个量级。
4. 进阶处理:内嵌图片、附件与邮件客户端兼容性
4.1 给邮件加入内嵌图片的正确姿势
业务中经常需要在邮件里放 logo、二维码、商品图。不少初接触邮件的同学第一反应是在模板里用<img src="https://xxx.com/logo.png">外链图片。这样做有个很现实的问题:不少邮件客户端默认拦截外链图片。收件人打开邮件,看到的是一堆破框和叉号,需要手动点击"加载图片"才能看到内容,转化率直接打折扣。
正确的做法是把图片作为内嵌资源随邮件一起发送。JavaMail 里对应的是MimeMessageHelper.addInline()方法:
ClassPathResource logoResource = new ClassPathResource("mail-resources/logo.png"); helper.addInline("logo", logoResource);模板里的 img 标签通过cid协议引用这个资源:
<img src="cid:logo" alt="公司 Logo" style="height: 40px;" />cid就是 Content-ID,addInline("logo", logoResource)的字符串名为这个内嵌资源分配了一个 Content-ID,邮件客户端看到src="cid:logo"就会去邮件的 multipart/related 部分查找对应的资源附件。整个过程不需要把图片上传到任何公网服务器,图片只存在于邮件本身,既避免了外链被拦截,也保护了用户隐私。
用 Thymeleaf 渲染时,如果这个 cid 名称是动态变化的,也可以用表达式生成:
<img th:src="'cid:' + ${logoCid}" src="cid:logo" />然后后端把logoCid设置为"logo"。不动态变化的场景直接用固定写法更简单,我日常就是这么用的。
4.2 附件添加与文件名中文编码
附件用addAttachment添加,用法也很直白:
helper.addAttachment("订单详情.txt", new ClassPathResource("mail-resources/order-detail.txt"));但这里有一个隐藏很深的中文乱码问题。邮件头的文件名参数不允许直接出现非 ASCII 字符,MimeMessageHelper默认对附件名做编码处理,在部分客户端上能正常显示,但某些严格模式的邮件服务器或老旧客户端会显示成乱码。稳妥做法是对文件名手动做 RFC 2047 编码:
String encodedFileName = MimeUtility.encodeWord("订单详情.txt", "UTF-8", "B"); helper.addAttachment(encodedFileName, new ClassPathResource("mail-resources/order-detail.txt"));MimeUtility.encodeWord会把中文文件名编码为类似=?UTF-8?B?xxxx?=的格式,邮件协议可以安全传输,主流客户端也能正确解码显示。这个坑我在正式环境踩过一次,当时收件人看到附件名是一串问号,排查了半天最后发现就是少了这个编码。
另外还要注意邮件大小限制。大部分邮箱服务商对单封邮件有 20MB~50MB 不等的限制,SMTP 协议层也存在单封邮件大小的限制。附件很大的场景,建议先压缩再发送,或者改用上传到内部文件服务、邮件里附链接的替代方案。
4.3 样式兼容:别再把它当普通网页写
做邮件模板和做 Web 页面是两套思路。Web 页面可以随意用 CSS 外部样式表、Flex 布局、JS 交互;但邮件客户端的 HTML 渲染能力非常有限,尤其小心 Outlook 桌面版——它用的是 Word 排版引擎,对现代 CSS 的支持差到令人发指。
| 客户端 | 外部 CSS | <style>块 | Flex/Grid | 内联样式 |
|---|---|---|---|---|
| 网页邮箱(大多数) | 部分支持 | 支持 | 有限支持 | 支持 |
| 移动端主流邮件 App | 基本不支持 | 部分支持 | 不稳定 | 支持 |
| Outlook 桌面版 | 不支持 | 不支持 | 不支持 | 支持 |
| Apple Mail | 支持 | 支持 | 部分支持 | 支持 |
从这张表能得出一个非常明确的结论:无论什么客户端都稳定支持的是内联样式。所以邮件模板实际开发时,我的做法是把样式直接写在每个标签的style属性上:
<p style="margin: 0 0 12px 0; color: #333333; font-size: 14px;">正文段落</p>另外还有几个经验:
- 布局尽量用 table 嵌套,不要依赖 Flex 或 Grid。这几年主流客户端的 Flex 支持好了一些,但为了绝对稳妥,核心结构我用 table,内联元素用 span。
- 字体、颜色、行高全显式指定。邮件客户端默认字体差异很大,你不指定,它就按自己的默认样式渲染,设计稿变样是必然的事。
- 不要使用 JavaScript,大多数邮件客户端直接忽略或拦截,还有可能因此把邮件判为垃圾邮件。
- 不要引用外部 CSS 文件,费劲维护还大概率被剥掉。
这套"内联样式 + table 布局"的组合虽然原始,但也是邮件场景里最可靠、最通用的方案。
5. 上线前必须处理的坑:从本地调试到投递成功
5.1 中文乱码的三个排查点
邮件中文乱码是很常见的问题,我总结下来主要有三个排查点,按先后顺序检查基本都能解决。
第一个是SMTP 会话字符集。application.yml里记得设置spring.mail.default-encoding: UTF-8,这是全局兜底字符集。
第二个是MimeMessage 本身的字符集。构造MimeMessageHelper时显式传 UTF-8:
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");这一点容易被忽略的是:要在构造 Helper 时指定,而不是发完再指定。setSubject和setText在写入 MIME 头时已经使用了指定字符集,晚了就来不及了。
第三个是邮件主题和附件名的编码。邮件头 RFC 规定非 ASCII 字符要经过 MIME 编码转换,MimeMessageHelper.setSubject内部会自动处理,通常不用管;但附件名建议像上文那样手工MimeUtility.encodeWord。
排查顺序建议是:先开 SMTP 调试日志看原始 MIME 结构,再检查 Helper 构造器字符集,最后检查附件编码。
5.2 被判定垃圾邮件的常见原因
邮件明明发送成功,却被对方扔进垃圾箱,这个问题比代码 Bug 更让人头疼。从我的经验看,主要原因是这几个:
首先是SPF/DKIM/DMARC 记录缺失或配置错误。简单说,收件方服务器会检查发件域名的 DNS 解析记录,确认这封邮件确实是由该域名授权的服务器发出的。如果发件域名从不配置 SPF 记录,或者发件 IP 不在授权范围内,大量邮件会被判为可疑。这个问题属于运维和域名管理范畴,开发同学排查时需要和运维确认域名解析记录。
其次是发件内容和主题触发了反垃圾规则。比如主题和正文里频繁出现免费、优惠、点击链接、发票等营销敏感词,或者 HTML 里只有一个大量图片、极少文字,都容易命中规则。正常业务通知类邮件,文案尽量平实,图片和文字比例均衡。
再次是发送频率和发送环境的信誉。一台服务器突然向同一域名发送大量邮件,或者长时间无发送记录、突然高频发送,都会被对方邮件系统判定为恶意行为。批次发送时建议加一个随机的延迟策略,让发送时间尽量自然分布。
5.3 没有真实邮箱服务商怎么本地测邮件
很多开发同学遇到的问题是:公司内网环境没有现成的 SMTP 服务,或者不想拿真实邮箱一遍遍试。这里有两种方案。
第一种最简单的,是开启 JavaMail 的调试日志,直观看 SMTP 交互过程:
spring: mail: properties: mail: debug: true打开之后,控制台会打印客户端和 SMTP 服务器之间的完整对话,包括 EHLO、AUTH LOGIN、DATA 传输等原始指令。发信失败时首先看这部分日志,比盲猜高效得多。
第二种适合自动化测试的,是在本地起一个假的 SMTP 服务,我在项目中用过 GreenMail。它就是一个纯本地运行的邮箱服务器,不真正外发邮件,收到的邮件可以在测试里直接抓取断言:
GreenMail greenMail = new GreenMail(ServerSetupTest.SMTP); greenMail.start(); // 执行发送邮件的业务代码 MimeMessage[] messages = greenMail.getReceivedMessages(); assertEquals(1, messages.length); String content = (String) messages[0].getContent(); assertTrue(content.contains("您的订单已发货"));本地测试时还会顺带验证附件、内嵌图片是否正确附加在 multipart 结构里。跑完测试自动停掉,不会污染任何真实用户邮箱。
6. 再聊两句实战体会
这套"JavaMail + Thymeleaf"方案上线之后,我已经用了挺长时间,最大的感受是邮件模板的维护成本直线下降。运营和前端同学直接改 HTML 文件,改完提交给我预览确认,基本不用等我排期。字符串拼接时代的那些转义噩梦、发布焦虑,彻底成了历史。
最后再分享一个我自己比较受用的小技巧:模板渲染失败时不要直接吞异常。templateEngine.process如果模板变量缺失或表达式写错,会抛出异常并明确指出哪个表达式、哪个模板片段出了问题。这时候把原始异常连同一份模板参数快照(打印 Context 里的变量名列表)一起记到日志里,排查问题比看堆栈方便得多。另外,如果邮件模板数量多、改动频繁,可以考虑把模板放到配置中心或数据库中,做成动态加载的版本,后端连重启都省了。
邮件发送这件事,技术上没有多么高深,但细节确实不少。只要把模板渲染和邮件传输的边界划清楚,再把字符集、MIME 结构、图片附件这些环节处理好,整套模块就能稳定跑很久。希望这份实践笔记能帮大家少踩一些我踩过的坑。