ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot邮件发送实战:从SMTP配置到异步与排坑

SpringBoot邮件发送实战:从SMTP配置到异步与排坑 我们做后端开发的十有八九都逃不过要发邮件这个需求注册激活、密码重置、告警通知、定时报表推送……哪样都离不开它。SpringBoot里头整合JavaMail其实就是加个依赖、配几个参数的事但真到了线上坑一个接一个邮箱验证码发不出去、丢进垃圾箱、附件名乱码、异步发送把线程池拖垮这些都是我踩过的实战问题。这篇博客我会从最简单的SmTP配置讲起一路拆解到模板邮件、附件、图片嵌入、异步化改造最后附上排查问题的一些思路。不管你是刚学SpringBoot的实习生还是被邮件功能折腾得够呛的老手按着这篇文章走一遍基本能把“发邮件”这件事彻底拿捏住。1. 邮件服务的技术选型与前置准备1.1 为什么不自己写协议而是用JavaMail先聊一个很多人没细想过的问题发邮件到底在做什么本质就是把一封符合RFC 5322规范的文本消息通过SMTP协议交给邮件服务器再由服务器投递给收件人的邮箱系统。如果你从零手写一个SMTP客户端也不是不行但你要处理认证、编码、附件分段、退信解析等一系列繁琐细节。JavaMail从JDK时代就存在已经把这些底层协议全部封装好了SpringBoot的spring-boot-starter-mail更是把JavaMail大大简化一个JavaMailSender接口就够用。实际企业项目里绝大多数场景都是“我们有一个应用需要发出邮件但我们不可能自建邮件服务器”所以都是接入现成的SMTP服务比如QQ邮箱、163邮箱、阿里云邮件推送、公司内部的Exchange或基于Postfix的服务器。SpringBoot在这件事上做得很干净只需要在application.yml里配置host、port、username、password一个自动配置的邮件发送器就绪了。1.2 选邮箱服务商的几个考量点我个人的经验是开发阶段用QQ邮箱或163邮箱免费SMTP最快因为开通简单、文档多、问题容易搜。上线阶段就要认真选了如果你给C端用户发注册激活邮件建议用云厂商的邮件推送服务比如阿里云DirectMail或腾讯SES因为这类服务自带发送量统计、退订管理、送达率优化而且IP信誉度比用自己的服务器裸发好得多。如果你只是给内部运维发告警用公司已有的企业邮箱SMTP就行省事又便宜。这里有一个容易被忽略的点SMTP发送频率和日限额。QQ邮箱免费版大概一天几百封超过直接拒发企业邮箱通常会宽松一些但也不是无限。做业务的时候一定要提前规划好量级否则上线没几天你就会接到“用户收不到验证码”的连环投诉。1.3 开发环境准备我用的是SpringBoot 2.7.xJDK 8/11均可Maven管理依赖。如果你用Gradle或者SpringBoot 3.x核心配置大同小异只是一些包名从javax.换成了jakarta.等下我会在代码里特别标注兼容点。首先创建一个Maven工程引入基础依赖。最简单的就两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency如果你后面要发HTML模板邮件建议再引入thymeleaf-spring5SpringBoot 2.x或者 spring-boot-starter-thymeleaf这样可以直接用Thymeleaf模板来渲染邮件内容。模板引擎的好处是邮件正文不再是散落在一堆字符串拼接里的地狱而是像写网页一样维护。这个我文章第三部分细说。注意SpringBoot 2.x对应JavaMail接口是javax.mailSpringBoot 3.x换成jakarta.mail。如果你从网上抄了一段老代码先看看import语句报ClassNotFoundException多半就是这里出了问题。2. 第一个能跑通的邮件发送实例2.1 配置文件里到底要写什么我们以QQ邮箱为例。先在QQ邮箱的设置里开启SMTP服务会得到一个授权码——注意不是你的QQ密码是专门给第三方客户端用的16位授权码。这个授权码直接作为spring.mail.password。application.yml的核心配置如下spring: mail: host: smtp.qq.com port: 465 username: yourqq.com password: your-auth-code protocol: smtps properties: mail: smtp: ssl: enable: true socketFactory: class: javax.net.ssl.SSLSocketFactory这里要解释几个关键参数。protocol: smtps意味着直接走SSL加密通道QQ邮箱的465端口是SSL端口587端口是STARTTLS端口。用587时配法略有不同spring: mail: host: smtp.qq.com port: 587 username: yourqq.com password: your-auth-code properties: mail: smtp: starttls: enable: true两种方式都行但我个人偏向587 STARTTLS因为STARTTLS是在普通连接上动态升级为TLS兼容性更好。如果你在云服务器上跑还要注意防火墙别把这两个端口封了。2.2 最简单的一封纯文本邮件配置好了之后注入JavaMailSender直接发送。这是最基础的代码任何项目都是从这一步开始的。import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; Service public class MailService { private final JavaMailSender mailSender; public MailService(JavaMailSender mailSender) { this.mailSender mailSender; } public void sendSimpleMail(String to, String subject, String text) { SimpleMailMessage message new SimpleMailMessage(); message.setFrom(yourqq.com); message.setTo(to); message.setSubject(subject); message.setText(text); mailSender.send(message); } }不要小看这几行代码里面有两个容易踩的细节。第一setFrom有时候不写也能发因为默认会拿配置文件里的username但有的邮箱服务必须要求From和认证账号一致否则被服务器拒绝。第二收件人如果是多个人setTo可以传多个字符串用,分隔也行但更推荐逐个写进数组减少解析异常。2.3 测试时最容易踩的坑连接超时和认证失败第一次跑测试类的时候最可能遇到的就是这两类报错AuthenticationFailedException: 多半是授权码错误或者账号没开SMTP服务。MailConnectException: 连不上服务器看端口、SSL配置、是否被防火墙拦截。连接超时也是一大坑。JavaMail默认的timeout是无穷大要是SMTP服务挂了你都不知道。我习惯在配置里加上超时控制spring: mail: properties: mail: smtp: connectiontimeout: 5000 timeout: 5000 writetimeout: 5000这三个分别表示连接SMTP服务器、读取响应、写出数据的超时时间单位毫秒。尤其是writetimeout发送大附件时如果服务器响应慢这个能帮你早一点感知并快速失败。3. 让邮件专业起来HTML模板与富文本内容3.1 为什么不用纯文本用户收到的验证码如果是一封黑底白字的纯文本邮件体验和十年前的短信差不多。稍微正经点的项目注册激活邮件、周报邮件都得是排版干净的HTML邮件。HTML邮件的本质就是一个MIME类型为text/html的邮件体JavaMail的MimeMessage天然支持。用HTML字符串拼接不是不能做但维护成本太高。比如一个欢迎邮件模板里面有用户名、激活链接、产品介绍你一旦想调整布局还得从Java代码里捞字符串那叫一个酸爽。所以我把Thymeleaf模板作为标准方案这也是我这次要重点演示的思路。3.2 用Thymeleaf渲染邮件正文在pom.xml里加模板引擎依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency然后在src/main/resources/templates/mail/下建一个verify.html!DOCTYPE html html langzh xmlns:thhttp://www.thymeleaf.org body h2亲爱的 span th:text${username}用户/span欢迎注册/h2 p请点击以下链接完成验证/p a th:href${verifyUrl}激活账号/a p链接有效期24小时如果无法点击请复制以下地址到浏览器/p p th:text${verifyUrl}/p /body /html在服务里注入TemplateEngine然后渲染字符串import org.springframework.mail.javamail.MimeMessageHelper; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; Service public class MailService { private final JavaMailSender mailSender; private final TemplateEngine templateEngine; public MailService(JavaMailSender mailSender, TemplateEngine templateEngine) { this.mailSender mailSender; this.templateEngine templateEngine; } public void sendHtmlTemplateMail(String to, String subject, MapString, Object params) { Context context new Context(); context.setVariables(params); String htmlContent templateEngine.process(mail/verify, context); MimeMessage mimeMessage mailSender.createMimeMessage(); try { MimeMessageHelper helper new MimeMessageHelper(mimeMessage, true, UTF-8); helper.setFrom(yourqq.com); helper.setTo(to); helper.setSubject(subject); helper.setText(htmlContent, true); // true表示HTML mailSender.send(mimeMessage); } catch (MessagingException e) { throw new RuntimeException(邮件发送失败, e); } } }这里有个关键点MimeMessageHelper构造函数的第二个参数true表示创建一个multipart邮件第三个参数UTF-8用来指定字符集。如果写成new MimeMessageHelper(mimeMessage)会导致中文字符在标题和正文里变成乱码这是网上很多老教程没提到的一个坑。3.3 静态图片嵌入与附件发送邮件里放图片有两种完全不同的做法。一种是把图片传到公网URL然后用img srchttps://...引用。另一种是图片以CIDContent-ID方式内嵌到邮件中这在一些内部的业务确认邮件里很实用——比如订单截图、报表截图。用SpringBoot实现CID内嵌其实很简单public void sendInlineImageMail(String to, String subject, String html, String imagePath, String imageId) throws MessagingException { MimeMessage mimeMessage mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(mimeMessage, true, UTF-8); helper.setTo(to); helper.setSubject(subject); helper.setText(html, true); FileSystemResource image new FileSystemResource(imagePath); helper.addInline(imageId, image); mailSender.send(mimeMessage); }对应的HTML中写img th:srccid:imageId /或img srccid:imageId。注意cid后面的字符串要和addInline里的id保持一致否则部分客户端就显示不了。至于附件helper.addAttachment(文件名.txt, new FileSystemResource(file))就完事。但这里有一个特别阴间的坑中英文文件名编码。你如果想发中文名附件直接用addAttachment(报表.pdf, resource)有些客户端收到的文件名会是乱码。解决办法是手工做一层RFC 2047编码比如用MimeUtility.encodeText(报表.pdf, UTF-8, B)import javax.mail.internet.MimeUtility; helper.addAttachment(MimeUtility.encodeText(报表.pdf, UTF-8, B), resource);如果你是SpringBoot 3.x注意import路径是jakarta.mail.internet.MimeUtility源码基本没变。3.4 HTML邮件的兼容性心得说句实在话HTML邮件和网页完全是两个物种。Outlook用的是Word的排版引擎对CSS的支持停留在2000年很多邮箱客户端会拦截外部图片请求导致你的表格布局全乱。我吃过不少亏总结几条保命经验布局用table不要用div。很多邮箱里div会塌。样式用内联style不要用外部css和style块至少保证主要属性内联。图片必须设置宽高并且最好放CDN不要用相对路径。字体尽量用系统字体避免web字体导致降级。Thymeleaf模板里可以用th:inlinecss做内联变量也可以提前用工具把CSS内联进HTML。如果只是给内部同事发报表不用太纠结兼容性如果是给海量C端用户发建议用现成的邮件模板框架研究一下各家客户端的坑。4. 进阶技巧异步发送、定时任务与参数加密4.1 异步发送防止接口被慢邮件拖死很多新手会犯同一个错误在用户注册接口里同步调用邮件发送。SMTP握手、认证、数据传输整个过程走下来快则几百毫秒慢则好几秒用户等得心慌接口的TP99直接飙升。正确的做法是异步化。最简单的方案就是Spring的Async注解Component public class MailAsyncService { Async(mailExecutor) public void sendAsync(MailContent content) { // 实际发送逻辑即前面写的sendHtmlTemplateMail } }前提是在启动类或配置类上加EnableAsync并且定义一个线程池Configuration public class MailExecutorConfig { Bean(mailExecutor) public Executor mailExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(100); executor.setThreadNamePrefix(mail-pool-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }重点说一下这个线程池参数。邮件发送属于IO密集操作线程不必太多2~5个完全足够。CallerRunsPolicy是我特意选的如果任务堆积超过队列容量新任务直接在调用线程里执行而不是抛出RejectedExecutionException。这样即使高并发打过来邮件的发送也只会延迟不会丢。但异步也不能乱用。异步意味着调用方不再关心结果你得自己处理失败重试。如果发送失败被吞了用户就永远收不到邮件了。我常用的做法是在异步方法里捕获异常记录日志并写入一张mail_send_record表由定时任务补偿重发。4.2 定时发送报表邮件定时邮件最常见的场景就是每天早上9点给管理层推送昨日核心数据。实现方式不外乎Scheduled注解。注册一个每天执行的定时器Component public class ReportMailJob { private final MailService mailService; public ReportMailJob(MailService mailService) { this.mailService mailService; } Scheduled(cron 0 0 9 * * MON-FRI) public void sendDailyReport() { MapString, Object data new HashMap(); data.put(date, LocalDate.now().minusDays(1)); data.put(revenue, 123456); // 组装数据渲染模板并发异步邮件 mailService.sendHtmlTemplateMail(bosscompany.com, 昨日经营日报, data); } }这里提醒一个坑Scheduled是单线程调度器默认情况下所有定时任务都是同一个线程排队执行。如果你有多个邮件定时任务并且其中一个因SMTP网络异常卡了30秒后面的任务会全部被阻塞。解决办法是在配置里加一个TaskScheduler线程池同时给每个任务配Async让定时器只管触发发信逻辑全部扔到异步池。4.3 邮件服务器密码不硬编码直接把邮箱密码写进application.yml再提交到Git仓库是给公司埋雷。我见过不止一次开发者把代码push到GitHub上几分钟后账号被扫描机器人盯上邮件服务被用来发垃圾邮件。建议至少做到两点环境变量引用password: ${MAIL_PASSWORD}部署时在环境变量或配置中心注入。如果是Spring Cloud项目用Nacos或Apollo管理密码敏感配置加密存储。如果你只想本地跑着玩用一个本地的application-local.yml并把它加进.gitignore。这是成本最低且最安全的做法。4.4 邮件发送记录与重试机制发邮件这件事天然是不可靠的——SMTP服务器可能临时拒绝、网络可能抖动、收件人邮箱可能满了退回。所以正经项目里一定要有发送记录表。我一般建一张表CREATE TABLE mail_send_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_type VARCHAR(64), to_address VARCHAR(255), subject VARCHAR(255), content TEXT, status TINYINT, -- 0待发送 1成功 2失败 fail_reason VARCHAR(500), retry_count INT DEFAULT 0, next_retry_time DATETIME, create_time DATETIME );发送前插入状态为0的记录调用SMTP后更新为1失败则记录异常原因并把状态置为2。然后一个补偿定时任务每隔5分钟扫描状态为2且retry_count小于3的记录重新投递。这套逻辑不复杂但能实实在在解决“用户没收到邮件”的投诉场景。我遇到过一个生产问题凌晨的一次促销活动瞬间发了几万封邮件SMTP限流导致三分之一失败如果没有重试机制后果就是用户投诉刷屏。5. 常见问题排查与性能优化5.1 五分钟定位邮件发送失败邮件发送出错信息千奇百怪但九成都能归到以下几类。我整理了一张排查表照着走能省不少时间。报错关键字可能原因处理办法UnknownHostException邮箱服务器域名解析失败检查网络DNS确认spring.mail.host拼写Connection refused端口不通或防火墙拦截测试Telnet host port检查云安全组AuthenticationFailedException授权码错误/账号未开启SMTP换授权码重新开启SMTP服务MessagingException: 501发件人地址与认证账号不一致保持setFrom和配置的username一致MailTimeoutExceptionSMTP响应超时加大timeout检查发信量是否触发限流550 Mailbox unavailable收件人地址不存在或对方拒收核对收件人邮箱举个例子如果你配的是阿里云邮箱报错535 Error: authentication failed不一定是密码错还可能是你的服务器IP被阿里云判定为异地登录需要先解锁再改密码。这种问题单单看报错信息是不够的一定要结合邮箱服务商的后台日志来看。5.2 垃圾箱与反垃圾策略邮件明明发送成功了用户就是收不到或者跑到垃圾箱里。这不是代码bug是发送方的信誉度问题。我踩过的坑有几个账号新注册就直接发大量营销邮件秒进垃圾箱。邮件正文里大量使用“免费”“中奖”“点击链接”等敏感词触发反垃圾规则。HTML里面只有一个大图片没有文本正文容易被判为垃圾邮件。改善手段先在低频率下养账号逐步提高日发送量保证邮件正文有足够的纯文本比例发件域名加上SPF和DKIM记录。SPF和DKIM是域名服务商提供的邮件身份认证设置起来不复杂。如果公司用自建邮件服务这个尤其重要否则别的邮箱大概率直接丢弃你的邮件。5.3 发送性能优化批量发送与连接复用JavaMail发送每一封邮件时默认会创建一个新的SMTP连接代价不小。如果你一次要发几千封最好是复用同一个Transport对象在一个连接里连续发送多封邮件。不过SpringBoot封装后的JavaMailSender没有暴露这种用法想走这个优化路子需要直接操作MimeMessage和Transport。另一个更简单的方案是并发异步发送前面那个2~5线程的池子再配合队列慢慢消费吞吐量足够大部分业务了。实际上对于中小规模系统线程池方案的效果已经很稳。真正海量的发送建议直接把邮件推给云邮件服务商的API而不是自己扛SMTP。5.4 参数校验与防刷治理验证码邮件往往是恶意攻击的重点对象。有人会用一个脚本不断请求你的发送接口如果你不做限制邮件服务商很快就会封你的账号甚至产生高额费用。我建议在Controller层做几件事同样的邮箱每分钟最多请求1次每天最多10次。用Redis做计数器最简单。同一个IP每天发送次数限制。验证码要有有效期且验证失败后要能重新获取而不是永远同一个码。Redis限流示例简图RestController RequestMapping(/api/code) public class CodeController { private final StringRedisTemplate redisTemplate; public CodeController(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } PostMapping(/send) public ResponseEntityVoid sendCode(RequestParam String email) { String key mail:limit: email; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 3) { // 超过次数直接拒绝 return ResponseEntity.status(429).build(); } redisTemplate.expire(key, 60, TimeUnit.SECONDS); // 发送逻辑... return ResponseEntity.ok().build(); } }注册激活邮件、找回密码邮件也都应该遵守同样的防刷原则。这虽然不是邮件功能本身但上线后保护邮件通道和你的钱包靠的就是这几行代码。6. 兼容SpringBoot 3.x与常见版本差异6.1 javax.mail还是jakarta.mailSpringBoot 3.0之后整个Jakarta EE迁移到新命名空间JavaMail从javax.mail变成了jakarta.mail。如果你在升级旧项目改动点其实不多首先是pom依赖坐标从org.springframework.boot:spring-boot-starter-mail不变但底层会拉jakarta.mail的包其次代码里的import需要全局替换。MimeMessageHelper是Spring封装后的内部帮我们做了兼容你一般不用碰它但是如果你直接操作Session、Transport就要改成jakarta的类。另外spring-boot-starter-thymeleaf在Boot 3.x下不需要改版本跟着parent走就行。6.2 与数据库连接池、Redis等组件混用时的资源竞争一个系统里邮件发送和数据库、Redis共享线程资源处理不好容易出事。我遇到过一次生产故障业务高峰期大量请求占用Tomcat线程去调用同步邮件发送SMTP响应又慢Tomcat线程池被占满导致整个应用不可用。排查到最后就是没有对邮件发送做异步隔离。解决方向任何外部IO都不建议占用Web请求线程。邮件发送尤其如此因为SMTP是一个“慢”协议。把邮件发送全部放到独立的线程池并用ThreadPoolExecutor的拒绝策略保证不拖垮核心业务接口这是每一个接入邮件功能的项目都应该遵守的底线。6.3 单元测试与本地调试本地调试邮件功能不要每次真的发到用户邮箱既慢又烦。两个技巧用一个假的JavaMailSender实现打印日志而不真发。或者编写集成测试时引用真实SMTP但收件人写成自己的临时邮箱。写单元测试时我更喜欢用MockBean把JavaMailSender替换掉SpringBootTest public class MailServiceTest { Autowired private MailService mailService; MockBean private JavaMailSender mailSender; Test public void testSendMail() { // 不发真邮件只验证逻辑 mailService.sendSimpleMail(testexample.com, title, text); verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); } }这样测试跑起来飞快也不污染发件账号的配额。真到了要验证格式、附件是否完整再单独写一个手动测试类用自己的邮箱收一次。7. 个人经验与最后几个小提醒邮件功能在项目里看似不起眼但它直接关系到用户能不能激活账号、能不能找回密码、运维能不能第一时间收到告警。做完这套整合我发现最容易被忽视的其实是“发送后的事”日志记录、失败重试、送达率统计。代码层面把JavaMailSender封装好只是第一步整个发送链路健壮才算完整。最后分享三个我一直在用的小技巧一发件人名称尽量设置成业务名称比如“XX系统管理员”而不是一长串邮箱地址。用helper.setFrom(yourqq.com, XX系统管理员)可以设置显示名。很多用户看到一串邮箱地址下意识觉得是垃圾邮件根本不点开。二发送邮件前做一个简单的收件地址格式校验。用CommonValidator判断是不是合法的Email格式能避免大量无意义的SMTP调用至少把明显非法的地址挡在前面。三建议在异步发送前就把MimeMessage构造好。因为MimeMessageHelper需要捕获MessagingException而异步线程里处理异常更麻烦。在同步阶段构造消息对象异步阶段只做mailSender.send(message)这样职责清晰且错误更容易暴露。我踩过几次坑之后最大的体会是邮件发送不是“能发出去就行”而是要围绕可靠性来设计。如果你正在做SpringBoot邮件功能建议把文章里提到的发送记录表、异步隔离、防刷限流一起做了上线后能省心一大半。如果你已经完成了基础发送下一步往模板邮件、腾讯云SES或阿里云DirectMail上迁移就会轻车熟路因为SpringBoot的配置模型在这些服务商那里基本一致换的只是host和账号密码而已。上面这些内容都是在实际项目里反复验证过的照着做至少不会绕大弯。如果你在整合邮件功能时还碰到过什么离谱的问题欢迎对照这篇文章的排查表逐条过一遍八成能定位到方向。
返回列表