资讯动态

Java实现发送邮件:正文+附件完整指南与避坑实践

发布时间:2026/10/16 6:45:57 来源:尧图企业网站定制
简介面向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.mailJava 发邮件绕不开 JavaMail但 JavaMail 在最近几年有一次明显的分叉。老教程、老项目里几乎全是javax.mail包名配com.sun.mail:javax.mail:1.4.7这个坐标我自己早期做的报表邮件模块用的就是这个组合。如果你在 Java 8 项目里做内部工具1.4.7 确实能跑但它在 TLS 协商和 MIME 编码处理上偏老遇到某些服务商强制要求 STARTTLS 时容易卡住。所以这份资源里用的是 1.6.2Maven 坐标不变包名还是javax.mailAPI 与老版本完全兼容可以平滑替换。如果是新起的 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 groupIdcom.sun.mail/groupId artifactIdjavax.mail/artifactId version1.6.2/version /dependencyactivation 会被传递引入不需要单独声明。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 和 portSMTP 配置不是把 host 填上就能完事。这个项目里我把配置拆成六项每一项都在 mail.properties 里给了默认值配置键示例值说明mail.smtp.hostsmtp.example.com邮箱服务商的 SMTP 地址开启 SMTP 服务后能看到mail.smtp.port465465 走 SSL587 走 STARTTLS25 常被服务商限制mail.smtp.authtrue开启认证绝大多数服务商要求mail.smtp.ssl.enabletrue使用 465 端口时必须为 truemail.smtp.connectiontimeout5000建连超时单位毫秒mail.smtp.timeout10000读取超时网络不稳时能救命这些键是 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把连接参数交给 JavaMailJavaMail 的入口是 Session而 Session 的出生需要一份 Properties——所有 SMTP 行为都由这些键值对控制。最直接的写法是逐个 putProperties 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.SSLSocketFactory1.6.2 开了 ssl.enable 之后这行可省但保留它兼容老服务商是常见做法。3.2 第二步创建 Session 和 AuthenticatorSession.getInstance 接收 Properties 和一个 Authenticator 回调。服务商要求认证时这个回调返回的 PasswordAuthentication 会在握手阶段被 JavaMail 自动提交Session session Session.getInstance(props, new Authenticator() { Override protected PasswordAuthentication getPasswordAuthentication() { // 授权码别硬编码建议从环境变量或配置中心读取 return new PasswordAuthentication(senderexample.com, 这里填授权码); } });Authenticator 的特点是它不是一个预先写死密码的对象而是在需要认证时被回调。这意味着你可以把授权码放到密钥管理平台运行时动态取出来塞进去代码里不落明文。另外注意session.setDebug(true)和 props 里的mail.debug效果相同但前者必须在创建 Session 后立即调用放在 getInstance 之前不会生效。调试完记得关掉。3.3 第三步构建 MimeMessage处理发件人、收件人和中文主题消息主体是 MimeMessage。一个消息最少要有发件人、收件人、主题三项MimeMessage message new MimeMessage(session); message.setFrom(new InternetAddress(senderexample.com)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(user1example.com,user2example.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 内部会走完整的连接、认证、发送、断开流程发送完自动释放连接适合低频、一次性发送。缺点是每次调用都新建连接如果你要循环给几百个收件人发不同附件这种写法会因为频繁握手被服务商限流。第二种是手动管理 Transporttry (Transport transport session.getTransport(smtp)) { // Service 实现了 AutoCloseable transport.connect(smtp.example.com, 465, senderexample.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.2JDK 17 或更高就切 jakarta.mail 2.x 那套坐标两种组合代码大同小异。剩下的关键准备在邮箱那边登录邮箱网页端找到设置里的 SMTP 服务开关打开它然后生成一个授权码。授权码是第三方客户端专用凭据网页登录密码在这里不好使服务端校验的就是它。我建议准备两个邮箱一个当发件人一个当收件人或者同一邮箱自己发给自己也行。自己发自己收能跑通但看不出投递耗时两个不同域名的邮箱互发更接近真实场景。把发件邮箱地址、授权码、SMTP 地址三个值记下来下一步直接替换。4.2 修改配置把占位符换掉项目里 mail.properties 是唯一必须改的地方mail.smtp.hostsmtp.example.com mail.smtp.port465 mail.smtp.authtrue mail.smtp.ssl.enabletrue mail.smtp.connectiontimeout5000 mail.smtp.timeout10000 mail.fromsenderexample.com mail.usersenderexample.com mail.password这里填授权码 mail.debugfalse注意几个值的对应关系mail.smtp.host不是网页端邮箱地址而是 SMTP 专用服务地址mail.user一般直接填完整发件邮箱地址mail.password填授权码而不是邮箱登录密码。MailConfig.java 做的事就是把 properties 读进来转成第 3.1 节里的 props 键。别在 Java 代码里再写一份配置两份配置一旦不一致你会花一个下午找为什么连不上。注意如果服务商端口是 587把mail.smtp.ssl.enable改成 false加上mail.smtp.starttls.enabletrue。4.3 启动与运行三种跑法IDE 直接运行 MailSender 的 main 方法最简单适合第一次验证。Maven 方式适合命令行环境mvn compile exec:java -Dexec.mainClasscom.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报表/h3p请查收附件/p, text/html; charsetUTF-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后面接手的人至少不会从头开始踩坑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