JavaMail + Thymeleaf:Spring Boot 实现 HTML 邮件模板发送全攻略 邮件功能在项目里的地位很有意思不做吧产品觉得你缺功能做吧很多人第一反应是“不就是发一封邮件吗”结果真正交付时排版、模板、附件、入垃圾箱的问题全冒出来。我接手的几个项目里最顺手的组合就是标题里这套——Java Mail Thymeleaf 模板引擎实现发送 HTML 格式邮件。JavaMail 负责搞定 SMTP 协议和 MIME 格式Thymeleaf 负责把业务数据渲染进一个像样的 HTML 模板里两者一拼一封带 logo、带按钮、带动态内容的邮件就诞生了。这篇内容会把依赖配置、核心代码、完整实操和踩坑经验一次讲透适合刚接触邮件功能的后端同学也适合正在用字符串拼接方式生成邮件、想立刻换掉的老手。1. 为什么偏偏是 JavaMail 加 Thymeleaf1.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency版本不用手动指定跟着 Spring Boot 的依赖管理走。需要留意的是 Spring Boot 2.x 和 3.x 在包名上有差异2.x 用的是javax.mail3.x 迁移到了jakarta.mail。写代码时 IDE 会自动导入对应版本的 MimeMessage 等类但你心里要清楚这个差异网上搜资料时看到老代码不要直接复制看一眼包路径再动手。另外不要手动去引老版本的 JavaMail 依赖。Starter 里已经带了正确的依赖版本手动引一个旧版很容易造成冲突启动时会报一些莫名其妙的方法找不到错误排查起来很费时间。2.2 邮件服务配置参数详解邮件配置主要在application.yml里下面是一份我常用的完整配置spring: mail: host: smtp.example.com port: 465 username: senderexample.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端口加密方式场景465SSL 直连配置简单连接后直接加密587STARTTLS 升级先建立普通连接再协商升级加密如果你用 587配置里就要把ssl.enabletrue换成starttls.enabletruesocketFactory那行也可以去掉。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, MapString, Object params) throws MessagingException { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, true, UTF-8); helper.setFrom(noreplyexample.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)的第二个参数特别容易漏。如果不传或者传falseHelper 会把内容当作纯文本发送收件人看到的是一堆 HTML 标签原文。只有传true才表示正文是 HTML收件端才会解析渲染。这个参数细节我整理了一张表出现问题可以对照排查参数错误用法现象multipartfalseaddInline/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 转义防止特殊字符破坏页面结构。如果某个参数来自用户输入比如用户昵称是scriptalert(1)/scriptth: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)}金额/p3.4 图片和附件从 cid 到 multipart 的完整链路邮件里的图片新手最容易犯的错误是写src/static/logo.png。这在网页里没问题但邮件收件方没有你的应用路径图片自然加载不出来。要解决图片显示主要有两条路远程 URL 和 cid 内嵌。远程 URL 就是把图片放在你网站的静态资源路径下模板写srchttps://example.com/static/logo.png。在支持远程图片加载的客户端里没问题但很多邮件客户端默认屏蔽远程图片需要用户手动点击加载体验一般。更可靠的是 cid 内嵌。做法是模板里写srccid:logo代码里调用helper.addInline(logo, new ClassPathResource(static/images/logo.png));收件方收到的邮件本身就是一个包含文字和图片的完整包即使离线也能看图。JavaMail 会把图片作为邮件的一部分发送cid:logo就是图片在邮件内部的引用地址。多张图片时还可以用 Map 统一管理MapString, 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.MimeUtilityBoot 2 是javax.mail.internet.MimeUtilityhelper.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:thhttp://www.thymeleaf.org head meta charsetUTF-8 / title注册成功/title /head body stylemargin:0;padding:0;background-color:#f2f4f8; table rolepresentation width100% cellpadding0 cellspacing0 stylebackground-color:#f2f4f8;padding:24px 0; tr td aligncenter table rolepresentation width600 cellpadding0 cellspacing0 stylewidth:600px;max-width:600px;background-color:#ffffff;border-radius:8px;overflow:hidden; tr td stylepadding:32px 40px; img srccid:logo altlogo styleheight:40px;width:auto; / h2 stylefont-size:22px;color:#333333;margin:24px 0 8px;注册成功/h2 p stylefont-size:14px;line-height:1.7;color:#555555;你好span th:text${userName} stylecolor:#333333;font-weight:600;用户/span欢迎加入。请在 24 小时内点击下方按钮完成账号激活。/p p stylefont-size:14px;line-height:1.7;color:#555555;注册时间span th:text${#dates.format(registerTime, yyyy-MM-dd HH:mm)} stylecolor:#333333;2025-01-01 10:00/span/p table rolepresentation cellpadding0 cellspacing0 stylemargin:28px 0 8px; tr td stylebackground-color:#3b7cff;border-radius:6px; a th:href${activateUrl} styledisplay:inline-block;padding:12px 32px;color:#ffffff;font-size:14px;text-decoration:none;激活账号/a /td /tr /table p stylefont-size:12px;color:#999999;margin-top:24px;如果这不是你的操作请忽略本邮件。/p /td /tr /table /td /tr /table /body /html模板里每个动态数据都给了默认值所以用浏览器直接打开这个文件看到的是一封效果完整的静态邮件非常方便前端同事调整样式。布局用了 table 嵌套样式全部内联这是邮件客户端兼容性的基本要求。Outlook 之类的客户端对 flex、外链 CSS 支持很差table 加内联样式虽然写起来啰嗦但基本不会翻车。4.2 组装数据与完整发送流程业务层组装参数并调用服务代码很直接MapString, Object params new HashMap(); params.put(userName, 小李); params.put(registerTime, LocalDateTime.now()); params.put(activateUrl, https://example.com/activate?tokenabc123); mailService.sendHtmlMail( xiaoliexample.com, 欢迎加入某应用, mail/register-success, params);激活链接里的 token 应该由业务侧生成并写入 Map模板里只负责渲染${activateUrl}。这样链接的可控性更高后续要做链接过期、埋点统计也都在业务层处理。前面的sendHtmlMail方法不包含内嵌图片逻辑所以针对这个模板我给它做一个增强版本支持传入内嵌资源 Mappublic void sendHtmlMailWithInline( String to, String subject, String templateName, MapString, Object params, MapString, Resource inlineResources) throws MessagingException { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, true, UTF-8); helper.setFrom(noreplyexample.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.EntryString, Resource entry : inlineResources.entrySet()) { helper.addInline(entry.getKey(), entry.getValue()); } } mailSender.send(message); }调用时把 logo 传进来MapString, Resource inline new HashMap(); inline.put(logo, new ClassPathResource(static/images/logo.png)); mailService.sendHtmlMailWithInline( xiaoliexample.com, 欢迎加入某应用, mail/register-success, params, inline);4.3 从同步到异步避免邮件拖垮主流程邮件发送走网络 IO一个 SMTP 请求包含建立连接、TLS 握手、认证、数据传输顺利时几百毫秒服务不稳定时可能拖到好几秒。如果用户在注册接口里面同步等待邮件发送完成接口响应时间会被明显拉长甚至因为邮件服务超时导致注册整体失败这在实际项目中很常见。我的建议是所有非强依赖的邮件都异步化。Spring 的做法是AsyncConfiguration 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, MapString, Object params, MapString, 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-8new MimeMessageHelper(msg, true, UTF-8)主题中文乱码同上同上附件名乱码JavaMail 对非 ASCII 文件名处理不一致用MimeUtility.encodeText编码文件名模板找不到process传参带了后缀传mail/register-success不要带.html模板找不到自定义解析器 prefix/suffix 写错确保目录和文件位置匹配修改模板不生效模板缓存开发环境设置spring.thymeleaf.cachefalse或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 的方案虽然看起来不花哨但足够稳定特别适合需要长期维护的业务系统。如果你接手的项目还在用字符串拼接生成邮件真的值得花半天时间把模板引擎迁过来改完你就知道这半天有多值。