简介:这是一套围绕“Java实现发送邮件内容+附件”的完整示例工程,面向需要快速掌握JavaMail的Java开发者,也适合初学与中级开发者按需参考,覆盖企业通知、报表分发、运维告警等常见自动化发信场景。压缩包共41个文件、1.33MB,既有9个java源码、11个class编译产物、7个jar依赖库,也有Eclipse工程配置文件与3个log运行日志;目录结构清晰,依赖jar已配齐,class产物可直接运行验证,便于边学边调试。源码从Properties配置SMTP、Authenticator认证开始,逐步演示Session创建、MimeMessage设置发件人/收件人/主题/正文、MimeBodyPart结合FileDataSource添加附件、Multipart组合内容、Transport发送及setDebug调试输出,并对网络异常、认证失败等错误给出处理思路。已有816人浏览学习,可导入工程直接对照理解JavaMail API的完整调用链路,也可复用其中的认证、多附件组装与排错片段,在此之上扩展HTML正文、抄送密送等需求,减少从零实现邮件功能的成本。
1. Java发送邮件的一站式实现:正文、附件一次搞定
这周接到一个内部运营系统的需求:用 Java 实现发送邮件内容+附件的完整逻辑,注册验证、待办通知、月度对账报表都要往这个入口上接。原以为半天能搞定,结果连续踩了附件名乱码、465 端口握手失败、发送成功但邮件被丢进垃圾箱三个坑。这个项目就是把 Java 发送邮件的整套链路收敛成一个可复用的发送器:一个参数对象带上收件人、主题、正文和任意数量的附件,调用一次就完成投递;支持纯文本、HTML 和流式附件,发送过程走异步线程并带手动重试。适合需要给业务系统快速补邮件能力、又不愿意从 JavaMail 底层 API 一步步拼装的开发者,拿回去改配置就能跑通。
2. 技术选型与工程结构:Spring Boot 邮件模块背后的完整链路
2.1 为什么用 spring-boot-starter-mail 而不是原生 JavaMail
JavaMail 是 JDK 时代留下的底层协议实现,Session、Transport、MimeMessage、鉴权握手、连接生命周期全要自己管理。一个 Sendmail 工具类写完,你会发现 80% 的代码在处理协议细节。Spring Boot 的spring-boot-starter-mail把这层封装成了JavaMailSender,同时把一个实现类JavaMailSenderImpl注册成 Bean,自动读取spring.mail.*前缀下的配置。这样业务代码不需要接触 Transport 和 Session,只管构造消息、调用发送方法。
引入依赖只需要一行:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency>这个 starter 内部传递引入了 Spring 的spring-context-support和 Jakarta Mail 的 API 包,具体版本由 Spring Boot 的 BOM 统一管理,不需要自己写版本号。装配完成后,项目里任意位置注入JavaMailSender就能创建MimeMessage。这个项目在此基础上又多做了两层:一个MimeMessageHelper负责把正文和附件组装进 MIME 消息,一个独立的发送器统一处理发件人、收件人、HTML 开关和重试逻辑。与直接用 JavaMail 相比,业务代码里不再出现MimeBodyPart、Multipart这些底层对象,出问题时的排查面也缩小到了「配置是否正确 + 参数是否合理」。
2.2 工程目录与核心类职责:从配置到发送的一条完整链路
项目按标准 Spring Boot 分包,层次很薄,核心类一眼能看完:
com.demo.mail ├── MailApplication.java ├── controller │ └── MailController.java ├── model │ ├── MailParam.java │ └── Attachment.java ├── service │ ├── MailSenderService.java │ └── impl │ └── MailSenderServiceImpl.java ├── config │ └── MailAsyncConfig.java └── listener └── MailSendEventListener.java各层职责如下表所示:
| 类名 | 职责 | 关键方法/注解 |
|---|---|---|
| MailController | 对外暴露 HTTP 发送入口,接收收件人、主题、正文和 MultipartFile 附件 | POST /mail/send |
| MailParam | 邮件请求参数封装:发件人、收件人、抄送、主题、正文、HTML 标记、附件列表 | addTo / setContent |
| Attachment | 附件模型,统一包装文件名和数据流来源 | fromFile / fromBytes / fromMultipartFile |
| MailSenderService | 发送接口,定义同步与异步两个方法 | send / sendAsync |
| MailSenderServiceImpl | 核心发送实现,调用 MimeMessageHelper 组装消息 | send(MailParam) |
| MailAsyncConfig | 异步线程池,定义 mailExecutor 供 @Async 使用 | ThreadPoolTaskExecutor |
| MailSendEventListener | 监听发送事件,为后续扩展监控留接口 | @EventListener |
接口和实现分开写,好处是二次开发时能替换发送方案而不影响调用方,单测时也可以直接 MockMailSenderService。Controller 只是一个入口,定时任务、消息队列消费者同样可以注入MailSenderService来发邮件,这个项目没有把发送逻辑绑死在 Web 层上。
3. 核心模块拆解:参数对象、MIME 组装与异步重试的实现
3.1 MailParam 参数对象:把一次发送请求收敛成结构化数据
没有参数对象的时候,发送方法签名会变成send(String to, String cc, String subject, String content, boolean html, List<File> files)——调用方写起来累,后面加一个bcc字段就得改所有调用点。这个项目用MailParam把一次请求的全部信息收进去,新增字段只影响构造参数的地方:
public class MailParam { /** 发件人,为空时默认取配置文件里的 username */ private String from; /** 收件人,支持多个 */ private List<String> to; private List<String> cc; private List<String> bcc; /** 邮件主题 */ private String subject; /** 正文,按 html 字段决定是纯文本还是 HTML */ private String content; /** true 表示按 HTML 渲染,false 表示纯文本 */ private boolean html; /** 附件列表,可为空 */ private List<Attachment> attachments; public void addTo(String address) { if (this.to == null) { this.to = new ArrayList<>(); } this.to.add(address); } public void addAttachment(Attachment attachment) { if (this.attachments == null) { this.attachments = new ArrayList<>(); } this.attachments.add(attachment); } // 其余 getter / setter 省略 }用对象而不是散列表传参,编译期就能校验字段类型,IDE 自动提示也比 Map 友好。群发场景直接把多个地址放进to列表,发送器里统一处理。attachments使用项目自定义的Attachment类型,后续小节会说明为什么用InputStreamSource而不是直接传文件路径——这样内存字节、磁盘文件、网络资源都能成为附件来源。
3.2 MailSenderService 核心发送器:MimeMessageHelper 组装邮件
发送器是项目的核心,MailSenderServiceImpl做了三件事:创建MimeMessage、把参数填入MimeMessageHelper、调用JavaMailSender.send投递。完整代码:
@Service public class MailSenderServiceImpl implements MailSenderService { private final JavaMailSender mailSender; private final MailProperties mailProperties; public MailSenderServiceImpl(JavaMailSender mailSender, MailProperties mailProperties) { this.mailSender = mailSender; this.mailProperties = mailProperties; } @Override public void send(MailParam param) throws MailSendException { try { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()); // 发件人默认用配置账号,避免和 SMTP 认证账号不一致被服务商拦截 helper.setFrom(StringUtils.hasText(param.getFrom()) ? param.getFrom() : mailProperties.getUsername()); helper.setTo(param.getTo().toArray(new String[0])); if (param.getCc() != null) { helper.setCc(param.getCc().toArray(new String[0])); } if (param.getBcc() != null) { helper.setBcc(param.getBcc().toArray(new String[0])); } helper.setSubject(param.getSubject()); // 第二个参数控制 Content-Type 是 text/plain 还是 text/html helper.setText(param.getContent(), param.isHtml()); if (param.getAttachments() != null) { for (Attachment attach : param.getAttachments()) { helper.addAttachment(attach.getFilename(), attach.getSource()); } } mailSender.send(message); } catch (MessagingException | IOException e) { throw new MailSendException("邮件发送失败: " + param.getSubject(), e); } } }这个方法的几个细节值得留意。new MimeMessageHelper(message, true, "UTF-8")的第二个参数multipart必须传true,表示允许正文之外携带附件;第三个参数指定编码,中文正文不乱码靠的就是它。setFrom取配置里的账号作为默认值,配合第 5 章避坑指南里的垃圾箱问题一起看会更有体会。setSubject不需要手动编码,MimeMessageHelper内部会对非 ASCII 字符做 RFC 2047 编码。最后统一把MessagingException和IOException包装成MailSendException抛给上层,调用方只需要处理一种异常类型。
3.3 附件处理:流式数据源与中文文件名编码
Attachment类没有直接持有File,而是持有 Spring 的InputStreamSource。这个抽象非常关键:文件、字节数组、MultipartFile、网络 URL 都能统一成「可重复打开的输入流」,发送器只管从InputStreamSource取数据,不关心附件到底存在哪里:
public class Attachment { private final String filename; private final InputStreamSource source; public Attachment(String filename, InputStreamSource source) { this.filename = filename; this.source = source; } /** 从磁盘文件构造附件 */ public static Attachment fromFile(String filename, File file) { return new Attachment(filename, new FileSystemResource(file)); } /** 从字节数组构造附件,适合运行时动态生成的内容 */ public static Attachment fromBytes(String filename, byte[] bytes) { return new Attachment(filename, new ByteArrayResource(bytes)); } /** 从 Spring MVC 上传的文件构造附件 */ public static Attachment fromMultipartFile(MultipartFile multipartFile) { return new Attachment(multipartFile.getOriginalFilename(), multipartFile::getInputStream); } }三种静态工厂方法覆盖了业务里最常见的三类场景:报表导出后从临时目录读取文件、动态生成 PDF 后拿字节数组发送、Web 接口里直接转发用户上传的附件。fromMultipartFile用方法引用实现InputStreamSource,避免把MultipartFile整个对象传进服务层,发送时文件流会按需打开。如果文件名带中文,helper.addAttachment会自动做编码处理;只有绕过 helper 手写MimeBodyPart时才需要手动MimeUtility.encodeText,具体踩坑记录放在第 5 章展开。
3.4 异步发送与重试:@Async 配合手动补偿
邮件发送涉及 SMTP 握手和数据传输,耗时在几百毫秒到几秒不等。同步阻塞在 HTTP 请求里会让接口变慢,这个项目用@Async把发送过程丢到独立线程池,同时做手动重试:
@Async("mailExecutor") public void sendAsync(MailParam param) { int maxAttempts = 2; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { send(param); log.info("邮件发送成功, to={}, subject={}", param.getTo(), param.getSubject()); return; } catch (Exception e) { log.error("邮件发送失败, attempt={}, subject={}", attempt, param.getSubject(), e); if (attempt < maxAttempts) { try { Thread.sleep(2000L); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } } } } }@Async("mailExecutor")指定了第 4 章配置的线程池,方法内部自己 try/catch,保证异常不会在异步线程里凭空消失。重试间隔先写死 2 秒,生产环境建议换成延时队列或者定时扫描任务表的方式。有一点必须提醒:@Async不能在同一类内部调用,比如MailSenderServiceImpl里sendAsync直接调自己的send是没问题的,但如果让send方法再调同类里的sendAsync,Spring 代理会失效,异步不会生效。异步方法的调用入口必须来自另一个 Bean。
4. 项目运行实录:从配置文件到收到第一封邮件
4.1 application.yml 邮件参数详解
项目运行前需要先拿到 SMTP 服务的地址、端口和授权码。配置文件里spring.mail前缀下的内容会被自动装配到JavaMailSenderImpl上:
spring: mail: host: smtp.example.com port: 465 username: notify@example.com password: ${SMTP_AUTH_CODE} protocol: smtp default-encoding: UTF-8 properties: mail: smtp: auth: true ssl: enable: true connectiontimeout: 5000 timeout: 5000 writetimeout: 5000 debug: true这里有几个参数经常被搞混。username填的是发件邮箱地址,password填的不是邮箱登录密码,而是邮箱服务商提供的 SMTP 授权码;port465 对应 SSL 加密,如果换成 587 端口,需要把ssl.enable改成starttls.enable,两者不能配错。三个 timeout 分别约束连接建立、数据读取和写入的等待时间,网络抖动时避免线程一直挂在 Socket 上。debug: true会输出完整的 SMTP 协议日志,初次调试强烈建议打开。
配套的异步线程池配置在这个类里:
@Configuration public class MailAsyncConfig { @Bean("mailExecutor") public Executor mailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix("mail-sender-"); executor.setRejectedExecutionHandler(new CallerRunsPolicy()); executor.initialize(); return executor; } }核心线程 2、最大 4、队列 100 对内部系统足够用;CallerRunsPolicy在队列满时由调用线程自己执行发送,保证任务不丢失。如果发送量大,可以把核心线程调大,但不能无限加,SMTP 服务器对单 IP 连接数有隐性限制。
4.2 测试入口与调用方式
项目提供了 REST 接口方便快速验证,同时也展示了从 HTTP 上传到邮件附件的完整转换过程:
@RestController @RequestMapping("/mail") public class MailController { private final MailSenderService mailSenderService; public MailController(MailSenderService mailSenderService) { this.mailSenderService = mailSenderService; } @PostMapping("/send") public void send(@RequestParam("to") String to, @RequestParam("subject") String subject, @RequestParam(value = "content", required = false) String content, @RequestParam(value = "html", defaultValue = "false") boolean html, @RequestParam(value = "files", required = false) MultipartFile[] files) { MailParam param = new MailParam(); param.addTo(to); param.setSubject(subject); param.setContent(content == null ? subject : content); param.setHtml(html); if (files != null) { for (MultipartFile file : files) { param.addAttachment(Attachment.fromMultipartFile(file)); } } mailSenderService.sendAsync(param); } }接口设计成一个通用入口,files为空时也能发纯文本邮件。启动应用后,用curl带表单参数调用即可:
curl -X POST http://localhost:8080/mail/send \ -F "to=someone@example.com" \ -F "subject=月度对账报表" \ -F "content=<h1>本月对账共 128 笔</h1>" \ -F "html=true" \ -F "files=@/tmp/report.xlsx"-F可以重复传多个files,每个都会变成一封邮件的独立附件。第一次验证建议把收件人填自己的邮箱,先确认能收到,再逐步增加附件和 HTML 内容。接口是异步发送,HTTP 立即返回,实际投递结果看日志。
4.3 打开 mail.debug 后的日志排查
debug: true会把 JavaMail 的协议交互打到控制台,这是排查发送问题最直接的依据。正常流程下日志长这样:
DEBUG: JavaMail connecting to: smtp.example.com/... DEBUG: EHLO host.local DEBUG: AUTH LOGIN DEBUG: 235 2.7.0 Authentication successful DEBUG: MAIL FROM:<notify@example.com> DEBUG: 250 2.1.0 OK DEBUG: RCPT TO:<someone@example.com> DEBUG: 250 2.1.5 OK DEBUG: DATA DEBUG: 354 End data with <CR><LF>.<CR><LF> DEBUG: 250 2.0.0 OK: queued as xxxxx日志里每一行都有明确含义:EHLO后收到250表示服务器支持扩展协议;AUTH LOGIN后235表示认证通过;MAIL FROM和RCPT TO分别代表发件人和收件人被服务器接受;DATA阶段以354开头,直到250 queued才表示消息进入邮件队列,整个发送链路才算完成。
如果卡在连接阶段抛Couldn't connect to SMTP host,优先检查端口和 SSL 配置是否匹配;535 Authentication failed是授权码错误;550一般是收件人地址被拒或发件域名信誉问题。这些状态码在日志里非常醒目,比对 4.1 的配置就能快速定位。
5. 避坑指南:附件乱码、465 端口与“发送成功但没收到”
5.1 附件中文名变成 Q-encoding 乱码
现象:收件人看到的附件名是一串类似=?UTF-8?B?5a...+?=的编码文本,而不是「对账单.xlsx」。 原因:SMTP 消息头不允许直接携带非 ASCII 字符,协议要求对文件名做 RFC 2047 编码。直接使用 JavaMail 的MimeBodyPart.setFileName("对账单.xlsx")时,消息头不会自动编码,部分客户端就会显示原始编码串。 解决:这个项目里用helper.addAttachment(filename, source)就能自动编码;如果绕开 helper 手写,需要手动处理:
MimeBodyPart part = new MimeBodyPart(); part.setDataHandler(new DataHandler(new ByteArrayDataSource(bytes, "application/octet-stream"))); part.setFileName(MimeUtility.encodeText("对账单.xlsx", "UTF-8", "B"));MimeUtility.encodeText把文件名转成=?...?=形式,收件客户端收到后会自动还原。只要统一走MimeMessageHelper,这个坑基本不会再踩。
5.2 465 端口连不上:SSL 与 STARTTLS 的配置差异
现象:配置了smtp.example.com:465,日志抛Couldn't connect to SMTP host或 SSL 握手中断。 原因:465 是 SSL 专用端口,必须在配置里开启mail.smtp.ssl.enable;587 是 STARTTLS 端口,需要开启的是starttls.enable。两个端口和两种加密方式交叉配错,连接必然失败。 解决:使用 465 端口时按 4.1 的配置把ssl.enable: true;切换 587 时改成:
properties: mail: smtp: starttls: enable: true如果两个配置同时开,部分服务器会因协议协商顺序不一致直接断开。接新邮箱服务商时,先去官网确认端口默认值,不要凭经验猜。
5.3 发送状态成功邮件却进垃圾箱
现象:日志里250 queued,发送没有异常,但对方收不到或在垃圾箱里。 原因:最常见的是setFrom填的地址与 SMTP 认证账号不一致,服务商判定为仿冒发件人;其次是发件域名没有配置 SPF/DKIM 记录,收件方反垃圾系统直接降权。 解决:from必须填配置文件里的认证邮箱本身,不要自定义一个看起来更官方的地址。如果后续域名信誉出问题,先查 SPF 记录有没有指向发件服务器。正文不要全篇大写,控制营销性词汇比例,这些细节都能降低进垃圾箱的概率。
5.4 异步发送异常“凭空消失”
现象:sendAsync里发送失败,但应用日志里没有任何错误,任务像没发生过。 原因:@Async方法由线程池执行,异常默认被容器吞掉,不配AsyncUncaughtExceptionHandler时日志不可见。 解决:像 3.4 那样在方法内部 try/catch 并打印日志;同时配置一个全局异步异常处理器兜底:
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> log.error("异步邮件任务异常, method={}", method.getName(), throwable); }我自己踩过这个坑:线上报邮件没发,控制台干干净净,查了半天才发现是异步异常没打出来。从那以后,凡是@Async方法,内部必然先接异常再谈业务。
5.5 HTML 正文被当成纯文本平铺展示
现象:收件人看到的邮件内容是一整片<h1>、<p>标签源码。 原因:helper.setText(content, false)把正文声明成text/plain,客户端按纯文本渲染,标签原样展示。 解决:根据参数正确设置 HTML 开关:
helper.setText(param.getContent(), param.isHtml());Controller 层调用时,如果content是富文本编辑器的输出,html参数必须传true。另一个隐蔽点是不要对 HTML 内容做二次转义,<变成<同样会以源码形式展示。
6. 二次开发思路:模板引擎、发送历史与监控告警
6.1 把正文替换成模板渲染
现在的content是调用方拼好的字符串,业务系统里更常见的做法是传模板名和参数,由邮件模块渲染。引入 Freemarker 后改造点很小:发送前用模板处理MailParam.content。
Map<String, Object> model = new HashMap<>(); model.put("userName", "张三"); model.put("reportUrl", "https://example.com/report/20250617"); String content = FreeMarkerTemplateUtils.processTemplateIntoString( freemarkerConfig.getTemplate("mail/refund-notice.ftl"), model); MailParam param = new MailParam(); param.addTo("user@example.com"); param.setSubject("退款通知"); param.setContent(content); param.setHtml(true);改造后调用方不再关心 HTML 结构,模板放在resources/templates/mail/下,运营直接改模板文件,不需要动代码。
6.2 发送历史落库与监控告警
邮件发送是典型的「可能失败但不会即时反馈」的操作,建议加一张任务表做状态追踪:
CREATE TABLE mail_send_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, to_addr VARCHAR(255) NOT NULL, subject VARCHAR(255), content TEXT, status TINYINT COMMENT '0待发送 1成功 2失败 3重试中', retry_count INT DEFAULT 0, error_msg VARCHAR(500), create_time DATETIME, send_time DATETIME );发送前插入一条0待发送记录,成功改成1,失败记录error_msg并更新重试次数。定时任务扫2状态的记录做补偿发送,比线程内Thread.sleep可靠得多。监控方面,用 Spring 事件解耦:发送器发布MailSentEvent,监听器统计耗时和失败率,超过阈值就推到告警群。这套改造完成之后,邮件从「发出去不管」变成「有记录、可重试、能告警」,才算真正适合线上业务。
之前我接邮件需求习惯直接抄一段 JavaMail 代码就跑,直到有一次生产环境静默丢了几十封待办通知,才意识到这类功能的复杂度不在「发出去」,而在协议细节、服务商规则和失败补偿上。从那以后,我每次动手前都会先确认三件事:目标邮箱的 SMTP 地址和端口、是否支持授权码、发件域名有没有 SPF 记录,再回来写代码。希望帮到你。
本文还有配套的精品资源,点击获取