/** * @file 邮件发送服务类 * @author hotok * @date 2025-06-29 * @lastEditor hotok * @lastEditTime 2025-06-29 * @description 提供邮件发送、模板邮件、健康检查等功能,支持重试机制和错误处理 */ import nodemailer from 'nodemailer'; import { smtpConfig, emailConfig, emailTemplates, emailOptions } from '@/config/email.config'; import { Logger } from '@/plugins/logger/logger.service'; import type { EmailTransporter, EmailSendOptions, EmailTemplateSendOptions, EmailSendResult, EmailServiceStatus, EmailHealthCheckResult, EmailTemplateType, EmailTemplateParams, } from '@/type/email.type'; /** * 邮件发送服务类 * 使用单例模式管理邮件发送功能 */ export class EmailService { /** 单例实例 */ private static instance: EmailService | null = null; /** 邮件传输器实例 */ private _transporter: EmailTransporter | null = null; /** 服务状态信息 */ private _status: EmailServiceStatus; /** 初始化标志 */ private _isInitialized = false; /** * 私有构造函数,防止外部实例化 */ private constructor() { this._status = { status: 'unhealthy', transporterStatus: 'disconnected', error: undefined, lastCheckAt: new Date(), }; } /** * 获取单例实例 */ public static getInstance(): EmailService { if (!EmailService.instance) { EmailService.instance = new EmailService(); } return EmailService.instance; } /** * 获取邮件传输器实例 */ public get transporter(): EmailTransporter { if (!this._transporter) { throw new Error('邮件服务未初始化,请先调用 initialize() 方法'); } return this._transporter; } /** * 获取服务状态信息 */ public get status(): EmailServiceStatus { return { ...this._status }; } /** * 检查是否已初始化 */ public get isInitialized(): boolean { return this._isInitialized; } /** * 验证邮件配置 */ private validateConfig(): void { if (!smtpConfig.host || !smtpConfig.port) { throw new Error('SMTP配置无效:缺少host或port'); } if (!smtpConfig.auth.user || !smtpConfig.auth.pass) { throw new Error('SMTP配置无效:缺少用户名或密码'); } if (smtpConfig.port < 1 || smtpConfig.port > 65535) { throw new Error(`SMTP端口号无效: ${smtpConfig.port}`); } } /** * 更新服务状态 */ private updateStatus( status: EmailServiceStatus['status'], transporterStatus: EmailServiceStatus['transporterStatus'], error?: string ): void { this._status = { status, transporterStatus, error, lastCheckAt: new Date(), }; } /** * 初始化邮件服务 */ public async initialize(): Promise { // 防止重复初始化 if (this._isInitialized && this._transporter) { Logger.debug('邮件服务已初始化,返回现有实例'); return this._transporter; } try { this.validateConfig(); this.updateStatus('unhealthy', 'disconnected'); // 创建邮件传输器 this._transporter = nodemailer.createTransport({ host: smtpConfig.host, port: smtpConfig.port, secure: smtpConfig.secure, auth: { user: smtpConfig.auth.user, pass: smtpConfig.auth.pass, }, connectionTimeout: smtpConfig.connectionTimeout, greetingTimeout: smtpConfig.greetingTimeout, socketTimeout: smtpConfig.socketTimeout, pool: true, // 使用连接池 maxConnections: 5, // 最大连接数 maxMessages: 100, // 每个连接最大消息数 }); // 验证SMTP连接 await this._transporter.verify(); this._isInitialized = true; this.updateStatus('healthy', 'connected'); Logger.debug({ message: '邮件服务初始化成功', host: smtpConfig.host, port: smtpConfig.port, secure: smtpConfig.secure, user: smtpConfig.auth.user, }); return this._transporter; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.updateStatus('unhealthy', 'error', errorMessage); Logger.error(new Error(`邮件服务初始化失败: ${errorMessage}`)); throw new Error(`邮件服务初始化失败: ${errorMessage}`); } } /** * 发送邮件 */ public async sendEmail(options: EmailSendOptions): Promise { const startTime = Date.now(); let retryCount = 0; while (retryCount <= emailOptions.retryAttempts) { try { if (!this._transporter) { throw new Error('邮件传输器未初始化'); } // 准备邮件选项 - 确保From字段格式正确 const fromAddress = options.from || emailConfig.from; if (!fromAddress) { throw new Error('发件人邮箱地址不能为空,请检查SMTP_USER或SMTP_FROM_EMAIL环境变量'); } const fromName = emailConfig.fromName || '星撰系统'; const formattedFrom = fromAddress.includes('<') ? fromAddress : `"${fromName}" <${fromAddress}>`; const mailOptions = { from: formattedFrom, to: Array.isArray(options.to) ? options.to.join(', ') : options.to, cc: options.cc ? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc) : undefined, bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc) : undefined, subject: options.subject, text: options.text, html: options.html, replyTo: options.replyTo || emailConfig.replyTo, priority: options.priority || emailConfig.priority, attachments: options.attachments, headers: options.headers, messageId: options.messageId, references: options.references, inReplyTo: options.inReplyTo, }; // 发送邮件 const result = await this._transporter.sendMail(mailOptions); const sendResult: EmailSendResult = { success: true, messageId: result.messageId, accepted: result.accepted || [], rejected: result.rejected || [], pending: result.pending || [], response: result.response, sentAt: new Date(), retryCount, }; Logger.debug({ message: '邮件发送成功', messageId: result.messageId, to: options.to, subject: options.subject, responseTime: Date.now() - startTime, retryCount, }); return sendResult; } catch (error) { retryCount++; const errorMessage = error instanceof Error ? error.message : String(error); Logger.warn({ message: '邮件发送失败', error: errorMessage, to: options.to, subject: options.subject, retryCount, maxRetries: emailOptions.retryAttempts, }); // 如果已达到最大重试次数,返回失败结果 if (retryCount > emailOptions.retryAttempts) { return { success: false, accepted: [], rejected: Array.isArray(options.to) ? options.to : [options.to], pending: [], error: errorMessage, sentAt: new Date(), retryCount: retryCount - 1, }; } // 等待重试延迟 await new Promise(resolve => setTimeout(resolve, emailOptions.retryDelay)); } } // 这个分支理论上不会执行,但为了类型安全 return { success: false, accepted: [], rejected: Array.isArray(options.to) ? options.to : [options.to], pending: [], error: '未知错误', sentAt: new Date(), retryCount: emailOptions.retryAttempts, }; } /** * 发送模板邮件 */ public async sendTemplateEmail(options: EmailTemplateSendOptions): Promise { try { const template = emailTemplates[options.template]; if (!template) { throw new Error(`未找到邮件模板: ${options.template}`); } // 渲染邮件内容 const { subject, html, text } = this.renderTemplate(options.template, options.params); // 构建发送选项 const sendOptions: EmailSendOptions = { to: options.to, cc: options.cc, bcc: options.bcc, subject: options.subject || subject, html, text, priority: options.priority, attachments: options.attachments, }; return await this.sendEmail(sendOptions); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); Logger.error(new Error(`模板邮件发送失败: ${errorMessage}`)); return { success: false, accepted: [], rejected: Array.isArray(options.to) ? options.to : [options.to], pending: [], error: errorMessage, sentAt: new Date(), retryCount: 0, }; } } /** * 渲染邮件模板 */ private renderTemplate( templateType: EmailTemplateType, params: EmailTemplateParams ): { subject: string; html: string; text: string } { const template = emailTemplates[templateType]; const defaultParams = { systemName: '星撰系统', companyName: '星撰科技', supportEmail: smtpConfig.auth.user, ...params, }; let subject = template.subject; let html = ''; let text = ''; switch (templateType) { case 'activation': subject = template.subject; html = this.getActivationTemplate(defaultParams); text = `您好 ${defaultParams.nickname || defaultParams.username},\n\n请点击以下链接激活您的账户:\n${defaultParams.activationUrl}\n\n或使用激活码:${defaultParams.activationCode}\n\n链接将在 ${defaultParams.expireTime} 后过期。\n\n如果您没有注册账户,请忽略此邮件。\n\n${defaultParams.systemName}`; break; case 'passwordReset': subject = emailTemplates.passwordReset.subject; html = this.getPasswordResetTemplate(defaultParams); text = `您好 ${defaultParams.nickname || defaultParams.username},\n\n我们收到了重置您账户密码的请求。请点击以下链接重置密码:\n${defaultParams.resetUrl}\n\n或使用重置码:${defaultParams.resetCode}\n\n链接将在 ${defaultParams.expireTime} 后过期。\n\n如果您没有请求重置密码,请忽略此邮件。\n\n${defaultParams.systemName}`; break; case 'welcome': subject = template.subject; html = this.getWelcomeTemplate(defaultParams); text = `欢迎 ${defaultParams.nickname || defaultParams.username}!\n\n感谢您注册 ${defaultParams.systemName}。您的账户已成功激活。\n\n如果您有任何问题,请联系我们:${defaultParams.supportEmail}\n\n${defaultParams.systemName}`; break; case 'passwordChanged': subject = emailTemplates.passwordChanged.subject; html = this.getPasswordChangedTemplate(defaultParams); text = `您好 ${defaultParams.nickname || defaultParams.username},\n\n您的账户密码已成功修改。如果这不是您本人的操作,请立即联系我们。\n\n联系邮箱:${defaultParams.supportEmail}\n\n${defaultParams.systemName}`; break; case 'notification': subject = template.subject; html = this.getNotificationTemplate(defaultParams); text = (defaultParams as any).message || '您有新的系统通知'; break; default: throw new Error(`不支持的邮件模板类型: ${templateType}`); } return { subject, html, text }; } /** * 获取激活邮件模板 */ private getActivationTemplate(params: EmailTemplateParams): string { return ` 激活您的账户

激活您的账户

您好 ${params.nickname || params.username},

感谢您注册 ${params.systemName}!请点击下面的按钮激活您的账户:

激活账户

或者您可以复制以下链接到浏览器地址栏:

${params.activationUrl}

激活码:${params.activationCode}

注意:此链接将在 ${params.expireTime} 后过期。

如果您没有注册账户,请忽略此邮件。


此邮件由 ${params.systemName} 自动发送,请勿回复。
如有疑问,请联系:${params.supportEmail}

`; } /** * 获取密码重置邮件模板 */ private getPasswordResetTemplate(params: EmailTemplateParams): string { return ` 重置您的密码

重置您的密码

您好 ${params.nickname || params.username},

我们收到了重置您账户密码的请求。请点击下面的按钮重置密码:

重置密码

或者您可以复制以下链接到浏览器地址栏:

${params.resetUrl}

重置码:${params.resetCode}

注意:此链接将在 ${params.expireTime} 后过期。

如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。


此邮件由 ${params.systemName} 自动发送,请勿回复。
如有疑问,请联系:${params.supportEmail}

`; } /** * 获取欢迎邮件模板 */ private getWelcomeTemplate(params: EmailTemplateParams): string { return ` 欢迎加入

欢迎加入 ${params.systemName}!

您好 ${params.nickname || params.username},

感谢您注册 ${params.systemName}!您的账户已成功激活,现在可以开始使用我们的服务了。

接下来您可以:

  • 完善您的个人资料
  • 探索系统功能
  • 联系我们的客服团队

如果您在使用过程中遇到任何问题,请随时联系我们。


此邮件由 ${params.systemName} 自动发送,请勿回复。
如有疑问,请联系:${params.supportEmail}

`; } /** * 获取密码修改通知模板 */ private getPasswordChangedTemplate(params: EmailTemplateParams): string { return ` 密码已修改

密码修改通知

您好 ${params.nickname || params.username},

您的账户密码已成功修改。

修改时间: ${new Date().toLocaleString('zh-CN')}

如果这不是您本人的操作,请立即联系我们并采取以下措施:

  • 立即联系客服:${params.supportEmail}
  • 检查账户安全设置
  • 更改相关联的其他账户密码

此邮件由 ${params.systemName} 自动发送,请勿回复。
如有疑问,请联系:${params.supportEmail}

`; } /** * 获取通知邮件模板 */ private getNotificationTemplate(params: EmailTemplateParams): string { const message = (params as any).message || '您有新的系统通知,请登录查看详情。'; return ` 系统通知

系统通知

您好 ${params.nickname || params.username},

${message}

此邮件由 ${params.systemName} 自动发送,请勿回复。
如有疑问,请联系:${params.supportEmail}

`; } /** * 执行健康检查 */ public async healthCheck(): Promise { const startTime = Date.now(); try { if (!this._transporter) { return { status: 'unhealthy', responseTime: 0, serviceStatus: this.status, error: '邮件传输器未初始化', }; } // 验证SMTP连接 await this._transporter.verify(); const responseTime = Date.now() - startTime; this.updateStatus('healthy', 'connected'); return { status: 'healthy', responseTime, serviceStatus: this.status, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.updateStatus('unhealthy', 'error', errorMessage); return { status: 'unhealthy', responseTime: Date.now() - startTime, serviceStatus: this.status, error: errorMessage, }; } } /** * 关闭邮件服务 */ public async close(): Promise { try { if (this._transporter) { this._transporter.close(); this._transporter = null; } this._isInitialized = false; this.updateStatus('unhealthy', 'disconnected'); Logger.debug('邮件服务已关闭'); } catch (error) { Logger.error(error instanceof Error ? error : new Error('关闭邮件服务时出错')); } } } // 创建单例实例 const emailService = EmailService.getInstance(); // 导出便捷方法 export const initializeEmailService = () => emailService.initialize(); export const sendEmail = (options: EmailSendOptions) => emailService.sendEmail(options); export const sendTemplateEmail = (options: EmailTemplateSendOptions) => emailService.sendTemplateEmail(options); export const getEmailServiceStatus = () => emailService.status; export const checkEmailServiceHealth = () => emailService.healthCheck(); export const closeEmailService = () => emailService.close(); // 导出服务实例 export { emailService };