Files
cwd/cwd-comments-api/src/utils/email.ts
anghunk 6d3bdb2b11 feat(邮件通知): 添加SMTP邮件发送支持并完善通知设置
- 新增nodemailer依赖实现SMTP邮件发送功能
- 扩展邮件通知设置界面,支持SMTP服务器配置
- 实现SMTP与原有邮件网关的自动切换逻辑
- 优化邮件通知相关API接口和数据库存储结构
2026-01-20 11:59:52 +08:00

339 lines
12 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.
import { Bindings } from '../bindings';
import { createTransport } from 'nodemailer';
export function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled';
const SMTP_HOST_KEY = 'email_smtp_host';
const SMTP_PORT_KEY = 'email_smtp_port';
const SMTP_USER_KEY = 'email_smtp_user';
const SMTP_PASS_KEY = 'email_smtp_pass';
const SMTP_SECURE_KEY = 'email_smtp_secure';
type MailGatewayPayload = {
to: string[];
subject: string;
html: string;
};
export type EmailNotificationSettings = {
globalEnabled: boolean;
smtp?: {
host: string;
port: number;
user: string;
pass: string;
secure: boolean;
};
};
async function dispatchMail(
env: Bindings,
payload: MailGatewayPayload,
smtpSettings?: EmailNotificationSettings['smtp']
) {
// 1. Try SMTP
if (smtpSettings && smtpSettings.user && smtpSettings.pass) {
try {
console.log('MailDispatch:SMTP:start', { host: smtpSettings.host, user: smtpSettings.user });
const transporter = createTransport({
host: smtpSettings.host || 'smtp.qq.com',
port: smtpSettings.port || 465,
secure: smtpSettings.secure ?? true,
auth: {
user: smtpSettings.user,
pass: smtpSettings.pass,
},
});
await transporter.sendMail({
from: `"评论通知" <${smtpSettings.user}>`,
to: payload.to.join(', '),
subject: payload.subject,
html: payload.html,
});
console.log('MailDispatch:SMTP:success', { to: payload.to });
return;
} catch (e: any) {
console.error('MailDispatch:SMTP:error', {
message: e?.message || String(e),
});
// Fallback to gateway?
}
}
if (!env.MAIL_GATEWAY_URL) {
if (!smtpSettings?.user) {
console.error('MailGateway:missingUrlAndSmtp');
}
return;
}
try {
const res = await fetch(env.MAIL_GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.MAIL_GATEWAY_TOKEN ? { 'X-Auth-Token': env.MAIL_GATEWAY_TOKEN } : {})
},
body: JSON.stringify(payload)
});
if (!res.ok) {
console.error('MailGateway:sendFailed', {
status: res.status,
statusText: res.statusText
});
}
} catch (e: any) {
console.error('MailGateway:error', {
message: e?.message || String(e)
});
}
}
function parseEnabled(raw: string | undefined, defaultValue: boolean) {
if (raw === undefined) return defaultValue;
return raw === '1';
}
export async function loadEmailNotificationSettings(
env: Bindings
): Promise<EmailNotificationSettings> {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const keys = [
EMAIL_NOTIFY_GLOBAL_KEY,
SMTP_HOST_KEY,
SMTP_PORT_KEY,
SMTP_USER_KEY,
SMTP_PASS_KEY,
SMTP_SECURE_KEY
];
const { results } = await env.CWD_DB.prepare(
`SELECT key, value FROM Settings WHERE key IN (${keys.map(() => '?').join(',')})`
)
.bind(...keys)
.all<{ key: string; value: string }>();
const map = new Map<string, string>();
for (const row of results) {
if (row && row.key) {
map.set(row.key, row.value);
}
}
const globalEnabled = parseEnabled(map.get(EMAIL_NOTIFY_GLOBAL_KEY), true);
const smtp: EmailNotificationSettings['smtp'] = {
host: map.get(SMTP_HOST_KEY) || 'smtp.qq.com',
port: parseInt(map.get(SMTP_PORT_KEY) || '465', 10),
user: map.get(SMTP_USER_KEY) || '',
pass: map.get(SMTP_PASS_KEY) || '',
secure: map.get(SMTP_SECURE_KEY) !== '0' // Default true
};
return {
globalEnabled,
smtp
};
}
export async function saveEmailNotificationSettings(
env: Bindings,
settings: {
globalEnabled?: boolean;
smtp?: Partial<EmailNotificationSettings['smtp']>;
}
) {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const entries: { key: string; value: string | undefined }[] = [];
if (settings.globalEnabled !== undefined) {
entries.push({ key: EMAIL_NOTIFY_GLOBAL_KEY, value: settings.globalEnabled ? '1' : '0' });
}
if (settings.smtp) {
if (settings.smtp.host !== undefined) entries.push({ key: SMTP_HOST_KEY, value: settings.smtp.host });
if (settings.smtp.port !== undefined) entries.push({ key: SMTP_PORT_KEY, value: String(settings.smtp.port) });
if (settings.smtp.user !== undefined) entries.push({ key: SMTP_USER_KEY, value: settings.smtp.user });
if (settings.smtp.pass !== undefined) entries.push({ key: SMTP_PASS_KEY, value: settings.smtp.pass });
if (settings.smtp.secure !== undefined) entries.push({ key: SMTP_SECURE_KEY, value: settings.smtp.secure ? '1' : '0' });
}
for (const entry of entries) {
if (entry.value !== undefined) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
.bind(entry.key, entry.value)
.run();
}
}
}
export async function sendCommentReplyNotification(
env: Bindings,
params: {
toEmail: string;
toName: string;
postTitle: string;
parentComment: string;
replyAuthor: string;
replyContent: string;
postUrl: string;
},
smtpSettings?: EmailNotificationSettings['smtp']
) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
console.log('EmailReplyNotification:start', {
toEmail,
toName,
postTitle
});
const html = `
<div style="background-color:#f4f4f5;padding:24px 0;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#2563eb,#4f46e5);">
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">评论回复 - ${postTitle}</h1>
<p style="margin:4px 0 0;font-size:12px;color:#e5e7eb;">你在文章下的评论收到了新的回复</p>
</div>
<div style="padding:24px 28px;">
<p style="margin:0 0 8px 0;font-size:14px;color:#374151;">Hi <span style="font-weight:600;">${toName}</span></p>
<p style="margin:0 0 16px 0;font-size:14px;color:#4b5563;">
<span style="font-weight:600;">${replyAuthor}</span> 回复了你在
<span style="font-weight:600;">《${postTitle}》</span>
中的评论:
</p>
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f3f4f6;border:1px solid #e5e7eb;">
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">你之前的评论</div>
<div style="font-size:14px;color:#374151;white-space:pre-wrap;">${parentComment}</div>
</div>
<div style="margin:0 0 24px 0;padding:14px 16px;border-radius:10px;background:#eff6ff;border:1px solid #bfdbfe;">
<div style="font-size:12px;color:#1d4ed8;margin-bottom:6px;">最新回复</div>
<div style="font-size:14px;color:#1f2937;white-space:pre-wrap;">${replyContent}</div>
</div>
<div style="text-align:center;margin-bottom:8px;">
<a href="${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#2563eb;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
打开文章查看完整对话
</a>
</div>
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
<span style="word-break:break-all;color:#6b7280;">${postUrl}</span>
</p>
</div>
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
此邮件由系统自动发送,请勿直接回复。
</p>
</div>
</div>
</div>
`;
if (!isValidEmail(toEmail)) {
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
return;
}
await dispatchMail(env, {
to: [toEmail],
subject: `评论回复 - ${postTitle}`,
html
}, smtpSettings);
console.log('EmailReplyNotification:sent', {
toEmail
});
}
/**
* 站长通知邮件
*/
export async function sendCommentNotification(
env: Bindings,
params: {
postTitle: string;
postUrl: string;
commentAuthor: string;
commentContent: string;
},
smtpSettings?: EmailNotificationSettings['smtp']
) {
const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env);
const html = `
<div style="background-color:#f4f4f5;padding:24px 0;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#0f766e,#059669);">
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">新评论提醒</h1>
<p style="margin:4px 0 0;font-size:12px;color:#d1fae5;">你的文章收到了新的评论</p>
</div>
<div style="padding:24px 28px;">
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;">
<span style="font-weight:600;">${commentAuthor}</span> 在文章
<span style="font-weight:600;">《${postTitle}》</span>
下发表了新评论:
</p>
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f9fafb;border:1px solid #e5e7eb;">
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">评论内容</div>
<div style="font-size:14px;color:#374151;white-space:pre-wrap;">${commentContent}</div>
</div>
<div style="margin:0 0 8px 0;">
<a href="${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#047857;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
打开后台查看并管理评论
</a>
</div>
<p style="margin:0;font-size:12px;color:#9ca3af;">
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
<span style="word-break:break-all;color:#6b7280;">${postUrl}</span>
</p>
</div>
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
此邮件由系统自动发送,如非本人操作可忽略本邮件。
</p>
</div>
</div>
</div>
`;
if (!isValidEmail(toEmail)) {
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });
return;
}
await dispatchMail(env, {
to: [toEmail],
subject: `新评论提醒 - ${postTitle}`,
html
}, smtpSettings);
console.log('EmailAdminNotification:sent', {
toEmail
});
}
export async function getAdminNotifyEmail(env: Bindings): Promise<string> {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('admin_notify_email')
.first<{ value: string }>();
if (row?.value && isValidEmail(row.value)) {
const cleanEmail = row.value.trim();
return cleanEmail;
}
throw new Error('未配置管理员通知邮箱或格式不正确');
}