feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+813
View File
@@ -0,0 +1,813 @@
import nodemailer from 'nodemailer'
import { db } from '~/drizzle/db'
import { emailTemplates, users } from '~/drizzle/schema'
import { and, eq, isNotNull } from 'drizzle-orm'
import { getSiteTitle } from '~~/server/utils/siteUtils'
import { formatIPForEmail } from '~~/server/utils/ip-utils'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
const TRUSTED_HTML_TEMPLATE_KEYS = new Set(['contentBlock'])
const EMAIL_REQUEST_SOURCE_LABEL = 'This email was requested from:'
const escapeHtml = (value: unknown): string => {
return String(value).replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character] ?? character)
}
const normalizeEmailActionUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || !value.trim()) return undefined
try {
const url = new URL(value.trim())
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
if (url.username || url.password) return undefined
return url.href
} catch {
return undefined
}
}
const normalizeEmailSubject = (value: unknown): string => {
return String(value ?? '')
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.trim()
}
/**
* SMTP邮件服务
*/
export class SmtpService {
private static instance: SmtpService
public transporter: nodemailer.Transporter | null = null
public smtpConfig: any = null
/** 基础邮件模板结构 */
private baseTemplate = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #333; margin: 0;">{{fromName}}</h1>
<p style="color: #666; margin: 5px 0 0 0;">{{headerSubtitle}}</p>
</div>
{{#if title}}
<h2 style="color: #333; margin-bottom: 20px;">{{title}}</h2>
{{/if}}
<div style="color: #555; line-height: 1.6; margin-bottom: 30px;">
{{contentBlock}}
</div>
{{#if actionUrl}}
<div style="text-align: center; margin: 30px 0;">
<a href="{{actionUrl}}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">{{actionText}}</a>
</div>
{{/if}}
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 12px; text-align: center;">
此邮件由系统自动发送,请勿回复。<br>
如有疑问,请联系管理员。{{#if ipAddress}}<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">{{ipAddress}}</span>{{/if}}
</p>
</div>
</div>
`
/** 内容块模板 */
private contentBlocks: Record<string, string> = {
verification: `
<p>您好,{{name}}!</p>
<p>您正在验证邮箱:<strong>{{email}}</strong></p>
<p>请在{{expiresInMinutes}}分钟内输入以下验证码完成验证:</p>
<div style="text-align: center; margin: 20px 0;">
<h2 style="letter-spacing: 4px; color: #007bff; background: #f8f9fa; padding: 15px; border-radius: 4px; display: inline-block;">{{code}}</h2>
</div>
<p style="color:#888">若非本人操作,请忽略本邮件。</p>
`,
generic: `
<div style="white-space: pre-wrap;">{{message}}</div>
`,
songSelected: `
<p>您投稿的歌曲《{{songTitle}}》已被安排播放。</p>
<p>播放日期:<strong>{{playDate}}</strong></p>
{{#if playTimeName}}
<p>播出时段:<strong>{{playTimeName}}</strong>{{#if playTimeRange}}({{playTimeRange}}){{/if}}</p>
{{/if}}
`,
songPlayed: `
<p>您投稿的歌曲《{{songTitle}}》已播放。</p>
`,
replaySongSelected: `
<p>您申请重播的歌曲《{{songTitle}}》已安排播放。</p>
<p>播放日期:<strong>{{playDate}}</strong></p>
{{#if playTimeName}}
<p>播出时段:<strong>{{playTimeName}}</strong></p>
{{/if}}
`,
songVoted: `
<p>您投稿的歌曲《{{songTitle}}》获得了新的投票。</p>
<p>当前共有 <strong>{{votesCount}}</strong> 个投票。</p>
`,
songRejected: `
<p>您投稿的歌曲《{{songTitle}}》已被管理员驳回。</p>
{{#if reason}}
<p>驳回原因:<strong>{{reason}}</strong></p>
{{/if}}
`,
collaborationInvite: `
<p>用户 <strong>{{inviterName}}</strong> 邀请您共同投稿歌曲《{{songTitle}}》。</p>
`
}
/** 模板配置 */
private builtinTemplates: Record<
string,
{
name: string
subject: string
contentType: string
headerSubtitle: string
actionText?: string
}
> = {
'verification.code': {
name: '邮箱验证码',
subject: '邮箱验证码 | {{siteTitle}}',
contentType: 'verification',
headerSubtitle: '邮箱验证'
},
'register': {
name: '注册通知',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '注册通知'
},
'register-approved': {
name: '注册审核通过',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '注册审核通知'
},
'register-rejected': {
name: '注册审核拒绝',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
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.replaySongSelected': {
name: '重播申请已安排',
subject: '重播已安排 | {{siteTitle}}通知推送',
contentType: 'replaySongSelected',
headerSubtitle: '通知推送'
},
'notification.songVoted': {
name: '收到新投票',
subject: '收到新投票 | {{siteTitle}}通知推送',
contentType: 'songVoted',
headerSubtitle: '通知推送'
},
'notification.songRejected': {
name: '歌曲被驳回',
subject: '歌曲被驳回 | {{siteTitle}}通知推送',
contentType: 'songRejected',
headerSubtitle: '通知推送'
},
'notification.collaborationInvite': {
name: '收到联合投稿邀请',
subject: '收到联合投稿邀请 | {{siteTitle}}通知推送',
contentType: 'collaborationInvite',
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<string, { name: string; subject: string; html: string }> {
const templates: Record<string, { name: string; subject: string; html: string }> = {}
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 || '查看详情'
},
{ rawKeys: TRUSTED_HTML_TEMPLATE_KEYS }
)
templates[key] = {
name: config.name,
subject: config.subject,
html
}
}
return templates
}
/**
* 初始化SMTP配置
* 仅当配置为空或显式要求强制刷新时才执行
*/
async initializeSmtpConfig(forceRefresh: boolean = false): Promise<boolean> {
if (!forceRefresh && this.transporter) {
return true
}
try {
const settings = await getSystemSettingsCached()
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
}
// 根据端口和安全设置调整配置
const isDevelopment = process.env.NODE_ENV === 'development'
if (port === 587 && !secure) {
// STARTTLS - 端口587通常使用STARTTLS
transporterConfig.requireTLS = true
transporterConfig.tls = {
// 仅在开发环境中跳过证书校验,生产环境必须校验
rejectUnauthorized: !isDevelopment
}
} else if (port === 465) {
// SSL/TLS - 端口465必须使用SSL
transporterConfig.secure = true
} else if (port === 25) {
// 通常不加密
transporterConfig.secure = false
transporterConfig.tls = {
// 仅在开发环境中跳过证书校验
rejectUnauthorized: !isDevelopment
}
}
// 创建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<boolean> {
// 确保配置已初始化
if (!(await this.ensureInitialized())) {
throw new Error('SMTP配置未初始化或无效')
}
// 兼容直接调用 sendMail 的邮件;模板已渲染来源 IP 时不重复追加
let finalHtml = htmlContent
if (ipAddress && !htmlContent.includes(EMAIL_REQUEST_SOURCE_LABEL)) {
const formattedIP = escapeHtml(formatIPForEmail(ipAddress))
finalHtml = htmlContent.replace(
/(<p[^>]*style="[^"]*text-align: center[^"]*"[^>]*>.*?此邮件由系统自动发送,请勿回复。.*?<\/p>)/s,
(footer) =>
footer.replace(
'此邮件由系统自动发送,请勿回复。',
`此邮件由系统自动发送,请勿回复。<br><br>${EMAIL_REQUEST_SOURCE_LABEL} <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${formattedIP}</span>`
)
)
}
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<string, any>
): 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<string, any>
// 如果没有自定义模板,使用内置模板系统
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, {
rawKeys: TRUSTED_HTML_TEMPLATE_KEYS
})
} else {
mergedData = await this.prepareTemplateData(data)
html = this.renderString(html, mergedData)
}
return {
subject: normalizeEmailSubject(
this.renderString(subject, mergedData, { escapeValues: false })
),
html
}
}
/**
* 渲染并发送模板
*/
async renderAndSend(
to: string,
key: string,
data: Record<string, any>,
ipAddress?: string
): Promise<boolean> {
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.fallbackTitle || '通知',
data.message || data.fallbackMessage || '',
data.actionUrl,
formattedIP
)
const fallbackSubject = normalizeEmailSubject(
`${data.title || data.fallbackTitle || '通知'} | ${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 = normalizeEmailSubject(`测试邮件 | ${templateData.siteTitle}通知推送`)
const htmlContent = this.generateEmailTemplate(
'测试邮件',
'这是一封来自校园广播站系统的测试邮件。<br>如果您收到这封邮件,说明SMTP配置已经正确设置。',
undefined,
formattedIP,
true
)
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,
trustedHtmlContent = false
): string {
const safeFromName = escapeHtml(this.smtpConfig?.fromName || '校园广播站')
const safeTitle = escapeHtml(title)
const safeContent = trustedHtmlContent ? content : escapeHtml(content)
const safeActionUrl = normalizeEmailActionUrl(actionUrl)
const safeFormattedIP = formattedIP ? escapeHtml(formattedIP) : ''
return `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #333; margin: 0;">${safeFromName}</h1>
<p style="color: #666; margin: 5px 0 0 0;">通知推送</p>
</div>
<h2 style="color: #333; margin-bottom: 20px;">${safeTitle}</h2>
<div style="color: #555; line-height: 1.6; margin-bottom: 30px; white-space: pre-wrap;">
${safeContent}
</div>
${
safeActionUrl
? `
<div style="text-align: center; margin: 30px 0;">
<a href="${escapeHtml(safeActionUrl)}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">查看详情</a>
</div>
`
: ''
}
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 12px; text-align: center;">
此邮件由系统自动发送,请勿回复。<br>
如有疑问,请联系管理员。${safeFormattedIP ? `<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${safeFormattedIP}</span>` : ''}
</p>
</div>
</div>
`
}
/**
* 确保SMTP配置已初始化
*/
private async ensureInitialized(): Promise<boolean> {
if (!this.transporter) {
return await this.initializeSmtpConfig()
}
return true
}
/**
* 基本占位符渲染:用 {{var}} 替换,支持 {{#if var}}...{{/if}} 嵌套条件
*/
private renderString(
tpl: string,
data: Record<string, any>,
options: { escapeValues?: boolean; rawKeys?: ReadonlySet<string> } = {}
): 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)
if (v == null) return ''
const renderedValue = String(v)
if (options.escapeValues === false || options.rawKeys?.has(key)) return renderedValue
return escapeHtml(renderedValue)
})
return tpl
}
/**
* 准备模板渲染数据
*/
private async prepareTemplateData(data: Record<string, any>): Promise<Record<string, any>> {
const siteTitle = await getSiteTitle()
return {
...data,
fromName: this.smtpConfig?.fromName || '校园广播站',
siteTitle,
actionUrl: normalizeEmailActionUrl(data.actionUrl)
}
}
}
/**
* 发送邮件通知给用户
*/
export async function sendEmailNotificationToUser(
userId: number,
notificationTitle: string,
notificationMessage: string,
url?: string,
templateKey?: string,
templateData?: Record<string, any>,
ipAddress?: string
): Promise<boolean> {
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,
{
fallbackTitle: notificationTitle,
fallbackMessage: notificationMessage,
message: notificationMessage,
actionUrl: url,
...(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 }
}