简介:这是一份面向Java开发者、企业级应用开发人员及自动化流程设计者的邮件发送实战资源,聚焦于通过JavaMail API实现带正文与附件的邮件发送功能。资源包共41个文件,以9个java源码、11个class编译文件、7个jar依赖库为主,另含xml配置、log日志及html页面等辅助文件,压缩包约1.33MB,结构完整可直接导入运行。已有816人学习下载,适合需要快速掌握SMTP配置、Session会话管理、MimeMessage与Multipart组合、附件添加及Transport发送等核心知识点的初中级开发者。通过研读源码,读者可理解邮件正文与附件混合发送的完整实现逻辑,掌握认证配置、异常处理与调试模式开启等实用技巧,并参考其目录组织方式构建自己的邮件发送模块,也可在此基础上扩展HTML正文、抄送密送及邮件头定制等进阶功能。
1. Java 发送邮件带附件:从 JavaMail 到 Jakarta Mail 的选型与落地
很多项目里都有这样一个需求:系统跑完批处理,要把结果报表发给运营;用户注册成功,要发一封带 PDF 的欢迎信;监控告警触发,要把日志文件当附件甩出去。这些场景用 Java 实现,绕不开的就是邮件协议和附件编码。我见过不少团队在这件事上翻车——本地跑得好好的,一上生产附件就乱码,或者被邮件服务商直接拒收。问题往往不在业务代码,而在对 MIME 结构和 SMTP 交互的理解。这篇笔记拆的是一个「Java 发送邮件内容+附件」的完整实现资源,覆盖纯文本、HTML 正文、多附件、中文文件名、SSL/TLS 连接这些实际会遇到的点。适合正在做通知模块的后端,也适合想搞清楚邮件底层结构的人。下面从依赖选型开始,一步步把可复现的代码和参数讲透。
2. 依赖选型与 MIME 结构:为什么你的附件会变成一串乱码
2.1 JavaMail 与 Jakarta Mail 的版本分水岭
Java 发邮件这件事,底层标准是 SMTP、POP3、IMAP,但 Java 侧的操作接口经历过一次命名空间迁移。早期用的是javax.mail,对应 artifact 是javax.mail:javax.mail-api加com.sun.mail:javax.mail实现。从 Jakarta EE 9 开始,包名整体迁移到jakarta.mail,artifact 变成jakarta.mail:jakarta.mail-api和org.eclipse.angus:angus-mail(Angus Mail 是 Jakarta Mail 的参考实现,接替了原来的 Sun 实现)。
选型上有个硬边界:如果你的项目还在 JDK 8 且短期内不升级,用javax.mail最稳,社区资料多,坑都被踩平了。如果项目已经上了 JDK 17 或 Spring Boot 3.x,那必须用jakarta.mail,因为 Spring 的JavaMailSender在 6.x 里已经切到 jakarta 命名空间,混用会直接ClassNotFoundException。我一般会先看项目里 Spring Boot 的版本,2.x 配 javax,3.x 配 jakarta,不要试图在一个工程里同时引两套。
Maven 依赖这样写(以 jakarta 为例):
<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> <scope>runtime</scope> </dependency>jakarta.mail-api只提供接口,angus-mail是运行时实现,scope 设成 runtime 就够了,编译期不需要。版本号不要照抄,去中央仓库看当前稳定版,我这里写的是写稿时的常见版本。
2.2 MIME 多部分结构:附件不是「贴上去」的
很多人以为发附件就是调一个setAttachment方法,实际上 JavaMail 里没有这种 API。附件的本质是把邮件体构造成一个Multipart容器,里面装若干BodyPart:第一个是正文(纯文本或 HTML),后面每个附件是一个独立的BodyPart,通过DataHandler包装文件流,并设置Content-Disposition: attachment; filename=...。
如果正文和附件没有正确分层,就会出现两种典型故障:一是附件内容被当成正文显示出来,满屏 base64 乱码;二是附件能收到但文件名变成=?UTF-8?B?...?=这种编码串。前者是Multipart的 subtype 用错了,后者是文件名没有做 RFC 2231 编码。理解这个结构,后面写代码就是填空。
常见的 MIME 类型对应关系:
| 场景 | Multipart subtype | 正文 BodyPart 类型 |
|---|---|---|
| 纯文本 + 附件 | mixed | text/plain |
| HTML + 附件 | mixed | text/html |
| HTML 内嵌图片 + 附件 | related 嵌套在 mixed 内 | text/html + image/* |
| 纯文本 + HTML 双版本 | alternative | text/plain + text/html |
大部分「正文+附件」的需求,用mixed就够了。只有正文里要内嵌图片(比如邮件签名里的 logo)才需要related,而且通常是mixed里套一个related,结构别搞反。
2.3 会话与连接参数:别把密码硬编码进代码
Session对象承载了 SMTP 服务器地址、端口、认证信息。生产环境绝对不要把授权码写在代码里,常见做法是走配置中心或环境变量。下面是一个典型的 Session 构建:
Properties props = new Properties(); props.put("mail.smtp.host", "smtp.example.com"); props.put("mail.smtp.port", "465"); props.put("mail.smtp.auth", "true"); props.put("mail.smtp.ssl.enable", "true"); // 465 端口用隐式 SSL props.put("mail.smtp.connectiontimeout", "5000"); // 连接超时,毫秒 props.put("mail.smtp.timeout", "5000"); // 读超时 props.put("mail.smtp.writetimeout", "5000"); // 写超时 Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(user, password); } });参数说明:465 端口是 SMTPS,走mail.smtp.ssl.enable=true;587 端口是 STARTTLS,走mail.smtp.starttls.enable=true,两者不能混。超时三个参数建议都设,默认是无限等待,网络抖动时线程会一直挂住。Session.getInstance和getDefaultInstance的区别在于后者会复用,多账号场景下容易串,我一般用getInstance。
3. 正文与附件组装:一份能直接抄的发送代码
3.1 构建 MimeMessage 的完整流程
下面这段代码覆盖了 HTML 正文加两个附件的场景,中文文件名做了编码处理。可以直接拿去改服务器地址和账号。
import jakarta.mail.*; import jakarta.mail.internet.*; import jakarta.activation.DataHandler; import jakarta.activation.FileDataSource; import java.io.File; import java.util.Properties; public class MailSender { public void sendWithAttachments(String host, String user, String pass, String to, String subject, String htmlBody, File[] attachments) throws Exception { Properties props = new Properties(); props.put("mail.smtp.host", host); props.put("mail.smtp.port", "465"); props.put("mail.smtp.auth", "true"); props.put("mail.smtp.ssl.enable", "true"); props.put("mail.smtp.connectiontimeout", "5000"); props.put("mail.smtp.timeout", "5000"); Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(user, pass); } }); MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress(user)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to)); message.setSubject(subject, "UTF-8"); // 正文部分 MimeBodyPart bodyPart = new MimeBodyPart(); bodyPart.setContent(htmlBody, "text/html; charset=UTF-8"); // 整个邮件是一个 mixed 容器 MimeMultipart multipart = new MimeMultipart("mixed"); multipart.addBodyPart(bodyPart); // 逐个追加附件 for (File file : attachments) { MimeBodyPart attachPart = new MimeBodyPart(); FileDataSource source = new FileDataSource(file); attachPart.setDataHandler(new DataHandler(source)); // 用 MimeUtility 编码中文文件名,避免收件端乱码 attachPart.setFileName(MimeUtility.encodeText(file.getName(), "UTF-8", "B")); multipart.addBodyPart(attachPart); } message.setContent(multipart); Transport.send(message); } }逻辑说明:先建正文MimeBodyPart,setContent第二个参数必须带 charset,否则中文正文在部分客户端会乱。然后建MimeMultipart("mixed"),先加正文再加附件,顺序不能反——正文必须是第一个 part,否则某些客户端会把第一个附件当正文预览。附件用FileDataSource包装,setFileName外面套MimeUtility.encodeText,这是中文文件名不乱码的关键。最后Transport.send内部会自动 connect、send、close,如果要做连接复用,可以手动Transport.connect后循环发。
参数上要注意:setSubject也要指定 UTF-8,不然标题里的中文同样会乱。InternetAddress.parse支持逗号分隔的多个收件人,但生产环境建议对地址做校验,非法地址会抛AddressException。
3.2 附件大小与流式处理
上面用的是FileDataSource,文件会整个读进内存再编码。附件超过 10MB 时,堆内存压力明显,尤其是并发发送场景。常见做法是改用ByteArrayDataSource配合流式读取,或者直接对文件流做分块编码。但要注意,SMTP 协议本身对单封邮件大小有限制,多数服务商在 25MB 到 50MB 之间,超过会被拒。我的经验是单封邮件附件总量控制在 10MB 以内,大文件走对象存储生成临时链接,把链接放正文里,而不是硬塞附件。
如果确实要发大附件,可以设置mail.smtp.allow8bitmime=true减少 base64 膨胀(base64 会让体积增大约 33%),但需要服务端支持 8BITMIME 扩展。不确定的话就保持默认。
3.3 抄送、密送与回复地址
实际业务里经常要抄送主管、密送归档邮箱。MimeMessage提供了对应的 RecipientType:
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to)); message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(cc)); message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(bcc)); message.setReplyTo(InternetAddress.parse(replyTo));CC 和 BCC 的区别在于 BCC 不会出现在邮件头里,适合归档或不想让收件人互相看到地址的场景。setReplyTo用于设置回复地址,和 From 可以不同,比如系统发信用 noreply 发件,但回复指向客服邮箱。这几个方法都支持多个地址,用逗号分隔的字符串传进去即可。
4. 避坑与排查:附件乱码、被拒收、超时的真实原因
4.1 中文附件名变成编码串
现象:收件人收到的附件文件名显示为=?UTF-8?B?5rWL6K+V?=这样的字符串,而不是中文。
原因:setFileName直接传了原始中文名,没有做 RFC 2231 或 RFC 2047 编码。不同邮件客户端对未编码文件名的解析行为不一致,有的能猜对,有的直接显示原始字节。
解决:统一用MimeUtility.encodeText(fileName, "UTF-8", "B")包一层。B 表示 base64 编码,也可以用 Q 表示 quoted-printable,中文场景下 B 更稳。注意编码后的文件名长度会增加,超长文件名可能被截断,建议业务侧限制文件名在 100 字符以内。
4.2 邮件被服务商拒收或进垃圾箱
现象:代码没报错,Transport.send正常返回,但收件人没收到,或者进了垃圾邮件文件夹。
原因:常见有三类。一是发件人地址和认证账号不一致,被 SPF 校验拦截;二是邮件主题或正文含敏感词、大量链接,触发内容过滤;三是发送频率过高,被判定为垃圾邮件行为。
解决:发件人地址必须和 SMTP 认证账号的域名一致,或者该域名在服务商处配置了 SPF 记录。批量发送时加间隔,比如每封之间 sleep 200 到 500 毫秒。正文里避免短链接和夸张营销词。如果是企业邮箱,找管理员确认发信额度,很多服务商对单账号有每小时上限。
4.3 连接超时或 SSL 握手失败
现象:抛MessagingException: Could not connect to SMTP host或SSLHandshakeException。
原因:端口和加密方式不匹配是最常见的。465 端口必须用ssl.enable=true,587 端口必须用starttls.enable=true,两个都开或都不开都会失败。另外 JDK 版本过低时,TLS 1.2 以下的协议被服务端禁用,也会握手失败。
解决:先确认服务商要求的端口和加密方式,然后检查 JDK 版本,JDK 8 早期版本需要手动开启 TLS 1.2。超时参数一定要设,否则网络不通时线程会永久阻塞。排查时可以把mail.debug设为 true,控制台会打印完整的 SMTP 交互过程,能看到卡在哪一步。
4.4 附件收到但打不开
现象:附件能下载,但打开提示文件损坏,或者大小变成 0。
原因:一是发送时文件流被提前关闭,FileDataSource在Transport.send之前文件被删除或移动;二是编码方式和服务端不兼容,8bit 和 base64 混用导致内容截断。
解决:确保发送期间文件不被其他线程改动,必要时先复制到临时目录。不要手动设置Content-Transfer-Encoding,让 JavaMail 根据内容自动选择。如果必须指定,统一用 base64。
4.5 正文里的 HTML 被当成纯文本显示
现象:收件人看到的是<h1>标题</h1>这样的源码,而不是渲染后的样式。
原因:setContent的第二个参数没写text/html,或者写成了text/plain。
解决:正文 part 必须显式声明text/html; charset=UTF-8。如果同时要兼容不支持 HTML 的老客户端,可以用alternative容器装纯文本和 HTML 两个版本,客户端会自动选支持的版本显示。
5. 进阶技巧:连接复用、异步发送与发送结果校验
5.1 连接复用降低握手开销
Transport.send每次都会新建连接、认证、发送、关闭。批量发信时这个开销很可观。可以手动管理 Transport:
Transport transport = session.getTransport("smtp"); transport.connect(host, user, pass); try { for (MimeMessage msg : messages) { transport.sendMessage(msg, msg.getAllRecipients()); } } finally { transport.close(); }这样一次连接发多封,认证只做一次。注意sendMessage的第二个参数是收件人数组,必须显式传,否则不会发送。连接复用时要处理异常,某封失败后连接可能已断开,需要重连。
5.2 异步发送与线程池
邮件发送是 IO 密集型操作,放在主流程里会拖慢接口响应。常见做法是丢到线程池异步执行,配合重试。但要注意线程池大小,SMTP 服务商通常对并发连接有限制,一般 5 到 10 个线程足够。重试策略上,认证失败不要重试,网络超时可以重试 2 到 3 次,间隔递增。
5.3 发送结果校验
Transport.send不抛异常只代表服务端接受了邮件,不代表对方一定收到。要确认投递结果,只能靠退信(NDR)或者查服务商的发送日志。如果业务要求强一致,可以在发送后记录 message-id,通过 IMAP 去查已发送文件夹,但这会引入额外复杂度。多数场景下,捕获MessagingException并记录日志就够了。
5.4 一个容易忽略的细节:邮件大小限制
不同服务商对单封邮件大小限制不同,有的是 25MB,有的是 50MB。附件 base64 编码后体积会膨胀约三分之一,所以实际能发的原始文件比限制值要小。我一般会在发送前算一下所有附件总大小,超过阈值就拒绝并提示走其他通道。这个校验放在业务层,比等 SMTP 报错再处理要友好得多。
从那以后我每次写邮件发送模块,都会先把 MIME 结构在草稿纸上画一遍,确认正文和附件的层级关系,再动手写代码。中文文件名编码和超时参数这两项,我强制自己每次都检查一遍,因为这两个坑踩一次就要花半天排查。希望帮到你。
本文还有配套的精品资源,点击获取