邮件模块
概述
邮件模块是基于 Hutool 的邮件工具构建的 Spring Boot 自动配置模块,提供简单易用的邮件发送功能。支持文本邮件、HTML邮件、带附件邮件以及内嵌图片邮件的发送。
核心特性
- 🚀 开箱即用:Spring Boot 自动配置,无需复杂设置
- 📧 多种格式:支持纯文本、HTML、带附件邮件
- 🖼️ 内嵌图片:支持HTML邮件中的内嵌图片
- 👥 批量发送:支持多收件人、抄送、密送
- 🔒 安全连接:支持SSL/TLS和STARTTLS安全连接
- ⚙️ 灵活配置:支持超时设置和多种SMTP服务器
快速开始
1. 添加依赖
确保项目中已包含邮件模块依赖。
2. 配置文件
在 application.yml 中添加邮件配置:
################## 邮件服务配置 ##################
--- # mail 邮件发送
mail:
# 是否启用邮件服务
enabled: true # 生产环境设为 true,开发环境可设为 false
# SMTP服务器地址
host: smtp.163.com # 163邮箱SMTP服务器
# SMTP服务器端口
port: 465 # SSL端口
# 是否需要用户名密码验证
auth: true # 开启认证
# 发送方,遵循RFC-822标准
from: xxx@163.com # 发件人邮箱地址
# 用户名(注意:如果使用foxmail邮箱,此处user为qq号)
user: xxx@163.com # 邮箱用户名
# 密码(注意,某些邮箱需要为SMTP服务单独设置密码,详情查看相关帮助)
pass: xxxxxxxxxx # 邮箱授权码(不是登录密码)
# 使用 STARTTLS安全连接,STARTTLS是对纯文本通信协议的扩展
starttlsEnable: true # 启用STARTTLS
# 使用SSL安全连接
sslEnable: true # 启用SSL
# SMTP超时时长,单位毫秒,缺省值不超时
timeout: 0 # 0表示不设置超时
# Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0 # 0表示不设置连接超时3. 发送邮件
import plus.ruoyi.common.mail.utils.MailUtils;
// 发送简单文本邮件
MailUtils.sendText("recipient@example.com", "测试邮件", "这是一封测试邮件");
// 发送HTML邮件
MailUtils.sendHtml("recipient@example.com", "HTML邮件", "<h1>这是HTML邮件</h1>");配置详解
配置属性说明
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mail.enabled | Boolean | false | 是否启用邮件功能 |
mail.host | String | - | SMTP服务器域名 |
mail.port | Integer | - | SMTP服务端口 |
mail.auth | Boolean | true | 是否需要用户名密码验证 |
mail.user | String | - | 用户名(foxmail邮箱填写QQ号) |
mail.pass | String | - | 密码或授权码(不是登录密码) |
mail.from | String | - | 发件人地址,遵循RFC-822标准 |
mail.starttlsEnable | Boolean | false | 是否启用STARTTLS安全连接 |
mail.sslEnable | Boolean | false | 是否启用SSL安全连接 |
mail.timeout | Long | 0 | SMTP超时时长(毫秒),0表示不超时 |
mail.connectionTimeout | Long | 0 | 连接超时时长(毫秒),0表示不超时 |
常见邮箱服务器配置
QQ邮箱
################## 邮件服务配置 ##################
--- # mail 邮件发送
mail:
enabled: true
host: smtp.qq.com
port: 587 # 使用STARTTLS端口
from: your-email@qq.com
user: your-email@qq.com
pass: your-auth-code # QQ邮箱授权码
auth: true
starttlsEnable: true
sslEnable: false # STARTTLS方式不启用SSL
timeout: 0
connectionTimeout: 0QQ邮箱授权码获取
- 登录QQ邮箱
- 进入 设置 → 账户
- 开启SMTP服务
- 获取授权码(16位字符)
163邮箱
################## 邮件服务配置 ##################
--- # mail 邮件发送
mail:
enabled: true
host: smtp.163.com
port: 465 # 使用SSL端口
from: your-email@163.com
user: your-email@163.com
pass: your-auth-code # 163邮箱授权码
auth: true
starttlsEnable: true
sslEnable: true # 启用SSL
timeout: 0 # 不设置超时
connectionTimeout: 0163邮箱授权码获取
- 登录163邮箱
- 进入 设置 → POP3/SMTP/IMAP
- 开启SMTP服务
- 获取授权码(不是登录密码)
Gmail
################## 邮件服务配置 ##################
--- # mail 邮件发送
mail:
enabled: true
host: smtp.gmail.com
port: 587
from: your-email@gmail.com
user: your-email@gmail.com
pass: your-app-password # Gmail应用专用密码
auth: true
starttlsEnable: true
sslEnable: false
timeout: 0
connectionTimeout: 0Gmail应用密码获取
- 开启两步验证
- 进入 Google账户 → 安全性 → 应用专用密码
- 生成应用专用密码(16位字符)
API 使用指南
基础邮件发送
发送文本邮件
// 发送给单个收件人
String messageId = MailUtils.sendText("user@example.com", "邮件标题", "邮件内容");
// 发送给多个收件人(逗号或分号分隔)
MailUtils.sendText("user1@example.com,user2@example.com", "邮件标题", "邮件内容");
// 发送给收件人列表
List<String> recipients = Arrays.asList("user1@example.com", "user2@example.com");
MailUtils.sendText(recipients, "邮件标题", "邮件内容");发送HTML邮件
String htmlContent = """
<html>
<body>
<h1>欢迎使用邮件服务</h1>
<p>这是一封<strong>HTML格式</strong>的邮件</p>
<a href="https://example.com">访问我们的网站</a>
</body>
</html>
""";
MailUtils.sendHtml("user@example.com", "HTML邮件", htmlContent);带附件邮件
import java.io.File;
File attachment1 = new File("/path/to/document.pdf");
File attachment2 = new File("/path/to/image.jpg");
// 发送带附件的文本邮件
MailUtils.sendText("user@example.com", "带附件邮件", "请查看附件", attachment1, attachment2);
// 发送带附件的HTML邮件
MailUtils.sendHtml("user@example.com", "带附件HTML邮件", htmlContent, attachment1, attachment2);抄送和密送
// 使用字符串形式(逗号或分号分隔)
MailUtils.send(
"to@example.com", // 收件人
"cc@example.com", // 抄送
"bcc@example.com", // 密送
"邮件标题",
"邮件内容",
true // 是否HTML格式
);
// 使用集合形式
List<String> tos = Arrays.asList("to1@example.com", "to2@example.com");
List<String> ccs = Arrays.asList("cc1@example.com", "cc2@example.com");
List<String> bccs = Arrays.asList("bcc1@example.com", "bcc2@example.com");
MailUtils.send(tos, ccs, bccs, "邮件标题", "邮件内容", true);内嵌图片邮件
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
// 准备图片映射
Map<String, InputStream> imageMap = new HashMap<>();
imageMap.put("logo", new FileInputStream("/path/to/logo.png"));
imageMap.put("banner", new FileInputStream("/path/to/banner.jpg"));
// HTML内容中引用图片
String htmlContent = """
<html>
<body>
<img src="cid:logo" alt="Logo" />
<h1>欢迎使用我们的服务</h1>
<img src="cid:banner" alt="Banner" />
<p>这是包含内嵌图片的邮件</p>
</body>
</html>
""";
MailUtils.sendHtml("user@example.com", "内嵌图片邮件", htmlContent, imageMap);自定义邮件账户
import cn.hutool.extra.mail.MailAccount;
// 临时更改发送账户信息
MailAccount customAccount = MailUtils.getMailAccount("custom@example.com", "custom@example.com", "custom-password");
MailUtils.send(customAccount, "recipient@example.com", "使用自定义账户", "邮件内容", false);
// 完全自定义邮件账户
MailAccount mailAccount = new MailAccount();
mailAccount.setHost("smtp.custom.com");
mailAccount.setPort(587);
mailAccount.setFrom("sender@custom.com");
mailAccount.setUser("sender@custom.com");
mailAccount.setPass("password");
mailAccount.setAuth(true);
mailAccount.setStarttlsEnable(true);
MailUtils.send(mailAccount, "recipient@example.com", "完全自定义账户", "邮件内容", false);高级用法
邮件模板
建议结合模板引擎使用:
// 使用Thymeleaf模板(示例)
@Service
public class EmailService {
@Autowired
private TemplateEngine templateEngine;
public void sendWelcomeEmail(String to, String username) {
Context context = new Context();
context.setVariable("username", username);
context.setVariable("loginUrl", "https://example.com/login");
String htmlContent = templateEngine.process("email/welcome", context);
MailUtils.sendHtml(to, "欢迎注册", htmlContent);
}
}异步发送
@Service
public class AsyncEmailService {
@Async("emailTaskExecutor")
public CompletableFuture<String> sendEmailAsync(String to, String subject, String content) {
try {
String messageId = MailUtils.sendHtml(to, subject, content);
return CompletableFuture.completedFuture(messageId);
} catch (Exception e) {
CompletableFuture<String> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
}批量发送优化
@Service
public class BulkEmailService {
public void sendBulkEmails(List<String> recipients, String subject, String content) {
// 分批发送,避免一次发送过多邮件
int batchSize = 50;
for (int i = 0; i < recipients.size(); i += batchSize) {
int end = Math.min(i + batchSize, recipients.size());
List<String> batch = recipients.subList(i, end);
try {
MailUtils.sendHtml(batch, subject, content);
// 批次间添加延迟,避免触发邮件服务器限制
Thread.sleep(1000);
} catch (Exception e) {
log.error("批量发送邮件失败,批次: {}-{}", i, end, e);
}
}
}
}错误处理
常见异常处理
public class EmailService {
public boolean sendEmailSafely(String to, String subject, String content) {
try {
MailUtils.sendHtml(to, subject, content);
return true;
} catch (cn.hutool.extra.mail.MailException e) {
log.error("邮件发送失败: {}", e.getMessage());
return false;
} catch (Exception e) {
log.error("发送邮件时发生未知错误", e);
return false;
}
}
}重试机制
@Component
public class EmailRetryService {
@Retryable(value = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 2000))
public String sendWithRetry(String to, String subject, String content) {
return MailUtils.sendHtml(to, subject, content);
}
@Recover
public String recover(MailException ex, String to, String subject, String content) {
log.error("邮件发送重试失败,收件人: {}, 标题: {}", to, subject, ex);
return null;
}
}性能优化
连接池配置
mail:
enabled: true
# ... 其他配置
timeout: 30000 # 适当设置超时时间
connectionTimeout: 10000 # 连接超时异步配置
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailTaskExecutor")
public TaskExecutor emailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}安全建议
- 使用授权码:不要使用邮箱登录密码,使用专用的授权码或应用密码
- 配置加密:敏感的邮箱密码建议使用配置加密
- 限制发送频率:避免触发邮件服务商的反垃圾机制
- 验证收件人:发送前验证邮箱地址格式的合法性
public class EmailValidator {
private static final String EMAIL_REGEX =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
public static boolean isValid(String email) {
return EMAIL_PATTERN.matcher(email).matches();
}
}开发环境配置
配置说明
开发环境中通常将 enabled: false 来禁用邮件发送,避免开发过程中误发邮件。以下是开发环境的推荐配置:
################## 邮件服务配置 ##################
--- # mail 邮件发送
mail:
# 开发环境建议设置为 false,避免误发邮件
enabled: false
# 以下配置在开发环境中可以使用测试邮箱
host: smtp.163.com
port: 465
auth: true
from: test@163.com
user: test@163.com
pass: test-auth-code
starttlsEnable: true
sslEnable: true
timeout: 0
connectionTimeout: 0环境切换策略
方案一:配置文件分离
application-dev.yml (开发环境)
mail:
enabled: false # 开发环境禁用application-prod.yml (生产环境)
mail:
enabled: true # 生产环境启用
host: smtp.163.com
port: 465
# ... 其他配置方案二:环境变量
mail:
enabled: ${MAIL_ENABLED:false} # 默认禁用
host: ${MAIL_HOST:smtp.163.com}
port: ${MAIL_PORT:465}
user: ${MAIL_USER:}
pass: ${MAIL_PASS:}
from: ${MAIL_FROM:}方案三:配置加密
使用 Jasypt 对敏感信息加密:
mail:
enabled: true
host: smtp.163.com
port: 465
user: ENC(encrypted_username)
pass: ENC(encrypted_password)
from: ENC(encrypted_email)开发调试技巧
1. 邮件发送测试
在开发环境中可以创建测试方法:
@SpringBootTest
class MailUtilsTest {
@Test
@Disabled("开发环境测试,需手动启用")
void testSendMail() {
// 临时启用邮件服务进行测试
String result = MailUtils.sendText(
"developer@example.com",
"开发环境测试邮件",
"这是一封测试邮件,请忽略"
);
assertNotNull(result);
System.out.println("邮件发送成功,MessageId: " + result);
}
}2. 邮件内容预览
开发环境中可以将邮件内容输出到控制台:
@Service
public class DevEmailService {
@Value("${mail.enabled:false}")
private boolean mailEnabled;
public void sendEmail(String to, String subject, String content) {
if (mailEnabled) {
MailUtils.sendHtml(to, subject, content);
} else {
// 开发环境下输出邮件内容到控制台
System.out.println("=== 邮件预览 ===");
System.out.println("收件人: " + to);
System.out.println("标题: " + subject);
System.out.println("内容: " + content);
System.out.println("===============");
}
}
}3. 使用邮件捕获工具
推荐使用 MailHog 或 MailCatcher 等工具在开发环境中捕获邮件:
# 使用 MailHog 的配置
mail:
enabled: true
host: localhost
port: 1025 # MailHog SMTP端口
auth: false
from: dev@localhost
sslEnable: false
starttlsEnable: false常见问题
邮件发送失败
- 检查SMTP服务器配置是否正确
- 确认用户名和密码/授权码是否正确
- 检查网络连接和防火墙设置
SSL/TLS连接问题
- 确认端口和加密设置匹配
- 检查Java版本是否支持所需的SSL/TLS版本
附件过大
- 检查邮件服务商的附件大小限制
- 考虑使用云存储链接替代大附件
日志配置
logging:
level:
cn.hutool.extra.mail: DEBUG
plus.ruoyi.common.mail: DEBUG邮件模板系统
Thymeleaf 模板集成
使用 Thymeleaf 模板引擎创建专业的邮件模板。
/**
* 邮件模板服务
*/
@Service
@Slf4j
public class MailTemplateService {
@Autowired
private TemplateEngine templateEngine;
/**
* 发送模板邮件
*/
public String sendTemplateEmail(String to, String templateName,
Map<String, Object> variables) {
try {
Context context = new Context();
context.setVariables(variables);
String htmlContent = templateEngine.process(templateName, context);
return MailUtils.sendHtml(to, getSubjectFromTemplate(templateName), htmlContent);
} catch (Exception e) {
log.error("发送模板邮件失败: to={}, template={}", to, templateName, e);
throw new ServiceException("邮件发送失败");
}
}
/**
* 发送欢迎邮件
*/
public void sendWelcomeEmail(String to, String username) {
Map<String, Object> variables = new HashMap<>();
variables.put("username", username);
variables.put("loginUrl", "https://example.com/login");
variables.put("year", Year.now().getValue());
sendTemplateEmail(to, "email/welcome", variables);
}
/**
* 发送密码重置邮件
*/
public void sendPasswordResetEmail(String to, String resetToken, int expireMinutes) {
Map<String, Object> variables = new HashMap<>();
variables.put("resetUrl", "https://example.com/reset?token=" + resetToken);
variables.put("expireMinutes", expireMinutes);
variables.put("year", Year.now().getValue());
sendTemplateEmail(to, "email/password-reset", variables);
}
/**
* 发送验证码邮件
*/
public void sendVerificationCodeEmail(String to, String code, int expireMinutes) {
Map<String, Object> variables = new HashMap<>();
variables.put("code", code);
variables.put("expireMinutes", expireMinutes);
sendTemplateEmail(to, "email/verification-code", variables);
}
/**
* 发送订单确认邮件
*/
public void sendOrderConfirmEmail(String to, OrderVo order) {
Map<String, Object> variables = new HashMap<>();
variables.put("order", order);
variables.put("items", order.getItems());
variables.put("totalAmount", order.getTotalAmount());
sendTemplateEmail(to, "email/order-confirm", variables);
}
}邮件模板示例
欢迎邮件模板 (resources/templates/email/welcome.html):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>欢迎加入</title>
<style>
body { font-family: 'Microsoft YaHei', Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #1890ff; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #1890ff;
color: white;
text-decoration: none;
border-radius: 4px;
}
.footer { padding: 20px; text-align: center; color: #999; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>欢迎加入我们</h1>
</div>
<div class="content">
<p>亲爱的 <span th:text="${username}">用户</span>,</p>
<p>感谢您注册我们的平台!您的账号已成功创建。</p>
<p>点击下方按钮开始使用:</p>
<p style="text-align: center;">
<a th:href="${loginUrl}" class="button">立即登录</a>
</p>
<p>如果您有任何问题,请随时联系我们的客服团队。</p>
</div>
<div class="footer">
<p>© <span th:text="${year}">2024</span> 公司名称. 保留所有权利.</p>
<p>如果这不是您的操作,请忽略此邮件。</p>
</div>
</div>
</body>
</html>验证码邮件模板 (resources/templates/email/verification-code.html):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>验证码</title>
<style>
.code-box {
background: #f5f5f5;
padding: 20px;
text-align: center;
margin: 20px 0;
}
.code {
font-size: 36px;
font-weight: bold;
color: #1890ff;
letter-spacing: 8px;
}
.expire-tip { color: #ff4d4f; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<p>您好,</p>
<p>您正在进行账户验证操作,验证码如下:</p>
<div class="code-box">
<span class="code" th:text="${code}">123456</span>
</div>
<p class="expire-tip">
验证码将在 <span th:text="${expireMinutes}">5</span> 分钟后失效,请尽快使用。
</p>
<p>如果这不是您的操作,请忽略此邮件,您的账户是安全的。</p>
</div>
</body>
</html>FreeMarker 模板支持
/**
* FreeMarker 邮件模板服务
*/
@Service
public class FreeMarkerMailService {
@Autowired
private Configuration freemarkerConfig;
public String processTemplate(String templateName, Map<String, Object> model) {
try {
Template template = freemarkerConfig.getTemplate(templateName + ".ftl");
StringWriter writer = new StringWriter();
template.process(model, writer);
return writer.toString();
} catch (Exception e) {
throw new RuntimeException("处理邮件模板失败: " + templateName, e);
}
}
public void sendTemplateEmail(String to, String subject,
String templateName, Map<String, Object> model) {
String content = processTemplate(templateName, model);
MailUtils.sendHtml(to, subject, content);
}
}邮件队列
消息队列集成
使用消息队列实现邮件异步发送,提高系统响应性能。
/**
* 邮件消息定义
*/
@Data
public class MailMessage implements Serializable {
private static final long serialVersionUID = 1L;
/** 消息ID */
private String messageId;
/** 收件人列表 */
private List<String> to;
/** 抄送列表 */
private List<String> cc;
/** 密送列表 */
private List<String> bcc;
/** 邮件主题 */
private String subject;
/** 邮件内容 */
private String content;
/** 是否HTML格式 */
private boolean html;
/** 附件路径列表 */
private List<String> attachments;
/** 模板名称 */
private String templateName;
/** 模板变量 */
private Map<String, Object> templateVariables;
/** 优先级(1-高,2-中,3-低) */
private Integer priority;
/** 计划发送时间 */
private Date scheduledTime;
/** 创建时间 */
private Date createTime;
/** 租户ID */
private String tenantId;
public MailMessage() {
this.messageId = IdUtils.fastUUID();
this.createTime = new Date();
this.priority = 2;
this.html = true;
}
}
/**
* 邮件消息生产者
*/
@Component
@Slf4j
public class MailMessageProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
private static final String MAIL_TOPIC = "mail-send-topic";
/**
* 发送即时邮件
*/
public void send(MailMessage message) {
rocketMQTemplate.asyncSend(MAIL_TOPIC, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("邮件消息发送成功: messageId={}, msgId={}",
message.getMessageId(), sendResult.getMsgId());
}
@Override
public void onException(Throwable e) {
log.error("邮件消息发送失败: messageId={}", message.getMessageId(), e);
}
});
}
/**
* 发送延迟邮件
*/
public void sendDelayed(MailMessage message, int delayLevel) {
Message<MailMessage> msg = MessageBuilder.withPayload(message).build();
rocketMQTemplate.syncSend(MAIL_TOPIC, msg, 3000, delayLevel);
}
/**
* 发送定时邮件
*/
public void sendScheduled(MailMessage message) {
if (message.getScheduledTime() == null) {
send(message);
return;
}
long delay = message.getScheduledTime().getTime() - System.currentTimeMillis();
if (delay <= 0) {
send(message);
return;
}
int delayLevel = calculateDelayLevel(delay);
sendDelayed(message, delayLevel);
}
private int calculateDelayLevel(long delayMs) {
long delaySeconds = delayMs / 1000;
if (delaySeconds <= 60) return 5; // 1分钟
if (delaySeconds <= 300) return 9; // 5分钟
if (delaySeconds <= 600) return 14; // 10分钟
if (delaySeconds <= 1800) return 16; // 30分钟
return 17; // 1小时
}
}
/**
* 邮件消息消费者
*/
@Component
@Slf4j
@RocketMQMessageListener(
topic = "mail-send-topic",
consumerGroup = "mail-consumer-group",
consumeThreadMax = 20
)
public class MailMessageConsumer implements RocketMQListener<MailMessage> {
@Autowired
private MailTemplateService templateService;
@Autowired
private MailSendRecordService recordService;
@Override
public void onMessage(MailMessage message) {
String messageId = message.getMessageId();
log.info("开始处理邮件消息: messageId={}", messageId);
long startTime = System.currentTimeMillis();
boolean success = false;
String errorMsg = null;
try {
// 处理模板
String content = message.getContent();
if (StringUtils.isNotBlank(message.getTemplateName())) {
content = templateService.processTemplate(
message.getTemplateName(),
message.getTemplateVariables()
);
}
// 发送邮件
if (message.isHtml()) {
MailUtils.sendHtml(message.getTo(), message.getSubject(), content);
} else {
MailUtils.sendText(message.getTo(), message.getSubject(), content);
}
success = true;
log.info("邮件发送成功: messageId={}, to={}", messageId, message.getTo());
} catch (Exception e) {
errorMsg = e.getMessage();
log.error("邮件发送失败: messageId={}", messageId, e);
throw new RuntimeException(e); // 触发重试
} finally {
// 记录发送结果
long costTime = System.currentTimeMillis() - startTime;
recordService.saveRecord(message, success, errorMsg, costTime);
}
}
}Redis 队列方案
/**
* 基于 Redis 的邮件队列服务
*/
@Service
@Slf4j
public class RedisMailQueueService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private MailSenderService mailSenderService;
private static final String MAIL_QUEUE_KEY = "mail:queue";
private static final String MAIL_DELAY_QUEUE_KEY = "mail:delay:queue";
/**
* 添加邮件到队列
*/
public void enqueue(MailMessage message) {
RQueue<MailMessage> queue = redissonClient.getQueue(MAIL_QUEUE_KEY);
queue.add(message);
log.info("邮件入队: messageId={}", message.getMessageId());
}
/**
* 添加延迟邮件到队列
*/
public void enqueueDelayed(MailMessage message, long delayMs) {
RDelayedQueue<MailMessage> delayedQueue = redissonClient.getDelayedQueue(
redissonClient.getQueue(MAIL_QUEUE_KEY)
);
delayedQueue.offer(message, delayMs, TimeUnit.MILLISECONDS);
log.info("延迟邮件入队: messageId={}, delay={}ms", message.getMessageId(), delayMs);
}
/**
* 消费邮件队列(定时任务调用)
*/
@Scheduled(fixedDelay = 1000)
public void processQueue() {
RQueue<MailMessage> queue = redissonClient.getQueue(MAIL_QUEUE_KEY);
MailMessage message;
while ((message = queue.poll()) != null) {
try {
mailSenderService.send(message);
} catch (Exception e) {
log.error("处理邮件失败: messageId={}", message.getMessageId(), e);
// 重新入队等待重试
handleFailedMessage(message);
}
}
}
/**
* 处理失败消息
*/
private void handleFailedMessage(MailMessage message) {
int retryCount = message.getRetryCount() + 1;
if (retryCount <= 3) {
message.setRetryCount(retryCount);
// 延迟重试(指数退避)
long delay = (long) Math.pow(2, retryCount) * 60 * 1000;
enqueueDelayed(message, delay);
} else {
log.error("邮件发送失败次数超过限制: messageId={}", message.getMessageId());
// 保存到失败队列
saveToFailedQueue(message);
}
}
}邮件发送记录
发送记录服务
/**
* 邮件发送记录实体
*/
@Data
@TableName("sys_mail_record")
public class MailSendRecord {
@TableId(type = IdType.AUTO)
private Long id;
/** 消息ID */
private String messageId;
/** 租户ID */
private String tenantId;
/** 收件人 */
private String recipients;
/** 抄送 */
private String cc;
/** 密送 */
private String bcc;
/** 主题 */
private String subject;
/** 内容摘要 */
private String contentSummary;
/** 发送状态(0-待发送,1-发送中,2-成功,3-失败) */
private Integer status;
/** 错误信息 */
private String errorMsg;
/** 重试次数 */
private Integer retryCount;
/** 发送耗时(毫秒) */
private Long costTime;
/** 创建时间 */
private Date createTime;
/** 发送时间 */
private Date sendTime;
/** 创建人 */
private String createBy;
}
/**
* 邮件发送记录服务
*/
@Service
@Slf4j
public class MailSendRecordService {
@Autowired
private MailSendRecordMapper recordMapper;
/**
* 保存发送记录
*/
public void saveRecord(MailMessage message, boolean success,
String errorMsg, long costTime) {
MailSendRecord record = new MailSendRecord();
record.setMessageId(message.getMessageId());
record.setTenantId(message.getTenantId());
record.setRecipients(String.join(",", message.getTo()));
record.setCc(CollUtil.isEmpty(message.getCc()) ? null :
String.join(",", message.getCc()));
record.setBcc(CollUtil.isEmpty(message.getBcc()) ? null :
String.join(",", message.getBcc()));
record.setSubject(message.getSubject());
record.setContentSummary(truncateContent(message.getContent(), 500));
record.setStatus(success ? 2 : 3);
record.setErrorMsg(errorMsg);
record.setRetryCount(0);
record.setCostTime(costTime);
record.setCreateTime(message.getCreateTime());
record.setSendTime(new Date());
record.setCreateBy(SecurityUtils.getUsername());
recordMapper.insert(record);
}
/**
* 分页查询发送记录
*/
public TableDataInfo<MailSendRecordVo> queryPageList(MailRecordQuery query) {
Page<MailSendRecord> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<MailSendRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(query.getRecipients()),
MailSendRecord::getRecipients, query.getRecipients());
wrapper.like(StringUtils.isNotBlank(query.getSubject()),
MailSendRecord::getSubject, query.getSubject());
wrapper.eq(query.getStatus() != null, MailSendRecord::getStatus, query.getStatus());
wrapper.between(query.getStartTime() != null && query.getEndTime() != null,
MailSendRecord::getSendTime, query.getStartTime(), query.getEndTime());
wrapper.orderByDesc(MailSendRecord::getCreateTime);
Page<MailSendRecord> result = recordMapper.selectPage(page, wrapper);
return TableDataInfo.build(BeanUtil.copyToList(result.getRecords(),
MailSendRecordVo.class), result.getTotal());
}
/**
* 获取发送统计
*/
public MailStatistics getStatistics(Date startDate, Date endDate) {
MailStatistics statistics = new MailStatistics();
// 总发送数
statistics.setTotalCount(recordMapper.selectCount(new LambdaQueryWrapper<MailSendRecord>()
.between(MailSendRecord::getSendTime, startDate, endDate)));
// 成功数
statistics.setSuccessCount(recordMapper.selectCount(new LambdaQueryWrapper<MailSendRecord>()
.eq(MailSendRecord::getStatus, 2)
.between(MailSendRecord::getSendTime, startDate, endDate)));
// 失败数
statistics.setFailCount(recordMapper.selectCount(new LambdaQueryWrapper<MailSendRecord>()
.eq(MailSendRecord::getStatus, 3)
.between(MailSendRecord::getSendTime, startDate, endDate)));
// 平均耗时
statistics.setAvgCostTime(recordMapper.selectAvgCostTime(startDate, endDate));
return statistics;
}
/**
* 重发失败邮件
*/
public void resend(Long recordId) {
MailSendRecord record = recordMapper.selectById(recordId);
if (record == null) {
throw new ServiceException("记录不存在");
}
if (record.getStatus() != 3) {
throw new ServiceException("只能重发失败的邮件");
}
// 构建邮件消息
MailMessage message = new MailMessage();
message.setTo(Arrays.asList(record.getRecipients().split(",")));
message.setSubject(record.getSubject());
// ... 从原始记录重建消息
// 发送邮件
mailMessageProducer.send(message);
// 更新状态为待发送
record.setStatus(1);
record.setRetryCount(record.getRetryCount() + 1);
recordMapper.updateById(record);
}
private String truncateContent(String content, int maxLength) {
if (StringUtils.isBlank(content)) {
return content;
}
if (content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
}记录查询接口
/**
* 邮件记录控制器
*/
@RestController
@RequestMapping("/mail/record")
@RequiredArgsConstructor
public class MailRecordController {
private final MailSendRecordService recordService;
/**
* 分页查询邮件记录
*/
@GetMapping("/list")
@SaCheckPermission("mail:record:list")
public TableDataInfo<MailSendRecordVo> list(MailRecordQuery query) {
return recordService.queryPageList(query);
}
/**
* 获取邮件详情
*/
@GetMapping("/{id}")
@SaCheckPermission("mail:record:query")
public R<MailSendRecordVo> getInfo(@PathVariable Long id) {
return R.ok(recordService.getRecordDetail(id));
}
/**
* 重发邮件
*/
@PostMapping("/resend/{id}")
@SaCheckPermission("mail:record:resend")
public R<Void> resend(@PathVariable Long id) {
recordService.resend(id);
return R.ok();
}
/**
* 获取发送统计
*/
@GetMapping("/statistics")
@SaCheckPermission("mail:record:query")
public R<MailStatistics> getStatistics(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate) {
return R.ok(recordService.getStatistics(startDate, endDate));
}
/**
* 导出邮件记录
*/
@PostMapping("/export")
@SaCheckPermission("mail:record:export")
public void export(MailRecordQuery query, HttpServletResponse response) {
List<MailSendRecordVo> list = recordService.queryList(query);
ExcelUtil.exportExcel(list, "邮件发送记录", MailSendRecordVo.class, response);
}
}多租户支持
租户邮件配置
/**
* 租户邮件配置实体
*/
@Data
@TableName("sys_tenant_mail_config")
public class TenantMailConfig {
@TableId(type = IdType.AUTO)
private Long id;
/** 租户ID */
private String tenantId;
/** SMTP服务器 */
private String host;
/** 端口 */
private Integer port;
/** 发件人地址 */
private String fromAddress;
/** 发件人名称 */
private String fromName;
/** 用户名 */
private String username;
/** 密码(加密存储) */
private String password;
/** 是否启用SSL */
private Boolean sslEnabled;
/** 是否启用STARTTLS */
private Boolean starttlsEnabled;
/** 连接超时(毫秒) */
private Long connectionTimeout;
/** 读取超时(毫秒) */
private Long timeout;
/** 每日发送限制 */
private Integer dailyLimit;
/** 是否启用 */
private Boolean enabled;
/** 创建时间 */
private Date createTime;
/** 更新时间 */
private Date updateTime;
}
/**
* 多租户邮件服务
*/
@Service
@Slf4j
public class TenantMailService {
@Autowired
private TenantMailConfigMapper configMapper;
@Autowired
private StringEncryptor stringEncryptor;
private final Cache<String, MailAccount> accountCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(30))
.maximumSize(100)
.build();
/**
* 获取租户邮件账户
*/
public MailAccount getTenantMailAccount(String tenantId) {
return accountCache.get(tenantId, key -> {
TenantMailConfig config = configMapper.selectOne(
new LambdaQueryWrapper<TenantMailConfig>()
.eq(TenantMailConfig::getTenantId, tenantId)
.eq(TenantMailConfig::getEnabled, true)
);
if (config == null) {
log.warn("租户邮件配置不存在: tenantId={}", tenantId);
return null;
}
return buildMailAccount(config);
});
}
/**
* 构建邮件账户
*/
private MailAccount buildMailAccount(TenantMailConfig config) {
MailAccount account = new MailAccount();
account.setHost(config.getHost());
account.setPort(config.getPort());
account.setFrom(config.getFromName() + " <" + config.getFromAddress() + ">");
account.setUser(config.getUsername());
account.setPass(stringEncryptor.decrypt(config.getPassword()));
account.setAuth(true);
account.setSslEnable(config.getSslEnabled());
account.setStarttlsEnable(config.getStarttlsEnabled());
if (config.getConnectionTimeout() != null && config.getConnectionTimeout() > 0) {
account.setConnectionTimeout(config.getConnectionTimeout());
}
if (config.getTimeout() != null && config.getTimeout() > 0) {
account.setTimeout(config.getTimeout());
}
return account;
}
/**
* 发送租户邮件
*/
public String sendTenantMail(String tenantId, String to, String subject,
String content, boolean isHtml) {
MailAccount account = getTenantMailAccount(tenantId);
if (account == null) {
throw new ServiceException("租户邮件服务未配置");
}
// 检查发送限制
checkDailyLimit(tenantId);
return cn.hutool.extra.mail.MailUtil.send(account,
Collections.singletonList(to), null, null, subject, content, isHtml);
}
/**
* 检查每日发送限制
*/
private void checkDailyLimit(String tenantId) {
TenantMailConfig config = configMapper.selectOne(
new LambdaQueryWrapper<TenantMailConfig>()
.eq(TenantMailConfig::getTenantId, tenantId)
);
if (config.getDailyLimit() == null || config.getDailyLimit() <= 0) {
return;
}
// 查询今日发送数量
long todaySent = recordMapper.countTodaySent(tenantId);
if (todaySent >= config.getDailyLimit()) {
throw new ServiceException("已达到每日发送限制: " + config.getDailyLimit());
}
}
/**
* 刷新租户配置缓存
*/
public void refreshCache(String tenantId) {
accountCache.invalidate(tenantId);
log.info("刷新租户邮件配置缓存: tenantId={}", tenantId);
}
/**
* 刷新所有缓存
*/
public void refreshAllCache() {
accountCache.invalidateAll();
log.info("刷新所有租户邮件配置缓存");
}
}租户配置管理接口
/**
* 租户邮件配置控制器
*/
@RestController
@RequestMapping("/tenant/mail/config")
@RequiredArgsConstructor
public class TenantMailConfigController {
private final TenantMailConfigService configService;
/**
* 获取当前租户配置
*/
@GetMapping
@SaCheckPermission("tenant:mail:query")
public R<TenantMailConfigVo> getConfig() {
String tenantId = TenantHelper.getTenantId();
return R.ok(configService.getConfig(tenantId));
}
/**
* 保存配置
*/
@PostMapping
@SaCheckPermission("tenant:mail:edit")
public R<Void> saveConfig(@Validated @RequestBody TenantMailConfigBo bo) {
String tenantId = TenantHelper.getTenantId();
configService.saveConfig(tenantId, bo);
return R.ok();
}
/**
* 测试邮件连接
*/
@PostMapping("/test")
@SaCheckPermission("tenant:mail:edit")
public R<Void> testConnection(@RequestBody TenantMailConfigBo bo) {
configService.testConnection(bo);
return R.ok();
}
/**
* 发送测试邮件
*/
@PostMapping("/sendTest")
@SaCheckPermission("tenant:mail:edit")
public R<Void> sendTestMail(@RequestParam String to) {
String tenantId = TenantHelper.getTenantId();
configService.sendTestMail(tenantId, to);
return R.ok();
}
}邮件服务健康检查
健康检查服务
/**
* 邮件服务健康检查
*/
@Component
@Slf4j
public class MailHealthIndicator implements HealthIndicator {
@Value("${mail.enabled:false}")
private boolean mailEnabled;
@Value("${mail.host:}")
private String mailHost;
@Value("${mail.port:0}")
private int mailPort;
@Override
public Health health() {
if (!mailEnabled) {
return Health.up()
.withDetail("status", "disabled")
.withDetail("message", "邮件服务未启用")
.build();
}
try {
// 测试SMTP连接
boolean connected = testSmtpConnection();
if (connected) {
return Health.up()
.withDetail("host", mailHost)
.withDetail("port", mailPort)
.withDetail("status", "connected")
.build();
} else {
return Health.down()
.withDetail("host", mailHost)
.withDetail("port", mailPort)
.withDetail("status", "connection_failed")
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("host", mailHost)
.withDetail("port", mailPort)
.withDetail("error", e.getMessage())
.build();
}
}
/**
* 测试SMTP连接
*/
private boolean testSmtpConnection() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(mailHost, mailPort), 5000);
return true;
} catch (IOException e) {
log.warn("SMTP连接测试失败: host={}, port={}", mailHost, mailPort, e);
return false;
}
}
}
/**
* 邮件连接测试服务
*/
@Service
@Slf4j
public class MailConnectionTestService {
/**
* 测试邮件服务器连接
*/
public ConnectionTestResult testConnection(MailAccount account) {
ConnectionTestResult result = new ConnectionTestResult();
result.setStartTime(new Date());
try {
// 1. 测试TCP连接
testTcpConnection(account.getHost(), account.getPort(), result);
// 2. 测试SMTP认证
testSmtpAuth(account, result);
result.setSuccess(true);
result.setMessage("连接测试成功");
} catch (Exception e) {
result.setSuccess(false);
result.setMessage("连接测试失败: " + e.getMessage());
log.error("邮件连接测试失败", e);
}
result.setEndTime(new Date());
result.setCostTime(result.getEndTime().getTime() - result.getStartTime().getTime());
return result;
}
private void testTcpConnection(String host, int port, ConnectionTestResult result) {
long start = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 10000);
result.setTcpConnected(true);
result.setTcpConnectTime(System.currentTimeMillis() - start);
} catch (IOException e) {
result.setTcpConnected(false);
result.setTcpError(e.getMessage());
throw new RuntimeException("TCP连接失败: " + e.getMessage());
}
}
private void testSmtpAuth(MailAccount account, ConnectionTestResult result) {
long start = System.currentTimeMillis();
try {
// 使用 Hutool 进行简单的发件人验证
Session session = cn.hutool.extra.mail.MailUtil.getSession(account, false);
Transport transport = session.getTransport("smtp");
transport.connect(account.getHost(), account.getPort(),
account.getUser(), account.getPass());
transport.close();
result.setAuthSuccess(true);
result.setAuthTime(System.currentTimeMillis() - start);
} catch (Exception e) {
result.setAuthSuccess(false);
result.setAuthError(e.getMessage());
throw new RuntimeException("SMTP认证失败: " + e.getMessage());
}
}
}
/**
* 连接测试结果
*/
@Data
public class ConnectionTestResult {
private boolean success;
private String message;
private Date startTime;
private Date endTime;
private long costTime;
private boolean tcpConnected;
private long tcpConnectTime;
private String tcpError;
private boolean authSuccess;
private long authTime;
private String authError;
}国际化支持
多语言邮件服务
/**
* 国际化邮件服务
*/
@Service
@Slf4j
public class I18nMailService {
@Autowired
private MessageSource messageSource;
@Autowired
private MailTemplateService templateService;
/**
* 发送国际化邮件
*/
public void sendI18nMail(String to, String templateKey,
Map<String, Object> variables, Locale locale) {
// 获取国际化主题
String subject = messageSource.getMessage(
"mail." + templateKey + ".subject", null, locale);
// 添加国际化变量
variables.put("locale", locale.getLanguage());
variables.put("greeting", getGreeting(locale));
// 使用对应语言的模板
String templateName = "email/" + templateKey + "_" + locale.getLanguage();
try {
templateService.sendTemplateEmail(to, templateName, variables);
} catch (Exception e) {
// 如果找不到语言特定模板,使用默认模板
log.warn("未找到语言特定模板: {}, 使用默认模板", templateName);
templateService.sendTemplateEmail(to, "email/" + templateKey, variables);
}
}
/**
* 获取问候语
*/
private String getGreeting(Locale locale) {
int hour = LocalTime.now().getHour();
String greetingKey;
if (hour < 12) {
greetingKey = "mail.greeting.morning";
} else if (hour < 18) {
greetingKey = "mail.greeting.afternoon";
} else {
greetingKey = "mail.greeting.evening";
}
return messageSource.getMessage(greetingKey, null, locale);
}
/**
* 发送多语言欢迎邮件
*/
public void sendWelcomeEmail(String to, String username, Locale locale) {
Map<String, Object> variables = new HashMap<>();
variables.put("username", username);
variables.put("loginUrl", "https://example.com/login");
sendI18nMail(to, "welcome", variables, locale);
}
}国际化资源配置
# messages_zh_CN.properties
mail.welcome.subject=欢迎加入我们
mail.password-reset.subject=密码重置
mail.verification.subject=验证码
mail.greeting.morning=早上好
mail.greeting.afternoon=下午好
mail.greeting.evening=晚上好
# messages_en_US.properties
mail.welcome.subject=Welcome to Join Us
mail.password-reset.subject=Password Reset
mail.verification.subject=Verification Code
mail.greeting.morning=Good Morning
mail.greeting.afternoon=Good Afternoon
mail.greeting.evening=Good Evening附件处理
大附件处理
/**
* 附件处理服务
*/
@Service
@Slf4j
public class MailAttachmentService {
@Autowired
private OssService ossService;
/** 直接附件最大大小(10MB) */
private static final long MAX_DIRECT_ATTACHMENT_SIZE = 10 * 1024 * 1024;
/** 单封邮件附件总大小限制(25MB) */
private static final long MAX_TOTAL_ATTACHMENT_SIZE = 25 * 1024 * 1024;
/**
* 处理附件,大文件转为下载链接
*/
public AttachmentResult processAttachments(List<File> files) {
AttachmentResult result = new AttachmentResult();
result.setDirectAttachments(new ArrayList<>());
result.setDownloadLinks(new ArrayList<>());
long totalSize = 0;
for (File file : files) {
long fileSize = file.length();
totalSize += fileSize;
if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) {
// 超过总大小限制,转为下载链接
String downloadUrl = uploadToOss(file);
result.getDownloadLinks().add(new AttachmentLink(
file.getName(), fileSize, downloadUrl
));
} else if (fileSize > MAX_DIRECT_ATTACHMENT_SIZE) {
// 单文件过大,转为下载链接
String downloadUrl = uploadToOss(file);
result.getDownloadLinks().add(new AttachmentLink(
file.getName(), fileSize, downloadUrl
));
totalSize -= fileSize; // 不计入直接附件大小
} else {
// 直接作为附件
result.getDirectAttachments().add(file);
}
}
return result;
}
/**
* 上传文件到OSS并返回下载链接
*/
private String uploadToOss(File file) {
try {
// 上传文件
String objectName = "mail-attachments/" + IdUtils.fastUUID() + "/" + file.getName();
ossService.upload(file, objectName);
// 生成临时下载链接(7天有效)
return ossService.getPresignedUrl(objectName, Duration.ofDays(7));
} catch (Exception e) {
log.error("上传附件到OSS失败: {}", file.getName(), e);
throw new ServiceException("附件处理失败");
}
}
/**
* 构建包含下载链接的邮件内容
*/
public String buildContentWithLinks(String content, List<AttachmentLink> links) {
if (CollUtil.isEmpty(links)) {
return content;
}
StringBuilder sb = new StringBuilder(content);
sb.append("<br/><br/>");
sb.append("<div style=\"background:#f5f5f5;padding:15px;border-radius:4px;\">");
sb.append("<p><strong>附件下载:</strong></p>");
sb.append("<ul>");
for (AttachmentLink link : links) {
sb.append("<li>");
sb.append("<a href=\"").append(link.getUrl()).append("\">");
sb.append(link.getFileName());
sb.append("</a>");
sb.append(" (").append(formatFileSize(link.getFileSize())).append(")");
sb.append(" - 链接7天内有效");
sb.append("</li>");
}
sb.append("</ul>");
sb.append("</div>");
return sb.toString();
}
private String formatFileSize(long size) {
if (size < 1024) {
return size + " B";
} else if (size < 1024 * 1024) {
return String.format("%.1f KB", size / 1024.0);
} else {
return String.format("%.1f MB", size / (1024.0 * 1024));
}
}
}
@Data
@AllArgsConstructor
public class AttachmentLink {
private String fileName;
private long fileSize;
private String url;
}
@Data
public class AttachmentResult {
private List<File> directAttachments;
private List<AttachmentLink> downloadLinks;
}监控与告警
邮件发送监控
/**
* 邮件监控服务
*/
@Component
@Slf4j
public class MailMonitorService {
@Autowired
private MeterRegistry meterRegistry;
@Autowired
private NotificationService notificationService;
// 发送计数器
private final Counter sendCounter;
private final Counter successCounter;
private final Counter failCounter;
// 发送耗时
private final Timer sendTimer;
// 失败率告警阈值
private static final double FAIL_RATE_THRESHOLD = 0.1;
public MailMonitorService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.sendCounter = Counter.builder("mail.send.total")
.description("邮件发送总数")
.register(meterRegistry);
this.successCounter = Counter.builder("mail.send.success")
.description("邮件发送成功数")
.register(meterRegistry);
this.failCounter = Counter.builder("mail.send.fail")
.description("邮件发送失败数")
.register(meterRegistry);
this.sendTimer = Timer.builder("mail.send.duration")
.description("邮件发送耗时")
.register(meterRegistry);
}
/**
* 记录发送请求
*/
public void recordSendRequest() {
sendCounter.increment();
}
/**
* 记录发送结果
*/
public void recordSendResult(boolean success, long costTime) {
if (success) {
successCounter.increment();
} else {
failCounter.increment();
}
sendTimer.record(costTime, TimeUnit.MILLISECONDS);
}
/**
* 定时检查失败率
*/
@Scheduled(fixedRate = 300000) // 每5分钟
public void checkFailRate() {
double total = sendCounter.count();
double fail = failCounter.count();
if (total > 100) {
double failRate = fail / total;
if (failRate > FAIL_RATE_THRESHOLD) {
sendAlert("邮件发送失败率告警",
String.format("当前邮件发送失败率 %.2f%% 超过阈值 %.2f%%",
failRate * 100, FAIL_RATE_THRESHOLD * 100));
}
}
}
/**
* 发送告警
*/
private void sendAlert(String title, String content) {
log.warn("邮件告警: {} - {}", title, content);
notificationService.sendDingTalkAlert(title, content);
}
}发送统计报表
/**
* 邮件统计服务
*/
@Service
public class MailStatisticsService {
@Autowired
private MailSendRecordMapper recordMapper;
/**
* 获取每日统计
*/
public List<DailyStatistics> getDailyStatistics(Date startDate, Date endDate) {
return recordMapper.selectDailyStatistics(startDate, endDate);
}
/**
* 获取按类型统计
*/
public Map<String, Long> getStatisticsByType(Date startDate, Date endDate) {
return recordMapper.selectStatisticsByType(startDate, endDate);
}
/**
* 生成月度报表
*/
public MonthlyReport generateMonthlyReport(int year, int month) {
MonthlyReport report = new MonthlyReport();
report.setYear(year);
report.setMonth(month);
LocalDate startDate = LocalDate.of(year, month, 1);
LocalDate endDate = startDate.plusMonths(1).minusDays(1);
Date start = Date.from(startDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
Date end = Date.from(endDate.atTime(23, 59, 59).atZone(ZoneId.systemDefault()).toInstant());
// 总发送数
report.setTotalCount(recordMapper.countByDateRange(start, end));
// 成功数
report.setSuccessCount(recordMapper.countByStatusAndDateRange(2, start, end));
// 失败数
report.setFailCount(recordMapper.countByStatusAndDateRange(3, start, end));
// 成功率
if (report.getTotalCount() > 0) {
report.setSuccessRate((double) report.getSuccessCount() / report.getTotalCount());
}
// 每日统计
report.setDailyStatistics(getDailyStatistics(start, end));
return report;
}
}注意事项
1. 授权码使用
// ✅ 正确:使用邮箱授权码
mail:
pass: abcdefghijklmnop # 16位授权码
// ❌ 错误:使用登录密码
mail:
pass: myLoginPassword123 # 登录密码通常无法用于SMTP2. 发件人格式
// ✅ 正确:RFC-822 标准格式
mail:
from: "系统通知 <noreply@example.com>" # 带名称
# 或
from: noreply@example.com # 仅地址
// ❌ 错误:格式不规范
mail:
from: <noreply@example.com> # 缺少名称时不需要尖括号3. SSL/TLS 配置
// ✅ 正确:SSL 模式(端口465)
mail:
port: 465
sslEnable: true
starttlsEnable: false
// ✅ 正确:STARTTLS 模式(端口587)
mail:
port: 587
sslEnable: false
starttlsEnable: true
// ❌ 错误:端口和加密方式不匹配
mail:
port: 465
sslEnable: false # 465端口通常需要SSL4. 批量发送限制
// ✅ 正确:分批发送,添加延迟
public void sendBulkEmails(List<String> recipients, String subject, String content) {
int batchSize = 50; // 每批50封
for (int i = 0; i < recipients.size(); i += batchSize) {
List<String> batch = recipients.subList(i, Math.min(i + batchSize, recipients.size()));
MailUtils.sendHtml(batch, subject, content);
// 批次间延迟
Thread.sleep(1000);
}
}
// ❌ 错误:一次发送大量邮件
public void sendBulkEmails(List<String> recipients, String subject, String content) {
// 可能触发服务商限制
MailUtils.sendHtml(recipients, subject, content);
}5. 邮件内容安全
// ✅ 正确:对用户输入进行转义
public String buildSafeHtmlContent(String userInput) {
String safeContent = HtmlUtil.escape(userInput);
return "<p>" + safeContent + "</p>";
}
// ❌ 错误:直接使用用户输入
public String buildHtmlContent(String userInput) {
// 可能导致XSS攻击
return "<p>" + userInput + "</p>";
}6. 附件大小限制
// ✅ 正确:检查附件大小
public void sendWithAttachment(String to, String subject, File attachment) {
// 检查单文件大小(通常限制10-25MB)
if (attachment.length() > 25 * 1024 * 1024) {
throw new ServiceException("附件大小超过限制");
}
MailUtils.sendHtml(to, subject, content, attachment);
}
// ❌ 错误:不检查附件大小
public void sendWithAttachment(String to, String subject, File attachment) {
// 可能因附件过大导致发送失败
MailUtils.sendHtml(to, subject, content, attachment);
}7. 收件人验证
// ✅ 正确:验证邮箱格式
public void sendEmail(String to, String subject, String content) {
if (!isValidEmail(to)) {
throw new ServiceException("无效的邮箱地址");
}
MailUtils.sendHtml(to, subject, content);
}
private boolean isValidEmail(String email) {
return email != null &&
email.matches("^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$");
}
// ❌ 错误:不验证直接发送
public void sendEmail(String to, String subject, String content) {
// 可能发送到无效地址
MailUtils.sendHtml(to, subject, content);
}8. 异步发送处理
// ✅ 正确:异步发送并处理异常
@Async("mailExecutor")
public CompletableFuture<String> sendEmailAsync(String to, String subject, String content) {
try {
String messageId = MailUtils.sendHtml(to, subject, content);
log.info("邮件发送成功: to={}, messageId={}", to, messageId);
return CompletableFuture.completedFuture(messageId);
} catch (Exception e) {
log.error("邮件发送失败: to={}", to, e);
// 记录失败,可以后续重试
recordFailedEmail(to, subject, content, e);
return CompletableFuture.failedFuture(e);
}
}
// ❌ 错误:异步发送不处理异常
@Async
public void sendEmailAsync(String to, String subject, String content) {
// 异常被吞没,难以追踪问题
MailUtils.sendHtml(to, subject, content);
}9. 敏感信息保护
// ✅ 正确:配置加密
mail:
pass: ENC(encrypted_auth_code) # 使用Jasypt加密
// ✅ 正确:使用环境变量
mail:
pass: ${MAIL_AUTH_CODE} # 从环境变量读取
// ❌ 错误:明文存储
mail:
pass: myAuthCode123456 # 授权码明文存储在配置文件中10. 日志脱敏
// ✅ 正确:日志中脱敏敏感信息
public void logMailSend(String to, String subject) {
String maskedTo = maskEmail(to); // user@example.com -> u***@example.com
log.info("发送邮件: to={}, subject={}", maskedTo, subject);
}
private String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex > 1) {
return email.charAt(0) + "***" + email.substring(atIndex);
}
return "***";
}
// ❌ 错误:记录完整邮箱地址
public void logMailSend(String to, String subject) {
log.info("发送邮件: to={}, subject={}", to, subject); // 泄露用户邮箱
}MailUtils API 完整参考
核心类概览
MailUtils 是邮件模块的核心工具类,继承自 Hutool 的邮件工具,提供了丰富的静态方法用于邮件发送。
/**
* 邮件工具类
*
* 特性:
* 1. 基于 Hutool 的 JakartaMail 实现
* 2. 自动从 Spring 容器获取 MailAccount 配置
* 3. 支持多种发送方式:文本、HTML、附件、内嵌图片
* 4. 支持多收件人、抄送、密送
* 5. 支持自定义邮件账户
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MailUtils {
// 从 Spring 容器获取邮件账户配置
private static final MailAccount ACCOUNT = SpringUtils.getBean(MailAccount.class);
}获取邮件账户
/**
* 获取默认邮件账户
* 从 Spring 容器获取配置好的 MailAccount 实例
*/
public static MailAccount getMailAccount();
/**
* 获取自定义邮件账户
* 基于默认账户,覆盖指定的发件人信息
*
* @param from 发件人地址(为空时使用默认值)
* @param user 用户名(为空时使用默认值)
* @param pass 密码/授权码(为空时使用默认值)
*/
public static MailAccount getMailAccount(String from, String user, String pass);使用示例:
// 获取默认账户
MailAccount account = MailUtils.getMailAccount();
log.info("默认发件人: {}", account.getFrom());
// 临时切换发件人
MailAccount customAccount = MailUtils.getMailAccount(
"另一个邮箱 <other@example.com>",
"other@example.com",
"other-auth-code"
);发送文本邮件
/**
* 发送文本邮件(单个或多个收件人)
* 多个收件人使用逗号","或分号";"分隔
*
* @param to 收件人
* @param subject 邮件标题
* @param content 邮件内容(纯文本)
* @param files 附件列表(可选)
* @return message-id
*/
public static String sendText(String to, String subject, String content, File... files);
/**
* 发送文本邮件给多人
*
* @param tos 收件人列表
* @param subject 邮件标题
* @param content 邮件内容
* @param files 附件列表
* @return message-id
*/
public static String sendText(Collection<String> tos, String subject, String content, File... files);使用示例:
// 发送给单个收件人
String messageId = MailUtils.sendText(
"user@example.com",
"测试邮件",
"这是一封测试邮件"
);
// 发送给多个收件人(逗号分隔)
MailUtils.sendText(
"user1@example.com,user2@example.com",
"通知",
"重要通知内容"
);
// 发送给多个收件人(列表方式)
List<String> recipients = Arrays.asList(
"user1@example.com",
"user2@example.com"
);
MailUtils.sendText(recipients, "群发通知", "这是群发的通知");
// 带附件的文本邮件
File report = new File("/path/to/report.pdf");
MailUtils.sendText(
"manager@example.com",
"月度报告",
"请查看附件中的月度报告",
report
);发送HTML邮件
/**
* 发送HTML邮件(单个或多个收件人)
*
* @param to 收件人
* @param subject 邮件标题
* @param content HTML格式内容
* @param files 附件列表
* @return message-id
*/
public static String sendHtml(String to, String subject, String content, File... files);
/**
* 发送HTML邮件给多人
*/
public static String sendHtml(Collection<String> tos, String subject, String content, File... files);
/**
* 发送带内嵌图片的HTML邮件
*
* @param to 收件人
* @param subject 邮件标题
* @param content HTML内容(使用cid:引用图片)
* @param imageMap 图片映射(key为占位符,value为图片流)
* @param files 附件列表
* @return message-id
*/
public static String sendHtml(String to, String subject, String content,
Map<String, InputStream> imageMap, File... files);使用示例:
// 简单HTML邮件
String html = """
<html>
<body>
<h1 style="color: #1890ff;">欢迎注册</h1>
<p>感谢您注册我们的服务!</p>
<a href="https://example.com" style="
display: inline-block;
padding: 10px 20px;
background: #1890ff;
color: white;
text-decoration: none;
border-radius: 4px;
">立即登录</a>
</body>
</html>
""";
MailUtils.sendHtml("user@example.com", "欢迎注册", html);
// 带内嵌图片的HTML邮件
Map<String, InputStream> images = new HashMap<>();
images.put("logo", new FileInputStream("/path/to/logo.png"));
images.put("banner", new FileInputStream("/path/to/banner.jpg"));
String htmlWithImages = """
<html>
<body>
<img src="cid:logo" alt="Logo" style="width: 100px;" />
<h1>新品上市</h1>
<img src="cid:banner" alt="Banner" style="width: 100%;" />
<p>点击查看更多...</p>
</body>
</html>
""";
MailUtils.sendHtml("customer@example.com", "新品推荐", htmlWithImages, images);发送抄送/密送邮件
/**
* 发送带抄送和密送的邮件
*
* @param to 收件人(多个用逗号或分号分隔)
* @param cc 抄送人(多个用逗号或分号分隔)
* @param bcc 密送人(多个用逗号或分号分隔)
* @param subject 标题
* @param content 内容
* @param isHtml 是否HTML格式
* @param files 附件
* @return message-id
*/
public static String send(String to, String cc, String bcc, String subject,
String content, boolean isHtml, File... files);
/**
* 发送带抄送和密送的邮件(列表方式)
*/
public static String send(Collection<String> tos, Collection<String> ccs,
Collection<String> bccs, String subject, String content,
boolean isHtml, File... files);使用示例:
// 使用字符串形式
MailUtils.send(
"primary@example.com", // 主收件人
"cc1@example.com,cc2@example.com", // 抄送
"bcc@example.com", // 密送
"项目进度报告",
"<h1>本周进度</h1><p>项目进展顺利...</p>",
true // HTML格式
);
// 使用列表形式
List<String> tos = Arrays.asList("manager@example.com");
List<String> ccs = Arrays.asList("team1@example.com", "team2@example.com");
List<String> bccs = Arrays.asList("archive@example.com");
MailUtils.send(tos, ccs, bccs, "会议纪要", meetingContent, true);使用自定义邮件账户
/**
* 使用自定义账户发送邮件
*
* @param mailAccount 自定义邮件账户
* @param to 收件人
* @param subject 标题
* @param content 内容
* @param isHtml 是否HTML格式
* @param files 附件
* @return message-id
*/
public static String send(MailAccount mailAccount, String to, String subject,
String content, boolean isHtml, File... files);
/**
* 使用自定义账户发送邮件给多人(完整参数版本)
*/
public static String send(MailAccount mailAccount, Collection<String> tos,
Collection<String> ccs, Collection<String> bccs, String subject,
String content, Map<String, InputStream> imageMap,
boolean isHtml, File... files);使用示例:
// 创建完全自定义的邮件账户
MailAccount customAccount = new MailAccount();
customAccount.setHost("smtp.custom.com");
customAccount.setPort(587);
customAccount.setFrom("系统通知 <notify@custom.com>");
customAccount.setUser("notify@custom.com");
customAccount.setPass("auth-code");
customAccount.setAuth(true);
customAccount.setStarttlsEnable(true);
customAccount.setSslEnable(false);
// 使用自定义账户发送
MailUtils.send(
customAccount,
"user@example.com",
"来自自定义账户的邮件",
"这是使用自定义账户发送的邮件",
false
);
// 多租户场景:每个租户使用不同的邮件账户
public void sendTenantEmail(String tenantId, String to, String subject, String content) {
MailAccount tenantAccount = getTenantMailAccount(tenantId);
MailUtils.send(tenantAccount, to, subject, content, true);
}获取邮件会话
/**
* 获取邮件客户端会话
*
* @param mailAccount 邮件账户配置
* @param isSingleton 是否使用单例会话(全局共享)
* @return Jakarta Mail Session
*/
public static Session getSession(MailAccount mailAccount, boolean isSingleton);使用示例:
// 获取单例会话(性能更好,适合高频发送)
Session sharedSession = MailUtils.getSession(MailUtils.getMailAccount(), true);
// 获取独立会话(隔离性更好,适合多账户场景)
Session isolatedSession = MailUtils.getSession(customAccount, false);
// 直接使用 Session 进行更底层的邮件操作
Transport transport = sharedSession.getTransport("smtp");
try {
transport.connect();
// ... 发送邮件
} finally {
transport.close();
}地址分割处理
MailUtils 内部支持多种收件人格式:
// 支持逗号分隔
"user1@example.com,user2@example.com"
// 支持分号分隔
"user1@example.com;user2@example.com"
// 自动去除空格
"user1@example.com, user2@example.com"
"user1@example.com ; user2@example.com"
// 支持单个地址
"user@example.com"自动配置原理
MailAutoConfiguration 详解
邮件模块使用 Spring Boot 自动配置机制,在应用启动时自动创建 MailAccount Bean。
/**
* 邮件服务自动配置类
*
* 配置启用条件:mail.enabled=true
*/
@AutoConfiguration
@EnableConfigurationProperties(MailProperties.class)
public class MailAutoConfiguration {
/**
* 创建邮件账户 Bean
*
* 条件:配置 mail.enabled=true 时才创建
*/
@Bean
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true")
public MailAccount mailAccount(MailProperties mailProperties) {
MailAccount account = new MailAccount();
// 基础连接配置
account.setHost(mailProperties.getHost());
account.setPort(mailProperties.getPort());
account.setAuth(mailProperties.getAuth());
// 用户认证信息
account.setFrom(mailProperties.getFrom());
account.setUser(mailProperties.getUser());
account.setPass(mailProperties.getPass());
// SSL/TLS 安全配置
account.setSocketFactoryPort(mailProperties.getPort());
account.setStarttlsEnable(mailProperties.getStarttlsEnable());
account.setSslEnable(mailProperties.getSslEnable());
// 超时配置
account.setTimeout(mailProperties.getTimeout());
account.setConnectionTimeout(mailProperties.getConnectionTimeout());
return account;
}
}MailProperties 配置属性
/**
* 邮件配置属性类
*
* 配置前缀:mail
*/
@Data
@ConfigurationProperties(prefix = "mail")
public class MailProperties {
/** 是否启用邮件服务 */
private Boolean enabled;
/** SMTP服务器域名 */
private String host;
/** SMTP服务端口 */
private Integer port;
/** 是否需要用户名密码验证 */
private Boolean auth;
/** 用户名 */
private String user;
/** 密码/授权码 */
private String pass;
/**
* 发件人地址(RFC-822标准)
*
* 支持格式:
* 1. user@xxx.xx
* 2. 名称 <user@xxx.xx>
*/
private String from;
/** 是否启用STARTTLS */
private Boolean starttlsEnable;
/** 是否启用SSL */
private Boolean sslEnable;
/** SMTP超时时长(毫秒),0表示不超时 */
private Long timeout;
/** 连接超时时长(毫秒),0表示不超时 */
private Long connectionTimeout;
}配置加载流程
1. 应用启动
↓
2. Spring Boot 扫描 @AutoConfiguration
↓
3. 加载 MailAutoConfiguration
↓
4. 绑定配置到 MailProperties
↓
5. 检查条件:mail.enabled=true?
├── 是 → 创建 MailAccount Bean
└── 否 → 跳过配置
↓
6. MailUtils 通过 SpringUtils 获取 MailAccount
↓
7. 邮件服务就绪JakartaMail 集成
底层实现说明
邮件模块基于 Hutool 的 JakartaMail 实现,这是对 Jakarta Mail(原 JavaMail)的封装。
/**
* 内部发送方法实现
*/
private static String send(MailAccount mailAccount, boolean useGlobalSession,
Collection<String> tos, Collection<String> ccs, Collection<String> bccs,
String subject, String content, Map<String, InputStream> imageMap,
boolean isHtml, File... files) {
// 创建 JakartaMail 实例
final JakartaMail mail = JakartaMail.create(mailAccount)
.setUseGlobalSession(useGlobalSession);
// 设置收件人
mail.setTos(tos.toArray(new String[0]));
// 设置抄送(可选)
if (CollUtil.isNotEmpty(ccs)) {
mail.setCcs(ccs.toArray(new String[0]));
}
// 设置密送(可选)
if (CollUtil.isNotEmpty(bccs)) {
mail.setBccs(bccs.toArray(new String[0]));
}
// 设置邮件内容
mail.setTitle(subject);
mail.setContent(content);
mail.setHtml(isHtml);
mail.setFiles(files);
// 处理内嵌图片
if (MapUtil.isNotEmpty(imageMap)) {
for (Entry<String, InputStream> entry : imageMap.entrySet()) {
mail.addImage(entry.getKey(), entry.getValue());
IoUtil.close(entry.getValue()); // 关闭流
}
}
// 发送并返回 message-id
return mail.send();
}Session 管理策略
/**
* 会话管理
*
* 单例模式(useGlobalSession=true):
* - 全局共享一个 Session 实例
* - 适合单账户高频发送场景
* - 性能更好,减少重复创建开销
*
* 非单例模式(useGlobalSession=false):
* - 每次创建新的 Session 实例
* - 适合多账户或需要隔离的场景
* - 更安全,避免配置污染
*/
public static Session getSession(MailAccount mailAccount, boolean isSingleton) {
Authenticator authenticator = null;
if (mailAccount.isAuth()) {
authenticator = new JakartaUserPassAuthenticator(
mailAccount.getUser(),
mailAccount.getPass()
);
}
return isSingleton
? Session.getDefaultInstance(mailAccount.getSmtpProps(), authenticator)
: Session.getInstance(mailAccount.getSmtpProps(), authenticator);
}测试策略
单元测试
/**
* MailUtils 单元测试
*/
@SpringBootTest
class MailUtilsTest {
@Test
@DisplayName("测试获取邮件账户")
void testGetMailAccount() {
MailAccount account = MailUtils.getMailAccount();
assertNotNull(account);
assertNotNull(account.getHost());
assertTrue(account.getPort() > 0);
}
@Test
@DisplayName("测试地址分割")
void testAddressSplit() {
// 使用反射测试私有方法
Method splitMethod = MailUtils.class.getDeclaredMethod(
"splitAddress", String.class);
splitMethod.setAccessible(true);
// 测试逗号分隔
List<String> result1 = (List<String>) splitMethod.invoke(null,
"a@test.com,b@test.com");
assertEquals(2, result1.size());
// 测试分号分隔
List<String> result2 = (List<String>) splitMethod.invoke(null,
"a@test.com;b@test.com");
assertEquals(2, result2.size());
// 测试单个地址
List<String> result3 = (List<String>) splitMethod.invoke(null,
"single@test.com");
assertEquals(1, result3.size());
// 测试空值
List<String> result4 = (List<String>) splitMethod.invoke(null, (String) null);
assertNull(result4);
}
@Test
@DisplayName("测试自定义账户")
void testCustomAccount() {
MailAccount account = MailUtils.getMailAccount(
"custom@test.com",
"custom@test.com",
"custom-pass"
);
assertEquals("custom@test.com", account.getFrom());
assertEquals("custom@test.com", account.getUser());
assertEquals("custom-pass", account.getPass());
}
}集成测试
/**
* 邮件发送集成测试
*
* 注意:集成测试需要真实的邮件服务器配置
*/
@SpringBootTest
@TestPropertySource(properties = {
"mail.enabled=true",
"mail.host=smtp.test.com",
"mail.port=587"
})
class MailIntegrationTest {
@Value("${test.mail.recipient:}")
private String testRecipient;
@Test
@DisplayName("发送文本邮件")
@Disabled("需要真实邮件配置")
void testSendTextMail() {
Assumptions.assumeTrue(StringUtils.isNotBlank(testRecipient),
"需要配置测试收件人");
String messageId = MailUtils.sendText(
testRecipient,
"集成测试 - 文本邮件",
"这是自动化测试发送的邮件,请忽略。\n测试时间: " + LocalDateTime.now()
);
assertNotNull(messageId);
log.info("邮件发送成功,MessageId: {}", messageId);
}
@Test
@DisplayName("发送HTML邮件")
@Disabled("需要真实邮件配置")
void testSendHtmlMail() {
Assumptions.assumeTrue(StringUtils.isNotBlank(testRecipient));
String html = """
<div style="font-family: Arial; padding: 20px;">
<h2 style="color: #1890ff;">集成测试</h2>
<p>这是自动化测试发送的HTML邮件</p>
<p>测试时间: %s</p>
</div>
""".formatted(LocalDateTime.now());
String messageId = MailUtils.sendHtml(
testRecipient,
"集成测试 - HTML邮件",
html
);
assertNotNull(messageId);
}
@Test
@DisplayName("发送带附件邮件")
@Disabled("需要真实邮件配置")
void testSendMailWithAttachment() throws IOException {
Assumptions.assumeTrue(StringUtils.isNotBlank(testRecipient));
// 创建临时测试文件
File tempFile = File.createTempFile("test-attachment", ".txt");
Files.writeString(tempFile.toPath(), "这是测试附件内容");
try {
String messageId = MailUtils.sendText(
testRecipient,
"集成测试 - 带附件邮件",
"请查看附件",
tempFile
);
assertNotNull(messageId);
} finally {
tempFile.delete();
}
}
}Mock 测试
/**
* 使用 Mock 的邮件测试
*/
@ExtendWith(MockitoExtension.class)
class MailServiceMockTest {
@Mock
private MailAccount mockMailAccount;
@InjectMocks
private EmailService emailService;
@Test
@DisplayName("测试邮件服务 - 成功场景")
void testSendEmailSuccess() {
// 配置 Mock
when(mockMailAccount.getHost()).thenReturn("smtp.test.com");
when(mockMailAccount.getPort()).thenReturn(587);
when(mockMailAccount.isAuth()).thenReturn(true);
// 测试逻辑
// ...
}
@Test
@DisplayName("测试邮件服务 - 异常处理")
void testSendEmailException() {
// 模拟异常
doThrow(new RuntimeException("连接失败"))
.when(mockMailAccount).getSmtpProps();
// 验证异常处理
assertThrows(ServiceException.class, () -> {
emailService.sendEmail("to@test.com", "测试", "内容");
});
}
}使用 GreenMail 进行邮件测试
/**
* 使用 GreenMail 进行本地邮件测试
*
* 添加依赖:
* <dependency>
* <groupId>com.icegreen</groupId>
* <artifactId>greenmail-junit5</artifactId>
* <version>2.0.0</version>
* <scope>test</scope>
* </dependency>
*/
@ExtendWith(GreenMailExtension.class)
class GreenMailTest {
@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(
ServerSetupTest.SMTP);
@Test
@DisplayName("使用 GreenMail 测试邮件发送")
void testWithGreenMail() throws Exception {
// 配置邮件账户指向 GreenMail
MailAccount account = new MailAccount();
account.setHost("localhost");
account.setPort(greenMail.getSmtp().getPort());
account.setFrom("sender@test.com");
account.setAuth(false);
// 发送测试邮件
MailUtils.send(account, "recipient@test.com",
"测试邮件", "测试内容", false);
// 验证邮件
MimeMessage[] messages = greenMail.getReceivedMessages();
assertEquals(1, messages.length);
assertEquals("测试邮件", messages[0].getSubject());
assertTrue(GreenMailUtil.getBody(messages[0]).contains("测试内容"));
}
}故障排查指南
常见错误及解决方案
1. 认证失败
错误信息:
javax.mail.AuthenticationFailedException: 535 Authentication failed可能原因:
- 密码错误(应使用授权码而非登录密码)
- 用户名格式不正确
- 邮箱未开启 SMTP 服务
解决方案:
// 检查配置
@Test
void diagnoseAuthFailure() {
MailAccount account = MailUtils.getMailAccount();
log.info("Host: {}", account.getHost());
log.info("Port: {}", account.getPort());
log.info("User: {}", account.getUser());
log.info("Pass length: {}",
account.getPass() != null ? account.getPass().length() : 0);
// 确保使用授权码
// QQ邮箱:16位字符
// 163邮箱:通常也是16位
// Gmail:16位应用专用密码
}2. 连接超时
错误信息:
java.net.SocketTimeoutException: connect timed out可能原因:
- 网络问题
- 端口被防火墙阻止
- SMTP 服务器地址错误
解决方案:
// 测试网络连接
public boolean testConnection(String host, int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 5000);
log.info("连接成功: {}:{}", host, port);
return true;
} catch (IOException e) {
log.error("连接失败: {}:{} - {}", host, port, e.getMessage());
return false;
}
}
// 使用命令行测试
// Windows: telnet smtp.qq.com 587
// Linux: nc -zv smtp.qq.com 5873. SSL/TLS 握手失败
错误信息:
javax.net.ssl.SSLHandshakeException: No appropriate protocol可能原因:
- Java 版本与 TLS 版本不兼容
- SSL 配置与端口不匹配
解决方案:
# 检查端口和SSL配置是否匹配
mail:
port: 465 # SSL 端口
sslEnable: true # 必须启用 SSL
# 或者
mail:
port: 587 # STARTTLS 端口
starttlsEnable: true
sslEnable: false// 如果是 Java 版本问题,可以尝试设置系统属性
System.setProperty("mail.smtp.ssl.protocols", "TLSv1.2");
System.setProperty("mail.smtp.ssl.ciphersuites",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");4. 发件人地址被拒绝
错误信息:
553 Mail from must equal authorized user可能原因:
- from 地址与认证用户不一致
解决方案:
mail:
from: sender@qq.com # 必须与 user 一致
user: sender@qq.com5. 收件人被拒绝
错误信息:
550 Invalid User / Mailbox not found可能原因:
- 收件人邮箱不存在
- 邮箱格式不正确
解决方案:
// 发送前验证邮箱格式
public boolean isValidEmail(String email) {
if (email == null || email.isBlank()) {
return false;
}
String regex = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*" +
"@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
return email.matches(regex);
}
// 批量发送时跳过无效地址
public void sendToValidRecipients(List<String> recipients,
String subject, String content) {
List<String> validEmails = recipients.stream()
.filter(this::isValidEmail)
.collect(Collectors.toList());
if (validEmails.isEmpty()) {
throw new ServiceException("没有有效的收件人");
}
MailUtils.sendHtml(validEmails, subject, content);
}6. 邮件被标记为垃圾邮件
可能原因:
- 发件人域名没有配置 SPF/DKIM
- 邮件内容触发垃圾邮件规则
- 发送频率过高
解决方案:
// 1. 配置合适的发件人名称
mail:
from: "系统通知 <noreply@yourdomain.com>"
// 2. 避免垃圾邮件特征
public String sanitizeContent(String content) {
// 避免全大写
// 避免过多链接
// 避免敏感词汇
// 确保有退订链接
return content + "\n\n如不想接收此类邮件,请点击退订。";
}
// 3. 控制发送频率
@RateLimiter(key = "mail:send", count = 100, time = 3600) // 每小时100封
public void sendEmail(String to, String subject, String content) {
MailUtils.sendHtml(to, subject, content);
}调试日志配置
# 开启详细的邮件调试日志
logging:
level:
# Hutool 邮件日志
cn.hutool.extra.mail: DEBUG
# 邮件模块日志
plus.ruoyi.common.mail: DEBUG
# Jakarta Mail 详细日志
jakarta.mail: DEBUG
com.sun.mail: DEBUG
# 或者在代码中开启调试
mail.debug=true// 启用邮件调试模式
@Bean
public MailAccount mailAccount(MailProperties properties) {
MailAccount account = new MailAccount();
// ... 其他配置
// 开启调试模式
account.setDebug(true);
return account;
}性能诊断
/**
* 邮件发送性能诊断
*/
@Component
@Slf4j
public class MailDiagnostics {
/**
* 诊断邮件发送性能
*/
public DiagnosticResult diagnose(String testRecipient) {
DiagnosticResult result = new DiagnosticResult();
// 1. 测试 TCP 连接
result.setTcpConnectTime(measureTcpConnect());
// 2. 测试 SMTP 握手
result.setSmtpHandshakeTime(measureSmtpHandshake());
// 3. 测试实际发送
if (testRecipient != null) {
result.setSendTime(measureSend(testRecipient));
}
return result;
}
private long measureTcpConnect() {
MailAccount account = MailUtils.getMailAccount();
long start = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress(account.getHost(), account.getPort()),
10000
);
} catch (IOException e) {
log.error("TCP 连接失败", e);
return -1;
}
return System.currentTimeMillis() - start;
}
private long measureSmtpHandshake() {
long start = System.currentTimeMillis();
try {
Session session = MailUtils.getSession(
MailUtils.getMailAccount(), false);
Transport transport = session.getTransport("smtp");
MailAccount account = MailUtils.getMailAccount();
transport.connect(account.getHost(), account.getPort(),
account.getUser(), account.getPass());
transport.close();
} catch (Exception e) {
log.error("SMTP 握手失败", e);
return -1;
}
return System.currentTimeMillis() - start;
}
private long measureSend(String recipient) {
long start = System.currentTimeMillis();
try {
MailUtils.sendText(recipient, "性能测试",
"这是性能测试邮件,请忽略。");
} catch (Exception e) {
log.error("发送失败", e);
return -1;
}
return System.currentTimeMillis() - start;
}
}
@Data
class DiagnosticResult {
private long tcpConnectTime;
private long smtpHandshakeTime;
private long sendTime;
public String getSummary() {
return String.format(
"TCP连接: %dms, SMTP握手: %dms, 发送: %dms",
tcpConnectTime, smtpHandshakeTime, sendTime
);
}
}常用诊断命令
# 测试 SMTP 连接(Windows)
Test-NetConnection -ComputerName smtp.qq.com -Port 587
# 测试 SMTP 连接(Linux/macOS)
nc -zv smtp.qq.com 587
telnet smtp.qq.com 587
# 使用 OpenSSL 测试 SSL 连接
openssl s_client -connect smtp.qq.com:465 -starttls smtp
# 查看 DNS 解析
nslookup smtp.qq.com
# 检查本地端口占用
netstat -an | grep 25
netstat -an | grep 587
netstat -an | grep 465