refactor(email): 重构邮件通知逻辑并改进邮件模板

- 移除对 EMAIL_ADDRESS 环境变量的依赖,统一从数据库获取管理员邮箱
- 重构邮件通知逻辑,增加管理员回复的验证
- 改进邮件模板的样式和内容
- 将 getAdminNotifyEmail 函数导出以供其他模块使用
This commit is contained in:
anghunk
2026-01-19 20:55:19 +08:00
parent 054d75d027
commit a722788cc9
4 changed files with 124 additions and 72 deletions

View File

@@ -9,8 +9,8 @@ export const getAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
const row = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind('admin_notify_email')
.first<{ value: string }>();
const email = row?.value || c.env.EMAIL_ADDRESS || null;
return c.json({ email: email });
const email = row?.value || null;
return c.json({ email });
} catch (e: any) {
return c.json({ message: e.message }, 500);
}

View File

@@ -1,7 +1,7 @@
import { Context } from 'hono';
import { UAParser } from 'ua-parser-js';
import { Bindings } from '../../bindings';
import { sendCommentNotification, sendCommentReplyNotification, isValidEmail } from '../../utils/email';
import { sendCommentNotification, sendCommentReplyNotification, isValidEmail, getAdminNotifyEmail } from '../../utils/email';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
@@ -69,8 +69,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
email,
ip,
hasSendEmailBinding: !!c.env.SEND_EMAIL,
fromEmail: c.env.CF_FROM_EMAIL,
emailAddressEnv: c.env.EMAIL_ADDRESS
fromEmail: c.env.CF_FROM_EMAIL
});
const uaParser = new UAParser(userAgent);
const uaResult = uaParser.getResult();
@@ -121,35 +120,53 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
).bind(data.parent_id).first<{ author: string, email: string, content_text: string }>();
if (parentComment && parentComment.email !== data.email) {
const recentUserMail = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1"
).bind(parentComment.email).first<{ created_at: string }>();
const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000);
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
});
let adminEmail: string | null = null;
try {
adminEmail = await getAdminNotifyEmail(c.env);
} catch (e) {
console.error('PostComment:mailDispatch:userReply:getAdminEmailFailed', e);
}
if (!canSendUserMail) {
console.log('PostComment:mailDispatch:userReply:skippedByRateLimit', {
toEmail: parentComment.email
const isAdminReply = !!adminEmail && email === adminEmail;
const isParentThirdParty = !!adminEmail && parentComment.email !== adminEmail;
if (isAdminReply && isParentThirdParty) {
const recentUserMail = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1"
).bind(parentComment.email).first<{ created_at: string }>();
const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000);
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 (!canSendUserMail) {
console.log('PostComment:mailDispatch:userReply:skippedByRateLimit', {
toEmail: parentComment.email
});
}
} else {
console.log('PostComment:mailDispatch:userReply:skipNonAdminReply', {
adminEmail,
currentEmail: email,
parentEmail: parentComment.email
});
}
}

View File

@@ -5,8 +5,7 @@ export type Bindings = {
CF_FROM_EMAIL?: string
SEND_EMAIL?: {
send: (message: any) => Promise<any>
}
EMAIL_ADDRESS?: string
}
ADMIN_NAME: string
ADMIN_PASSWORD: string
}

View File

@@ -30,25 +30,45 @@ export async function sendCommentReplyNotification(
});
const html = `
<div style="font-family: sans-serif; line-height: 1.6; color: #333;">
<p>Hi <b>${toName}</b></p>
<p>${replyAuthor} 回复了你在 <b>${postTitle}</b> 中的评论:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #e2e8f0; background: #f8fafc;">
${parentComment}
</blockquote>
<p>最新回复:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #3b82f6; background: #eff6ff;">
${replyContent}
</blockquote>
<p style="margin-top: 20px;">
<a href="${postUrl}" style="background: #3b82f6; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; display: inline-block;">
查看完整回复
</a>
</p>
<hr style="border: none; border-top: 1px solid #eee; margin-top: 30px;">
<p style="font-size: 12px; color: #999;">此邮件由系统自动发送,请勿直接回复。</p>
<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 (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
console.error('EmailReplyNotification:missingBinding', {
@@ -66,7 +86,7 @@ export async function sendCommentReplyNotification(
await env.SEND_EMAIL.send({
to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL },
subject: `你在 example.com 上的评论有了新回复`,
subject: `评论回复 - ${postTitle}`,
html
});
@@ -98,12 +118,38 @@ export async function sendCommentNotification(
});
const html = `
<div style="font-family: sans-serif;">
<p><b>${commentAuthor}</b> 在文章《${postTitle}》下发表了评论:</p>
<div style="padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
${commentContent}
<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;">新评论提醒 - ${postTitle}</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>
<p><a href="${postUrl}">点击跳转到文章</a></p>
</div>
`;
@@ -128,7 +174,7 @@ export async function sendCommentNotification(
await env.SEND_EMAIL.send({
to: [toEmail],
from: env.CF_FROM_EMAIL,
subject: `新评论通知:${postTitle}`,
subject: `新评论提醒 - ${postTitle}`,
html
});
} catch (sendError: any) {
@@ -145,7 +191,7 @@ export async function sendCommentNotification(
});
}
async function getAdminNotifyEmail(env: Bindings): Promise<string> {
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();
@@ -161,18 +207,8 @@ async function getAdminNotifyEmail(env: Bindings): Promise<string> {
});
return cleanEmail;
}
if (env.EMAIL_ADDRESS && isValidEmail(env.EMAIL_ADDRESS)) {
const cleanEmail = env.EMAIL_ADDRESS.trim();
console.log('EmailAdminNotification:useEnvEmail', {
email: cleanEmail,
originalLength: env.EMAIL_ADDRESS.length,
cleanLength: cleanEmail.length
});
return cleanEmail;
}
console.error('EmailAdminNotification:noAdminEmail', {
dbValue: row?.value,
envValue: env.EMAIL_ADDRESS
dbValue: row?.value
});
throw new Error('未配置管理员通知邮箱或格式不正确');
}