实现使用QQ邮箱注册网站功能
两个版本,之前用过Java配置,现在使用的是go配置,迁移学习一下
1.Java
1.1 环境搭建
开通qq的SMTP邮件服务,拿到授权码:
登录网页版QQ邮箱 — 设置 — 账户 — POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 — IMAP/SMTP服务
导入依赖:
<!-- 邮件发送-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置application文件,文件内容如下:
# 邮件发送
# qq邮件服务器地址
spring.mail.host=smtp.qq.com
# 邮箱名
spring.mail.username=xxxxxx@qq.com
# 授权码
spring.mail.password=xxxxxxx
# smtp服务端口号
spring.mail.port=465
# 配置 对smtp服务支持的java类
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
spring.mail.default-encoding=UTF-8
1.2 邮件发送实现
RedisConfig:
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.lang.reflect.Method;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description: Redis工具类
*/
@Configuration
@EnableCaching // 启用缓存
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class RedisConfig extends CachingConfigurerSupport {
// RedisTemplate
@Bean
@SuppressWarnings("all")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
// String序列化方式
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// 使用Jackson2JsonRedisSerialize替换默认序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 设置key的序列化规则,采用String序列化方式
redisTemplate.setKeySerializer(stringSerializer);
// 设置hash的key序列化规则,采用String序列化方式
redisTemplate.setHashKeySerializer(stringSerializer);
// 设置value的序列化规则,采用Jackson2JsonRedisSerializer序列化方式
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
// 设置hash的value序列化规则,采用Jackson2JsonRedisSerializer序列化方式
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
EmailSendUtil:
package com.qi.cm.utils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description: 邮件发送工具类
*/
@Component
public class EmailSendUtil {
// 注入邮件发送服务类
@Resource
private JavaMailSender javaMailSender;
// 读取发件人邮箱
@Value("${spring.mail.username}")
private String myEmail;
/**
*
* @param toEmail 收件人
* @param subject 标题
* @param content 邮件正文
* @return
*/
public boolean send(String toEmail,String subject,String content){
// 信封
SimpleMailMessage message = new SimpleMailMessage();
// 发件人
message.setFrom(myEmail);
// 收件人
message.setTo(toEmail + "@qq.com");
// 标题
message.setSubject(subject);
// 正文
message.setText(content);
try{
javaMailSender.send(message);
return true;
}catch (Exception e){
e.printStackTrace();
}
return false;
}
}
CaptchaUtil:
package com.qi.cl.utils;
import java.util.Random;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description: 验证码工具类
*/
public class CaptchaUtil {
public static String createCode() {
StringBuilder code = new StringBuilder();
Random r = new Random();
for (int i = 0; i < 4; i++) {
int type = r.nextInt(3);
switch (type) {
case 0:
char c1 = (char) (r.nextInt(26) + 65);
code.append(c1);
break;
case 1:
char c2 = (char) (r.nextInt(26) + 97);
code.append(c2);
break;
case 2:
int num = r.nextInt(10);
code.append(num);
break;
}
}
// 使用 commons-lang3 工具包把生成的验证码转为小写
return code.toString();
}
}
Email:
package com.qi.cl.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description: 邮件发送实体类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Email {
private String receiveEmailUsername;
private String captcha;
}
EmailService:
/**
* 接口说明
*
* @author:chenmei
* @date:2023/2/17
* @description:
*/
public interface EmailService {
ResultInfo sendCaptcha(Email user);
}
EmailServiceImpl:
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description:
*/
@Service
public class EmailServiceImpl implements EmailService {
@Resource
private EmailSendUtil emailSendUtil;
@Override
public ResultInfo sendCaptcha(Email email) {
boolean b = emailSendUtil.send(email.getReceiveEmailUsername(), "获取验证码", "你的验证码是" + email.getCaptcha());
if (b){
return new ResultInfo(200,"发送成功");
}else {
return new ResultInfo(500,"发送失败");
}
}
}
EmailController:
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 类说明
*
* @author:chenmei
* @date:2023/2/17
* @description: 邮件发送控制器
*/
@RestController
@RequestMapping("/email")
public class EmailController {
@Resource
private EmailService emailService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@GetMapping("/sendCaptcha")
public ResultInfo getCaptcha(String receiveEmailUsername){
Email email = new Email();
String captcha = CaptchaUtil.createCode();
email.setCaptcha(captcha);
email.setReceiveEmailUsername(receiveEmailUsername);
// 使用邮箱名作为 key
redisTemplate.opsForValue().set(email.getReceiveEmailUsername(),captcha);
return emailService.sendCaptcha(email);
}
}
ChangePassword:
<template>
<div>
<!-- 获取验证码-->
<el-form label-position="right" :rules="rules" ref="sendCaptchaRules" label-width="37%" :model="email" style="margin-top: 13%;" :hidden="isEmailForm">
<el-form-item label="邮箱" prop="receiveEmailUsername">
<el-col :span="8">
<el-input v-model="email.receiveEmailUsername" type="text" :disabled="isDisabled"></el-input>
</el-col>
</el-form-item>
<el-form-item label="验证码" prop="captcha" :hidden="isHidden">
<el-col :span="8">
<el-input v-model="email.captcha" type="text"></el-input>
</el-col>
</el-form-item>
<el-button type="primary" @click="sendCaptcha('sendCaptchaRules')" style="margin-left: 39.5%;" :style="{display:isSendButton?'none':''}">{{send}}</el-button>
<el-button type="primary" @click="checkCaptcha" style="margin-left: 39.5%;" :style="{display:isCheckButton?'none':''}">{{send}}</el-button>
<el-button type="info" @click="cancel" style="margin-left: 65px;">取消</el-button>
</el-form>
<!-- 修改密码-->
<el-form label-position="right" :rules="rules" label-width="37%" :model="user" style="margin-top: 13%;" :hidden="isChangePasswordForm">
<el-form-item label="用户名" prop="username">
<el-col :span="8">
<el-input v-model="user.username" type="text" disabled></el-input>
</el-col>
</el-form-item>
<el-form-item label="密码" prop="password" :hidden="isHidden">
<el-col :span="8">
<el-input v-model="user.password" type="password"></el-input>
</el-col>
</el-form-item>
<el-button type="primary" @click="changePassword" style="margin-left: 39.5%;">{{send}}</el-button>
<el-button type="info" @click="cancel" style="margin-left: 65px;">取消</el-button>
</el-form>
</div>
</template>
<script>
export default {
data(){
// 验证邮箱的规则
let checkEmail = (rules,value,cb) => {
// 验证邮箱的正则表达式
const regEmail = /^([1-9][0-9]{7,9})+@[Qq][Qq]\.com+/
if (regEmail.test(value)) {
return cb()
}
cb(new Error('请输入合法的邮箱'))
}
return{
email:{
receiveEmailUsername: '3503294776@qq.com',
captcha: '1111',
},
rules: {
receiveEmailUsername: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ validator:checkEmail,trigger:'blur'}
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 4, message: '长度为 4 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 3, max: 7, message: '长度为 3-7 个字符', trigger: 'blur' },
]
},
isHidden: true, // 设置验证码输入框是否显示
isDisabled: false, // 设置邮箱输入框是否可以输入
isSendButton: false, // 设置发送验证码的按钮和验证验证码的按钮切换显示
isCheckButton: true, // 设置发送验证码的按钮和验证验证码的按钮切换显示
send: '发送', // 发送按钮
isEmailForm: false, // 设置发送验证码的表单是否显示
isChangePasswordForm: true, // 设置修改密码的表单是否显示
user: { // 用户信息
username: '',
password: '',
}
}
},
methods:{
cancel(){
this.$router.go(0);
},
sendCaptcha(sendCaptchaRules){ // 发送验证码
let _this = this;
this.$refs[sendCaptchaRules].validate((valid) => {
if (valid) {
this.$axios({
method: 'get',
params: {
receiveEmailUsername: this.email.receiveEmailUsername,
},
url: 'email/sendCaptcha',
}).then((rs)=>{
_this.$message(rs.data.msg);
if (rs.data.status == 200){
_this.isHidden = false;
_this.isDisabled = true;
_this.isSendButton = true;
_this.isCheckButton = false;
_this.send = '验证';
}
}).catch((error)=>{
_this.$message(error);
})
} else {
_this.$message('error submit!!');
return false;
}
});
},
checkCaptcha(){ // 验证验证码
let _this = this;
if (this.email.captcha.length === 0) {
this.$message('请输入验证码');
}else {
this.$axios({
method: 'post',
url: 'email/checkCaptcha',
data: this.email,
}).then((rs)=>{
_this.$message(rs.data.msg);
if (rs.data.status == 200){
_this.isEmailForm = true;
_this.isChangePasswordForm = false;
_this.send = '确定';
// 输入验证码进行验证
this.findUsernameByEmail()
}
}).catch((error)=>{
_this.$message(error);
})
}
},
findUsernameByEmail(){ // 通过用户输入的邮箱号,查找到对应的用户名
let _this = this;
this.$axios({
method: 'get',
url: 'user/user/' + this.email.receiveEmailUsername,
}).then((rs)=>{
_this.user.username = rs.data.data;
}).catch((error)=>{
_this.$message(error);
})
},
changePassword(){ // 修改密码
let _this = this;
if (this.user.password.length === 0) {
this.$message('请输入密码');
}else {
this.$axios({
method: 'put',
url: 'user/user/password',
data: JSON.stringify(this.user),
}).then((rs)=>{
console.log(rs);
}).catch((error)=>{
_this.$message(error);
})
}
},
},
mounted() {
}
}
</script>
<style scoped>
</style>
2.Go
这里以一个开源项目为例,技术栈:
后端:Golang (Gin + gRPC + GORM)
这个开源项目作者写了一个页面启动邮件服务主要用于配置邮箱服务:
对应上文的授权码,相当于这里的SMTP密码
发件人名称为公司名称
2.1 配置邮箱服务
邮件配置:结构体与常量
config.go
const (
ConfigEmailEnable = "enable" // 是否启用邮件服务
ConfigEmailHost = "host" // SMTP 服务器地址
ConfigEmailPort = "port" // SMTP 服务器端口
ConfigEmailIsTLS = "is_tls" // 是否启用TLS
ConfigEmailFromName = "from_name"
ConfigEmailUsername = "username" // SMTP 用户名
ConfigEmailPassword = "password" // SMTP 密码
ConfigEmailDuration = "duration" // 验证码有效期,单位为分钟
ConfigEmailTestEmail = "test_email"
ConfigEmailReplyTo = "reply_to"
ConfigEmailSecret = "secret" // 找回密码邮件的签名密钥
)
type ConfigEmail struct {
Enable bool `json:"enable"` // 是否启用邮件服务
Host string `json:"host"` // SMTP 服务器地址
Port int `json:"port"` // SMTP 服务器端口
IsTLS bool `json:"is_tls"` // 是否启用TLS
FromName string `json:"from_name"`
Username string `json:"username"`
Password string `json:"password"`
Duration int `json:"duration"` // 验证码有效期,单位为分钟
TestEmail string `json:"test_email"`
Secret string `json:"secret"`
// ReplyTo string `json:"reply_to"`
}
常量定义了邮件服务配置项的键名,在配置文件或代码中统一引用,减少硬编码带来的维护成本。
配置项:
- 是否启用邮件服务(enable)
- SMTP服务器地址和端口(host, port)
- 是否启用TLS加密(is_tls)
- 发件人名称(from_name)
- SMTP认证用户名和密码(username, password)
- 验证码有效期(duration)
邮件服务的所有配置信息,可以在程序中统一读取和传递。字段含义:
- Duration:验证码有效期,单位为分钟,常用于注册、找回密码等场景
- TestEmail:测试用邮箱地址
- Secret:找回密码等敏感操作的签名密钥
- ReplyTo:回复地址(根据需求)
具体实现:在用户相关业务中的应用
注册流程中的邮箱验证
- 在biz/user.go的注册逻辑中,支持“是否开启注册邮箱验证”配置(EnableVerifyRegisterEmail)。
- 若开启邮箱验证,注册时需校验邮箱验证码(通过EmailCode表管理),并校验验证码是否过期、是否已被使用。
- 注册成功后,标记验证码为已用,防止重复利用。
GetConfigOfEmail
unc (m *DBModel) GetConfigOfEmail(name ...string) (config ConfigEmail) {
var configs []Config
db := m.db.Where("category = ?", ConfigCategoryEmail)
if len(name) > 0 {
db = db.Where("name in (?)", name)
}
err := db.Find(&configs).Error
if err != nil && err != gorm.ErrRecordNotFound {
m.logger.Error("GetConfigOfEmail", zap.Error(err))
}
data := m.convertConfig2Map(configs)
bytes, _ := json.Marshal(data)
json.Unmarshal(bytes, &config)
m.logger.Debug("GetConfigOfEmail", zap.Any("data", data), zap.Any("config", config))
return
}
找回密码流程
- 找回密码时,系统会校验邮箱格式、查找用户、生成带签名的找回密码链接(签名密钥来自邮件配置)。
- 邮件配置中的Duration字段控制找回密码链接的有效期,Secret字段用于链接签名,提升安全性。
// 找回密码:重置密码
func (s *UserAPIService) FindPasswordStepTwo(ctx context.Context, req *v1.FindPasswordRequest) (res *emptypb.Empty, err error) {
if !util.IsValidEmail(req.Email) {
return nil, status.Errorf(codes.InvalidArgument, "邮箱格式不正确")
}
if len(req.Password) < 6 {
return nil, status.Errorf(codes.InvalidArgument, "密码长度不能小于6位")
}
cfgSec := s.dbModel.GetConfigOfSecurity(model.ConfigSecurityEnableCaptchaFindPassword)
if cfgSec.EnableCaptchaFindPassword {
if req.CaptchaId == "" || req.Captcha == "" {
return nil, status.Errorf(codes.InvalidArgument, "请输入验证码")
}
if !captcha.VerifyCaptcha(req.CaptchaId, req.Captcha, true) {
return nil, status.Errorf(codes.InvalidArgument, "验证码错误")
}
}
// 验证token
claims := &jwt.StandardClaims{}
cfgEmail := s.dbModel.GetConfigOfEmail(model.ConfigEmailSecret)
// 验证JWT是否合法
jwtToken, err := jwt.ParseWithClaims(req.Token, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(cfgEmail.Secret), nil
})
if err != nil || !jwtToken.Valid {
return nil, status.Errorf(codes.InvalidArgument, "找回密码 token 无效")
}
user, _ := s.dbModel.GetUserByEmail(req.Email, "id")
if user.Id == 0 {
return nil, status.Errorf(codes.NotFound, "用户不存在")
}
// 更新密码
err = s.dbModel.UpdateUserPassword(user.Id, req.Password)
if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
}
return &emptypb.Empty{}, nil
}
- 发送验证码
- 发送验证码时,先校验邮箱格式,再判断邮箱是否已注册(如注册时不能重复,找回密码时必须存在)。
- 验证码通过邮件发送,发送逻辑会读取邮件配置(如SMTP服务器、账号、密码等)。
SendMail
func (m *DBModel) SendMail(subject, email string, body string) error {
cfg := m.GetConfigOfEmail()
m.logger.Debug("SendMail", zap.Any("cfg", cfg), zap.String("email", email), zap.String("subject", subject), zap.String("body", body))
if !cfg.Enable {
return errors.New("邮件服务未启用")
}
fromName := cfg.Username
if fn := strings.TrimSpace(cfg.FromName); fn != "" {
fromName = fn
}
message := gomail.NewMessage()
message.SetHeader("From", message.FormatAddress(cfg.Username, fromName))
message.SetHeader("To", email)
message.SetHeader("Subject", subject)
message.SetBody("text/html", body)
// if cfg.ReplyTo != "" {
// message.SetHeader("Reply-To", cfg.ReplyTo)
// }
mail := gomail.NewDialer(cfg.Host, cfg.Port, cfg.Username, cfg.Password)
if cfg.IsTLS {
mail.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
return mail.DialAndSend(message)
}
toml文件
[Email]
enable = true
host = "smtp.example.com"
port = 465
is_tls = true
from_name = "xxx"
username = "noreply@example.com"
password = "yourpassword"
duration = 10
test_email = "test@example.com"
secret = "your_secret_key"