diff --git a/apps/api/package.json b/apps/api/package.json index 66837c0..b8766b0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/auth/auth-mail.service.ts b/apps/api/src/auth/auth-mail.service.ts new file mode 100644 index 0000000..b0944bb --- /dev/null +++ b/apps/api/src/auth/auth-mail.service.ts @@ -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 { + 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: `

你的验证码是 ${code},${ttlSeconds} 秒内有效。

` + }); + } 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("MAIL_FROM_NAME")?.trim() || "TodoList"; + const fromAddress = this.configService.get("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(key)?.trim(); + if (!value) { + throw new InternalServerErrorException(`邮件配置缺失: ${key}`); + } + + return value; + } + + private getRequiredNumber(key: string): number { + const rawValue = this.configService.get(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(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}>`; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 1641223..707399a 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index e108ac3..ede59b7 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 4e25fa1..11e9223 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfba0f7..8b155e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + nodemailer: + specifier: ^8.0.4 + version: 8.0.4 otplib: specifier: ^13.4.0 version: 13.4.0 @@ -6091,6 +6094,13 @@ packages: integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg== } + nodemailer@8.0.4: + resolution: + { + integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ== + } + engines: { node: ">=6.0.0" } + normalize-path@3.0.0: resolution: { @@ -12248,6 +12258,8 @@ snapshots: node-releases@2.0.37: {} + nodemailer@8.0.4: {} + normalize-path@3.0.0: {} npm-run-path@4.0.1: