ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java邮件发送全攻略:从SMTP协议到IDEA实现与异步重试

Java邮件发送全攻略:从SMTP协议到IDEA实现与异步重试

开发邮件发送功能,在很多 Java 后端项目里看着简单,实际动手却常常卡壳:依赖引入了、代码也抄了、授权码也填了,一跑就报错,或者发出去了收不到,又或者中文乱码成一片问号。这篇文章想把这条链路完整讲清楚——在 IntelliJ IDEA 里从零搭一个邮件发送项目,从 SMTP 协议基础、JavaMail API 的核心模型,到 IDEA 里的环境配置、三层邮件代码实现,再到生产环境里必须考虑的异步发送、连接复用和重试,最后是我自己踩过的几个坑。适合正在做注册验证码、告警通知、报表推送,或者纯粹想把邮件功能搞明白的 Java 开发者。

1. 邮件发送这件事,先弄懂 SMTP、JavaMail 和授权码

很多人上来就复制代码,结果配置文件里每个键是什么含义都不知道,出了问题无从下手。所以我先花点篇幅把底层的东西讲透。理解这层之后,后面所有代码都是顺理成章。

1.1 SMTP 到底做了什么

邮件发送的底层协议是 SMTP(Simple Mail Transfer Protocol),中文叫简单邮件传输协议。它做的事情很简单:你的程序作为客户端,连接上邮件服务商的 SMTP 服务器,然后按协议把发件人、收件人、标题、正文这些信息告诉服务器,服务器负责把邮件投递给收件人的邮箱服务器。

整个通信和 HTTP 很像:客户端发一条指令,服务器回一个状态码。比如你发HELO,服务器会回250;你验明身份,服务器可能回235表示认证通过;你发完数据后,服务器回250 OK表示接受这封邮件。这和去柜台寄快递一模一样——你把包裹交给快递员,快递员给你一个回执,但至于包裹什么时候到,中间要走几道分拣,那是快递网络内部的事。

所以你在 Java 里写邮件功能,本质上就是一个 SMTP 客户端,你不需要关心收件人的收件服务器在哪,也不需要对邮件传输路由做任何处理。这个设计对我们开发者非常友好,但也带来一个直觉上的误区:消息发出去了,并不代表对方打开了,甚至不代表对方服务器一定收下了,只能代表发件服务器受理了。

1.2 JavaMail 的会话模型:Session、MimeMessage、Transport

JavaMail API 是 Java 平台上处理邮件的标准接口。它把整个发送过程抽象成三个角色:

  • Session:全局配置对象,保存 SMTP 服务器地址、端口、是否认证、是否走 SSL 等参数。你可以把它理解为快递营业点的“环境配置”——营业点在哪、要不要验身份、包裹走陆运还是航空。
  • MimeMessage:一封邮件的内容载体,包括发件人、收件人、抄送、密送、主题、正文、附件。相当于你填好的快递面单和包裹本尊。
  • Transport:真正负责和 SMTP 服务器通信的发送器。它拿着 Session 的配置去连接服务器,再把 MimeMessage 里装好的内容通过网络发送出去。

代码上最典型的流程就是:

Session session = Session.getInstance(props, authenticator); MimeMessage message = buildMessage(session); Transport.send(message);

值得说明的是Transport.send(message)是一个静态方法,它内部自己创建连接、发送、关闭。对于大多数场景,这么写没问题。但如果你要高频发送,比如一个告警系统每分钟发几十封,就建议手动管理Transport连接,复用一个长连接。这个我在第 4 章会展开。

1.3 授权码与端口选择:95% 初始化报错都出在这里

现在的邮箱服务商早就不会让你用登录密码直接走 SMTP 了。你需要在邮箱设置里开启 SMTP 服务,然后生成一个专门的授权码。这个授权码通常是一串比较长的随机字符串,专门给第三方客户端或程序使用,可以单独吊销。和你的登录密码隔离,即使程序里泄露了,也不会让你的邮箱账号被直接登录。

你如果收到AuthenticationFailedException或者服务器回535错误,十有八九是授权码不对,或者账号密码参数写反了。注意一点:有的服务商在开启 SMTP 服务时,还必须绑定一个 IP 或者设置“仅允许授权码登录”,这一步漏掉,后面怎么试都是失败。

端口选择也是重灾区。常见端口有这么几个:

端口加密方式适用场景
25无加密或机会加密服务器间转发,客户端使用极易被运营商拦截
465SSL 直接加密老式客户端,配置简单,很多老项目在用
587STARTTLS 升级加密目前各大服务商最推荐的客户端提交端口

