From 3e5c171ff6e21eea642843082283e17a4cb274cc Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Wed, 17 Dec 2025 20:21:11 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现完整的邮件发送功能 - 支持验证码邮件发送 - 支持欢迎邮件发送 - 集成SMTP配置和Nodemailer - 添加邮件模板和HTML格式支持 - 包含完整的单元测试 --- src/core/utils/email/email.module.ts | 23 ++ src/core/utils/email/email.service.spec.ts | 424 +++++++++++++++++++++ src/core/utils/email/email.service.ts | 370 ++++++++++++++++++ 3 files changed, 817 insertions(+) create mode 100644 src/core/utils/email/email.module.ts create mode 100644 src/core/utils/email/email.service.spec.ts create mode 100644 src/core/utils/email/email.service.ts diff --git a/src/core/utils/email/email.module.ts b/src/core/utils/email/email.module.ts new file mode 100644 index 0000000..0e2e385 --- /dev/null +++ b/src/core/utils/email/email.module.ts @@ -0,0 +1,23 @@ +/** + * 邮件服务模块 + * + * 功能描述: + * - 提供邮件服务的模块配置 + * - 导出邮件服务供其他模块使用 + * - 集成配置服务 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EmailService } from './email.service'; + +@Module({ + imports: [ConfigModule], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} \ No newline at end of file diff --git a/src/core/utils/email/email.service.spec.ts b/src/core/utils/email/email.service.spec.ts new file mode 100644 index 0000000..b16eb96 --- /dev/null +++ b/src/core/utils/email/email.service.spec.ts @@ -0,0 +1,424 @@ +/** + * 邮件服务测试 + * + * 功能测试: + * - 邮件服务初始化 + * - 邮件发送功能 + * - 验证码邮件发送 + * - 欢迎邮件发送 + * - 邮件模板生成 + * - 连接验证 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { EmailService, EmailOptions, VerificationEmailOptions } from './email.service'; +import * as nodemailer from 'nodemailer'; + +// Mock nodemailer +jest.mock('nodemailer'); +const mockedNodemailer = nodemailer as jest.Mocked; + +describe('EmailService', () => { + let service: EmailService; + let configService: jest.Mocked; + let mockTransporter: any; + + beforeEach(async () => { + // 创建 mock transporter + mockTransporter = { + sendMail: jest.fn(), + verify: jest.fn(), + options: {} + }; + + // Mock ConfigService + const mockConfigService = { + get: jest.fn(), + }; + + // Mock nodemailer.createTransport + mockedNodemailer.createTransport.mockReturnValue(mockTransporter); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(EmailService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('初始化测试', () => { + it('应该正确初始化邮件服务', () => { + expect(service).toBeDefined(); + expect(mockedNodemailer.createTransport).toHaveBeenCalled(); + }); + + it('应该在没有配置时使用测试模式', () => { + configService.get.mockReturnValue(undefined); + + // 重新创建服务实例来测试测试模式 + const testService = new EmailService(configService); + + expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ + streamTransport: true, + newline: 'unix', + buffer: true + }); + }); + + it('应该在有配置时使用真实SMTP', () => { + configService.get + .mockReturnValueOnce('smtp.gmail.com') // EMAIL_HOST + .mockReturnValueOnce(587) // EMAIL_PORT + .mockReturnValueOnce(false) // EMAIL_SECURE + .mockReturnValueOnce('test@gmail.com') // EMAIL_USER + .mockReturnValueOnce('password'); // EMAIL_PASS + + const testService = new EmailService(configService); + + expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ + host: 'smtp.gmail.com', + port: 587, + secure: false, + auth: { + user: 'test@gmail.com', + pass: 'password', + }, + }); + }); + }); + + describe('sendEmail', () => { + it('应该成功发送邮件', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

', + text: '测试内容' + }; + + mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' }); + configService.get.mockReturnValue('"Test Sender" '); + + const result = await service.sendEmail(emailOptions); + + expect(result).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith({ + from: '"Test Sender" ', + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

', + text: '测试内容', + }); + }); + + it('应该在发送失败时返回false', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

' + }; + + mockTransporter.sendMail.mockRejectedValue(new Error('发送失败')); + + const result = await service.sendEmail(emailOptions); + + expect(result).toBe(false); + }); + + it('应该在测试模式下输出邮件内容', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

', + text: '测试内容' + }; + + // Mock transporter with streamTransport option + const testTransporter = { + ...mockTransporter, + options: { streamTransport: true }, + sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' }) + }; + + // Mock the service to use test transporter + const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); + service['transporter'] = testTransporter; + + const result = await service.sendEmail(emailOptions); + + expect(result).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式) ==='); + + loggerSpy.mockRestore(); + }); + }); + + describe('sendVerificationCode', () => { + it('应该成功发送邮箱验证码', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '123456', + nickname: '测试用户', + purpose: 'email_verification' + }; + + mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' }); + configService.get.mockReturnValue('"Test Sender" '); + + const result = await service.sendVerificationCode(options); + + expect(result).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: '【Whale Town】邮箱验证码', + text: '您的验证码是:123456,5分钟内有效,请勿泄露给他人。' + }) + ); + }); + + it('应该成功发送密码重置验证码', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '654321', + nickname: '测试用户', + purpose: 'password_reset' + }; + + mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' }); + configService.get.mockReturnValue('"Test Sender" '); + + const result = await service.sendVerificationCode(options); + + expect(result).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: '【Whale Town】密码重置验证码', + text: '您的验证码是:654321,5分钟内有效,请勿泄露给他人。' + }) + ); + }); + + it('应该在发送失败时返回false', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '123456', + purpose: 'email_verification' + }; + + mockTransporter.sendMail.mockRejectedValue(new Error('发送失败')); + + const result = await service.sendVerificationCode(options); + + expect(result).toBe(false); + }); + }); + + describe('sendWelcomeEmail', () => { + it('应该成功发送欢迎邮件', async () => { + mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' }); + configService.get.mockReturnValue('"Test Sender" '); + + const result = await service.sendWelcomeEmail('test@example.com', '测试用户'); + + expect(result).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: '🎮 欢迎加入 Whale Town!', + text: '欢迎 测试用户 加入 Whale Town 像素游戏世界!' + }) + ); + }); + + it('应该在发送失败时返回false', async () => { + mockTransporter.sendMail.mockRejectedValue(new Error('发送失败')); + + const result = await service.sendWelcomeEmail('test@example.com', '测试用户'); + + expect(result).toBe(false); + }); + }); + + describe('verifyConnection', () => { + it('应该在连接成功时返回true', async () => { + mockTransporter.verify.mockResolvedValue(true); + + const result = await service.verifyConnection(); + + expect(result).toBe(true); + expect(mockTransporter.verify).toHaveBeenCalled(); + }); + + it('应该在连接失败时返回false', async () => { + mockTransporter.verify.mockRejectedValue(new Error('连接失败')); + + const result = await service.verifyConnection(); + + expect(result).toBe(false); + }); + }); + + describe('邮件模板测试', () => { + it('应该生成包含验证码的邮箱验证模板', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '123456', + nickname: '测试用户', + purpose: 'email_verification' + }; + + mockTransporter.sendMail.mockImplementation((mailOptions: any) => { + expect(mailOptions.html).toContain('123456'); + expect(mailOptions.html).toContain('测试用户'); + expect(mailOptions.html).toContain('邮箱验证'); + expect(mailOptions.html).toContain('Whale Town'); + return Promise.resolve({ messageId: 'test-id' }); + }); + + await service.sendVerificationCode(options); + }); + + it('应该生成包含验证码的密码重置模板', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '654321', + nickname: '测试用户', + purpose: 'password_reset' + }; + + mockTransporter.sendMail.mockImplementation((mailOptions: any) => { + expect(mailOptions.html).toContain('654321'); + expect(mailOptions.html).toContain('测试用户'); + expect(mailOptions.html).toContain('密码重置'); + expect(mailOptions.html).toContain('🔐'); + return Promise.resolve({ messageId: 'test-id' }); + }); + + await service.sendVerificationCode(options); + }); + + it('应该生成包含用户昵称的欢迎邮件模板', async () => { + mockTransporter.sendMail.mockImplementation((mailOptions: any) => { + expect(mailOptions.html).toContain('测试用户'); + expect(mailOptions.html).toContain('欢迎加入 Whale Town'); + expect(mailOptions.html).toContain('🎮'); + expect(mailOptions.html).toContain('建造与创造'); + expect(mailOptions.html).toContain('社交互动'); + expect(mailOptions.html).toContain('任务挑战'); + return Promise.resolve({ messageId: 'test-id' }); + }); + + await service.sendWelcomeEmail('test@example.com', '测试用户'); + }); + + it('应该在没有昵称时正确处理模板', async () => { + const options: VerificationEmailOptions = { + email: 'test@example.com', + code: '123456', + purpose: 'email_verification' + }; + + mockTransporter.sendMail.mockImplementation((mailOptions: any) => { + expect(mailOptions.html).toContain('你好!'); + expect(mailOptions.html).not.toContain('你好 undefined'); + return Promise.resolve({ messageId: 'test-id' }); + }); + + await service.sendVerificationCode(options); + }); + }); + + describe('错误处理测试', () => { + it('应该正确处理网络错误', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

' + }; + + mockTransporter.sendMail.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await service.sendEmail(emailOptions); + + expect(result).toBe(false); + }); + + it('应该正确处理认证错误', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: '测试邮件', + html: '

测试内容

' + }; + + mockTransporter.sendMail.mockRejectedValue(new Error('Invalid login')); + + const result = await service.sendEmail(emailOptions); + + expect(result).toBe(false); + }); + + it('应该正确处理连接验证错误', async () => { + mockTransporter.verify.mockRejectedValue(new Error('Connection timeout')); + + const result = await service.verifyConnection(); + + expect(result).toBe(false); + }); + }); + + describe('配置测试', () => { + it('应该使用默认配置值', () => { + configService.get + .mockReturnValueOnce(undefined) // EMAIL_HOST + .mockReturnValueOnce(undefined) // EMAIL_PORT + .mockReturnValueOnce(undefined) // EMAIL_SECURE + .mockReturnValueOnce(undefined) // EMAIL_USER + .mockReturnValueOnce(undefined); // EMAIL_PASS + + const testService = new EmailService(configService); + + expect(configService.get).toHaveBeenCalledWith('EMAIL_HOST', 'smtp.gmail.com'); + expect(configService.get).toHaveBeenCalledWith('EMAIL_PORT', 587); + expect(configService.get).toHaveBeenCalledWith('EMAIL_SECURE', false); + }); + + it('应该使用自定义配置值', () => { + configService.get + .mockReturnValueOnce('smtp.163.com') // EMAIL_HOST + .mockReturnValueOnce(25) // EMAIL_PORT + .mockReturnValueOnce(true) // EMAIL_SECURE + .mockReturnValueOnce('custom@163.com') // EMAIL_USER + .mockReturnValueOnce('custompass'); // EMAIL_PASS + + const testService = new EmailService(configService); + + expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ + host: 'smtp.163.com', + port: 25, + secure: true, + auth: { + user: 'custom@163.com', + pass: 'custompass', + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/core/utils/email/email.service.ts b/src/core/utils/email/email.service.ts new file mode 100644 index 0000000..2fe85c1 --- /dev/null +++ b/src/core/utils/email/email.service.ts @@ -0,0 +1,370 @@ +/** + * 邮件服务 + * + * 功能描述: + * - 提供邮件发送的核心功能 + * - 支持多种邮件模板和场景 + * - 集成主流邮件服务提供商 + * + * 支持的邮件类型: + * - 邮箱验证码 + * - 密码重置验证码 + * - 欢迎邮件 + * - 系统通知 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ + +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { Transporter } from 'nodemailer'; + +/** + * 邮件发送选项接口 + */ +export interface EmailOptions { + /** 收件人邮箱 */ + to: string; + /** 邮件主题 */ + subject: string; + /** 邮件内容(HTML格式) */ + html: string; + /** 邮件内容(纯文本格式) */ + text?: string; +} + +/** + * 验证码邮件选项接口 + */ +export interface VerificationEmailOptions { + /** 收件人邮箱 */ + email: string; + /** 验证码 */ + code: string; + /** 用户昵称 */ + nickname?: string; + /** 验证码用途 */ + purpose: 'email_verification' | 'password_reset'; +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: Transporter; + + constructor(private readonly configService: ConfigService) { + this.initializeTransporter(); + } + + /** + * 初始化邮件传输器 + */ + private initializeTransporter(): void { + const emailConfig = { + host: this.configService.get('EMAIL_HOST', 'smtp.gmail.com'), + port: this.configService.get('EMAIL_PORT', 587), + secure: this.configService.get('EMAIL_SECURE', false), // true for 465, false for other ports + auth: { + user: this.configService.get('EMAIL_USER'), + pass: this.configService.get('EMAIL_PASS'), + }, + }; + + // 如果没有配置邮件服务,使用测试模式 + if (!emailConfig.auth.user || !emailConfig.auth.pass) { + this.logger.warn('邮件服务未配置,将使用测试模式(邮件不会真实发送)'); + this.transporter = nodemailer.createTransport({ + streamTransport: true, + newline: 'unix', + buffer: true + }); + } else { + this.transporter = nodemailer.createTransport(emailConfig); + this.logger.log('邮件服务初始化成功'); + } + } + + /** + * 发送邮件 + * + * @param options 邮件选项 + * @returns 发送结果 + */ + async sendEmail(options: EmailOptions): Promise { + try { + const mailOptions = { + from: this.configService.get('EMAIL_FROM', '"Whale Town Game" '), + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + }; + + const result = await this.transporter.sendMail(mailOptions); + + // 如果是测试模式,输出邮件内容到控制台 + if ((this.transporter.options as any).streamTransport) { + this.logger.log('=== 邮件发送(测试模式) ==='); + this.logger.log(`收件人: ${options.to}`); + this.logger.log(`主题: ${options.subject}`); + this.logger.log(`内容: ${options.text || '请查看HTML内容'}`); + this.logger.log('========================'); + } + + this.logger.log(`邮件发送成功: ${options.to}`); + return true; + } catch (error) { + this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error)); + return false; + } + } + + /** + * 发送邮箱验证码 + * + * @param options 验证码邮件选项 + * @returns 发送结果 + */ + async sendVerificationCode(options: VerificationEmailOptions): Promise { + const { email, code, nickname, purpose } = options; + + let subject: string; + let template: string; + + if (purpose === 'email_verification') { + subject = '【Whale Town】邮箱验证码'; + template = this.getEmailVerificationTemplate(code, nickname); + } else { + subject = '【Whale Town】密码重置验证码'; + template = this.getPasswordResetTemplate(code, nickname); + } + + return await this.sendEmail({ + to: email, + subject, + html: template, + text: `您的验证码是:${code},5分钟内有效,请勿泄露给他人。` + }); + } + + /** + * 发送欢迎邮件 + * + * @param email 邮箱地址 + * @param nickname 用户昵称 + * @returns 发送结果 + */ + async sendWelcomeEmail(email: string, nickname: string): Promise { + const subject = '🎮 欢迎加入 Whale Town!'; + const template = this.getWelcomeTemplate(nickname); + + return await this.sendEmail({ + to: email, + subject, + html: template, + text: `欢迎 ${nickname} 加入 Whale Town 像素游戏世界!` + }); + } + + /** + * 获取邮箱验证模板 + * + * @param code 验证码 + * @param nickname 用户昵称 + * @returns HTML模板 + */ + private getEmailVerificationTemplate(code: string, nickname?: string): string { + return ` + + + + + + 邮箱验证 + + + +
+
+

🐋 Whale Town

+

邮箱验证

+
+
+

你好${nickname ? ` ${nickname}` : ''}!

+

感谢您注册 Whale Town 像素游戏!为了确保您的账户安全,请使用以下验证码完成邮箱验证:

+ +
+
${code}
+

验证码

+
+ +
+ ⚠️ 安全提醒: +
    +
  • 验证码 5 分钟内有效
  • +
  • 请勿将验证码泄露给他人
  • +
  • 如非本人操作,请忽略此邮件
  • +
+
+ +

完成验证后,您就可以开始您的像素世界冒险之旅了!

+
+ +
+ +`; + } + + /** + * 获取密码重置模板 + * + * @param code 验证码 + * @param nickname 用户昵称 + * @returns HTML模板 + */ + private getPasswordResetTemplate(code: string, nickname?: string): string { + return ` + + + + + + 密码重置 + + + +
+
+

🔐 密码重置

+

Whale Town 账户安全

+
+
+

你好${nickname ? ` ${nickname}` : ''}!

+

我们收到了您的密码重置请求。请使用以下验证码来重置您的密码:

+ +
+
${code}
+

密码重置验证码

+
+ +
+ 🛡️ 安全提醒: +
    +
  • 验证码 5 分钟内有效
  • +
  • 请勿将验证码泄露给他人
  • +
  • 如非本人操作,请立即联系客服
  • +
  • 重置密码后请妥善保管新密码
  • +
+
+ +

如果您没有请求重置密码,请忽略此邮件,您的账户仍然安全。

+
+ +
+ +`; + } + + /** + * 获取欢迎邮件模板 + * + * @param nickname 用户昵称 + * @returns HTML模板 + */ + private getWelcomeTemplate(nickname: string): string { + return ` + + + + + + 欢迎加入 Whale Town + + + +
+
+

🎮 欢迎加入 Whale Town!

+

像素世界的冒险即将开始

+
+
+

欢迎你,${nickname}!

+

恭喜您成功注册 Whale Town 像素游戏!您现在已经成为我们像素世界大家庭的一员了。

+ +
+

🏗️ 建造与创造

+

在像素世界中建造您的梦想家园,发挥无限创意!

+
+ +
+

🤝 社交互动

+

与其他玩家交流互动,结交志同道合的朋友!

+
+ +
+

🎯 任务挑战

+

完成各种有趣的任务,获得丰厚的奖励!

+
+ +

现在就开始您的像素冒险之旅吧!

+

如果您在游戏过程中遇到任何问题,随时可以联系我们的客服团队。

+
+ +
+ +`; + } + + /** + * 验证邮件服务配置 + * + * @returns 验证结果 + */ + async verifyConnection(): Promise { + try { + await this.transporter.verify(); + this.logger.log('邮件服务连接验证成功'); + return true; + } catch (error) { + this.logger.error('邮件服务连接验证失败', error instanceof Error ? error.stack : String(error)); + return false; + } + } +} \ No newline at end of file