Spring Boot丨发送邮件验证码

前言

对于用户模块来说,除了用户登录,用户注册也是一个需要验证的流程。为了防止被恶意注册,有限资源被占用,一般都会需要能证明是真人的信息:手机号、身份证、邮箱、社交应用......但如果没有涉及到金融领域(支付、投资等),就不需要繁琐的认证,用户也可能不想把个人信息给你。

流程

如下图所示,这是一个简单的注册表,只有用户名、密码(没有重复确认密码)、邮箱、注册码这四个输入框。
注册表单
点击“发送邮件注册码”按钮,就判断邮箱输入框中是否有输入,如果没有就提示,有就继续判断是否为正确的邮箱格式;如果不是正确的邮箱格式就提示,是就向把这个邮箱作为参数向后端接口发送请求;后端接收邮箱后,判断这个邮箱是否已经被注册了,如果已经被注册了,就返回提示给前端,没有就发送注册码到这个邮箱。

依赖

在项目中导入以下依赖,版本号与你的 Spring Boot 的版本一致就行,可以在 阿里云云效 Maven 中搜索。

<!-- mail 依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- thymeleaf 依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

本文不讨论 Thymeleaf 的性能如何,以及为什么不用 Beetl。

与其它模板引擎相比,Thymeleaf 最大的特点是,即使不启动 Web 应用,也可以直接在浏览器中打开并正确显示模板页面。

配置

先要进入你的 QQ 邮箱,设置开启 SMTP 服务,官方说明:帮助中心。获取客户端的授权码,一般需要发送一条短信。
QQ邮箱设置
设置完毕之后,接着在项目的配置文件中加入以下相关配置。

# 字符集编码,默认 UTF-8
spring.mail.default-encoding=UTF-8
# SMTP 服务器 host,QQ 邮箱的为 smtp.qq.com;端口为 465 或 587
spring.mail.host=smtp.qq.com
# SMTP 服务器端口,不同的服务商不一样
spring.mail.port=465
# SMTP 服务器使用的协议
spring.mail.protocol=smtp
# 发送端的用户邮箱名
spring.mail.username=xxx@qq.com
# 发送端的密码,也就是获取的授权码
spring.mail.password=oooooxxxxxxxx
# 指定 mail 会话的 jndi 名称,一般我们不需要
spring.mail.jndi-name=xxx
# 指定是否在启动时测试邮件服务器连接,默认为 false
spring.mail.test-connection=false

当然,具体的配置,因人而异。
基本配置

Controller 层

Controller 层接收表单参数,可以使用 Map 接收 Post 请求参数,或者使用 HttpServeletRequest 的形式接收,还可以使用 Java bean 接收......我这里使用 String 类型接收单个参数:邮箱。发送注册码后,把邮箱和对应的注册码存储到 Redis 中,如果注册码尚未过期,用户继续请求时就不用再次发送了,还方便后续表单的校验,最后返回提示给前端。

@ApiOperation("获取邮件注册码")
@AnonymousPostMapping(value = "/emailCode")
public ResponseEntity<Object> getEmailCode(@RequestBody String param) {
    Map<String, Object> sendResult = new HashMap<>(2);
    JSONObject parse = JSONObject.parseObject(param);
    // 获取 Email
    String email = parse.getString("userEmail");
    // 先查询该 Email 是否已经发送过注册码
    if (redisUtils.hasKey(email)) {
        // 如果获取到数据则直接返回
        sendResult.put("regRes", 210);
        sendResult.put("regTip", "已发送注册码,请检查邮箱。");
    } else {
        /**
         * 再查询该 Email 是否被注册了
         * 如果只是缓存过期了,在大量的请求中就只能有一个线程能够拿到查询数据库的锁,
         * 而其他的请求只能进入等待状态,等待第一个线程更新缓存,其他线程再从更新的缓存中获得
         */
        Integer exist = userService.findByEmail(email);
        if (exist != null) {
            // 邮箱已存在数据库中
            sendResult.put("regRes", 210);
            sendResult.put("regTip", "该邮箱已被注册使用,请更换邮箱。");
        } else {
            // 产生 6 位数的注册码,获得一个随机的字符串(只包含数字和字符)
            String randomCode = RandomUtil.randomString(6);
            // 发送
            emailService.sendEmailCode(email, randomCode);
            // 存储在 Redis 中,用于后续验证。key--邮箱账号;value--邮箱注册码
            redisUtils.set(email, randomCode, 5, TimeUnit.MINUTES);
            sendResult.put("regRes", 200);
            sendResult.put("regTip", "注册码已发送,请留意您的邮箱。");
        }
    }
    // 返回
    return ResponseEntity.ok(sendResult);
}

