聊一个看起来不起眼、但几乎每个 Java 后端工程都躲不开的东西邮件。早几年大家封装邮件发送工具类时清一色是javax.mail加com.sun.mail的组合这两年随着 Jakarta EE 9 之后的包名大迁移Maven 里最常用的坐标变成了org.eclipse.angus:jakarta.mail。很多从旧工程升级上来的同学第一步就卡在依赖上——代码写起来差不多但 Maven 一直报错运行起来又是NoClassDefFoundError。这篇文章就拿 1.1.0 这个版本做例子把 Maven 依赖怎么配、jar 包和源码去哪里拿、API 接口怎么用、收发邮件怎么落地完整过一遍同时给一份核心术语的中英对照速查。无论你是刚接触 Java 邮件还是老项目要迁到jakarta.*命名空间这篇都能帮你省掉不少查资料的功夫。1. 项目背景与依赖生态盘点1.1 从 javax.mail 到 jakarta.mail到底发生了什么Java 邮件这套 API 的历史比很多人想象的长。早期它叫 JavaMail属于 Java EE 的一部分一路跟着规范走包名一直是javax.mail。后来整套 Java EE 技术栈移交给 Eclipse 社区改名为 Jakarta EE其中一个关键变化就是包名从javax.开头的旧命名空间整体迁移到jakarta.开头。javax.mail变成了jakarta.mail就像整条街改了门牌号房子还是那些房子但寄信地址全部要换。这只是第一步。规范的参考实现同样发生了转移。以前你引用的实现是com.sun.mail:javax.mail或者com.sun.mail:jakarta.mail现在主流的实现落到了 Eclipse Angus 项目上也就是标题里那个org.eclipse.angus。Angus 项目做的事情是继续维护和实现jakarta.mail这个规范同时补齐了jakarta.activation这一套相关能力。所以当你看到坐标变成org.eclipse.angus:jakarta.mail时不要觉得陌生它本质上还是你熟悉的邮件发送 API只是换了一个更加社区化的维护位置并且换上了新命名空间。真正要改的是依赖坐标和 import 路径。1.2 Angus 全版本坐标与 1.1.0 选型理由从“全版本”的角度看Angus 相关构件大致经历过这样几条线旧时代com.sun.mail:javax.mail包名是javax.mail对应 Java EE 8 及更早的体系。过渡期com.sun.mail:jakarta.mail包名已经改成jakarta.mail但维护方和命名风格还没完全迁移。现在org.eclipse.angus:jakarta.mail这是 Eclipse Angus 项目发布的完整构件从 API 到协议实现都在这条坐标下。org.eclipse.angus:jakarta.mail下面其实还有更细分的构件比如纯 API 的jakarta.mail-api以及负责 SMTP、IMAP、POP3 等协议实现的angus-mail。但你日常使用不需要关心这些拆分直接引jakarta.mail这个聚合构件就可以Maven 会自动把相关子模块带进来。为什么拿 1.1.0 举例因为在这个系列里1.1.0 是一个比较主流、稳定的版本底层对应 Jakarta Mail API 2.1.x整体按 JDK 11 以上编译在 JDK 17、JDK 21 上跑也都正常。如果你只是搭个工具模块或者给 Spring Boot 3 这类工程补邮件能力这个版本值得优先试。当然如果你们工程里有统一的依赖管理也可以用 BOM 或父级 POM 锁版本这个后面会讲。2. Maven 依赖与源码获取实操2.1 Maven 依赖配置先看最核心的东西Maven 坐标。dependency groupIdorg.eclipse.angus/groupId artifactIdjakarta.mail/artifactId version1.1.0/version /dependency这一条依赖就够了。引入之后Maven 会自动把对应的 API 模块、协议实现模块以及jakarta.activation相关依赖一起拉下来。早期的javax.mail需要你手动补一个 activation jar现在这套组合已经不需要再关心那件事了。如果你用的是 Gradle写法也差不多implementation org.eclipse.angus:jakarta.mail:1.1.0很多刚切换过来的同学会问为什么不需要写jakarta.mail-api和angus-mail分开引原因很简单org.eclipse.angus:jakarta.mail是一个聚合型的完整构件它帮你把依赖关系都排好了。自己单独拆开引反而容易把版本弄乱比如 API 是 2.1.x实现却是另一个版本最后运行时行为会很奇怪。正常情况下保持一个坐标、一个版本号就好。提示如果你们的项目里有独立的依赖锁定机制比如拒绝直接写具体版本、全部由父 POM 统一管理注意确认父 POM 里锁的是不是同一套 Angus 版本避免实际生效版本和你想用的版本不一致。2.2 jar 包与源码获取方式有时候碰到奇怪的报错需要扒开 jar 看字节码或者读源码理解行为。jar 包和源码本身不需要特意去下载Maven 会自动从中央仓库拉。但如果你面对的是一个没有联网构建的环境或者想手动确认文件内容可以去 Maven 中央仓库对应目录下直接取curl -O https://repo1.maven.org/maven2/org/eclipse/angus/jakarta.mail/1.1.0/jakarta.mail-1.1.0.jar curl -O https://repo1.maven.org/maven2/org/eclipse/angus/jakarta.mail/1.1.0/jakarta.mail-1.1.0-sources.jar curl -O https://repo1.maven.org/maven2/org/eclipse/angus/jakarta.mail/1.1.0/jakarta.mail-1.1.0-javadoc.jar三个文件的区别需要弄清楚.jar是编译后的字节码运行时需要它-sources.jar是源码用来在 IDE 里跳进第三方类查看实现逻辑-javadoc.jar是 API 文档的 HTML 版本适合离线查阅。建议调试时都把源码下载下来IDEA 里会自动提示“Choose Sources”选中sources.jar之后按 Ctrl 加左键就能直接跳进MimeMessage的源码里排查问题效率会高很多。2.3 依赖树检查与传递依赖说明引入一个依赖后最忌讳的是云里雾里不知道实际拉了什么。用 Maven 插件查一下依赖树心里就有底了mvn dependency:tree -Dincludesorg.eclipse.angus正常你会看到类似这样的一组关系org.eclipse.angus:jakarta.mail:1.1.0org.eclipse.angus:jakarta.mail-api:2.1.3org.eclipse.angus:angus-mail:2.0.3org.eclipse.angus:angus-activationjakarta.activation:jakarta.activation-api解释一下这里为什么会有angus-mail和jakarta.mail-api前者是真正的协议实现管 SMTP 怎么连、IMAP 怎么抓、MIME 怎么解析后者是规范定义的接口定义Session、Message、Transport这些类型。运行时两者缺一不可。你不需要手动管理它们的版本直接看传递依赖即可。不过我还是要提醒一句子构件的版本号不一定和聚合构件完全一致。比如jakarta.mail是 1.1.0其内部的 API 模块可能是 2.1.x实现模块可能是 2.0.x。这是正常的不要觉得很奇怪它们各自有各自的版本节奏。3. API 核心接口与中英对照速查3.1 会话与消息Session / Messagejakarta.mail的 API 结构并不复杂核心对象就那么几个。先看两个最基础的。Session邮件会话是整个邮件操作的总入口。它负责读取配置文件、管理连接信息、持有认证器。可以把它理解成你整个应用和邮件服务器之间的一个会话上下文。Session.getInstance(props, authenticator)是常用的创建方式不过要注意Session不是轻量对象一个应用里保持单例通常更合理避免反复创建。Message邮件消息是“一封信”的抽象。它下面最重要的子类是MimeMessage实现了 MIME 格式的邮件消息。信要有发件人、收件人、主题、正文这些对应到代码里就是setFrom、setRecipients、setSubject、setText或setContent。不要直接 new 一个裸Message在 Jakarta Mail 里你几乎总是用MimeMessage。核心术语对照英文中文解释典型类/接口Session邮件会话全局配置与认证上下文jakarta.mail.SessionMessage邮件消息抽象一封信jakarta.mail.MessageMimeMessageMIME 格式邮件消息最常用实现jakarta.mail.internet.MimeMessageAddress邮件地址抽象jakarta.mail.AddressInternetAddress互联网邮件地址如某邮箱地址jakarta.mail.internet.InternetAddressAuthenticator认证器用于提供用户名密码jakarta.mail.AuthenticatorPasswordAuthentication用户名密码的简单载体jakarta.mail.PasswordAuthentication实际写代码时你不需要把这些接口全部记住关键是理解Session负责一切底层环境Message负责信封和内容剩下的操作都围绕这两个展开。3.2 发送与接收Transport / Store / Folder邮件不止发送一件事接收同样是刚需。发送侧看Transport接收侧看Store和Folder。Transport是传输服务负责把Message送到远端服务器。最简单的使用方式就是Transport.send(message)它会把消息交给正确协议的传输服务并通过SMTP协议发出去。如果你想在发送前单独建立连接也可以手动transport.connect()再sendMessage这样能复用连接提高性能。接收侧稍微复杂一点。Store代表远程消息存储的抽象连接到邮箱服务器之后通过它拿到Folder。Folder对应邮件目录最常见的就是收件箱INBOX。要读信基本路径是拿到 Store 连接、打开 INBOX、调用getMessages()获得Message[]数组。对照表如下英文中文解释典型方法Transport传输服务负责发送邮件Transport.send(message)Store远程邮箱存储用于接收邮件session.getStore(imap)Folder邮件文件夹如收件箱store.getFolder(INBOX)Multipart复合邮件体支持正文加附件new MimeMultipart()BodyPart邮件体的一部分MimeBodyPartFlags邮件状态标记已读、删除等message.setFlag(Flags.Flag.SEEN, true)3.3 常用配置属性速查表邮件 API 的行为高度依赖配置项这些配置项以mail.smtp.*或mail.imap.*的形式放在Properties里。它们不是代码里硬编码的常量只是普通的字符串 key写错一个字母运行时就会默默走默认策略然后给你一个不明所以的报错。常用属性整理如下配置 key作用说明mail.smtp.hostSMTP 服务器主机名mail.smtp.portSMTP 端口常用 25、465、587mail.smtp.auth是否需要认证通常为 truemail.smtp.starttls.enable是否启用 STARTTLS 加密mail.smtp.ssl.enable是否直接使用 SSL 连接mail.smtp.connectiontimeout连接超时时间单位毫秒mail.smtp.timeout读取超时时间单位毫秒mail.smtp.writetimeout写入超时时间单位毫秒mail.store.protocol接收协议值为 imap 或 pop3mail.imap.host/mail.imap.portIMAP 服务器地址和端口这些 key 在旧版javax.mail里也是这样迁移到jakarta.mail之后没有变化。唯一要注意的是starttls.enable和ssl.enable的语义不同前者是“先用明文连接再升级成 TLS”后者是“一开始就用 SSL比如 465 端口”。两者不要同时打开否则可能连接行为会很奇怪。4. 实操一段可跑的邮件收发代码4.1 发送一封纯文本邮件理论说再多不如直接跑一段代码。先写最基础、也最常见的纯文本邮件发送。这里拿常见邮箱服务商为例假设 SMTP 地址是smtp.example.comProperties props new Properties(); props.put(mail.smtp.host, smtp.example.com); props.put(mail.smtp.port, 587); props.put(mail.smtp.auth, true); props.put(mail.smtp.starttls.enable, true); Session session Session.getInstance(props, new Authenticator() { Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(senderexample.com, 授权码或密码); } }); // 建议开启调试第一次跑能看到完整 SMTP 对话 session.setDebug(true); MimeMessage message new MimeMessage(session); message.setFrom(new InternetAddress(senderexample.com)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(receiverexample.com)); message.setSubject(这是一封测试邮件, UTF-8); message.setText(Hello这是一封来自 Jakarta Mail 的测试邮件。, UTF-8); Transport.send(message); System.out.println(发送成功);这段代码有几个关键点值得展开。第一setDebug(true)在排查问题时非常有用它会在控制台打印客户端和服务器之间的整个 SMTP 对话过程认证失败、端口不通、TLS 协商失败都能看得一清二楚。生产环境记得关掉。第二InternetAddress.parse支持解析字符串形式的多个地址比如aexample.com, bexample.com它能自动按逗号拆分比手动一个个new InternetAddress方便。第三授权码。现在很多邮箱服务商在开启 SMTP 服务时不会让你直接用登录密码而是要求生成一个独立的授权码。如果认证一直报错先确认是不是这个原因。4.2 发送 HTML 邮件与附件实际业务里纯文本邮件用得太少。更多时候你需要发 HTML 内容或者带附件甚至两者同时存在。这里就涉及Multipart和BodyPart的用法。MimeMessage message new MimeMessage(session); message.setFrom(new InternetAddress(senderexample.com)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(receiverexample.com)); message.setSubject(带附件的 HTML 邮件, UTF-8); // 创建一个复合邮件体 MimeMultipart multipart new MimeMultipart(); // 第一部分HTML 正文 MimeBodyPart htmlPart new MimeBodyPart(); htmlPart.setContent(h1你好/h1p这是 bHTML/b 邮件。/p, text/html; charsetUTF-8); multipart.addBodyPart(htmlPart); // 第二部分附件 MimeBodyPart attachment new MimeBodyPart(); attachment.attachFile(new File(/path/to/report.pdf)); attachment.setFileName(MimeUtility.encodeText(季度报表.pdf, UTF-8, null)); multipart.addBodyPart(attachment); message.setContent(multipart); Transport.send(message);这里最容易踩的坑是只有setContent(multipart)才能把附件和 HTML 一起发出去。如果你先message.setText(...)再message.setContent(multipart)后者会覆盖前者这没什么问题但如果你只用setText而忘记setContent附件就永远不会出现在邮件里。setFileName这里用到了MimeUtility.encodeText它的作用是给中文文件名做 RFC 2047 编码。如果你直接塞一个中文文件名进去某些邮件客户端收到后附件名会显示成乱码或者一串?UTF-8?...的字符。这个细节非常容易被忽略。4.3 接收邮件与解析常用写法接收邮件的场景稍微少一些但在处理“退信通知”“邮箱自动处理”这类需求时是必备技能。IMAP 协议下读收件箱的基本写法如下Properties props new Properties(); props.put(mail.store.protocol, imap); props.put(mail.imap.host, imap.example.com); props.put(mail.imap.port, 993); props.put(mail.imap.ssl.enable, true); Session session Session.getInstance(props); Store store session.getStore(imap); store.connect(imap.example.com, 993, userexample.com, 授权码); Folder inbox store.getFolder(INBOX); inbox.open(Folder.READ_ONLY); Message[] messages inbox.getMessages(); for (Message message : messages) { System.out.println(主题: message.getSubject()); System.out.println(发件人: InternetAddress.toString(message.getFrom())); System.out.println(发送时间: message.getSentDate()); Object content message.getContent(); if (content instanceof String) { System.out.println(正文: content); } else if (content instanceof Multipart) { Multipart multipart (Multipart) content; for (int i 0; i multipart.getCount(); i) { BodyPart part multipart.getBodyPart(i); System.out.println(部件类型: part.getContentType()); } } } inbox.close(false); store.close();这段代码里getContent()的返回值类型是不确定的。纯文本邮件返回String带附件的邮件返回Multipart。判断类型再处理是最稳妥的写法直接强转会出ClassCastException。inbox.close(false)里的布尔参数表示是否在关闭前同步删除已标记的邮件。false意味着只关闭不删邮件这个参数建议默认用 false避免误删数据。4.4 中文乱码与字符集问题处理邮件开发绕不开中文乱码这个问题的根源在于 MIME 规范涉及的编码环节太多每个环节默认值又未必是 UTF-8。经验法则只有一条所有涉及字符串和文件名的入口都显式指定 UTF-8。标题要这样写message.setSubject(测试标题, UTF-8);正文要这样写message.setText(中文正文, UTF-8); // 或 htmlPart.setContent(htmlContent, text/html; charsetUTF-8);附件文件名要这样写attachment.setFileName(MimeUtility.encodeText(中文附件.pdf, UTF-8, null));如果是从邮件里读取内容遇到标题乱码可以尝试String subject MimeUtility.decodeText(message.getSubject());手动解码通常能救回一部分因为编码标记缺失而变乱码的标题。至于正文乱码多半是发送端没有声明 charset或者接收端用的默认字符集不对这种只能从发送端下手规范修正。5. 常见问题与排查技巧实录5.1 依赖冲突与包名错位升级到jakarta.mail之后最常见的问题就是编译期一切正常运行时直接NoClassDefFoundError: jakarta/mail/Message。这种情况大概率是依赖冲突工程里同时存在新旧两套邮件坐标旧的com.sun.mail:javax.mail对应javax.mail.*。新的org.eclipse.angus:jakarta.mail对应jakarta.mail.*。如果你的代码写的是import jakarta.mail.*但某个间接依赖把旧的javax.mail也带进来了两者并不会冲突到编译失败但类加载器里可能只有旧实现于是运行时报错。排查方法还是看依赖树mvn dependency:tree -Dincludescom.sun.mail如果发现旧的坐标就在你主动引入的依赖上排除掉dependency groupIdcom.example.something/groupId artifactIdsome-old-library/artifactId exclusions exclusion groupIdcom.sun.mail/groupId artifactIdjavax.mail/artifactId /exclusion /exclusions /dependency5.2 认证失败与 TLS 异常认证失败是另一个高频问题典型报错是javax.mail.AuthenticationFailedException: 535 5.7.8 Authentication credentials invalid先检查三件事用户名是不是完整邮箱、密码是不是授权码而不是登录密码、账号是否真的开启了 SMTP 服务。很多服务商默认不会帮你开启 SMTP需要在网页端设置里手动打开。另一个是 TLS 异常常见现象是连接被服务器断开或SSLHandshakeException: Received fatal alert: handshake_failure。这通常是 JDK 支持的 TLS 协议版本和服务器要求对不上。可以在props里显式指定props.put(mail.smtp.ssl.protocols, TLSv1.2);如果还不行就要考虑是不是 JRE 的信任库没有导入服务器证书。自建邮件服务器很常见这个问题解决方案是导出服务器证书并导入到 JDK 的 cacerts 里或者用自定义的SSLContext配合SSLSocketFactory实现更灵活的信任策略。5.3 连接超时与端口选错端口选错问题通常表现为“连接不上”或“卡很久才超时”。这里有一个行业背景需要知道很多云服务器厂商默认封掉 25 端口用来防止垃圾邮件。所以在公网环境里发信优先考虑 587STARTTLS或 465SSL而不是裸的 25。为了防止连接阶段无限等待一定要设置超时props.put(mail.smtp.connectiontimeout, 5000); props.put(mail.smtp.timeout, 5000); props.put(mail.smtp.writetimeout, 5000);单位是毫秒设成 5000 表示 5 秒。这三个值分别管连接建立、读取响应、写入数据三个阶段。没有超时配置的邮件模块在邮件服务器不可达时可能挂起几十秒甚至更久这在生产环境里是非常糟糕的体验。常见问题速查表问题现象快速排查方向依赖冲突运行时报 NoClassDefFoundError查 dependency:tree 是否有 com.sun.mail认证失败535 或 534 错误码确认账号、授权码、SMTP 开关TLS 失败SSLHandshakeException指定 ssl.protocols检查信任库连接超时长时间无响应检查端口、封禁策略、超时配置中文乱码标题或文件名为乱码显式 UTF-8encodeText 编码附件名附件丢失收到邮件但只有正文检查是否用了 setContent(multipart)6. 从旧版本迁移的实际体会6.1 改包名之后需要同步调整的点如果是从javax.mail迁到jakarta.mail表面上看只是换依赖和改 import实际动起来要注意的细节不少。第一所有import javax.mail.*要变成import jakarta.mail.*包括子包比如javax.mail.internet.InternetAddress变成jakarta.mail.internet.InternetAddress。这个用 IDE 的全局替换就能做但替换完之后最好全文搜索一下javax.mail字符串确认没有漏网之鱼。第二Maven 坐标要彻底替换。如果旧坐标是通过间接依赖进来的得在依赖树里确认它已经被排除干净。否则会有两套类同时存在于 classpath虽然编译期不报错运行时却可能出现诡异问题。第三如果项目里使用了一些框架自带的邮件starter注意框架管理的版本和你引入的版本可能不一样。以框架的依赖为准是常规做法但前提是框架已经适配了jakarta.mail新坐标。6.2 几个我一直在用的实用小习惯邮件模块看起来简单但真的出问题时排查成本不低。说几个我自己的习惯。第一个习惯是无论如何都保留session.setDebug(true)的开关入口。用配置项控制线上出问题时可以先开 debug 看日志而不是重新改代码发一版。当然debug 日志会打印完整的邮件内容隐私敏感场景要谨慎临时开启后记得关掉。第二个习惯是Session尽量复用。创建一个Session的成本虽然不高但在高并发发信场景下每次都重建会产生大量不必要的对象和连接。正确做法是把Session做成单例Transport.send(message)内部会管理连接比手动反复连接再断开干净很多。第三个习惯是谨慎处理附件文件名。很多线上乱码问题都出在setFileName没有做 MIME 编码。只要文件名可能含中文就用MimeUtility.encodeText包一层这个习惯可以帮你避开一大半的“中文文件名乱码”抱怨。最后再分享一个排查思路邮件发不出去时不要急着看代码先打开 debug 日志找到 SMTP 服务器返回的具体错误码。连接被拒、认证失败、TLS 协商失败这几个问题在 SMTP 响应里非常明确跟着错误码走比瞎猜配置快得多。邮件这块功能平时不起眼但一旦坏了就是线上事故把依赖和编码基础打扎实能省下不少半夜值班的心力。
阅读完成 · 觉得有帮助?