首页 / 资讯中心 / 文章详情

Spring Boot邮件发送实战:正文、附件与异步重试

Spring Boot邮件发送实战:正文、附件与异步重试 ★ FEATURED ARTICLE
简介这是一套围绕“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 而不是原生 JavaMailJavaMail 是 JDK 时代留下的底层协议实现Session、Transport、MimeMessage、鉴权握手、连接生命周期全要自己管理。一个 Sendmail 工具类写完你会发现 80% 的代码在处理协议细节。Spring Boot 的spring-boot-starter-mail把这层封装成了JavaMailSender同时把一个实现类JavaMailSenderImpl注册成 Bean自动读取spring.mail.*前缀下的配置。这样业务代码不需要接触 Transport 和 Session只管构造消息、调用发送方法。引入依赖只需要一行dependency groupIdorg.springframework.boot/groupId artifactIdspring-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/sendMailParam邮件请求参数封装发件人、收件人、抄送、主题、正文、HTML 标记、附件列表addTo / setContentAttachment附件模型统一包装文件名和数据流来源fromFile / fromBytes / fromMultipartFileMailSenderService发送接口定义同步与异步两个方法send / sendAsyncMailSenderServiceImpl核心发送实现调用 MimeMessageHelper 组装消息send(MailParam)MailAsyncConfig异步线程池定义 mailExecutor 供 Async 使用ThreadPoolTaskExecutorMailSendEventListener监听发送事件为后续扩展监控留接口EventListener接口和实现分开写好处是二次开发时能替换发送方案而不影响调用方单测时也可以直接 MockMailSenderService。Controller 只是一个入口定时任务、消息队列消费者同样可以注入MailSenderService来发邮件这个项目没有把发送逻辑绑死在 Web 层上。3. 核心模块拆解参数对象、MIME 组装与异步重试的实现3.1 MailParam 参数对象把一次发送请求收敛成结构化数据没有参数对象的时候发送方法签名会变成send(String to, String cc, String subject, String content, boolean html, ListFile files)——调用方写起来累后面加一个bcc字段就得改所有调用点。这个项目用MailParam把一次请求的全部信息收进去新增字段只影响构造参数的地方public class MailParam { /** 发件人为空时默认取配置文件里的 username */ private String from; /** 收件人支持多个 */ private ListString to; private ListString cc; private ListString bcc; /** 邮件主题 */ private String subject; /** 正文按 html 字段决定是纯文本还是 HTML */ private String content; /** true 表示按 HTML 渲染false 表示纯文本 */ private boolean html; /** 附件列表可为空 */ private ListAttachment 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方法再调同类里的sendAsyncSpring 代理会失效异步不会生效。异步方法的调用入口必须来自另一个 Bean。4. 项目运行实录从配置文件到收到第一封邮件4.1 application.yml 邮件参数详解项目运行前需要先拿到 SMTP 服务的地址、端口和授权码。配置文件里spring.mail前缀下的内容会被自动装配到JavaMailSenderImpl上spring: mail: host: smtp.example.com port: 465 username: notifyexample.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 tosomeoneexample.com \ -F subject月度对账报表 \ -F contenth1本月对账共 128 笔/h1 \ -F htmltrue \ -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:notifyexample.com DEBUG: 250 2.1.0 OK DEBUG: RCPT TO:someoneexample.com DEBUG: 250 2.1.5 OK DEBUG: DATA DEBUG: 354 End data with CRLF.CRLF DEBUG: 250 2.0.0 OK: queued as xxxxx日志里每一行都有明确含义EHLO后收到250表示服务器支持扩展协议AUTH LOGIN后235表示认证通过MAIL FROM和RCPT TO分别代表发件人和收件人被服务器接受DATA阶段以354开头直到250 queued才表示消息进入邮件队列整个发送链路才算完成。如果卡在连接阶段抛Couldnt 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日志抛Couldnt connect to SMTP host或 SSL 握手中断。 原因465 是 SSL 专用端口必须在配置里开启mail.smtp.ssl.enable587 是 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 内容做二次转义变成lt;同样会以源码形式展示。6. 二次开发思路模板引擎、发送历史与监控告警6.1 把正文替换成模板渲染现在的content是调用方拼好的字符串业务系统里更常见的做法是传模板名和参数由邮件模块渲染。引入 Freemarker 后改造点很小发送前用模板处理MailParam.content。MapString, 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(userexample.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 记录再回来写代码。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站