随着互联网的发展,越来越多的应用需要确保用户的注册行为真实有效。其中一种常见的方式就是通过发送邮件进行身份认证——即我们常说的「邮箱验证」机制。本文将带你一步步搭建一个基于 Spring Boot 3.x 版本并集成 SMTP 邮件服务来实现用户注册后的邮箱激活流程。
大体流程也很简单:用户注册完,系统自动发个带验证链接的邮件,用户点一下链接,就能完成账号激活。
一、环境准备
首先说下开发环境:
- JDK 17+;
- Spring Boot 3.0+(本文用 3.2.5);
- 邮箱服务商:QQ邮箱(个人开发最方便,不用额外配置)
接下来直接在pom.xml中添加以下依赖::
<!-- 邮件发送核心依赖 --> org.springframework.boot spring-boot-starter-mail <!-- 工具类依赖 --> cn.hutool hutool-all 5.8.25 <!-- Web依赖 --> org.springframework.boot spring-boot-starter-web <!-- MyBatis-Plus 核心依赖 --> com.baomidou mybatis-plus-spring-boot3-starter 3.5.16 <!-- 数据库驱动 --> com.mysql mysql-connector-j runtime 二、邮箱配置
不管用哪种邮箱,核心就一件事:获取“授权码”,注意不是邮箱密码!然后把邮件参数配置到application.yml里。这里以QQ邮箱为例,步骤很简单:
2.1 获取QQ邮箱授权码
- 登录自己的QQ邮箱,点击右上角「设置」,再选「账号与安全-安全设置」;
- 找到「POP3/IMAP/SMTP/Exchange/CardDAV 服务」,把「IMAP/SMTP服务」打开;
- 按照提示发送短信验证,验证通过后,会生成一个授权码,一定要保存好,配置的时候要用,忘了就得重新获取。
如下图:
2.2 配置application.yml
把邮箱、数据库这些参数,都配置到application.yml里:
spring: # 邮件配置 mail: # 邮件服务器地址(QQ邮箱固定是smtp.qq.com,163邮箱是smtp.163.com) host: smtp.qq.com # 发送方邮箱(换成你自己的QQ邮箱,比如123456789@qq.com) username: 123456789@qq.com # 刚才获取的授权码(替换成你自己的,别直接复制我的) password: abcdefghijklmnop # 编码格式,固定UTF-8,避免中文乱码 default-encoding: UTF-8 # 端口配置(QQ邮箱SMTP端口465,必须开SSL;163邮箱用25或465都行) port: 465 properties: mail: smtp: auth: true ssl: enable: true starttls: enable: true required: true # 数据库配置(MyBatis-Plus必须配,不然连不上数据库) datasource: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver # 替换成自己的数据库地址、用户名、密码,数据库要提前建好 url: jdbc:mysql://localhost:3306/email_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456# MyBatis-Plus 配置(可选,按需调整,新手默认这样就行)mybatis-plus: mapper-locations: classpath:mapper/*.xml # mapper.xml文件路径,没有就不用改 type-aliases-package: com.example.emaildemo.entity # 实体类包路径,换成你自己的项目包名 configuration: map-underscore-to-camel-case: true # 开启下划线转驼峰,不用手动对应字段名 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL,开发时打开,上线关闭# 自定义配置(可根据自己需求改,不用动核心)email: # 验证链接有效期(单位:分钟,这里设24小时,1440分钟,够长了) validate-expire: 1440 # 前端跳转地址(用户点击验证链接后,跳转到你的前端页面,换成自己的) front-redirect-url: http://localhost:8080/register/validate # 邮件发送者昵称(收件箱显示的发件人名称,比如“XX系统通知”) sender-name: 系统通知三、核心功能实现
核心流程很简单:生成唯一的验证token → 拼接成验证链接 → 发邮件给用户 → 用户点击链接,前端调用后端接口 → 后端校验token是否有效 → 校验通过,激活账。
3.1 实体类
用户表核心字段就几个:邮箱、验证状态、验证token、token过期时间:
import com.baomidou.mybatisplus.annotation.*;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("sys_user") // 对应数据库表名public class User { // 主键自增 @TableId(type = IdType.AUTO) private Long id; // 邮箱(唯一,作为注册账号,添加唯一约束) private String email; // 密码(加密存储,本文不涉及密码加密,重点在邮箱验证) private String password; // 邮箱验证状态:0-未验证,1-已验证(设置默认值) @TableField(value = "email_validated", fill = FieldFill.INSERT) private Integer emailValidated = 0; // 验证token(唯一,用于校验链接有效性) private String validateToken; // token过期时间 private LocalDateTime tokenExpireTime; // 创建时间 @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime;} 3.2 工具类(生成唯一token)
token要保证唯一,不然会出现验证混乱的问题。我直接用hutool工具类的方法:
import cn.hutool.core.util.IdUtil;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import java.time.LocalDateTime;import java.time.temporal.ChronoUnit;@Componentpublic class EmailValidateUtil { // 从配置文件读取token有效期,不用硬编码,方便修改 @Value("${email.validate-expire}") private Integer validateExpire; // 生成唯一验证token,hutool的simpleUUID,去掉了横线,更简洁 public String generateValidateToken() { return IdUtil.simpleUUID(); } // 计算token过期时间,当前时间加上配置的有效期 public LocalDateTime calculateExpireTime() { return LocalDateTime.now().plus(validateExpire, ChronoUnit.MINUTES); } // 校验token是否过期,当前时间超过过期时间,就失效 public boolean isTokenExpired(LocalDateTime expireTime) { return LocalDateTime.now().isAfter(expireTime); }} 3.3 邮件发送服务
邮件发送是核心,这里要封装成一个服务,支持HTML格式(因为验证链接要能点击,普通文本不行),还要处理发送异常:
import jakarta.mail.MessagingException;import jakarta.mail.internet.MimeMessage;import org.springframework.beans.factory.annotation.Value;import org.springframework.mail.javamail.JavaMailSender;import org.springframework.mail.javamail.MimeMessageHelper;import org.springframework.stereotype.Service;@Servicepublic class EmailSendService { // 注入SpringBoot自动配置的邮件发送对象 private final JavaMailSender javaMailSender; // 从配置文件读取发送方邮箱 @Value("${spring.mail.username}") private String senderEmail; // 从配置文件读取发送者昵称 @Value("${email.sender-name}") private String senderName; // 从配置文件读取前端跳转地址 @Value("${email.front-redirect-url}") private String frontRedirectUrl; // 构造方法注入(SpringBoot3推荐构造方法注入,避免字段注入的弊端) public EmailSendService(JavaMailSender javaMailSender) { this.javaMailSender = javaMailSender; } /** * 发送邮箱验证邮件 * @param recipientEmail 收件人邮箱 * @param validateToken 验证token * @throws MessagingException 邮件发送异常 */ public void sendValidateEmail(String recipientEmail, String validateToken) throws Exception{ // 1. 创建MimeMessage对象(用于发送HTML邮件) MimeMessage mimeMessage = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); // 2. 设置邮件基本信息 helper.setFrom(senderEmail, senderName); // 发件人(邮箱+昵称) helper.setTo(recipientEmail); // 收件人邮箱 helper.setSubject("账号邮箱验证通知"); // 邮件标题 // 3. 拼接验证链接(前端地址+token参数,前端拿到token后调用后端验证接口) String validateUrl = frontRedirectUrl + "?token=" + validateToken; // 4. 构建HTML邮件内容(简洁明了,避免冗余,适配手机和电脑端) String emailContent = "" + "尊敬的用户,您好!
" + "感谢您的注册,请点击下方链接完成邮箱验证,激活您的账号:
" + "点击验证邮箱" + "温馨提示:链接有效期为24小时,过期后请重新申请验证
" + "若您未进行注册操作,请忽略此邮件,感谢您的理解!
" + ""; // 5. 设置邮件内容(HTML格式) helper.setText(emailContent, true); // 6. 发送邮件 javaMailSender.send(mimeMessage); }}3.4 业务逻辑层
这一层是核心,把注册、生成token、发邮件、验证token这些逻辑整合起来,还要处理重复注册、邮件发送失败等异常,加上事务管理,确保数据一致——比如邮件发失败了,用户注册也得回滚,不能出现“注册成功但收不到验证邮件”的情况:

import cn.iocoder.boot.entity.User;import cn.iocoder.boot.mapper.UserMapper;import cn.iocoder.boot.utils.EmailValidateUtil;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;import jakarta.annotation.Resource;import jakarta.mail.MessagingException;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.time.LocalDateTime;@Servicepublic class UserService { @Resource private UserMapper userMapper; @Resource private EmailValidateUtil emailValidateUtil; @Resource private EmailSendService emailSendService; /** * 用户注册+发送验证邮件 * @param email 注册邮箱 * @param password 注册密码(实际开发中需加密,如BCrypt) */ @Transactional(rollbackFor = Exception.class) // 事务管理,发送失败回滚注册操作 public void registerAndSendValidateEmail(String email, String password) throws MessagingException { // 1. 校验邮箱是否已注册(避免重复注册) LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(User::getEmail, email); User existUser = userMapper.selectOne(queryWrapper); if (existUser != null) { throw new RuntimeException("该邮箱已注册,请更换邮箱重试"); } // 2. 生成验证token和过期时间 String validateToken = emailValidateUtil.generateValidateToken(); LocalDateTime tokenExpireTime = emailValidateUtil.calculateExpireTime(); // 3. 构建用户对象(未验证状态) User user = new User(); user.setEmail(email); user.setPassword(password); // 实际开发中需加密:BCrypt.hashpw(password, BCrypt.gensalt()) user.setValidateToken(validateToken); user.setTokenExpireTime(tokenExpireTime); user.setCreateTime(LocalDateTime.now()); // 4. 保存用户(事务管理,后续发送邮件失败会回滚) userMapper.insert(user); // 5. 发送验证邮件 try { emailSendService.sendValidateEmail(email, validateToken); } catch (Exception e) { throw new RuntimeException(e); } } /** * 邮箱验证(用户点击链接后,前端调用此接口) * @param token 验证token */ @Transactional(rollbackFor = Exception.class) public void validateEmail(String token) { // 1. 根据token查询用户(唯一) LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(User::getValidateToken, token); User user = userMapper.selectOne(queryWrapper); if (user == null) { throw new RuntimeException("验证链接无效,请重新申请验证"); } // 2. 校验用户是否已验证 if (user.getEmailValidated() == 1) { throw new RuntimeException("该邮箱已验证,无需重复操作"); } // 3. 校验token是否过期 boolean isExpired = emailValidateUtil.isTokenExpired(user.getTokenExpireTime()); if (isExpired) { throw new RuntimeException("验证链接已过期,请重新申请验证"); } // 4. 更新用户验证状态(设置为已验证,清空token和过期时间) user.setEmailValidated(1); user.setValidateToken(null); user.setTokenExpireTime(null); userMapper.updateById(user); }} 补充一下UserMapper接口,MyBatis-Plus的核心,继承BaseMapper:
import com.baomidou.mybatisplus.core.mapper.BaseMapper;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface UserMapper extends BaseMapper { } 3.5 控制层
控制层很简单,写两个接口,一个处理注册,一个处理邮箱验证,统一返回结果:
import jakarta.mail.MessagingException;import org.springframework.web.bind.annotation.*;@RestController@RequestMapping("/api/user")public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } /** * 用户注册接口 * @param email 注册邮箱 * @param password 注册密码 * @return 统一返回结果 */ @PostMapping("/register") public Result register(@RequestParam String email, @RequestParam String password) { try { userService.registerAndSendValidateEmail(email, password); return Result.success("注册成功,验证邮件已发送,请查收(记得看垃圾邮件箱)"); } catch (MessagingException e) { return Result.error("注册失败,邮件发送异常,请重试"); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } /** * 邮箱验证接口(用户点击链接后,前端调用此接口) * @param token 验证token * @return 统一返回结果 */ @GetMapping("/validate-email") public Result validateEmail(@RequestParam String token) { try { userService.validateEmail(token); return Result.success("邮箱验证成功,账号已激活,可正常登录"); } catch (RuntimeException e) { return Result.error(e.getMessage()); } }} 在附上简化版本的统一返回结果类:
import lombok.Data;@Data// 统一返回结果类public class Result { private Integer code; // 0-成功,1-失败 private String message; private Object data; public static Result success(String message) { Result result = new Result(); result.setCode(0); result.setMessage(message); return result; } public static Result error(String message) { Result result = new Result(); result.setCode(1); result.setMessage(message); return result; }}四、测试验证
测试步骤如下:
- 调用POST接口 http://localhost:8080/api/user/register,传入邮箱和密码;
- 查收验证邮件(含垃圾邮件箱),点击验证链接;
- 用Postman调用GET接口 http://localhost:8080/api/user/validate-email?token=xxx(替换链接中的token);
- 查看数据库sys_user表,emailValidated为1、token及过期时间为空,即验证成功。
补充一下sys_user表的建表SQL(MySQL8+):
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `email` varchar(100) NOT NULL COMMENT '用户邮箱(唯一)', `password` varchar(100) NOT NULL COMMENT '用户密码(加密存储)', `email_validated` int NOT NULL DEFAULT '0' COMMENT '邮箱验证状态:0-未验证,1-已验证', `validate_token` varchar(100) DEFAULT NULL COMMENT '邮箱验证token', `token_expire_time` datetime DEFAULT NULL COMMENT 'token过期时间', `create_time` datetime NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_email` (`email`) COMMENT '邮箱唯一约束') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; 五、常见问题排查
问题1:邮件发送报“Authentication failed”异常
- 解决方案:大概率是授权码错了,重新获取授权码,确认配置文件里的password填的是授权码,不是邮箱密码;
问题2:链接点击后提示“验证链接无效”
- 解决方案:检查token是不是和数据库里的validate_token一致,有没有多空格;MyBatis-Plus的话,检查查询条件,别把字段名和数据库列名搞混了;
问题3:163邮箱发送失败
- 解决方案:把host改成smtp.163.com,port改成25,授权码获取方式和QQ邮箱一样,开启SMTP服务就行;
六、优化建议
上面的代码,能满足基础的邮箱验证需求,实际开发中,还可以做一些优化,贴合生产环境,提升用户体验和安全性,我列几个常用的:
- 限流控制:给注册接口和验证接口加限流,比如用Redis实现,避免恶意请求刷接口,导致服务器压力过大;
- 邮件重试:邮件发送失败很常见(比如网络波动),加个重试机制,比如用Spring的Retry注解,重试3次,减少发送失败的概率;
- 日志记录:在邮件发送、验证的关键步骤加日志(用SLF4J),后面排查问题的时候,能快速定位问题所在;
- 多邮箱支持:封装一下邮箱配置,支持QQ、163、企业邮箱,通过配置文件切换,不用改代码,更灵活。
七、总结
其实邮箱验证的核心逻辑一点都不复杂,就是生成唯一token、拼接链接、发邮件、校验token这几步,关键在于细节处理——比如token的唯一性、过期时间的计算、事务管理。