feat(邮件通知): 添加可自定义的邮件模板功能

支持在后台设置中配置管理员通知和回复通知的邮件模板
添加默认模板和恢复默认模板功能
更新相关API以支持模板的存储和使用
This commit is contained in:
anghunk
2026-01-20 16:03:38 +08:00
parent 4a4349e0b9
commit eaebda76a8
5 changed files with 385 additions and 161 deletions

View File

@@ -162,7 +162,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
replyAuthor: name,
replyContent: contentHtml,
postUrl: data.post_url,
}, notifySettings.smtp);
}, notifySettings.smtp, notifySettings.templates?.reply);
console.log('PostComment:mailDispatch:userReply:sent', {
toEmail: parentComment.email
});
@@ -175,7 +175,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
postUrl: data.post_url,
commentAuthor: name,
commentContent: contentHtml
}, notifySettings.smtp);
}, notifySettings.smtp, notifySettings.templates?.admin);
console.log('PostComment:mailDispatch:admin:sent');
}
} catch (mailError) {

View File

@@ -184,10 +184,12 @@ app.put('/admin/settings/email-notify', async (c) => {
const globalEnabled =
typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined;
const smtp = body.smtp && typeof body.smtp === 'object' ? body.smtp : undefined;
const templates = body.templates && typeof body.templates === 'object' ? body.templates : undefined;
await saveEmailNotificationSettings(c.env, {
globalEnabled,
smtp
smtp,
templates
});
return c.json({ message: '保存成功' });

View File

@@ -11,6 +11,8 @@ 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';
const EMAIL_TEMPLATE_REPLY_KEY = 'email_template_reply';
const EMAIL_TEMPLATE_ADMIN_KEY = 'email_template_admin';
type MailGatewayPayload = {
to: string[];
@@ -27,8 +29,91 @@ export type EmailNotificationSettings = {
pass: string;
secure: boolean;
};
templates?: {
reply?: string;
admin?: string;
};
};
const DEFAULT_REPLY_TEMPLATE = `<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;">\${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;">\${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>
`;
const DEFAULT_ADMIN_TEMPLATE = `<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;">\${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>
`;
function replaceTemplate(template: string, variables: Record<string, string>) {
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] || '');
}
async function dispatchMail(
env: Bindings,
payload: MailGatewayPayload,
@@ -153,7 +238,9 @@ export async function loadEmailNotificationSettings(
SMTP_PORT_KEY,
SMTP_USER_KEY,
SMTP_PASS_KEY,
SMTP_SECURE_KEY
SMTP_SECURE_KEY,
EMAIL_TEMPLATE_REPLY_KEY,
EMAIL_TEMPLATE_ADMIN_KEY
];
const { results } = await env.CWD_DB.prepare(
@@ -176,12 +263,18 @@ export async function loadEmailNotificationSettings(
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
secure: map.get(SMTP_SECURE_KEY) !== '0'
};
const templates = {
reply: map.get(EMAIL_TEMPLATE_REPLY_KEY) || DEFAULT_REPLY_TEMPLATE,
admin: map.get(EMAIL_TEMPLATE_ADMIN_KEY) || DEFAULT_ADMIN_TEMPLATE
};
return {
globalEnabled,
smtp
smtp,
templates
};
}
@@ -190,6 +283,7 @@ export async function saveEmailNotificationSettings(
settings: {
globalEnabled?: boolean;
smtp?: Partial<EmailNotificationSettings['smtp']>;
templates?: Partial<NonNullable<EmailNotificationSettings['templates']>>;
}
) {
await env.CWD_DB.prepare(
@@ -209,6 +303,11 @@ export async function saveEmailNotificationSettings(
if (settings.smtp.secure !== undefined) entries.push({ key: SMTP_SECURE_KEY, value: settings.smtp.secure ? '1' : '0' });
}
if (settings.templates) {
if (settings.templates.reply !== undefined) entries.push({ key: EMAIL_TEMPLATE_REPLY_KEY, value: settings.templates.reply });
if (settings.templates.admin !== undefined) entries.push({ key: EMAIL_TEMPLATE_ADMIN_KEY, value: settings.templates.admin });
}
for (const entry of entries) {
if (entry.value !== undefined) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
@@ -229,7 +328,8 @@ export async function sendCommentReplyNotification(
replyContent: string;
postUrl: string;
},
smtpSettings?: EmailNotificationSettings['smtp']
smtpSettings?: EmailNotificationSettings['smtp'],
template?: string
) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
@@ -239,46 +339,15 @@ export async function sendCommentReplyNotification(
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;">${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;">${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>
`;
const html = replaceTemplate(template || DEFAULT_REPLY_TEMPLATE, {
toEmail,
toName,
postTitle,
parentComment,
replyAuthor,
replyContent,
postUrl
});
if (!isValidEmail(toEmail)) {
console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
@@ -307,46 +376,18 @@ export async function sendCommentNotification(
commentAuthor: string;
commentContent: string;
},
smtpSettings?: EmailNotificationSettings['smtp']
smtpSettings?: EmailNotificationSettings['smtp'],
template?: string
) {
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;">${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>
`;
const html = replaceTemplate(template || DEFAULT_ADMIN_TEMPLATE, {
postTitle,
postUrl,
commentAuthor,
commentContent
});
if (!isValidEmail(toEmail)) {
console.warn('EmailAdminNotification:invalidRecipient', { toEmail });