邮件功能在项目里的地位很有意思:不做吧,产品觉得你缺功能;做吧,很多人第一反应是“不就是发一封邮件吗”,结果真正交付时,排版、模板、附件、入垃圾箱的问题全冒出来。我接手的几个项目里,最顺手的组合就是标题里这套——Java Mail + Thymeleaf 模板引擎实现发送 HTML 格式邮件。JavaMail 负责搞定 SMTP 协议和 MIME 格式,Thymeleaf 负责把业务数据渲染进一个像样的 HTML 模板里,两者一拼,一封带 logo、带按钮、带动态内容的邮件就诞生了。这篇内容会把依赖配置、核心代码、完整实操和踩坑经验一次讲透,适合刚接触邮件功能的后端同学,也适合正在用字符串拼接方式生成邮件、想立刻换掉的老手。
1. 为什么偏偏是 JavaMail 加 Thymeleaf
1.1 JavaMail 在项目里的真实定位
JavaMail 本质上就是 Java 平台提供的邮件协议访问 API,它封装了 Session、Transport、Message 这些概念,底层走的是 SMTP 协议。如果你完全裸写,需要自己创建 Session、配置 Host、打开 Transport、构造 MimeMessage、send 完还要记得 close,稍微一个分支没处理好,连接就泄漏。Spring Boot 的出现把这些样板操作收敛到了一行——注入一个JavaMailSender,调用send()方法就完事了。
实际项目中,spring-boot-starter-mail是自动配置好的,你只要在配置文件里写清楚发信服务商的主机地址、端口、账号密码,代码里要做的只是mailSender.createMimeMessage()然后塞内容。JavaMail 的另一个价值在于它对 MIME 协议的支持非常完整。一封 HTML 邮件不只是“把 HTML 字符串放进邮件正文”,它可能还带内嵌图片、附件、不同字符集的内容,底层全靠 MIME 的 multipart 机制。JavaMail 对这些结构的处理很成熟,比那些只支持纯文本的第三方 HTTP 接口更灵活。
可能有人会问:现在第三方邮件服务不少,为什么还要用 JavaMail?我的看法是,JavaMail 不绑定任何商业服务商,只要对方提供 SMTP 就能用,私有化部署、内网环境、自建邮件网关都能跑。作为底层通道,它最稳妥。
1.2 邮件模板为什么非用模板引擎不可
早期我见过不少项目用 StringBuilder 拼 HTML,模板长一点就是灾难。你拼一个带循环的列表,要写一堆循环代码;用户名字里带个<或者&,还得小心翼翼做转义,稍不注意就在邮件里渲染出错。更要命的是,产品想改按钮颜色,你得改 Java 代码、重新部署,前端同学想帮忙都插不上手。
Thymeleaf 解决了这个痛点。邮件模板就是一份完整的 HTML 文件,浏览器能直接打开,开发阶段双击预览看到的是默认效果,非常直观。业务数据通过th:text、th:href这些属性嵌在标签里,模板结构不会被占位符打乱。而且 Thymeleaf 的表达式语法天然支持循环、条件、日期格式化,基本覆盖了邮件模板的所有需求。
也有人用 FreeMarker 做这件事,效果也还行。但两者对比下来,Thymeleaf 更贴近 HTML 原生形态,标签属性写起来就像在正常写前端,团队里如果前端同学需要调整模板,上手成本更低。我的习惯是,只要项目里已经用了 Spring Boot,邮件模板就固定用 Thymeleaf,逻辑统一、心智负担小。
2. 先把环境和依赖铺好
2.1 依赖清单与版本选型
项目里引入两个 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 的依赖管理走。需要留意的是 Spring Boot 2.x 和 3.x 在包名上有差异:2.x 用的是javax.mail,3.x 迁移到了jakarta.mail。写代码时 IDE 会自动导入对应版本的 MimeMessage 等类,但你心里要清楚这个差异,网上搜资料时看到老代码不要直接复制,看一眼包路径再动手。
另外不要手动去引老版本的 JavaMail 依赖。Starter 里已经带了正确的依赖版本,手动引一个旧版很容易造成冲突,启动时会报一些莫名其妙的方法找不到错误,排查起来很费时间。
2.2 邮件服务配置参数详解
邮件配置主要在application.yml里,下面是一份我常用的完整配置:
spring: mail: host: smtp.example.com port: 465 username: sender@example.com password: your-auth-code protocol: smtp default-encoding: UTF-8 properties: mail: smtp: auth: true ssl: enable: true socketFactory: class: javax.net.ssl.SSLSocketFactory connectiontimeout: 5000 timeout: 5000 writetimeout: 5000这里逐项说一下:host是发信服务商的 SMTP 地址,username是发件账号,password通常不是登录密码,而是服务商后台生成的授权码。很多发信平台出于安全考虑,不允许直接用账号密码登录 SMTP,需要单独申请授权码,这点一定看清楚文档。
端口和加密方式要注意,常见组合是 465 或 587:
| 端口 | 加密方式 | 场景 |
|---|---|---|
| 465 | SSL 直连 | 配置简单,连接后直接加密 |
| 587 | STARTTLS 升级 | 先建立普通连接再协商升级加密 |
如果你用 587,配置里就要把ssl.enable=true换成starttls.enable=true,socketFactory那行也可以去掉。default-encoding: UTF-8很关键,它会影响后续MimeMessageHelper对邮件头和正文的编码处理,避免中文乱码。
还有一个开发阶段比较实用的开关:
spring: mail: test-connection: true启动时应用会尝试连接一次发信服务,帮助定位配置错误。这个开关每次启动都会握手一次,生产环境建议关掉。
2.3 Thymeleaf 模板引擎的两种管理方式
Thymeleaf 模板文件放哪里,有两种主流做法。
第一种最省事,复用 Spring Boot 自动配置的模板引擎。默认的模板解析器前缀是classpath:/templates/,后缀是.html,所以你把邮件模板放到src/main/resources/templates/mail/目录下,然后在代码里执行templateEngine.process("mail/register-success", context)就能渲染。这个方案我推荐作为默认选择,几乎零配置,绝大多数项目够用。
第二种是单独定义一个邮件模板引擎实例,适合邮件模板特别多、希望集中管理目录的团队:
@Configuration public class MailTemplateConfig { @Bean public SpringTemplateEngine mailTemplateEngine() { SpringTemplateEngine engine = new SpringTemplateEngine(); engine.setTemplateResolver(mailTemplateResolver()); return engine; } private ClassLoaderTemplateResolver mailTemplateResolver() { ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); resolver.setPrefix("mail-templates/"); resolver.setSuffix(".html"); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCharacterEncoding("UTF-8"); resolver.setCacheable(false); return resolver; } }这样模板可以放到src/main/resources/mail-templates/,不跟 Web 页面模板混在一起。
但我得提醒一句:如果你的项目同时用 Thymeleaf 渲染 Web 页面,再定义一个SpringTemplateEngineBean 就有风险,可能会覆盖 Spring Boot 自动配置的引擎,导致页面模板解析也指向邮件模板目录,一访问页面就报模板找不到。我踩过这个坑之后,现在的习惯是默认就走第一种方案,等模板规模真的变大、团队也理清了引擎归属,再切第二种。
3. 核心代码:从 MimeMessage 到模板渲染
3.1 一个最小可用的邮件发送服务
先看一个基础版代码,把整个链路跑通再说:
@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 sendHtmlMail( String to, String subject, String templateName, Map<String, Object> params) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom("noreply@example.com", "某应用"); helper.setTo(to); helper.setSubject(subject); Context context = new Context(); context.setVariables(params); String htmlContent = templateEngine.process(templateName, context); helper.setText(htmlContent, true); mailSender.send(message); } }这段代码的逻辑很清晰:创建空邮件,用 Helper 填充发件人、收件人、主题,然后把业务参数塞进模板引擎,渲染出 HTML 字符串,最后标记为 HTML 并发送。setFrom里第二个参数是发件人显示名,收件人收到邮件时会看到“某应用”而不是一串邮箱地址。
3.2 MimeMessageHelper 中容易被忽略的参数
new MimeMessageHelper(message, true, "UTF-8")的三个参数,每一个都有讲究。第二参是multipart,必须为true,否则后续addInline或addAttachment会直接抛异常,因为邮件体不是 multipart 结构,装不下附件和图片。第三参是编码,固定写UTF-8,邮件标题和正文里的中文就靠它保证不乱码。
helper.setText(htmlContent, true)的第二个参数特别容易漏。如果不传或者传false,Helper 会把内容当作纯文本发送,收件人看到的是一堆 HTML 标签原文。只有传true才表示正文是 HTML,收件端才会解析渲染。
这个参数细节我整理了一张表,出现问题可以对照排查:
| 参数 | 错误用法 | 现象 |
|---|---|---|
| multipart | false | addInline/addAttachment 抛异常 |
| encoding | 未指定或非 UTF-8 | 标题/正文中文乱码 |
| setText 的布尔参数 | false 或漏传 | 邮件显示 HTML 源码 |
3.3 模板参数的传递与渲染原理
Thymeleaf 通过Context传递参数,本质上就是一个 KV 容器。模板里用${userName}取值,服务端只要在 Map 里放同名的 key 就行。看这个模板片段:
<h2 th:text="${title}">默认标题</h2> <p th:text="${userName}">默认用户</p>浏览器直接打开这个文件时,看到的是“默认标题”、“默认用户”,但在 Java 代码执行渲染后,这些位置会被替换成真实的业务数据。
这里还有一个关键点:th:text会自动做 HTML 转义,防止特殊字符破坏页面结构。如果某个参数来自用户输入,比如用户昵称是<script>alert(1)</script>,th:text会把它转义成无害的字符内容,不会执行。而th:utext是不转义的,专门用来渲染富文本内容,但如果你把用户输入直接交给th:utext,等于打开了注入的风险口。邮件模板里我基本只用th:text,除非内容经过严格过滤。
Thymeleaf 内置的工具类在邮件模板里非常实用。格式化日期、数字、字符串不用在 Java 层预处理,模板里直接写:
<p th:text="${#dates.format(orderTime, 'yyyy-MM-dd HH:mm')}">订单时间</p> <p th:text="${#numbers.formatDecimal(amount, 1, 2)}">金额</p>3.4 图片和附件:从 cid 到 multipart 的完整链路
邮件里的图片,新手最容易犯的错误是写src="/static/logo.png"。这在网页里没问题,但邮件收件方没有你的应用路径,图片自然加载不出来。要解决图片显示,主要有两条路:远程 URL 和 cid 内嵌。
远程 URL 就是把图片放在你网站的静态资源路径下,模板写src="https://example.com/static/logo.png"。在支持远程图片加载的客户端里没问题,但很多邮件客户端默认屏蔽远程图片,需要用户手动点击加载,体验一般。
更可靠的是 cid 内嵌。做法是模板里写src="cid:logo",代码里调用:
helper.addInline("logo", new ClassPathResource("static/images/logo.png"));收件方收到的邮件本身就是一个包含文字和图片的完整包,即使离线也能看图。JavaMail 会把图片作为邮件的一部分发送,cid:logo就是图片在邮件内部的引用地址。多张图片时,还可以用 Map 统一管理:
Map<String, Resource> inlineResources = new HashMap<>(); inlineResources.put("logo", new ClassPathResource("static/images/logo.png")); inlineResources.put("qrCode", new ClassPathResource("static/images/qrcode.png"));添加附件同样简单:
helper.addAttachment("用户手册.pdf", new ClassPathResource("files/manual.pdf"));如果附件名是中文,个别老客户端可能出现乱码,保险的做法是先用MimeUtility转一下编码。注意 Spring Boot 3 里这个类的包路径是jakarta.mail.internet.MimeUtility,Boot 2 是javax.mail.internet.MimeUtility:
helper.addAttachment(MimeUtility.encodeText("用户手册.pdf", "UTF-8", "B"), new ClassPathResource("files/manual.pdf"));4. 手把手实操:注册成功通知邮件
4.1 需求拆解与模板设计
现在做一个最常见的场景:用户注册成功后,给用户发一封带品牌 logo、用户名、注册时间和激活链接的 HTML 邮件。这个场景几乎涵盖了邮件功能的所有基础要素:动态文本、日期格式化、按钮链接、内嵌图片。
邮件模板我放在src/main/resources/templates/mail/register-success.html,完整内容如下:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>注册成功</title> </head> <body style="margin:0;padding:0;background-color:#f2f4f8;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f2f4f8;padding:24px 0;"> <tr> <td align="center"> <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:600px;background-color:#ffffff;border-radius:8px;overflow:hidden;"> <tr> <td style="padding:32px 40px;"> <img src="cid:logo" alt="logo" style="height:40px;width:auto;" /> <h2 style="font-size:22px;color:#333333;margin:24px 0 8px;">注册成功</h2> <p style="font-size:14px;line-height:1.7;color:#555555;">你好,<span th:text="${userName}" style="color:#333333;font-weight:600;">用户</span>,欢迎加入。请在 24 小时内点击下方按钮完成账号激活。</p> <p style="font-size:14px;line-height:1.7;color:#555555;">注册时间:<span th:text="${#dates.format(registerTime, 'yyyy-MM-dd HH:mm')}" style="color:#333333;">2025-01-01 10:00</span></p> <table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;"> <tr> <td style="background-color:#3b7cff;border-radius:6px;"> <a th:href="${activateUrl}" style="display:inline-block;padding:12px 32px;color:#ffffff;font-size:14px;text-decoration:none;">激活账号</a> </td> </tr> </table> <p style="font-size:12px;color:#999999;margin-top:24px;">如果这不是你的操作,请忽略本邮件。</p> </td> </tr> </table> </td> </tr> </table> </body> </html>模板里每个动态数据都给了默认值,所以用浏览器直接打开这个文件,看到的是一封效果完整的静态邮件,非常方便前端同事调整样式。布局用了 table 嵌套,样式全部内联,这是邮件客户端兼容性的基本要求。Outlook 之类的客户端对 flex、外链 CSS 支持很差,table 加内联样式虽然写起来啰嗦,但基本不会翻车。
4.2 组装数据与完整发送流程
业务层组装参数并调用服务,代码很直接:
Map<String, Object> params = new HashMap<>(); params.put("userName", "小李"); params.put("registerTime", LocalDateTime.now()); params.put("activateUrl", "https://example.com/activate?token=abc123"); mailService.sendHtmlMail( "xiaoli@example.com", "欢迎加入某应用", "mail/register-success", params);激活链接里的 token 应该由业务侧生成并写入 Map,模板里只负责渲染${activateUrl}。这样链接的可控性更高,后续要做链接过期、埋点统计也都在业务层处理。
前面的sendHtmlMail方法不包含内嵌图片逻辑,所以针对这个模板,我给它做一个增强版本,支持传入内嵌资源 Map:
public void sendHtmlMailWithInline( String to, String subject, String templateName, Map<String, Object> params, Map<String, Resource> inlineResources) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom("noreply@example.com", "某应用"); helper.setTo(to); helper.setSubject(subject); Context context = new Context(); context.setVariables(params); String htmlContent = templateEngine.process(templateName, context); helper.setText(htmlContent, true); if (inlineResources != null) { for (Map.Entry<String, Resource> entry : inlineResources.entrySet()) { helper.addInline(entry.getKey(), entry.getValue()); } } mailSender.send(message); }调用时把 logo 传进来:
Map<String, Resource> inline = new HashMap<>(); inline.put("logo", new ClassPathResource("static/images/logo.png")); mailService.sendHtmlMailWithInline( "xiaoli@example.com", "欢迎加入某应用", "mail/register-success", params, inline);4.3 从同步到异步:避免邮件拖垮主流程
邮件发送走网络 IO,一个 SMTP 请求包含建立连接、TLS 握手、认证、数据传输,顺利时几百毫秒,服务不稳定时可能拖到好几秒。如果用户在注册接口里面同步等待邮件发送完成,接口响应时间会被明显拉长,甚至因为邮件服务超时导致注册整体失败,这在实际项目中很常见。
我的建议是,所有非强依赖的邮件都异步化。Spring 的做法是@Async:
@Configuration @EnableAsync public class AsyncConfig { @Bean("mailExecutor") public Executor mailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(200); executor.setThreadNamePrefix("mail-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.initialize(); return executor; } }然后在MailService里加一个异步方法:
@Async("mailExecutor") public void sendHtmlMailAsync( String to, String subject, String templateName, Map<String, Object> params, Map<String, Resource> inlineResources) { try { sendHtmlMailWithInline(to, subject, templateName, params, inlineResources); } catch (MessagingException ex) { // 记录失败日志,后续走补偿 } }这里有个经典坑必须记住:@Async不生效,通常是因为同类内部自调用。比如在同一个 Controller 的同类里调用本类的异步方法,Spring 的代理根本不会介入,等于同步执行。异步方法应该放在独立 Bean 里,通过注入或外部调用触发。
邮件这种外部调用,一旦失败不能就这样静默吞掉。最简单的补偿方案是失败时记录到日志表,定时任务对失败邮件做重发;更复杂的可以引入 Spring Retry 或者可靠消息队列。这个不强制一步到位,但至少要把异常和失败记录留好,否则线上用户收不到邮件,你连原因都查不到。
5. 踩坑记录与排查清单
5.1 邮件进垃圾箱的隐形因素
邮件进垃圾箱,问题往往不在 JavaMail 代码,而在发信域名的可信度。收件服务器会检查发件域名是否配置了 SPF 和 DKIM 记录,这是发信服务商或域名服务商后台要配置的 DNS 记录,用来证明“这封邮件确实来自你声称的那个域名”。没有这些记录,邮件被识别为垃圾邮件的概率会明显上升。
其次是内容层面。邮件标题不要全大写、不要堆满感叹号,正文里避免大量外链和营销敏感词,比如那些“免费”“赚钱”类的字眼。发送频率也很重要,同一批用户短时间内收到大量相同内容的邮件,很容易触发反垃圾策略。
项目如果有稳定的企业邮箱或者专门的消息发送服务,垃圾箱概率会低很多。自建邮件服务器也不是不行,但要注意服务器 IP 的声誉管理,这块往往比代码本身更让人头疼。
5.2 图片就是不显示的几类原因
图片不显示是邮件功能里最常见的反馈,我列一下排查顺序:
- 先看模板里的
src是不是cid:xxx,如果你写成了/static/xxx.png,收件方肯定加载不了,改成 cid 或公网 URL。 - 再看
addInline的第一个参数和模板里的cid名字是否完全一致。模板写cid:logo,代码里 key 写成logo1,那就对不上。 - 如果用的是远程 URL,还要考虑客户端默认屏蔽远程图片的问题。这个不是代码 bug,但可以引导用户添加白名单,或者干脆改用 cid 内嵌。
我在开发阶段用一个笨办法验证图片是否正常:把渲染后的 HTML 保存到本地文件,用浏览器打开后右键查看邮件源码,检查 img 标签里的 src 值是不是正确的 cid 引用,顺便确认图片附件有没有被带上。
5.3 中文乱码与模板找不到的集中排查
模板和编码问题比较基础,但每天都有同学踩,我整理成速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 正文中文乱码 | 编码没指定 UTF-8 | new MimeMessageHelper(msg, true, "UTF-8") |
| 主题中文乱码 | 同上 | 同上 |
| 附件名乱码 | JavaMail 对非 ASCII 文件名处理不一致 | 用MimeUtility.encodeText编码文件名 |
| 模板找不到 | process传参带了后缀 | 传mail/register-success,不要带.html |
| 模板找不到 | 自定义解析器 prefix/suffix 写错 | 确保目录和文件位置匹配 |
| 修改模板不生效 | 模板缓存 | 开发环境设置spring.thymeleaf.cache=false或resolver.setCacheable(false) |
提醒一句,如果你把模板放在templates/mail/register-success.html,那么process的参数应该是mail/register-success,不带后缀。因为解析器已经配了.html后缀,你多写一遍反而找不着。
5.4 本地没有发信服务怎么验证
很多团队本地开发环境连不上外网邮件服务,或者不想把测试邮件发到真实账号。这方面有个非常实用的招数:本地跑一个模拟 SMTP 服务,让代码往localhost发邮件,它不会真正外发,而是在管理界面展示邮件内容和附件,用来验证模板效果。JavaMail 的代码不需要改动,只需要把host和port指向本地模拟服务即可。
另一个更朴素的方案是加一个 debug 开关,开发环境不真正发送,而是把渲染好的 HTML 写到本地文件:
if (debugToFile) { Files.write( Paths.get("/tmp/mail/" + subject + ".html"), htmlContent.getBytes(StandardCharsets.UTF_8)); return; }然后直接用浏览器打开这个文件,所见即所得地检查模板效果。这种方式对排查模板问题非常高效,也不依赖任何外部服务。
写到这里,我想再提醒一句:邮件发送这个功能,单看某个点都不难,但把它放在整个系统里,很容易因为一个“发送太慢”或“模板分层不清晰”变成日后的坑。我现在的习惯是,邮件模板单独分目录管理、发送动作统一走一个 Service、线上默认异步加失败日志,这套 Java Mail + Thymeleaf 的方案虽然看起来不花哨,但足够稳定,特别适合需要长期维护的业务系统。如果你接手的项目还在用字符串拼接生成邮件,真的值得花半天时间把模板引擎迁过来,改完你就知道这半天有多值。