如果你发现自己连接被重置、超时,先别急着怀疑代码,把端口从 25 换到 587 或者 465 试试。我自己就遇到过一次,开发环境用 25 端口死活连不上,换成 587 秒通。另外记得检查服务商文档,有的服务商对 465 和 587 使用的证书策略不同,代码里的启动 TLS 开关也要对应调整。

2. 从 IDEA 开始搭环境:Maven 依赖、配置文件与编码准备

环境搭建本身不难,但有几个看似不起眼的细节会浪费你半小时。这一章就按我平时的操作顺序来说。

2.1 新建工程和依赖引入

打开 IntelliJ IDEA,新建一个普通的 Java 工程,模板选 Maven 即可。如果你的团队用的还是 Java 8 的老项目,邮件库用javax.mail的坐标;如果是新的 Java 17 以上项目,建议直接用jakarta.mail系列。以下以javax.mail为例,因为存量项目里它仍然是主流:

<dependencies> <dependency> <groupId>com.sun.mail</groupId> <artifactId>javax.mail</artifactId> <version>1.6.2</version> </dependency> </dependencies>

新版的项目用这个:

<dependencies> <dependency> <groupId>jakarta.mail</groupId> <artifactId>jakarta.mail-api</artifactId> <version>2.1.3</version> </dependency> <dependency> <groupId>org.eclipse.angus</groupId> <artifactId>angus-mail</artifactId> <version>2.0.3</version> </dependency> </dependencies>

新版比旧版的主要区别是包名从javax.mail变成了jakarta.mail,同时加入了新的 TLS 协议支持。如果你的 JDK 版本较新,旧版javax.mail在 TLS 握手时可能会因为默认算法组的问题报Received fatal alert: handshake_failure,遇到这种别纠结,切到新版就好。

2.2 mail.properties 与编码配置

邮件配置我不赞成写死在代码里。原因是:本地调试、测试环境、生产环境,SMTP 地址和授权码基本都不一样,写死在类里每次都要改代码重编译,太蠢。正确做法是放在一个mail.properties文件里,放到src/main/resources下:

mail.smtp.host=smtp.example.com mail.smtp.port=465 mail.smtp.ssl.enable=true mail.smtp.auth=true mail.smtp.connectiontimeout=5000 mail.smtp.timeout=5000 mail.smtp.writetimeout=5000 mail.from=you@example.com mail.user=you@example.com mail.password=your-auth-code mail.debug=false

然后代码里用ResourceBundle或者Properties.load读进来。设置超时参数是很多教程忽略的重灾区:默认情况下 JavaMail 的很多超时可能是无限等待,一旦 SMTP 服务器响应异常,你的请求线程会卡住十几分钟甚至更久。所以connectiontimeout、timeout、writetimeout这三个参数建议一定显式配上,单位都是毫秒。

2.3 运行前的三处检查

环境搭好后,先别急着写代码,做三分钟检查能省不少事。

第一,确认 IDEA 的文件编码。打开 Settings - Editor - File Encodings,把 Global Encoding、Project Encoding、Properties Files 的编码全部设成 UTF-8。否则后面 properties 文件里有中文注释或主题内容时,读进来可能出现乱码。

第二,确认mail.properties中填的主机地址是你所用邮箱服务商的真实 SMTP 地址,不要照抄任何教程的示例地址。各家的地址后缀规则不太一样,但一般都能在其设置页面的“SMTP 服务”说明里找到。

第三,先手动在你自己的邮箱客户端或者命令行里验证授权码能不能正常发送。如果还没有验证过就进入代码调试,你会同时面临“配置错、授权码错、代码错”三座大山,很难定位。我习惯的做法是先用一个最简单的测试类把 Session 和 Transport 跑通,确认网络和认证无误,再开始写业务代码。

3. 三种邮件类型逐个落地:文本、HTML 与附件

环境就绪后,写代码就是核心环节了。我会按照“文本邮件 → HTML 邮件 → 附件邮件”的顺序逐个实现。这三个层级不是孤立的,后面两个是在前一个基础上叠加。理解顺序,后面遇到复杂需求就不会慌。

3.1 第一封文本邮件

先写一个最简单的文本邮件,把所有关键步骤串起来:

public class SimpleMailSender { public static void main(String[] args) throws Exception { Properties props = loadProperties("mail.properties"); Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(props.getProperty("mail.user"), props.getProperty("mail.password")); } }); MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress(props.getProperty("mail.from"))); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse("receiver@example.com")); message.setSubject("第一封测试邮件"); message.setText("Hello,这是一封来自 JavaMail 的测试邮件。"); Transport.send(message); System.out.println("发送成功"); } private static Properties loadProperties(String name) throws IOException { Properties props = new Properties(); try (InputStream in = SimpleMailSender.class.getClassLoader().getResourceAsStream(name)) { props.load(in); } return props; } }

这里有几个值得注意的地方。

Session.getInstance(props, authenticator)里的authenticator不是可选的摆设。你在 properties 里写了mail.smtp.auth=true,就必须提供这个回调,否则服务器会拒绝认证。

MimeMessage.setText()默认会使用text/plain,也就是纯文本格式。注意setText方法内部会根据 Session 的mail.mime.charset属性判断编码,如果没设置,默认可能是平台编码。保险的做法是像上面代码一样在 properties 里加上mail.mime.charset=UTF-8,这样中文标题和正文都不容易乱码。

Transport.send(message)虽然方便,但它会把 Session 配置、连接、发信、断开全包了。如果发送失败,它会抛出MessagingException,异常里往往还带着 SMTP 服务器的返码和错误描述。调试阶段建议在创建 Session 后加一句session.setDebug(true),这样 JavaMail 会把和服务器交互的每一行指令都打到控制台,定位问题会直观很多。

3.2 HTML 邮件与模板占位符

第二封邮件要支持富文本。核心改动只有一行:把setText(plainText)换成setContent(htmlText, "text/html;charset=UTF-8")。下面是一个带简单模板的示例:

public class HtmlMailSender { private static final String TEMPLATE = """ <html> <body style="font-family: sans-serif; color: #333;"> <h2>Hello, %s!</h2> <p>您的验证码是:<strong style="font-size: 20px; color: #4A90D9;">%s</strong></p> <p>该验证码 5 分钟内有效,请勿泄露给他人。</p> </body> </html> """; public static void main(String[] args) throws Exception { Properties props = loadProperties("mail.properties"); Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(props.getProperty("mail.user"), props.getProperty("mail.password")); } }); MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress(props.getProperty("mail.from"))); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse("receiver@example.com")); message.setSubject("验证码通知"); message.setContent(String.format(TEMPLATE, "王工", "482913"), "text/html;charset=UTF-8"); Transport.send(message); System.out.println("HTML 邮件发送成功"); } }

用String.format拼模板,简单应用完全够用。但要注意:如果你的模板里需要接收用户输入的内容,直接用String.format会面临 HTML 注入风险。比如一个用户注册后发欢迎信,用户名是用户在表单里填的,如果用户名里带有<script>标签,你的邮件正文就会把它执行。所以生产代码里要记得对用户输入做 HTML 转义,或者使用模板引擎的自动转义功能。

实际业务里,验证码、告警、报表这类邮件都不建议在代码里拼 HTML,维护成本太高。后续可以引入模板引擎,把模板放到独立文件里,和 Java 代码彻底分开。我的经验是项目一旦超过两三种邮件模板,重构是迟早的事。

3.3 附件邮件:MimeMultipart 的正确姿势

带附件的邮件就要用到多部件结构。它的原理是:整封邮件是一个MimeMultipart,里面包含多个MimeBodyPart,每个MimeBodyPart是邮件的一部分——可以是纯文本,可以是 HTML,也可以是二进制文件。我把常见的“正文 + 附件”结构写出来:

public class AttachmentMailSender { public static void main(String[] args) throws Exception { Properties props = loadProperties("mail.properties"); Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(props.getProperty("mail.user"), props.getProperty("mail.password")); } }); MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress(props.getProperty("mail.from"))); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse("receiver@example.com")); message.setSubject("季度报表送达"); MimeBodyPart textPart = new MimeBodyPart(); textPart.setContent("<h3>请看附件。</h3><p>这是上季度的销售报表。</p>", "text/html;charset=UTF-8"); MimeBodyPart filePart = new MimeBodyPart(); filePart.setDataHandler(new DataHandler(new FileDataSource("report.xlsx"))); filePart.setFileName(MimeUtility.encodeText("季度报表.xlsx", "UTF-8", null)); MimeMultipart multipart = new MimeMultipart(); multipart.addBodyPart(textPart); multipart.addBodyPart(filePart); message.setContent(multipart); Transport.send(message); System.out.println("附件邮件发送成功"); } }

这里有一个很容易踩的坑:setFileName如果直接传中文文件名,很多客户端会显示成乱码,所以必须用MimeUtility.encodeText(fileName, "UTF-8", null)把文件名编码成 RFC 2047 格式。不写这一步,你在 Foxmail、Outlook 里看到的附件名大概率是一串问号。