Service 层

参数检验最好放在 Controller 层,而 Service 层聚焦于业务逻辑的处理,那就是处理发送邮件。实现类中的代码如下:

@Override
@Transactional(rollbackFor = Exception.class)
public void sendEmailCode(String email, String code) {
    // 创建邮件正文
    Context context = new Context();
    // String 类型的注册码转 char 数组
    char[] chars = code.toCharArray();
    context.setVariable("verifyCode", chars);
    // 将模块引擎内容解析成 HTML字符串
    String emailContent = templateEngine.process("sendEmailCode", context);
    MimeMessage message = mailSender.createMimeMessage();
    // 发送
    try {
        // true 表示需要创建一个 multipart message
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        helper.setFrom(sendEmailUser, "一个靓仔");
        helper.setTo(email);
        helper.setSubject("注册验证码");
        // 设置邮件内容,注意加参数 true,表示启用 Html 格式
        helper.setText(emailContent, true);
        mailSender.send(message);
    } catch (MailException | MessagingException | UnsupportedEncodingException e) {
        throw new BadRequestException(e.getMessage());
    }
}

邮件模板

写 CSS 是很痛苦的,所以我直接从 CodePen 找到一个我比较喜欢的拟物风格的按钮:Neumorphism Button,然后从中复制了部分 CSS。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" content="emialCode">
    <title>邮箱注册验证码</title>
    <style>
        body {
            background:#eeeeee;
            display:flex;
            align-items:center;
            justify-content:center;
        }

        table {
            width: 700px;
            margin: 0 auto;
        }

        #top {
            width: 700px;
            border-bottom: 1px solid #ccc;
            margin: 0 auto 30px;
        }

        #top table {
            font: 12px arial, 'Hiragino Sans GB', '\5b8b\4f53', sans-serif;
            height: 40px;
        }

        #content {
            width: 680px;
            padding: 0 10px;
            margin: 0 auto;
        }

        #content_top {
            line-height: 1.5;
            margin-bottom: 25px;
            color: #4d4d4d;
        }

        #content_top strong {
            display: block;
            margin-bottom: 15px;
        }
        
        #content_top strong span {
            font-size: 18px;
            color: #FE4F70;
        }

        #verificationCode {
            height: 100px;
            width: 680px;
            text-align: center;
            margin: 30px 0;
            color: #00b894;
            font-size: 24px;
        }

        #content_bottom {
            margin-bottom: 30px;
        }

        #content_bottom small {
            display: block;
            margin-bottom: 20px;
            font-size: 12px;
            color: #747474;
        }

        #bottom {
            width: 700px;
            margin: 0 auto;
        }

        #bottom div {
            padding: 10px 10px 0;
            border-top: 1px solid #ccc;
            color: #747474;
            margin-bottom: 20px;
            line-height: 1.3em;
            font-size: 12px;
        }

        #sign {
            text-align: right;
            font-size: 18px;
            color: #FE4F70;
            font-weight: bold;
        }

        .m_button {
            -webkit-tap-highlight-color: rgba(0,0,0,0);
            -webkit-tap-highlight-color: transparent;
            display:flex;
            float: left;
            align-items:center;
            justify-content:center;
            flex-direction:column;
            cursor:pointer;
            background-color:#eeeeee;
            width:80px;
            height:80px;
            margin-left: 30px;
            border-radius:10px;
            box-shadow: -7px -7px 20px 0px #fff9,
            -4px -4px 5px 0px #fff9,
            7px 7px 20px 0px #0002,
            4px 4px 5px 0px #0001,
            inset 0px 0px 0px 0px #fff9,
            inset 0px 0px 0px 0px #0001,
            inset 0px 0px 0px 0px #fff9,
            inset 0px 0px 0px 0px #0001;
            transition:box-shadow 0.6s cubic-bezier(.79,.21,.06,.81);
        }

        .m_button span{
            font-size: 40px;
            border-radius:4px;
            margin:3px 0px 3px 0px;
            transition:margin 0.4s cubic-bezier(.79,.21,.06,.81),transform 0.4s cubic-bezier(.79,.21,.06,.81);
        }

        .m_button:hover{
            box-shadow: 0px 0px 0px 0px #fff9,
            0px 0px 0px 0px #fff9,
            0px 0px 0px 0px #0001,
            0px 0px 0px 0px #0001,
            inset -7px -7px 20px 0px #fff9,
            inset -4px -4px 5px 0px #fff9,
            inset 7px 7px 20px 0px #0003,
            inset 4px 4px 5px 0px #0001;
        }
    </style>
