cursor-init/src/plugins/email/email.service.ts
HeXiaoLong:Suanier c6a3ad5332 fix: 修复QQ邮箱From字段格式问题
- 修正From字段配置,确保邮箱地址与SMTP认证用户一致

- 添加fromName支持,支持自定义发件人显示名称

- 改进From字段格式化,符合RFC5322标准

- 添加配置验证,防止空邮箱地址错误

- 创建QQ邮箱配置指南和测试demo

解决550 From header错误,完善邮件服务
2025-07-04 18:42:20 +08:00

636 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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<EmailTransporter> {
// 防止重复初始化
if (this._isInitialized && this._transporter) {
Logger.info('邮件服务已初始化,返回现有实例');
return this._transporter;
}
try {
this.validateConfig();
this.updateStatus('unhealthy', 'disconnected');
console.log({
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, // 每个连接最大消息数
})
// 创建邮件传输器
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.info({
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<EmailSendResult> {
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.info({
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<EmailSendResult> {
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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>激活您的账户</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">激活您的账户</h2>
<p>您好 ${params.nickname || params.username}</p>
<p>感谢您注册 ${params.systemName}!请点击下面的按钮激活您的账户:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${params.activationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">激活账户</a>
</div>
<p>或者您可以复制以下链接到浏览器地址栏:</p>
<p style="word-break: break-all; background-color: #f3f4f6; padding: 10px; border-radius: 5px;">${params.activationUrl}</p>
<p>激活码:<strong>${params.activationCode}</strong></p>
<p><strong>注意:</strong>此链接将在 ${params.expireTime} 后过期。</p>
<p>如果您没有注册账户,请忽略此邮件。</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #6b7280;">
此邮件由 ${params.systemName} 自动发送,请勿回复。<br>
如有疑问,请联系:${params.supportEmail}
</p>
</div>
</body>
</html>`;
}
/**
* 获取密码重置邮件模板
*/
private getPasswordResetTemplate(params: EmailTemplateParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>重置您的密码</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #dc2626;">重置您的密码</h2>
<p>您好 ${params.nickname || params.username}</p>
<p>我们收到了重置您账户密码的请求。请点击下面的按钮重置密码:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${params.resetUrl}" style="background-color: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">重置密码</a>
</div>
<p>或者您可以复制以下链接到浏览器地址栏:</p>
<p style="word-break: break-all; background-color: #f3f4f6; padding: 10px; border-radius: 5px;">${params.resetUrl}</p>
<p>重置码:<strong>${params.resetCode}</strong></p>
<p><strong>注意:</strong>此链接将在 ${params.expireTime} 后过期。</p>
<p>如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #6b7280;">
此邮件由 ${params.systemName} 自动发送,请勿回复。<br>
如有疑问,请联系:${params.supportEmail}
</p>
</div>
</body>
</html>`;
}
/**
* 获取欢迎邮件模板
*/
private getWelcomeTemplate(params: EmailTemplateParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>欢迎加入</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #059669;">欢迎加入 ${params.systemName}</h2>
<p>您好 ${params.nickname || params.username}</p>
<p>感谢您注册 ${params.systemName}!您的账户已成功激活,现在可以开始使用我们的服务了。</p>
<div style="background-color: #f0f9ff; padding: 20px; border-radius: 5px; margin: 20px 0;">
<h3 style="color: #0369a1; margin-top: 0;">接下来您可以:</h3>
<ul>
<li>完善您的个人资料</li>
<li>探索系统功能</li>
<li>联系我们的客服团队</li>
</ul>
</div>
<p>如果您在使用过程中遇到任何问题,请随时联系我们。</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #6b7280;">
此邮件由 ${params.systemName} 自动发送,请勿回复。<br>
如有疑问,请联系:${params.supportEmail}
</p>
</div>
</body>
</html>`;
}
/**
* 获取密码修改通知模板
*/
private getPasswordChangedTemplate(params: EmailTemplateParams): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>密码已修改</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #f59e0b;">密码修改通知</h2>
<p>您好 ${params.nickname || params.username}</p>
<p>您的账户密码已成功修改。</p>
<div style="background-color: #fef3c7; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<p style="margin: 0;"><strong>修改时间:</strong> ${new Date().toLocaleString('zh-CN')}</p>
</div>
<p>如果这不是您本人的操作,请立即联系我们并采取以下措施:</p>
<ul>
<li>立即联系客服:${params.supportEmail}</li>
<li>检查账户安全设置</li>
<li>更改相关联的其他账户密码</li>
</ul>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #6b7280;">
此邮件由 ${params.systemName} 自动发送,请勿回复。<br>
如有疑问,请联系:${params.supportEmail}
</p>
</div>
</body>
</html>`;
}
/**
* 获取通知邮件模板
*/
private getNotificationTemplate(params: EmailTemplateParams): string {
const message = (params as any).message || '您有新的系统通知,请登录查看详情。';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>系统通知</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #6366f1;">系统通知</h2>
<p>您好 ${params.nickname || params.username}</p>
<div style="background-color: #f8fafc; padding: 20px; border-radius: 5px; margin: 20px 0;">
${message}
</div>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 14px; color: #6b7280;">
此邮件由 ${params.systemName} 自动发送,请勿回复。<br>
如有疑问,请联系:${params.supportEmail}
</p>
</div>
</body>
</html>`;
}
/**
* 执行健康检查
*/
public async healthCheck(): Promise<EmailHealthCheckResult> {
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<void> {
try {
if (this._transporter) {
this._transporter.close();
this._transporter = null;
}
this._isInitialized = false;
this.updateStatus('unhealthy', 'disconnected');
Logger.info('邮件服务已关闭');
} 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 };