feat(api-auth): send email login codes via SMTP

This commit is contained in:
2026-04-05 14:40:52 +08:00
parent 7192cda20f
commit ec1a4f7478
6 changed files with 155 additions and 9 deletions
+1
View File
@@ -47,6 +47,7 @@
"@prisma/client": "^7.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"nodemailer": "^8.0.4",
"otplib": "^13.4.0",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
+131
View File
@@ -0,0 +1,131 @@
import {
Injectable,
InternalServerErrorException,
Logger,
ServiceUnavailableException
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createTransport, type Transporter } from "nodemailer";
type MailRuntimeConfig = {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
fromName: string;
fromAddress: string;
};
@Injectable()
export class AuthMailService {
private readonly logger = new Logger(AuthMailService.name);
private cachedConfig: MailRuntimeConfig | null = null;
private transporter: Transporter | null = null;
constructor(private readonly configService: ConfigService) {}
async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise<void> {
const config = this.getRuntimeConfig();
const transporter = this.getTransporter(config);
try {
await transporter.sendMail({
from: this.resolveFromField(config),
to: email,
subject: "TodoList 登录验证码",
text: `你的验证码是 ${code}${ttlSeconds} 秒内有效。`,
html: `<p>你的验证码是 <strong>${code}</strong>${ttlSeconds} 秒内有效。</p>`
});
} catch (error) {
this.logger.error(
`验证码邮件发送失败: ${email}`,
error instanceof Error ? error.stack : undefined
);
throw new ServiceUnavailableException("验证码邮件发送失败,请稍后重试");
}
}
private getTransporter(config: MailRuntimeConfig): Transporter {
if (this.transporter) {
return this.transporter;
}
this.transporter = createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.user,
pass: config.pass
}
});
return this.transporter;
}
private getRuntimeConfig(): MailRuntimeConfig {
if (this.cachedConfig) {
return this.cachedConfig;
}
const host = this.getRequiredString("MAIL_SMTP_HOST");
const port = this.getRequiredNumber("MAIL_SMTP_PORT");
const secure = this.getBoolean("MAIL_SMTP_SECURE", port === 465);
const user = this.getRequiredString("MAIL_SMTP_USER");
const pass = this.getRequiredString("MAIL_SMTP_PASS");
const fromName = this.configService.get<string>("MAIL_FROM_NAME")?.trim() || "TodoList";
const fromAddress = this.configService.get<string>("MAIL_FROM_ADDRESS")?.trim() || user;
const config: MailRuntimeConfig = {
host,
port,
secure,
user,
pass,
fromName,
fromAddress
};
this.cachedConfig = config;
return config;
}
private getRequiredString(key: string): string {
const value = this.configService.get<string>(key)?.trim();
if (!value) {
throw new InternalServerErrorException(`邮件配置缺失: ${key}`);
}
return value;
}
private getRequiredNumber(key: string): number {
const rawValue = this.configService.get<string>(key)?.trim();
if (!rawValue) {
throw new InternalServerErrorException(`邮件配置缺失: ${key}`);
}
const parsedValue = Number(rawValue);
if (!Number.isFinite(parsedValue)) {
throw new InternalServerErrorException(`邮件配置格式错误: ${key}`);
}
return parsedValue;
}
private getBoolean(key: string, fallback: boolean): boolean {
const rawValue = this.configService.get<string>(key);
if (!rawValue) {
return fallback;
}
const normalizedValue = rawValue.trim().toLowerCase();
return normalizedValue === "true" || normalizedValue === "1";
}
private resolveFromField(config: MailRuntimeConfig): string {
const sanitizedName = config.fromName.replace(/"/g, "");
return `"${sanitizedName}" <${config.fromAddress}>`;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export class AuthController {
@Post("email/send-code")
async sendEmailCode(
@Body() body: SendEmailCodeDto
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
): Promise<{ success: boolean; expiresInSeconds: number }> {
return this.authService.sendEmailCode(body.email);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { AuthMailService } from "./auth-mail.service";
import { AuthService } from "./auth.service";
import { GithubStrategy } from "./strategies/github.strategy";
import { QqStrategy } from "./strategies/qq.strategy";
@@ -27,6 +28,6 @@ import { WechatStrategy } from "./strategies/wechat.strategy";
})
],
controllers: [AuthController],
providers: [AuthService, GithubStrategy, QqStrategy, WechatStrategy]
providers: [AuthService, AuthMailService, GithubStrategy, QqStrategy, WechatStrategy]
})
export class AuthModule {}
+8 -7
View File
@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "node:crypto";
import { authenticator } from "@otplib/preset-default";
import { AuthMailService } from "./auth-mail.service";
type EmailCodeEntry = {
code: string;
@@ -44,22 +45,22 @@ export class AuthService {
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService
private readonly jwtService: JwtService,
private readonly authMailService: AuthMailService
) {}
async sendEmailCode(
email: string
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> {
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
const code = this.generateCode();
const expiresAt = Date.now() + ttlSeconds * 1000;
const normalizedEmail = email.toLowerCase();
this.emailCodeStore.set(email.toLowerCase(), { code, expiresAt });
await this.authMailService.sendLoginCode(normalizedEmail, code, ttlSeconds);
this.emailCodeStore.set(normalizedEmail, { code, expiresAt });
return {
success: true,
expiresInSeconds: ttlSeconds,
debugCode: code
expiresInSeconds: ttlSeconds
};
}