multipart内部的顺序也很重要。通常把正文放前面,附件放后面,个别邮件客户端对顺序敏感,反了可能渲染异常。

如果需要发内嵌图片(邮件正文里直接显示图片而不是附件),结构就变成“related”模式的 multipart:一个 HTML 正文块,加上一个图片块,图片块通过setHeader("Content-ID", "<image001>")关联,然后 HTML 里用<img src='cid:image001'>引用。思路和附件差不多,只是把附件Content-Type设为image/png,我用得较少,但面试时偶尔会被问到,在这里一并说明。

3.4 一个容易被忽视的细节:收件人解析

很多人在setRecipients时传一个字符串"aaa@example.com, bbb@example.com",然后发现只有第一个收件人收到。其实 JavaMail 支持用InternetAddress.parse把逗号分隔的字符串解析成数组,然后传给该方法。上面代码里那段就是正确姿势。

如果你的收件人名称里有中文,也要编码。比如:

InternetAddress address = new InternetAddress("receiver@example.com", "张三", "UTF-8");

类似这种细节,文档里一般不写,但实际业务里经常遇到。

4. 从能跑到好用:异步发送、连接复用与重试策略

跑通了第一封邮件,只是完成了 10%。剩下的 90% 是把“能发”变成“好用”。我个人认为,这一章才是一个邮件功能能否上生产的分水岭。

4.1 别在业务线程里同步发邮件

如果你在一个注册接口里直接Transport.send(message),等于把这个接口的响应时间绑定在了 SMTP 服务器的响应时间上。网络正常时可能几百毫秒,但 SMTP 服务器一旦排队或者网络抖动,接口延迟就是几秒、几十秒。用户注册一个账号,按钮转圈半天,体验极差。

所以生产项目里,发邮件一定要异步化。发送请求进入队列后立刻返回,发送动作由后台线程池处理。这样即使邮件服务全挂,也不影响主业务。下面的代码展示了一个简单的异步发送封装:

public class MailAsyncService { private final ExecutorService executor = new ThreadPoolExecutor( 2, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("mail-sender-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy()); public void sendAsync(Runnable sendTask) { executor.execute(sendTask); } }

这里我特意用CallerRunsPolicy而不是AbortPolicy。邮件属于“丢了可惜、晚点没事”的任务,当队列满时,让主线程自己执行一次,比直接抛RejectedExecutionException要温和得多。

4.2 线程池 + 重试 + 日志的组合

异步发送之后,紧接着要处理失败重试。我的经验是:重试必须加退避,且必须加上限,否则一次 SMTP 服务短暂故障就能触发疯狂重试,把自己系统的线程池打满。

简单可靠的做法是使用带延迟的重试队列,比如提交到线程池后捕获异常,如果重试次数小于 3,就 sleep 一段时间再提交。更优雅的方案是引入消息队列,把发邮件作为一个可靠投递任务处理。企业内部如果已经有 MQ,邮件任务直接丢进去走消费端重试是最省心的。

日志也是不可省的。每次发送都记录发件人、收件人、主题、Message-ID、发送结果、耗时。Message-ID 可以从MimeMessage里拿,它在协议层是唯一标识,出了问题你可以拿着这个 ID 去邮箱服务商那边查投递记录。

4.3 连接复用与 Spring Boot 场景补充

如果你确认自己就是高频率发送场景,那每次Transport.send(message)重新建连就很浪费。SMTP 握手也是要时间,TCP 连接建立 + TLS 握手,一次至少几百毫秒。正确做法是手动维护一个长连接:

Transport transport = session.getTransport("smtp"); transport.connect(host, port, user, password); // 反复发送多封邮件 for (MimeMessage message : messages) { message.saveChanges(); transport.sendMessage(message, message.getAllRecipients()); } transport.close();

注意sendMessage(Message, Address[])是Transport的底层方法,和上面的静态方法Transport.send(message)不一样,用这个你必须显式调saveChanges()确保内容头更新,否则可能发出去的邮件缺头或内容陈旧。

Spring Boot 项目里,通常直接用JavaMailSender和MimeMessageHelper就能覆盖大部分需求,配置项也都在spring.mail.*下面,思路完全一样。封装好了之后,你在业务代码里只需要一行mailService.send(to, subject, content),底层是同步还是异步,用户完全不感知。

5. 踩坑实录:认证失败、超时、乱码和垃圾邮件判定

这一章写我实际遇到、并且花费过不少时间的问题。每一个都是真实场景,不是文档里的概念。对照着自己项目里的报错排查,大概率能对上。

5.1 一张表对照常见异常

先给一个速查表。出现问题时,先看报错关键词,再对着表找答案。

症状常见原因处理方向
AuthenticationFailedException授权码错误、账号格式错误、SMTP 未开启核对授权码,确认服务已开启
连接超时Connection refused端口被封、防火墙拦截切换 465/587,检查网络策略
TLS 握手失败旧版 JavaMail 和新 JDK 不兼容升级到 jakarta.mail 系列
中文主题乱码未设置mail.mime.charset加上 UTF-8 参数
附件名乱码setFileName未编码用MimeUtility.encodeText
邮件被退信发件域名无 SPF/DKIM 记录在 DNS 里配置域名认证
线程池耗尽同步发送 + 重试无退避异步化,加重试上限

这个表是我给团队做排查手册的原始版本,你完全可以复制过去再补几条。

5.2 一次延迟事故的完整排查链路

有一次线上反馈:验证码邮件大面积延迟,用户点“发送验证码”后等了几分钟才收到。我当时的排查链路是这样的。

先看应用日志,发现发送任务大量堆积在线程池队列里,执行耗时正常。说明不是 SMTP 慢,而是发送任务来不及处理。再看监控,线程池活动线程数始终打满,任务队列从几十涨到几千。

我第一反应是 SMTP 服务器出了问题,于是手动用命令行发了一封测试邮件,结果秒发。这就排除了服务商故障。

继续深入日志,发现每条发送任务的耗时都正常,但重试次数异常高。翻看更早的日志,找到一根导火索:某一次业务高峰,线程池因为队列满触发了CallerRunsPolicy,主线程被迫去执行发送任务,主线程被阻塞,业务接口变慢,前端的重试请求又产生了更多发送任务,形成了恶性循环。

根因其实是两件事叠加:第一,重试逻辑没有退避,失败后立刻重试,放大了瞬时压力;第二,线程池的队列容量设定没有考虑峰值流量,一旦入口流量抖一下,系统没有缓冲余地。

后续修复方案是:重试改为指数退避,上限 3 次;线程池队列扩到 1000,但加了拒绝监控;服务的发送入口和业务请求彻底分离。之后再没出现过类似事故。

5.3 被判定为垃圾邮件怎么办

代码写得再完美,邮件进垃圾箱,业务一样完蛋。这里头说白了就是信誉问题。发件服务器的 IP 因为频繁退信被降权,或者发件域名的身份认证缺失,都会导致邮件被拦截。

域名侧需要配置 SPF 和 DKIM 记录。SPF 是告诉收件服务器“我这个域名授权的发件服务器有哪些 IP”;DKIM 是给邮件做签名,收件服务器可以验证邮件确实由该域名发出。这俩不是程序能搞定的,要找你公司管 DNS 的同学帮忙配。

内容侧也有影响。邮件正文里大量使用“免费”“中奖”“点击领取”这类词,命中垃圾邮件规则的概率会直线上升。正规业务邮件里也没必要写这些词,需要转化类文案时,建议用好域名和正规邮件营销平台。

开发环境里被拦就算了,生产环境务必把发件人邮箱和域名信誉养起来。

6. 我在这块的一些真实体会

最后分享几个不一定写进教程、但我在实际项目中深有体会的点。

第一,邮件配置一定要走外部化。公司里一个项目继承另一个项目的邮件配置,结果换了一个邮箱服务商,代码没改,光配置就调了一周。把 SMTP 地址、授权码、超时时间全部放到配置中心或环境变量里之后,部署环境之间的差异彻底隔离。

第二,开发调试时务必打开mail.debug。这个开关会打印和 SMTP 服务器之间的完整指令对话。有一次授权码看起来完全正确,但一直认证失败,打开 debug 才发现是邮箱服务商返回了“client host rejected access denied”,根本不是密码问题,而是对方安全策略限制了解析不到的主机名。

第三,重要业务邮件要独立监控。我的做法是每封验证码邮件都打日志,包含 Message-ID、耗时、是否成功;日常只做成功率统计,同时挂一个“发送失败数 > 阈值”的告警。这样邮件出问题不用等用户投诉,系统自己就能发现。

邮件功能看着小,涉及的细节真不少。但只要把协议、API 模型、环境、代码、生产策略这条链路理顺了,后面再做告警邮件、报表订阅、营销触达,都只是在同一个地基上加砖。希望这篇能帮你少走一些我走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进