简介:面向Java开发者的邮件发送示例项目,围绕SMTP协议与JavaMail API,完整演示带正文和附件的邮件发送流程,适合企业应用开发、自动化通知及Java网络编程学习者参考。压缩包共41个文件、1.33MB,包含9个java源码、11个class编译文件、7个jar依赖库,以及xml、log等配置与运行日志,目录下src、WebContent、build等结构清晰,便于导入工程查看和调试。已有816人学习,说明该示例具备一定实用参考性。内容覆盖Session创建、MimeMessage构建、Multipart与MimeBodyPart添加附件、Transport发送等关键环节,同时延伸说明HTML正文、抄送/密送、自定义邮件头等进阶定制,并给出SMTP配置、认证及调试模式排错思路。项目结构完整,附有编译后的class与运行日志,可帮助读者快速掌握JavaMail核心用法并复用现有代码。
1. Java 实现发送邮件:一个看似简单却细节密集的「正文 + 附件」工程
给一批用户发带 PDF 附件的月报、给运维同学发磁盘告警、给团队发每周统计——这些场景背后都离不开同一件事:Java 实现发送邮件。Java 发送邮件看着简单,把 SMTP 地址一填、调个 API 就完事。可真要把「正文 + 附件」这个组合做扎实,里面藏着不少坑:授权码怎么拿、465 和 587 用哪个、附件中文名怎么编码、发出去会不会被扔进垃圾箱。这份资源就是把这套完整链路拆开讲:从依赖选型、配置编写、核心代码到本地跑通与二次改造,适合刚接手邮件模块的开发者,也适合想把自己的工具类升级成稳定服务的熟手。
2. 技术栈与工程结构:javax.mail 选型、目录骨架与 SMTP 配置项
2.1 依赖选择:javax.mail 还是 jakarta.mail
Java 发邮件绕不开 JavaMail,但 JavaMail 在最近几年有一次明显的分叉。老教程、老项目里几乎全是javax.mail包名配com.sun.mail:javax.mail:1.4.7这个坐标;我自己早期做的报表邮件模块用的就是这个组合。如果你在 Java 8 项目里做内部工具,1.4.7 确实能跑,但它在 TLS 协商和 MIME 编码处理上偏老,遇到某些服务商强制要求 STARTTLS 时容易卡住。所以这份资源里用的是 1.6.2:Maven 坐标不变,包名还是javax.mail,API 与老版本完全兼容,可以平滑替换。
如果是新起的 Jakarta EE 项目,比如 Spring Boot 3.x 或者 JDK 17 起步,就要选jakarta.mail2.x 系列,包名从javax.mail改成jakarta.mail,同时还要带一个 angus-activation 依赖处理 MIME 类型。两条技术路线的核心代码逻辑一致,差异只在 import 语句和坐标上。判断标准很简单:先看项目里有没有javax.servlet,如果整个工程已经 Jakarta 化了,就不要再引javax.mail老坐标,否则类加载器里会出现两个签名不同的 MimeMessage,运行时到处报NoSuchMethodError。
2.2 工程目录结构:一个最小可运行的项目长什么样
一个最小可运行的邮件 Demo 不需要 Spring,不需要 Web 容器。我拆过的这类工程里,标准骨架是这几个文件:
mail-demo/ ├── pom.xml └── src/main/ ├── java/com/demo/mail/ │ ├── MailSender.java # 主流程:构建并发送邮件 │ ├── MailConfig.java # 从配置读取 SMTP 参数 │ └── AttachmentTest.java # 生成一个测试附件(可选) └── resources/ └── mail.properties # SMTP 主机、端口、账号、授权码MailSender 负责整个发送链路,MailConfig 负责把 properties 读成 Session 需要的键值,AttachmentTest 这个类可有可无——我一般会留一个,因为自测时需要固定的文件当附件,手工找文件太麻烦。整套骨架不到四个类,根因是 JavaMail 本身足够薄,不需要额外抽象。这份资源附带的工程骨架就是按这个结构组织的,下载后直接导入 IDE 就能跑。
对应的 pom.xml 只引一个依赖就够(Java 8 版本):
<dependency> <groupId>com.sun.mail</groupId> <artifactId>javax.mail</artifactId> <version>1.6.2</version> </dependency>activation 会被传递引入,不需要单独声明。maven-compiler-plugin 的 source/target 设为 1.8,同时把 encoding 设成 UTF-8,避免不同系统上编译出的中文乱掉。如果你用 JDK 17,把坐标换成jakarta.mail:jakarta.mail-api:2.1.3加org.eclipse.angus:angus-mail即可。
2.3 核心配置字段:SMTP 参数不只是 host 和 port
SMTP 配置不是把 host 填上就能完事。这个项目里我把配置拆成六项,每一项都在 mail.properties 里给了默认值:
| 配置键 | 示例值 | 说明 |
|---|---|---|
| mail.smtp.host | smtp.example.com | 邮箱服务商的 SMTP 地址,开启 SMTP 服务后能看到 |
| mail.smtp.port | 465 | 465 走 SSL,587 走 STARTTLS,25 常被服务商限制 |
| mail.smtp.auth | true | 开启认证,绝大多数服务商要求 |
| mail.smtp.ssl.enable | true | 使用 465 端口时必须为 true |
| mail.smtp.connectiontimeout | 5000 | 建连超时,单位毫秒 |
| mail.smtp.timeout | 10000 | 读取超时,网络不稳时能救命 |
这些键是 JavaMail 自己的标准属性名,MailConfig 读取时直接塞进 Properties 传给 Session。有两个容易混淆的键单独提一下:mail.smtp.starttls.enable和mail.smtp.ssl.enable是两套机制,前者是在 587 端口上先明文握手再升级 TLS,后者是直接建立 SSL 隧道;不要同时开,部分服务商看到这种握手方式会直接拒绝连接。另外mail.debug也建议放进配置,默认 false,排查问题时改成 true,控制台会打出完整的 SMTP 对话记录,包括服务端返回的每一行状态码。生产环境千万别开,日志会带出邮件正文和收件人地址,隐私角度很不合适。
2.4 邮箱服务商的差异:最容易翻车的地方
不同邮箱服务商的 SMTP 行为差异,是新手最容易踩坑的位置。首先,绝大多数免费邮箱要求先到网页端「开启 SMTP 服务」,然后生成一个授权码;第三方客户端登录时填的密码必须是这个授权码,而不是邮箱本身的登录密码,服务端一看密码不对,直接回 535。其次,企业邮箱往往有独立的 SMTP 出口域名,端口也不一定是常规的 465/587,要去管理后台看配置文档。再者,部分服务商对单封邮件大小有限制,常见的是 25MB 左右,超过就拒收。这类限制通常写在服务商帮助文档里,配置附件大小之前先确认一下,能省去半夜被运维叫起来排查的麻烦。
3. 核心模块拆解:带附件邮件从 Properties 配置到 Transport 发送的完整链路
3.1 第一步:配置 Properties,把连接参数交给 JavaMail
JavaMail 的入口是 Session,而 Session 的出生需要一份 Properties——所有 SMTP 行为都由这些键值对控制。最直接的写法是逐个 put:
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"); props.put("mail.smtp.connectiontimeout", "5000"); props.put("mail.smtp.timeout", "10000"); props.setProperty("mail.debug", "true"); // 调试时开,生产环境必须关有一个细节值得说:props 里所有值都是字符串,即使端口写成整数,JavaMail 内部也会做转换,所以不用纠结"465"和 465 的区别。mail.debug打开后,JavaMail 会在标准输出打印每一行 SMTP 命令和服务端响应,这是排查发送失败最直接的窗口。如果用的是老版本 JavaMail,可能还需要额外配一行mail.smtp.socketFactory.class指向javax.net.ssl.SSLSocketFactory;1.6.2 开了 ssl.enable 之后这行可省,但保留它兼容老服务商是常见做法。
3.2 第二步:创建 Session 和 Authenticator
Session.getInstance 接收 Properties 和一个 Authenticator 回调。服务商要求认证时,这个回调返回的 PasswordAuthentication 会在握手阶段被 JavaMail 自动提交:
Session session = Session.getInstance(props, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { // 授权码别硬编码,建议从环境变量或配置中心读取 return new PasswordAuthentication("sender@example.com", "这里填授权码"); } });Authenticator 的特点是:它不是一个预先写死密码的对象,而是在需要认证时被回调。这意味着你可以把授权码放到密钥管理平台,运行时动态取出来塞进去,代码里不落明文。另外注意session.setDebug(true)和 props 里的mail.debug效果相同,但前者必须在创建 Session 后立即调用,放在 getInstance 之前不会生效。调试完记得关掉。
3.3 第三步:构建 MimeMessage,处理发件人、收件人和中文主题
消息主体是 MimeMessage。一个消息最少要有发件人、收件人、主题三项:
MimeMessage message = new MimeMessage(session); message.setFrom(new InternetAddress("sender@example.com")); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse("user1@example.com,user2@example.com")); message.setSubject("月度报表:6 月销售数据", "UTF-8"); // 带 charset 的重载,避免主题乱码setSubject 我建议永远用带 charset 的重载,中文主题才不会在部分客户端里显示成问号。收件人解析用InternetAddress.parse而不是挨个new InternetAddress:前者可以一次解析逗号分隔的多个地址,并且会过滤掉格式明显非法的地址——但它只做语法解析,不做真实性校验,发给不存在的域名要等服务端回执才知道失败。如果需要抄送和密送,把第一个参数换成Message.RecipientType.CC或Message.RecipientType.BCC即可。
3.4 第四步:用 MimeMultipart 把正文和附件组装到一起
正文和附件不是两个独立属性,而是被组装进同一个 MimeMultipart 容器,再通过 setContent 挂到 message 上。这一步是最容易理解错的地方:
MimeMultipart multipart = new MimeMultipart(); // 默认 mixed 类型 MimeBodyPart textPart = new MimeBodyPart(); textPart.setText("6 月销售数据请查收附件,如有问题请联系我。", "UTF-8"); multipart.addBodyPart(textPart); MimeBodyPart filePart = new MimeBodyPart(); filePart.attachFile(new File("/tmp/report_202506.pdf")); // 显示文件名必须做 MIME 编码,否则中文名在收件端变成乱码 filePart.setFileName(MimeUtility.encodeText("6月销售报表.pdf", "UTF-8", "B")); multipart.addBodyPart(filePart); message.setContent(multipart);textPart 用 setText 指定 UTF-8,避免纯文本正文在部分客户端里按 GBK 解码导致乱码。filePart 先attachFile把本地文件读进来,再通过setFileName覆盖最终显示名——这两步是独立的:attachFile 负责文件内容,setFileName 负责收件人看到的文件名。中文文件名必须用MimeUtility.encodeText包装,否则传输层会把中文按平台默认编码处理,到收件箱就变成一串等号开头的乱码。MimeMultipart 默认的 subtype 是 mixed,适合「正文 + 附件」结构;将来如果要做正文里直接显示图片而不当附件下载,构造函数要换成new MimeMultipart("related"),把图片 bodyPart 的 disposition 设为 INLINE,再通过 cid 引用。
3.5 第五步:发送与收尾,静态 Transport.send 还是手动连接
最终发送有两个层次的选择。最简单的是静态调用:
Transport.send(message); // 低频发送,内部自动完成连接-认证-发送-断开Transport.send 内部会走完整的连接、认证、发送、断开流程,发送完自动释放连接,适合低频、一次性发送。缺点是每次调用都新建连接;如果你要循环给几百个收件人发不同附件,这种写法会因为频繁握手被服务商限流。
第二种是手动管理 Transport:
try (Transport transport = session.getTransport("smtp")) { // Service 实现了 AutoCloseable transport.connect("smtp.example.com", 465, "sender@example.com", "授权码"); transport.sendMessage(message, message.getAllRecipients()); }JavaMail 1.6 里 Service 实现了 AutoCloseable,所以 try-with-resources 可以直接用,连接用完自动关闭。手动 connect 的好处是复用连接,批量发送时性能明显更好;同时sendMessage抛出的SendFailedException里带 validSent、validUnsent、invalidAddresses 三个列表,能精确统计哪些地址成功了、哪些没发出去。生产环境的批量发送模块,我强烈建议走第二种——那年线上批量通知因为频繁建连被限流的事,我不想再经历第二次。如果手动发送前改过 message 内容,稳妥起见先调一次message.saveChanges(),让内容变更落进消息结构。
4. 本地跑通:从配置授权码到收到第一封真实邮件
4.1 准备阶段:确认环境与授权码
跑这个项目不需要复杂的中间件,本地一个 JDK 8+、一个 Maven 3.6+ 就够。JDK 8 配 javax.mail 1.6.2,JDK 17 或更高就切 jakarta.mail 2.x 那套坐标,两种组合代码大同小异。剩下的关键准备在邮箱那边:登录邮箱网页端,找到设置里的 SMTP 服务开关,打开它,然后生成一个授权码。授权码是第三方客户端专用凭据,网页登录密码在这里不好使,服务端校验的就是它。
我建议准备两个邮箱:一个当发件人,一个当收件人,或者同一邮箱自己发给自己也行。自己发自己收能跑通但看不出投递耗时,两个不同域名的邮箱互发更接近真实场景。把发件邮箱地址、授权码、SMTP 地址三个值记下来,下一步直接替换。
4.2 修改配置:把占位符换掉
项目里 mail.properties 是唯一必须改的地方:
mail.smtp.host=smtp.example.com mail.smtp.port=465 mail.smtp.auth=true mail.smtp.ssl.enable=true mail.smtp.connectiontimeout=5000 mail.smtp.timeout=10000 mail.from=sender@example.com mail.user=sender@example.com mail.password=这里填授权码 mail.debug=false注意几个值的对应关系:mail.smtp.host不是网页端邮箱地址,而是 SMTP 专用服务地址;mail.user一般直接填完整发件邮箱地址;mail.password填授权码而不是邮箱登录密码。MailConfig.java 做的事就是把 properties 读进来,转成第 3.1 节里的 props 键。别在 Java 代码里再写一份配置,两份配置一旦不一致,你会花一个下午找为什么连不上。
注意:如果服务商端口是 587,把
mail.smtp.ssl.enable改成 false,加上mail.smtp.starttls.enable=true。
4.3 启动与运行:三种跑法
IDE 直接运行 MailSender 的 main 方法最简单,适合第一次验证。Maven 方式适合命令行环境:
mvn compile exec:java -Dexec.mainClass="com.demo.mail.MailSender"这种方式要求 pom 里声明了 exec-maven-plugin,或者本地 Maven 版本自带该插件;如果没有,命令行会报找不到主类,IDE 里却一切正常。粘到服务器验证时我一般先打包再跑:
mvn clean package -DskipTests java -cp target/mail-demo-1.0-SNAPSHOT.jar:target/dependency/* com.demo.mail.MailSender第二种方式需要先把依赖也打进去,或者用 maven-dependency-plugin 把依赖复制到指定目录再拼 classpath,否则运行时会报 ClassNotFoundException。
4.4 如何确认真的成功
运行后控制台出现「邮件发送成功」只是第一步,真正的验证在收件箱:主题能正常显示中文而不是乱码;正文排版正常;附件存在且文件大小和本地一致。打开附件对比一下文件大小,能排除传输过程中被转码的可能性。如果收件箱没有,去垃圾箱看一眼——很多免费邮箱会把来自陌生域名的邮件直接归入垃圾箱,这是正常的反垃圾策略,不代表代码有问题。
第一次跑如果不顺,最可能的位置还是配置:SMTP 地址抄错、授权码多复制了一个空格、465 端口被本地安全软件拦截。把 mail.debug 改成 true 再跑一次,看日志里握手阶段的状态码,比逐个猜测快得多。跑通之后把 debug 关回 false,避免日志里出现收件人地址和正文内容。
5. 避坑指南:SMTP 发送邮件实践里的典型翻车现场
5.1 认证失败 535:授权码还是密码,这是个问题
现象:连接成功,日志里出现535 Error: authentication failed,或者直接抛javax.mail.AuthenticationFailedException。
原因:账密不对。绝大多数免费邮箱的第三方客户端登录,要求的不是网页登录密码,而是后台生成的授权码;另一种常见情况是复制授权码时带进了行尾空格,肉眼完全看不出来。
解决:回到邮箱后台重新生成一次授权码,粘贴时检查首尾有没有空格;确认mail.user和mail.from是同一个邮箱地址,有些服务商两者不一致时也报 535。改完把 mail.debug 打开,日志里会显示认证使用的用户名,一眼就能看出是不是值传错了。
5.2 附件中文名变成一串乱码
现象:收件人看到的附件名是=?UTF-8?B?...?=一长串,或者干脆变成几个下划线。
原因:setFileName 直接传入中文字符串,JavaMail 在编码时用了平台默认字符集。本地 Windows 开发正常,部署到 Linux 服务器就变样——不是代码随环境漂移,是编码根本没写死。
解决:写死MimeUtility.encodeText(fileName, "UTF-8", "B"),把文件名编码和服务器环境解耦。这是我拆这个项目时第一个修复的点,从那以后遇到任何带文件名的发送场景,我都默认走 encodeText,再没出过乱码。
5.3 本地发得好好的,部署到服务器就超时
现象:本地 IDE 跑通,打包上传到云服务器后报Connection timed out或SocketException。
原因:云厂商默认封禁 25 端口,或者安全组出口策略阻止了 465/587 的流量;有些机房对未备案域名也有额外限制。
解决:改用 465 或 587 端口,确认服务器所在环境的防火墙出方向放行对应端口;如果必须走 25,先和管理员确认是否被限。测试连通性时用这条命令快速验证:
nc -vz smtp.example.com 465能通再看代码,不通直接找网络策略。常见坑是只放行了入方向规则,忘了出方向,连接照样卡死。
5.4 邮件进了垃圾箱,反垃圾策略不是玄学
现象:代码显示发送成功,收件人翻遍收件箱找不到,最后在垃圾箱里发现。
原因:短时间大量发送相同内容、发件方域名缺少 SPF/DKIM 记录、标题包含营销高频词、收件人曾把类似邮件标记为垃圾邮件,这些都会直接影响投放率。
解决:生产环境在 DNS 里配置 SPF、DKIM/TXT 记录,指向自己的发件域名;正文加一行落地地址或联系电话,提升可信度;同一批邮件错峰发送,避免一秒钟几百封相同标题。自测时把收件箱和垃圾箱都检查一遍,不要只看到发送成功就收工。
5.5 大附件发送又慢又容易断
现象:几 MB 的 PDF 没问题,超过 30MB 就长时间卡住,最后超时或对方收到不完整附件。
原因:JavaMail 默认把附件整个读进内存,大文件会吃内存;服务商对单封邮件大小常有上限;timeout 配置太短导致长传中断。
解决:附件超过服务商上限时先压缩再发,或者拆成多封邮件分批投递;更大的文件先传到对象存储,正文里给下载链接,比直接塞附件更稳。同时把超时参数调大:
props.put("mail.smtp.connectiontimeout", "5000"); props.put("mail.smtp.timeout", "60000"); props.put("mail.smtp.writetimeout", "60000");writetimeout 是写数据超时,单位毫秒,很多人只调了 connectiontimeout 和 timeout,忽略写超时,结果大附件卡在传输中间。这三项一起调才完整。
6. 二次开发:把 main 方法重构成可上线的邮件服务
6.1 封装成配置驱动的 MailService
把 MailSender 的 main 方法改造成一个可复用的服务类:构造器接收 Properties,对外提供sendText、sendHtmlWithAttachment、sendWithAttachments三个方法;每个方法内部走第 3.5 节手动 Transport 的流程。关键变化是把「读配置」和「发邮件」解耦,业务代码只传标题、正文、附件路径,不用关心 SMTP 细节。业务层注入这个服务时,配置读取失败要尽早暴露,别等发送时才报空指针。
6.2 两个高性价比改造:HTML 正文与批量发送
HTML 正文这块改动最小:把textPart.setText(text, "UTF-8")换成textPart.setContent("<h3>报表</h3><p>请查收附件</p>", "text/html; charset=UTF-8"),MimeMultipart 保持 mixed 即可。注意 HTML 里的特殊字符需要转义,否则部分客户端渲染异常。批量发送则要复用同一个 Transport 连接,循环里逐个 build MimeMessage、逐个 sendMessage,对 SendFailedException 里的 invalidAddresses 与 validUnsent 做记录,发送完成后把失败地址攒起来统一重试。
6.3 验证方法:本地起一个假 SMTP 服务
上线前别拿真实用户邮箱试错。本地可以起一个内存版 SMTP 服务,监听到 2525 端口,把mail.smtp.host指到 localhost、port 改 2525,跑一遍 sendWithAttachments,确认 multipart 结构和附件内容没问题了,再切回真实服务商。这样跑一遍至少能提前暴露附件中文名、正文编码这两类高频问题,比直接发到真实邮箱再被垃圾箱拦截要省事得多。
从那以后,我每拆一个涉及邮件模块的项目,都会强制自己先回答三个问题:授权码从哪里读、用的是 465 还是 587、发送失败时日志里能不能看到 SMTP 状态码。这三个答案写进 README,后面接手的人至少不会从头开始踩坑。希望帮到你。
本文还有配套的精品资源,点击获取