邮件发送
Spring Boot 通过 spring-boot-starter-mail 封装 JavaMail,支持文本邮件、HTML 邮件、附件和模板邮件,几行配置即可发送。
依赖与配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>spring:
mail:
host: smtp.example.com
port: 587
username: noreply@example.com
password: ${MAIL_PASSWORD} # 从环境变量读取,不要硬编码
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true # 587 端口用 STARTTLS
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000常用邮件服务商配置:
| 服务商 | host | port | 说明 |
|---|---|---|---|
| Gmail | smtp.gmail.com | 587 | 需开启”应用专用密码” |
| QQ 邮箱 | smtp.qq.com | 587 | 需开启 SMTP 并获取授权码 |
| 163 邮箱 | smtp.163.com | 465 | SSL,port 改 465,starttls 关闭 |
| 企业微信 | smtp.exmail.qq.com | 465 | SSL |
| Outlook | smtp-mail.outlook.com | 587 | STARTTLS |
发送文本邮件
@Service
@RequiredArgsConstructor
@Slf4j
public class MailService {
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from;
// 简单文本邮件
public void sendText(String to, String subject, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
log.info("文本邮件已发送: to={}, subject={}", to, subject);
}
// 群发
public void sendToMultiple(String[] recipients, String subject, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(recipients);
message.setCc("admin@example.com"); // 抄送
message.setBcc("archive@example.com"); // 密送
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
}
}发送 HTML 邮件
public void sendHtml(String to, String subject, String htmlContent) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
// true = multipart(支持内嵌图片和附件)
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true = HTML 内容
mailSender.send(message);
}发送带附件的邮件
public void sendWithAttachment(String to, String subject,
String content, File attachment) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true);
// 文件附件
helper.addAttachment(attachment.getName(), attachment);
// 从 classpath 加载附件
ClassPathResource resource = new ClassPathResource("templates/contract.pdf");
helper.addAttachment("合同.pdf", resource);
// 从字节数组添加附件(如动态生成的报表)
byte[] reportBytes = reportService.generate();
helper.addAttachment("报表.xlsx",
new ByteArrayResource(reportBytes),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
mailSender.send(message);
}内嵌图片(邮件正文图片)
public void sendWithInlineImage(String to, String subject) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
// HTML 中用 cid:logoImage 引用内嵌图片
String html = """
<html>
<body>
<h1>欢迎注册</h1>
<img src='cid:logoImage' width='200'/>
<p>感谢您的注册!</p>
</body>
</html>
""";
helper.setText(html, true);
// 内嵌图片(cid 需与 HTML 中的 cid: 一致)
ClassPathResource logo = new ClassPathResource("static/images/logo.png");
helper.addInline("logoImage", logo);
mailSender.send(message);
}Thymeleaf 模板邮件
将 HTML 内容从代码中分离到模板文件,便于维护:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>src/main/resources/templates/mail/order-confirm.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>订单确认</title>
</head>
<body>
<h2 th:text="'亲爱的 ' + ${userName} + ',您好!'"></h2>
<p>您的订单 <strong th:text="${orderId}"></strong> 已确认。</p>
<table>
<tr th:each="item : ${items}">
<td th:text="${item.productName}"></td>
<td th:text="${item.quantity}"></td>
<td th:text="${#numbers.formatDecimal(item.price, 1, 2)}"></td>
</tr>
</table>
<p>总计:<strong th:text="${#numbers.formatDecimal(total, 1, 2)}"></strong> 元</p>
<a th:href="${trackingUrl}">查看物流</a>
</body>
</html>@Service
@RequiredArgsConstructor
@Slf4j
public class TemplateMailService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine; // Thymeleaf TemplateEngine
@Value("${spring.mail.username}")
private String from;
public void sendOrderConfirm(String to, Order order) throws MessagingException {
// 1. 填充模板变量
Context context = new Context(Locale.CHINESE);
context.setVariable("userName", order.getUserName());
context.setVariable("orderId", order.getId());
context.setVariable("items", order.getItems());
context.setVariable("total", order.getTotal());
context.setVariable("trackingUrl", "https://example.com/track/" + order.getId());
// 2. 渲染 HTML
String html = templateEngine.process("mail/order-confirm", context);
// 3. 发送
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject("订单确认 - " + order.getId());
helper.setText(html, true);
mailSender.send(message);
log.info("订单确认邮件已发送: orderId={}, to={}", order.getId(), to);
}
}Thymeleaf 模板语法详见 Thymeleaf。
异步发送
邮件发送通常耗时数百毫秒到数秒,应放入异步线程池,不阻塞业务线程:
@Service
@RequiredArgsConstructor
@Slf4j
public class AsyncMailService {
private final TemplateMailService templateMailService;
@Async("mailExecutor") // 指定邮件专用线程池
public void sendOrderConfirmAsync(String to, Order order) {
try {
templateMailService.sendOrderConfirm(to, order);
} catch (MessagingException e) {
log.error("邮件发送失败: to={}, orderId={}", to, order.getId(), e);
// 可结合重试机制或写入失败队列
}
}
}
// 邮件专用线程池(与主业务线程池隔离)
@Bean("mailExecutor")
public Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("mail-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}异步线程池详见 异步与线程池。
重试机制
@Service
@RequiredArgsConstructor
@Slf4j
public class ReliableMailService {
private final JavaMailSender mailSender;
// Spring Retry:最多重试 3 次,每次间隔 2 秒
@Retryable(
retryFor = {MailException.class, MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
public void sendWithRetry(String to, String subject, String content)
throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true);
mailSender.send(message);
}
// 全部重试失败后的兜底处理
@Recover
public void recover(MailException ex, String to, String subject, String content) {
log.error("邮件发送最终失败,写入失败记录: to={}, subject={}", to, subject, ex);
failedMailRepository.save(FailedMail.of(to, subject, content));
}
}需引入 Spring Retry 依赖:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>邮件发送记录
@Entity
@Table(name = "mail_log")
@Data
@Builder
public class MailLog {
@Id @GeneratedValue
private Long id;
private String recipient;
private String subject;
@Enumerated(EnumType.STRING)
private MailStatus status; // PENDING / SENT / FAILED
private String errorMessage;
private Integer retryCount;
private LocalDateTime sentAt;
private LocalDateTime createdAt;
}
@Service
@RequiredArgsConstructor
public class LoggedMailService {
private final JavaMailSender mailSender;
private final MailLogRepository logRepository;
@Transactional
public void send(String to, String subject, String html) throws MessagingException {
MailLog log = logRepository.save(MailLog.builder()
.recipient(to)
.subject(subject)
.status(MailStatus.PENDING)
.createdAt(LocalDateTime.now())
.build());
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(html, true);
mailSender.send(message);
logRepository.updateStatus(log.getId(), MailStatus.SENT, LocalDateTime.now(), null);
} catch (Exception e) {
logRepository.updateStatus(log.getId(), MailStatus.FAILED,
null, e.getMessage());
throw e;
}
}
}测试邮件发送
本地开发使用 Mailpit 或 MailHog 捕获邮件,不实际发出:
# 开发环境:所有邮件发到本地 SMTP 捕获工具
spring:
mail:
host: localhost
port: 1025 # Mailpit 默认 SMTP 端口
username: ""
password: ""
properties:
mail.smtp.auth: false
mail.smtp.starttls.enable: false# Docker 启动 Mailpit(Web 界面在 8025 端口)
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit单元测试(使用 GreenMail):
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-spring</artifactId>
<version>2.1.0</version>
<scope>test</scope>
</dependency>@SpringBootTest
@ExtendWith(GreenMailExtension.class)
class MailServiceTest {
@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP)
.withConfiguration(GreenMailConfiguration.aConfig()
.withUser("test@example.com", "password"))
.withPerMethodLifecycle(false);
@Autowired
private MailService mailService;
@Test
void shouldSendWelcomeEmail() throws Exception {
mailService.sendText("user@example.com", "欢迎注册", "感谢注册!");
MimeMessage[] messages = greenMail.getReceivedMessages();
assertThat(messages).hasSize(1);
assertThat(messages[0].getSubject()).isEqualTo("欢迎注册");
assertThat(messages[0].getAllRecipients()[0].toString())
.isEqualTo("user@example.com");
}
}常见问题
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 535 认证失败 | 密码错误或使用了登录密码而非授权码 | QQ / 163 需获取授权码,Gmail 用应用专用密码 |
| 连接超时 | 防火墙阻断 SMTP 端口 | 确认 587 / 465 出站端口已开放 |
| 邮件进垃圾箱 | SPF / DKIM / DMARC 未配置 | 域名 DNS 配置 SPF 和 DKIM 记录 |
| 中文乱码 | 编码未设置 | MimeMessageHelper 构造时指定 UTF-8 |
| 大附件发送失败 | 超出 SMTP 服务器大小限制 | 改用对象存储分享链接,不直接发附件 |