</head>
<body>
<table>
    <tbody>
    <tr>
        <td>
            <div id="top">
                <table>
                    <tbody><tr><td></td></tr></tbody>
                </table>
            </div>
            <div id="content">
                <div id="content_top">
                    <strong>尊敬的用户:您好!</strong>
                    <strong>
                        您正在进行<span>注册账号</span>操作,请在注册码输入框中输入以下注册码完成操作:
                    </strong>
                    <div id="verificationCode">
                        <div class="m_button" th:each="chara:${verifyCode}">
                            <span>[[${chara}]]</span>
                        </div>
                    </div>
                </div>
                <div id="content_bottom">
                    <small>
                        注意:此操作可能会修改您的密码、登录邮箱或绑定手机。如非本人操作,请勿泄露!
                        <br>(注册码有效期为 5 分钟,及时使用。)
                    </small>
                </div>
            </div>

            <div id="bottom">
                <div>
                    <p>此为系统邮件,无需回复<br>
                        请保管好您的邮箱,避免账号被他人盗用
                    </p>
                    <p id="sign">——NewADemo</p>
                </div>
            </div>
        </td>
    </tr>
    </tbody>
</table>
</body>

DeBug

在运行过程中,遇到了一些错误。
异常 1:一开始我使用的是 Hutool 的 MailUtil 测试发送邮件,但总是会出现:MessagingException: Could not connect to SMTP host: smtp.qq.com, port: 465,接着查询了一下这个异常信息,在 CSDN 博客中看到“JDK 1.8 的配置中禁止了这个 SLLv3 协议,所以本地发送邮件才会一直报错,需要删除它以及 TLSv1, TLSv1.1”,我就开始检查我的 JDK。
发不出去
我的 JDK 版本是 11,而且不是 Oracle JDK,后来想起来是以前在开发 Mirai 插件时用的(推荐使用 AdoptOpenJDK),没切换回来。切换 JDK 后,就正常发送了。
AdoptOpenJDK11
异常 2:Error resolving template [sendEmailCode], template might not exist or might not be accessible by any of the configured Template Resolvers,翻译一下就是模板不存在(没有引入依赖),或者模板无法被解析器解析(没有配置 Thymeleaf)。在 application.yml 中添加配置即可:

spring:
  thymeleaf:
    prefix: classpath:/template/email/
    suffix: .html
    mode: HTML
    cache: false
    encoding: UTF-8

异常 3:Got bad greeting from SMTP host: smtp.qq.com, port: 465, response: [EOF]. Failed messages:,这个问题与配置有关:如果设置 spring.mail.port 为 465,需要将 spring.mail.protocol 设置为 smtps;如果端口改为 587,则可以使用 smtp。
异常 4:java.lang.IllegalArgumentException: To address must not be null,这个就是收件人地址不能为空。注意一下参数是否传进来了。

前端

前端使用 Vue2,需要设置:点击了发送邮件按钮后,按钮就进入 90 秒的禁用状态,倒计时完毕后才可以再次点击。弹窗使用的是 sweetalert,它是一款漂亮的 JavaScript 弹窗,对于 Vue2 可以使用 vue-sweetalert2

sendEmailCode() {
    // 只对邮箱这一项单独进行验证
    this.$refs['logonForm'].validateField(['useremail'], errorMsg => {
    // 检查无误
    if (!errorMsg) {
      if (!this.canClick) {
        return
      }
      this.canClick = false
      this.sendEmailMsg = this.totalTime + 's后重新发送'
      // 请求后端发送邮件
      getEmailCode(this.logonForm.useremail).then(res => {
        if (res.regRes === 200) {
          this.$swal('成功', res.regTip, 'success')
        } else if (res.regRes === 210) {
          this.$swal('警告', res.regTip, 'warning')
          this.canClick = false
        }
      })
      // 倒计时
      const clock = window.setInterval(() => {
        this.totalTime--
        this.sendEmailMsg = this.totalTime + 's后重新发送'
        if (this.totalTime < 0) {
          window.clearInterval(clock)
          this.sendEmailMsg = '重新发送注册码'
          this.totalTime = 90
          // 这里重新开启计时
          this.canClick = true
        }
      }, 1000)
    } else {
      this.$swal('错误', '您的注册邮箱好像不太对...', 'error')
      return false
    }
})

参考

CSDN - SpringBoot 发送邮箱验证码
CSDN - 本地代码无问题邮件却发送失败的问题
打赏
评论区
头像
文章目录