import nodemailer from 'nodemailer' import { db } from '~/drizzle/db' import { emailTemplates, systemSettings, users } from '~/drizzle/schema' import { and, eq, isNotNull } from 'drizzle-orm' import { getSiteTitle } from '~~/server/utils/siteUtils' import { formatIPForEmail } from '~~/server/utils/ip-utils' /** * SMTP邮件服务 */ export class SmtpService { private static instance: SmtpService public transporter: nodemailer.Transporter | null = null public smtpConfig: any = null /** 基础邮件模板结构 */ private baseTemplate = `

{{fromName}}

{{headerSubtitle}}

{{#if title}}

{{title}}

{{/if}}
{{contentBlock}}
{{#if actionUrl}}
{{actionText}}
{{/if}}

此邮件由系统自动发送,请勿回复。
如有疑问,请联系管理员。{{#if ipAddress}}

This email was requested from: {{ipAddress}}{{/if}}

` /** 内容块模板 */ private contentBlocks: Record = { verification: `

您好,{{name}}!

您正在验证邮箱:{{email}}

请在{{expiresInMinutes}}分钟内输入以下验证码完成验证:

{{code}}

若非本人操作,请忽略本邮件。

`, generic: `
{{message}}
`, songSelected: `

您投稿的歌曲《{{songTitle}}》已被安排播放。

播放日期:{{playDate}}

{{#if playTimeName}}

播出时段:{{playTimeName}}{{#if playTimeRange}}({{playTimeRange}}){{/if}}

{{/if}} {{#if message}}

{{message}}

{{/if}} `, songPlayed: `

您投稿的歌曲《{{songTitle}}》已播放。

{{#if message}}

{{message}}

{{/if}} `, songVoted: `

您投稿的歌曲《{{songTitle}}》获得了新的投票。

当前共有 {{votesCount}} 个投票。

{{#if message}}

{{message}}

{{/if}} ` } /** 模板配置 */ private builtinTemplates: Record< string, { name: string subject: string contentType: string headerSubtitle: string actionText?: string } > = { 'verification.code': { name: '邮箱验证码', subject: '邮箱验证码 | {{siteTitle}}', contentType: 'verification', headerSubtitle: '邮箱验证' }, 'notification.generic': { name: '通用通知', subject: '{{title}} | {{siteTitle}}通知推送', contentType: 'generic', headerSubtitle: '通知推送', actionText: '查看详情' }, 'notification.songSelected': { name: '歌曲被选中', subject: '收到新选中 | {{siteTitle}}通知推送', contentType: 'songSelected', headerSubtitle: '通知推送' }, 'notification.songPlayed': { name: '歌曲已播放', subject: '歌曲已播放 | {{siteTitle}}通知推送', contentType: 'songPlayed', headerSubtitle: '通知推送' }, 'notification.songVoted': { name: '收到新投票', subject: '收到新投票 | {{siteTitle}}通知推送', contentType: 'songVoted', headerSubtitle: '通知推送' } } private constructor() {} static getInstance(): SmtpService { if (!SmtpService.instance) { SmtpService.instance = new SmtpService() // 首次创建实例时,尝试初始化SMTP配置(异步执行,不阻塞) SmtpService.instance.initializeSmtpConfig().catch((error) => { console.log('SmtpService实例创建时初始化配置失败:', error.message) }) } return SmtpService.instance } // 暴露内置模板(只读) getBuiltinTemplates(): Record { const templates: Record = {} for (const [key, config] of Object.entries(this.builtinTemplates)) { const contentBlock = this.contentBlocks[config.contentType] || '' const html = this.renderString(this.baseTemplate, { contentBlock, headerSubtitle: config.headerSubtitle, actionText: config.actionText || '查看详情' }) templates[key] = { name: config.name, subject: config.subject, html } } return templates } /** * 初始化SMTP配置 */ async initializeSmtpConfig(): Promise { try { const settingsResult = await db.select().from(systemSettings).limit(1) const settings = settingsResult[0] if (!settings || !settings.smtpEnabled || !settings.smtpHost) { this.smtpConfig = null this.transporter = null return false } const port = settings.smtpPort || 587 const secure = settings.smtpSecure || false this.smtpConfig = { host: settings.smtpHost, port: port, secure: secure, auth: settings.smtpUsername && settings.smtpPassword ? { user: settings.smtpUsername, pass: settings.smtpPassword } : undefined, fromEmail: settings.smtpFromEmail || settings.smtpUsername, fromName: settings.smtpFromName || '校园广播站' } // 创建transporter配置 const transporterConfig: any = { host: this.smtpConfig.host, port: this.smtpConfig.port, secure: this.smtpConfig.secure, auth: this.smtpConfig.auth } // 根据端口和安全设置调整配置 if (port === 587 && !secure) { // STARTTLS - 端口587通常使用STARTTLS transporterConfig.requireTLS = true transporterConfig.tls = { // 不验证服务器证书(用于测试环境) rejectUnauthorized: false } } else if (port === 465) { // SSL/TLS - 端口465必须使用SSL transporterConfig.secure = true } else if (port === 25) { // 通常不加密 transporterConfig.secure = false transporterConfig.tls = { rejectUnauthorized: false } } // 创建transporter this.transporter = nodemailer.createTransport(transporterConfig) // 验证SMTP连接 await this.transporter.verify() return true } catch (error) { console.error('初始化SMTP配置失败:', error instanceof Error ? error.message : '未知错误') this.smtpConfig = null this.transporter = null return false } } /** * 发送邮件 */ async sendMail( to: string, subject: string, htmlContent: string, textContent?: string, ipAddress?: string ): Promise { // 确保配置已初始化 if (!(await this.ensureInitialized())) { throw new Error('SMTP配置未初始化或无效') } // 如果提供了IP地址,在邮件内容中添加IP信息 let finalHtml = htmlContent if (ipAddress) { const formattedIP = formatIPForEmail(ipAddress) // 在邮件末尾添加IP信息 finalHtml = htmlContent.replace( /(]*style="[^"]*text-align: center[^"]*"[^>]*>.*?此邮件由系统自动发送,请勿回复。.*?<\/p>)/s, `$1`.replace( '此邮件由系统自动发送,请勿回复。', `此邮件由系统自动发送,请勿回复。

This email was requested from: ${formattedIP}` ) ) } const mailOptions = { from: { name: this.smtpConfig.fromName, address: this.smtpConfig.fromEmail }, to, subject, html: finalHtml, text: textContent || finalHtml.replace(/<[^>]*>/g, '') // 简单的HTML转文本 } try { const result = await this.transporter!.sendMail(mailOptions) console.log(`邮件发送成功: ${result.messageId}`) return true } catch (error: any) { // 自动重试机制:如果是因为发件人地址不匹配导致的553/501错误,尝试使用认证用户作为发件人 if ( (error.responseCode === 553 || error.responseCode === 501) && this.smtpConfig.auth?.user ) { console.warn( `SMTP发件人地址不匹配 (${error.responseCode}),正在尝试使用认证用户重试发送...` ) try { // 这里可以安全地使用 mailOptions,因为它在 try 块外部定义 const retryMailOptions = { ...mailOptions, from: { ...mailOptions.from, address: this.smtpConfig.auth.user } } const result = await this.transporter!.sendMail(retryMailOptions) console.log(`重试发送成功: ${result.messageId}`) return true } catch (retryError) { console.error('重试发送失败:', retryError) throw error // 如果重试也失败,抛出原始错误 } } console.error('发送邮件失败:', error) throw error } } /** * 渲染模板:优先使用自定义模板,否则回退到内置模板 */ async renderTemplate( key: string, data: Record ): Promise<{ subject: string; html: string }> { const builtin = this.builtinTemplates[key] let subject = builtin?.subject || '' let html = '' try { const rows = await db .select() .from(emailTemplates) .where(eq(emailTemplates.key, key)) .limit(1) const custom = rows[0] if (custom) { subject = custom.subject html = custom.html } } catch (e) { // 忽略读取失败,走内置 } let mergedData: Record // 如果没有自定义模板,使用内置模板系统 if (!html && builtin) { mergedData = await this.prepareTemplateData(data) const contentBlock = this.renderString( this.contentBlocks[builtin.contentType] || '', mergedData ) const templateData = { ...mergedData, contentBlock, headerSubtitle: builtin.headerSubtitle, actionText: builtin.actionText || '查看详情' } html = this.renderString(this.baseTemplate, templateData) } else { mergedData = await this.prepareTemplateData(data) html = this.renderString(html, mergedData) } return { subject: this.renderString(subject, mergedData), html } } /** * 渲染并发送模板 */ async renderAndSend( to: string, key: string, data: Record, ipAddress?: string ): Promise { try { // 确保配置已初始化 if (!(await this.ensureInitialized())) { return false } // 格式化IP地址用于模板渲染(统一处理) const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined const templateData = { ...data, ipAddress: formattedIP } const { subject, html } = await this.renderTemplate(key, templateData) if (!subject || !html) { // 若模板缺失,退回到简单包装(传入已格式化的IP) const mergedData = await this.prepareTemplateData(templateData) const fallbackHtml = this.generateEmailTemplate( data.title || '通知', data.message || '', data.actionUrl, formattedIP ) const fallbackSubject = `${data.title || '通知'} | ${mergedData.siteTitle}通知推送` return await this.sendMail(to, fallbackSubject, fallbackHtml, undefined, ipAddress) } return await this.sendMail(to, subject, html, undefined, ipAddress) } catch (error) { console.error('渲染并发送邮件失败:', error) return false } } /** * 测试SMTP连接 */ async testConnection(): Promise<{ success: boolean; message: string; detail?: string }> { if (!this.transporter) { try { const initialized = await this.initializeSmtpConfig() if (!initialized) { return { success: false, message: 'SMTP配置无效或未启用' } } } catch (error) { return { success: false, message: '初始化SMTP配置失败', detail: error instanceof Error ? error.message : '未知错误' } } } try { await this.transporter!.verify() return { success: true, message: 'SMTP连接测试成功' } } catch (error) { return { success: false, message: 'SMTP连接测试失败', detail: error instanceof Error ? error.message : '未知错误' } } } /** * 发送测试邮件 */ async sendTestEmail( to: string, ipAddress?: string ): Promise<{ success: boolean; message: string; detail?: string }> { try { const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined const templateData = await this.prepareTemplateData({ ipAddress: formattedIP }) const subject = `测试邮件 | ${templateData.siteTitle}通知推送` const htmlContent = this.generateEmailTemplate( '测试邮件', '这是一封来自校园广播站系统的测试邮件。
如果您收到这封邮件,说明SMTP配置已经正确设置。', undefined, formattedIP ) const success = await this.sendMail(to, subject, htmlContent, undefined, ipAddress) return { success, message: success ? '测试邮件发送成功' : '测试邮件发送失败' } } catch (error) { return { success: false, message: '测试邮件发送失败', detail: error instanceof Error ? error.message : '未知错误' } } } /** * 生成邮件HTML模板 */ generateEmailTemplate( title: string, content: string, actionUrl?: string, formattedIP?: string ): string { return `

${this.smtpConfig?.fromName || '校园广播站'}

通知推送

${title}

${content}
${ actionUrl ? ` ` : '' }

此邮件由系统自动发送,请勿回复。
如有疑问,请联系管理员。${formattedIP ? `

This email was requested from: ${formattedIP}` : ''}

` } /** * 确保SMTP配置已初始化 */ private async ensureInitialized(): Promise { if (!this.transporter) { return await this.initializeSmtpConfig() } return true } /** * 基本占位符渲染:用 {{var}} 替换,支持 {{#if var}}...{{/if}} 嵌套条件 */ private renderString(tpl: string, data: Record): string { // 递归处理嵌套的 if 块 const processIfBlocks = (template: string): string => { let hasChanges = true let result = template while (hasChanges) { hasChanges = false // 从最内层开始处理,匹配不包含嵌套{{#if}}的if块 result = result.replace( /\{\{#if\s+([a-zA-Z0-9_\.]+)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/g, (match, key, inner) => { const v = key .split('.') .reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data) hasChanges = true return v ? inner : '' } ) } return result } // 处理所有if块 tpl = processIfBlocks(tpl) // 处理变量 tpl = tpl.replace(/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/g, (_, key) => { const v = key.split('.').reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data) return v == null ? '' : String(v) }) return tpl } /** * 准备模板渲染数据 */ private async prepareTemplateData(data: Record): Promise> { const siteTitle = await getSiteTitle() return { fromName: this.smtpConfig?.fromName || '校园广播站', siteTitle, ...data } } } /** * 发送邮件通知给用户 */ export async function sendEmailNotificationToUser( userId: number, notificationTitle: string, notificationMessage: string, url?: string, templateKey?: string, templateData?: Record, ipAddress?: string ): Promise { try { const smtpService = SmtpService.getInstance() // 获取用户信息 const userResult = await db .select({ name: users.name, email: users.email, emailVerified: users.emailVerified }) .from(users) .where(eq(users.id, userId)) .limit(1) const user = userResult[0] // 仅检查用户是否有邮箱且已验证 if (!user?.email || !user.emailVerified) { return false } // 使用指定模板,否则回退通用模板 if (templateKey) { return await smtpService.renderAndSend(user.email, templateKey, templateData || {}, ipAddress) } return await smtpService.renderAndSend( user.email, 'notification.generic', { title: notificationTitle, message: notificationMessage, actionUrl: url }, ipAddress ) } catch (error) { console.error('发送邮件通知失败:', error) return false } } /** * 批量发送邮件通知 */ export async function sendBatchEmailNotifications( userIds: number[], notificationTitle: string, notificationMessage: string, url?: string, ipAddress?: string ): Promise<{ success: number; failed: number }> { let success = 0 let failed = 0 const smtpService = SmtpService.getInstance() // 获取有邮箱且已验证的用户 const usersWithEmail = await db .select({ id: users.id, name: users.name, email: users.email }) .from(users) .where(and(eq(users.emailVerified, true), isNotNull(users.email))) // 并发发送邮件(限制并发数) const batchSize = 5 const targetUsers = usersWithEmail.filter((user) => userIds.includes(user.id)) for (let i = 0; i < targetUsers.length; i += batchSize) { const batch = targetUsers.slice(i, i + batchSize) const promises = batch.map(async (user) => { // 确保 email 不为 null if (!user.email) { return false } const emailSuccess = await smtpService.renderAndSend( user.email, 'notification.generic', { title: notificationTitle, message: notificationMessage, actionUrl: url }, ipAddress ) return emailSuccess }) const results = await Promise.allSettled(promises) results.forEach((result) => { if (result.status === 'fulfilled' && result.value) { success++ } else { failed++ } }) } return { success, failed } }