feat(邮件通知): 重构邮件通知系统并增加配置选项
- 移除 Cloudflare Email 绑定,改为支持外部邮件网关 - 新增邮件通知全局开关及管理员/用户通知独立配置 - 在管理后台添加邮件通知配置界面 - 优化邮件发送逻辑,增加错误处理和日志记录 - 更新相关文档说明
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import { Context } from 'hono';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { sendCommentNotification, sendCommentReplyNotification, isValidEmail, getAdminNotifyEmail } from '../../utils/email';
|
||||
import {
|
||||
sendCommentNotification,
|
||||
sendCommentReplyNotification,
|
||||
isValidEmail,
|
||||
getAdminNotifyEmail,
|
||||
loadEmailNotificationSettings,
|
||||
EmailNotificationSettings
|
||||
} from '../../utils/email';
|
||||
|
||||
// 检查内容,将<script>标签之间的内容删除
|
||||
export function checkContent(content: string): string {
|
||||
@@ -68,9 +75,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
hasParent: parentId !== null && parentId !== undefined,
|
||||
author,
|
||||
email,
|
||||
ip,
|
||||
hasSendEmailBinding: !!c.env.SEND_EMAIL,
|
||||
fromEmail: c.env.CF_FROM_EMAIL
|
||||
ip
|
||||
});
|
||||
const uaParser = new UAParser(userAgent);
|
||||
const uaResult = uaParser.getResult();
|
||||
@@ -108,8 +113,20 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
ip
|
||||
});
|
||||
|
||||
// 5. 发送邮件通知 (后台异步执行,不阻塞用户响应)
|
||||
if (c.env.SEND_EMAIL && c.env.CF_FROM_EMAIL) {
|
||||
let notifySettings: EmailNotificationSettings = {
|
||||
globalEnabled: true,
|
||||
adminEnabled: true,
|
||||
userEnabled: true
|
||||
};
|
||||
try {
|
||||
notifySettings = await loadEmailNotificationSettings(c.env);
|
||||
} catch (e) {
|
||||
console.error('PostComment:mailDispatch:loadEmailSettingsFailed', e);
|
||||
}
|
||||
|
||||
if (!notifySettings.globalEnabled) {
|
||||
console.log('PostComment:mailDispatch:disabledByGlobalConfig');
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:start', {
|
||||
hasParent: parentId !== null && parentId !== undefined
|
||||
});
|
||||
@@ -140,25 +157,29 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
}
|
||||
|
||||
if (canSendUserMail && isValidEmail(parentComment.email)) {
|
||||
console.log('PostComment:mailDispatch:userReply:send', {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.author
|
||||
});
|
||||
await sendCommentReplyNotification(c.env, {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.author,
|
||||
postTitle: data.post_title,
|
||||
parentComment: parentComment.content_text,
|
||||
replyAuthor: author,
|
||||
replyContent: content,
|
||||
postUrl: data.post_url,
|
||||
});
|
||||
await c.env.CWD_DB.prepare(
|
||||
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
|
||||
).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run();
|
||||
console.log('PostComment:mailDispatch:userReply:logInserted', {
|
||||
toEmail: parentComment.email
|
||||
});
|
||||
if (!notifySettings.userEnabled) {
|
||||
console.log('PostComment:mailDispatch:userReply:disabledByConfig');
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:userReply:send', {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.author
|
||||
});
|
||||
await sendCommentReplyNotification(c.env, {
|
||||
toEmail: parentComment.email,
|
||||
toName: parentComment.author,
|
||||
postTitle: data.post_title,
|
||||
parentComment: parentComment.content_text,
|
||||
replyAuthor: author,
|
||||
replyContent: content,
|
||||
postUrl: data.post_url,
|
||||
});
|
||||
await c.env.CWD_DB.prepare(
|
||||
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
|
||||
).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run();
|
||||
console.log('PostComment:mailDispatch:userReply:logInserted', {
|
||||
toEmail: parentComment.email
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -167,17 +188,21 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
).first<{ created_at: string }>();
|
||||
const canSendAdminMail = !adminEmailRow || (Date.now() - new Date(adminEmailRow.created_at).getTime() > 15 * 1000);
|
||||
if (canSendAdminMail) {
|
||||
console.log('PostComment:mailDispatch:admin:send');
|
||||
await sendCommentNotification(c.env, {
|
||||
postTitle: data.post_title,
|
||||
postUrl: data.post_url,
|
||||
commentAuthor: author,
|
||||
commentContent: content
|
||||
});
|
||||
await c.env.CWD_DB.prepare(
|
||||
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
|
||||
).bind('admin', 'admin-notify', ip, new Date().toISOString()).run();
|
||||
console.log('PostComment:mailDispatch:admin:logInserted');
|
||||
if (!notifySettings.adminEnabled) {
|
||||
console.log('PostComment:mailDispatch:admin:disabledByConfig');
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:admin:send');
|
||||
await sendCommentNotification(c.env, {
|
||||
postTitle: data.post_title,
|
||||
postUrl: data.post_url,
|
||||
commentAuthor: author,
|
||||
commentContent: content
|
||||
});
|
||||
await c.env.CWD_DB.prepare(
|
||||
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
|
||||
).bind('admin', 'admin-notify', ip, new Date().toISOString()).run();
|
||||
console.log('PostComment:mailDispatch:admin:logInserted');
|
||||
}
|
||||
}
|
||||
if (!canSendAdminMail) {
|
||||
console.log('PostComment:mailDispatch:admin:skippedByRateLimit');
|
||||
@@ -187,13 +212,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
console.error("Mail Notification Failed:", mailError);
|
||||
}
|
||||
})());
|
||||
} else {
|
||||
console.log('PostComment:mailDispatch:skipNoBinding', {
|
||||
hasSendEmailBinding: !!c.env.SEND_EMAIL,
|
||||
fromEmail: c.env.CF_FROM_EMAIL
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ message: "Comment submitted. Awaiting moderation." });
|
||||
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -2,10 +2,8 @@ export type Bindings = {
|
||||
CWD_DB: D1Database
|
||||
CWD_AUTH_KV: KVNamespace;
|
||||
ALLOW_ORIGIN: string
|
||||
CF_FROM_EMAIL?: string
|
||||
SEND_EMAIL?: {
|
||||
send: (message: any) => Promise<any>
|
||||
}
|
||||
MAIL_GATEWAY_URL?: string
|
||||
MAIL_GATEWAY_TOKEN?: string
|
||||
ADMIN_NAME: string
|
||||
ADMIN_PASSWORD: string
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Hono } from 'hono';
|
||||
import { Bindings } from './bindings';
|
||||
import { customCors } from './utils/cors';
|
||||
import { adminAuth } from './utils/auth';
|
||||
import { isValidEmail } from './utils/email';
|
||||
import {
|
||||
isValidEmail,
|
||||
loadEmailNotificationSettings,
|
||||
saveEmailNotificationSettings
|
||||
} from './utils/email';
|
||||
|
||||
import { getComments } from './api/public/getComments';
|
||||
import { postComment } from './api/public/postComment';
|
||||
@@ -115,11 +119,11 @@ app.use('*', async (c, next) => {
|
||||
});
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
|
||||
const corsMiddleware = customCors();
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
app.use('/admin/*', async (c, next) => {
|
||||
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
|
||||
const corsMiddleware = customCors();
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
@@ -145,9 +149,37 @@ app.use('/admin/*', adminAuth);
|
||||
app.delete('/admin/comments/delete', deleteComment);
|
||||
app.get('/admin/comments/list', listComments);
|
||||
app.put('/admin/comments/status', updateStatus);
|
||||
// 设置接口
|
||||
app.get('/admin/settings/email', getAdminEmail);
|
||||
app.put('/admin/settings/email', setAdminEmail);
|
||||
app.get('/admin/settings/email-notify', async (c) => {
|
||||
try {
|
||||
const settings = await loadEmailNotificationSettings(c.env);
|
||||
return c.json(settings);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '加载邮件通知配置失败' }, 500);
|
||||
}
|
||||
});
|
||||
app.put('/admin/settings/email-notify', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const globalEnabled =
|
||||
typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined;
|
||||
const adminEnabled =
|
||||
typeof body.adminEnabled === 'boolean' ? body.adminEnabled : undefined;
|
||||
const userEnabled =
|
||||
typeof body.userEnabled === 'boolean' ? body.userEnabled : undefined;
|
||||
|
||||
await saveEmailNotificationSettings(c.env, {
|
||||
globalEnabled,
|
||||
adminEnabled,
|
||||
userEnabled
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '保存失败' }, 500);
|
||||
}
|
||||
});
|
||||
app.get('/admin/settings/comments', async (c) => {
|
||||
try {
|
||||
const settings = await loadCommentSettings(c.env);
|
||||
|
||||
@@ -4,9 +4,138 @@ export function isValidEmail(email: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复通知邮件
|
||||
*/
|
||||
const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled';
|
||||
const EMAIL_NOTIFY_ADMIN_KEY = 'email_notify_admin_enabled';
|
||||
const EMAIL_NOTIFY_USER_KEY = 'email_notify_user_enabled';
|
||||
|
||||
type MailGatewayPayload = {
|
||||
to: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
async function dispatchMail(env: Bindings, payload: MailGatewayPayload) {
|
||||
if (!env.MAIL_GATEWAY_URL) {
|
||||
console.error('MailGateway:missingUrl');
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type EmailNotificationSettings = {
|
||||
globalEnabled: boolean;
|
||||
adminEnabled: boolean;
|
||||
userEnabled: boolean;
|
||||
};
|
||||
|
||||
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, EMAIL_NOTIFY_ADMIN_KEY, EMAIL_NOTIFY_USER_KEY];
|
||||
const { results } = await env.CWD_DB.prepare(
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?)'
|
||||
)
|
||||
.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 adminEnabled = parseEnabled(map.get(EMAIL_NOTIFY_ADMIN_KEY), true);
|
||||
const userEnabled = parseEnabled(map.get(EMAIL_NOTIFY_USER_KEY), true);
|
||||
|
||||
return {
|
||||
globalEnabled,
|
||||
adminEnabled,
|
||||
userEnabled
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveEmailNotificationSettings(
|
||||
env: Bindings,
|
||||
settings: {
|
||||
globalEnabled?: boolean;
|
||||
adminEnabled?: boolean;
|
||||
userEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
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 }[] = [
|
||||
{
|
||||
key: EMAIL_NOTIFY_GLOBAL_KEY,
|
||||
value:
|
||||
typeof settings.globalEnabled === 'boolean'
|
||||
? settings.globalEnabled
|
||||
? '1'
|
||||
: '0'
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: EMAIL_NOTIFY_ADMIN_KEY,
|
||||
value:
|
||||
typeof settings.adminEnabled === 'boolean'
|
||||
? settings.adminEnabled
|
||||
? '1'
|
||||
: '0'
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
key: EMAIL_NOTIFY_USER_KEY,
|
||||
value:
|
||||
typeof settings.userEnabled === 'boolean'
|
||||
? settings.userEnabled
|
||||
? '1'
|
||||
: '0'
|
||||
: undefined
|
||||
}
|
||||
];
|
||||
|
||||
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: {
|
||||
@@ -24,9 +153,7 @@ export async function sendCommentReplyNotification(
|
||||
console.log('EmailReplyNotification:start', {
|
||||
toEmail,
|
||||
toName,
|
||||
postTitle,
|
||||
fromEmail: env.CF_FROM_EMAIL,
|
||||
hasSendBinding: !!env.SEND_EMAIL
|
||||
postTitle
|
||||
});
|
||||
|
||||
const html = `
|
||||
@@ -70,22 +197,13 @@ export async function sendCommentReplyNotification(
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
|
||||
console.error('EmailReplyNotification:missingBinding', {
|
||||
hasSendBinding: !!env.SEND_EMAIL,
|
||||
fromEmail: env.CF_FROM_EMAIL
|
||||
});
|
||||
throw new Error('未配置邮件发送绑定或发件人地址');
|
||||
}
|
||||
|
||||
if (!isValidEmail(toEmail)) {
|
||||
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
|
||||
return;
|
||||
}
|
||||
|
||||
await env.SEND_EMAIL.send({
|
||||
await dispatchMail(env, {
|
||||
to: [toEmail],
|
||||
from: env.CF_FROM_EMAIL,
|
||||
subject: `评论回复 - ${postTitle}`,
|
||||
html
|
||||
});
|
||||
@@ -110,13 +228,6 @@ export async function sendCommentNotification(
|
||||
const { postTitle, postUrl, commentAuthor, commentContent } = params;
|
||||
const toEmail = await getAdminNotifyEmail(env);
|
||||
|
||||
console.log('EmailAdminNotification:start', {
|
||||
toEmail,
|
||||
postTitle,
|
||||
fromEmail: env.CF_FROM_EMAIL,
|
||||
hasSendBinding: !!env.SEND_EMAIL
|
||||
});
|
||||
|
||||
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;">
|
||||
@@ -153,38 +264,16 @@ export async function sendCommentNotification(
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
|
||||
console.error('EmailAdminNotification:missingBinding', {
|
||||
hasSendBinding: !!env.SEND_EMAIL,
|
||||
fromEmail: env.CF_FROM_EMAIL
|
||||
});
|
||||
throw new Error('未配置邮件发送绑定或发件人地址');
|
||||
}
|
||||
|
||||
if (!isValidEmail(toEmail)) {
|
||||
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('EmailAdminNotification:send:start', {
|
||||
to: toEmail,
|
||||
from: env.CF_FROM_EMAIL
|
||||
await dispatchMail(env, {
|
||||
to: [toEmail],
|
||||
subject: `新评论提醒 - ${postTitle}`,
|
||||
html
|
||||
});
|
||||
try {
|
||||
await env.SEND_EMAIL.send({
|
||||
to: [toEmail],
|
||||
from: env.CF_FROM_EMAIL,
|
||||
subject: `新评论提醒 - ${postTitle}`,
|
||||
html
|
||||
});
|
||||
} catch (sendError: any) {
|
||||
console.error('EmailAdminNotification:send:error', {
|
||||
error: sendError.message,
|
||||
to: toEmail,
|
||||
from: env.CF_FROM_EMAIL
|
||||
});
|
||||
throw sendError;
|
||||
}
|
||||
|
||||
console.log('EmailAdminNotification:sent', {
|
||||
toEmail
|
||||
|
||||
Reference in New Issue
Block a user