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

@@ -51,6 +51,10 @@ export type EmailNotifySettingsResponse = {
pass: string; pass: string;
secure: boolean; secure: boolean;
}; };
templates?: {
reply?: string;
admin?: string;
};
}; };
export async function loginAdmin(name: string, password: string): Promise<string> { export async function loginAdmin(name: string, password: string): Promise<string> {
@@ -97,6 +101,10 @@ export function saveEmailNotifySettings(data: {
pass?: string; pass?: string;
secure?: boolean; secure?: boolean;
}; };
templates?: {
reply?: string;
admin?: string;
};
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/email-notify', data); return put<{ message: string }>('/admin/settings/email-notify', data);
} }

View File

@@ -32,7 +32,9 @@
<input v-model="avatarPrefix" class="form-input" type="text" /> <input v-model="avatarPrefix" class="form-input" type="text" />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">允许调用的域名多个域名用逗号分隔留空则不限制</label> <label class="form-label"
>允许调用的域名多个域名用逗号分隔留空则不限制</label
>
<textarea <textarea
v-model="allowedDomains" v-model="allowedDomains"
class="form-input" class="form-input"
@@ -59,62 +61,128 @@
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">管理员通知邮箱</label> <label class="form-label">管理员通知邮箱</label>
<input v-model="email" class="form-input" type="email" placeholder="接收新评论提醒的邮箱" /> <input
v-model="email"
class="form-input"
type="email"
placeholder="接收新评论提醒的邮箱"
/>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<h4 class="card-subtitle">SMTP 发件配置</h4> <h4 class="card-subtitle">1. SMTP 发件配置</h4>
<div class="form-item"> <div class="form-item">
<label class="form-label">邮件服务商</label> <label class="form-label">邮件服务商</label>
<select v-model="smtpProvider" class="form-input" @change="onProviderChange"> <select v-model="smtpProvider" class="form-input" @change="onProviderChange">
<option value="qq">QQ邮箱</option> <option value="qq">QQ 邮箱</option>
<option value="custom">自定义SMTP</option> <option value="custom">自定义 SMTP</option>
</select> </select>
</div> </div>
<div v-if="smtpProvider === 'custom'"> <div v-if="smtpProvider === 'custom'">
<div class="form-item"> <div class="form-item">
<label class="form-label">SMTP服务器</label> <label class="form-label">SMTP 服务器</label>
<input v-model="smtpHost" class="form-input" placeholder="smtp.example.com" /> <input v-model="smtpHost" class="form-input" placeholder="smtp.example.com" />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">SMTP端口</label> <label class="form-label">SMTP 端口</label>
<input v-model="smtpPort" class="form-input" type="number" placeholder="465" /> <input
</div> v-model="smtpPort"
<div class="form-item"> class="form-input"
<label class="form-label">SSL安全连接</label> type="number"
<label class="switch"> placeholder="465"
<input v-model="smtpSecure" type="checkbox" /> />
<span class="slider" /> </div>
</label> <div class="form-item">
</div> <label class="form-label">SSL 安全连接</label>
<label class="switch">
<input v-model="smtpSecure" type="checkbox" />
<span class="slider" />
</label>
</div>
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">发件邮箱账号</label> <label class="form-label">发件邮箱账号</label>
<input v-model="smtpUser" class="form-input" placeholder="例如: 123456@qq.com" /> <input
v-model="smtpUser"
class="form-input"
placeholder="例如: 123456@qq.com"
/>
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">授权码/密码</label> <label class="form-label">授权码/密码</label>
<input v-model="smtpPass" class="form-input" type="password" placeholder="QQ邮箱请使用授权码" /> <input
v-model="smtpPass"
class="form-input"
type="password"
placeholder="QQ邮箱请使用授权码"
/>
<div v-if="smtpProvider === 'qq'" class="form-hint"> <div v-if="smtpProvider === 'qq'" class="form-hint">
注意QQ邮箱必须使用<a href="https://service.mail.qq.com/detail/0/75" target="_blank">授权码</a>而非QQ密码<br/> 注意QQ 邮箱必须使用<a
请登录QQ邮箱网页版设置-账户中开启 POP3/SMTP 服务并生成授权码 href="https://service.mail.qq.com/detail/0/75"
target="_blank"
>授权码</a
>而非 QQ 密码<br />
请登录 QQ 邮箱网页版设置 - 账户中开启 POP3/SMTP 服务并生成授权码
</div> </div>
</div> </div>
<div class="divider"></div>
<h4 class="card-subtitle">2. 邮件模板设置</h4>
<div class="form-item">
<label class="form-label">管理员通知模板 (HTML)</label>
<div class="form-hint">
可用变量${commentAuthor} (评论人昵称), ${postTitle} (文章标题), ${postUrl}
(文章链接), ${commentContent} (评论内容)
</div>
<textarea
v-model="templateAdmin"
class="form-input"
rows="6"
placeholder="留空则使用默认模板"
></textarea>
</div>
<div class="form-item">
<label class="form-label">回复通知模板 (HTML)</label>
<div class="form-hint">
可用变量${toName} (接收人昵称), ${replyAuthor} (回复人昵称), ${postTitle}
(文章标题), ${postUrl} (文章链接), ${parentComment} (原评论), ${replyContent}
(回复内容)
</div>
<textarea
v-model="templateReply"
class="form-input"
rows="6"
placeholder="留空则使用默认模板"
></textarea>
</div>
<div <div
v-if="message && messageType === 'error'" v-if="message && messageType === 'error'"
class="form-message form-message-error" class="form-message form-message-error"
> >
{{ message }} {{ message }}
</div> </div>
<div class="card-actions" style="justify-content: space-between;"> <div class="card-actions" style="justify-content: space-between">
<button class="card-button secondary" :disabled="testingEmail" @click="testEmail"> <button
class="card-button secondary"
:disabled="testingEmail"
@click="testEmail"
>
<span v-if="testingEmail">发送中...</span> <span v-if="testingEmail">发送中...</span>
<span v-else>发送测试邮件</span> <span v-else>发送测试邮件</span>
</button> </button>
<button
class="card-button secondary"
type="button"
@click="resetTemplatesToDefault"
>
恢复默认模板
</button>
<button class="card-button" :disabled="savingEmail" @click="saveEmail"> <button class="card-button" :disabled="savingEmail" @click="saveEmail">
<span v-if="savingEmail">保存中...</span> <span v-if="savingEmail">保存中...</span>
<span v-else>保存配置</span> <span v-else>保存配置</span>
@@ -134,9 +202,84 @@ import {
saveCommentSettings, saveCommentSettings,
fetchEmailNotifySettings, fetchEmailNotifySettings,
saveEmailNotifySettings, saveEmailNotifySettings,
sendTestEmail sendTestEmail,
} from "../api/admin"; } from "../api/admin";
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>
`;
const email = ref(""); const email = ref("");
const emailGlobalEnabled = ref(true); const emailGlobalEnabled = ref(true);
const commentAdminEmail = ref(""); const commentAdminEmail = ref("");
@@ -154,16 +297,18 @@ const toastMessage = ref("");
const toastType = ref<"success" | "error">("success"); const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false); const toastVisible = ref(false);
const smtpProvider = ref('qq'); const smtpProvider = ref("qq");
const smtpHost = ref('smtp.qq.com'); const smtpHost = ref("smtp.qq.com");
const smtpPort = ref(465); const smtpPort = ref(465);
const smtpUser = ref(''); const smtpUser = ref("");
const smtpPass = ref(''); const smtpPass = ref("");
const smtpSecure = ref(true); const smtpSecure = ref(true);
const templateAdmin = ref(DEFAULT_ADMIN_TEMPLATE);
const templateReply = ref(DEFAULT_REPLY_TEMPLATE);
function onProviderChange() { function onProviderChange() {
if (smtpProvider.value === 'qq') { if (smtpProvider.value === "qq") {
smtpHost.value = 'smtp.qq.com'; smtpHost.value = "smtp.qq.com";
smtpPort.value = 465; smtpPort.value = 465;
smtpSecure.value = true; smtpSecure.value = true;
} }
@@ -178,6 +323,11 @@ function showToast(msg: string, type: "success" | "error" = "success") {
}, 2000); }, 2000);
} }
function resetTemplatesToDefault() {
templateAdmin.value = DEFAULT_ADMIN_TEMPLATE;
templateReply.value = DEFAULT_REPLY_TEMPLATE;
}
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
@@ -191,21 +341,33 @@ async function load() {
commentAdminBadge.value = commentRes.adminBadge || "博主"; commentAdminBadge.value = commentRes.adminBadge || "博主";
avatarPrefix.value = commentRes.avatarPrefix || ""; avatarPrefix.value = commentRes.avatarPrefix || "";
commentAdminEnabled.value = !!commentRes.adminEnabled; commentAdminEnabled.value = !!commentRes.adminEnabled;
allowedDomains.value = commentRes.allowedDomains ? commentRes.allowedDomains.join(", ") : ""; allowedDomains.value = commentRes.allowedDomains
? commentRes.allowedDomains.join(", ")
: "";
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled; emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
if (emailNotifyRes.templates) {
templateAdmin.value =
emailNotifyRes.templates.admin || DEFAULT_ADMIN_TEMPLATE;
templateReply.value =
emailNotifyRes.templates.reply || DEFAULT_REPLY_TEMPLATE;
} else {
templateAdmin.value = DEFAULT_ADMIN_TEMPLATE;
templateReply.value = DEFAULT_REPLY_TEMPLATE;
}
if (emailNotifyRes.smtp) { if (emailNotifyRes.smtp) {
smtpHost.value = emailNotifyRes.smtp.host; smtpHost.value = emailNotifyRes.smtp.host;
smtpPort.value = emailNotifyRes.smtp.port; smtpPort.value = emailNotifyRes.smtp.port;
smtpUser.value = emailNotifyRes.smtp.user; smtpUser.value = emailNotifyRes.smtp.user;
smtpPass.value = emailNotifyRes.smtp.pass; smtpPass.value = emailNotifyRes.smtp.pass;
smtpSecure.value = emailNotifyRes.smtp.secure; smtpSecure.value = emailNotifyRes.smtp.secure;
if (emailNotifyRes.smtp.host === 'smtp.qq.com') { if (emailNotifyRes.smtp.host === "smtp.qq.com") {
smtpProvider.value = 'qq'; smtpProvider.value = "qq";
} else { } else {
smtpProvider.value = 'custom'; smtpProvider.value = "custom";
} }
} }
} catch (e: any) { } catch (e: any) {
message.value = e.message || "加载失败"; message.value = e.message || "加载失败";
@@ -229,12 +391,16 @@ async function saveEmail() {
saveEmailNotifySettings({ saveEmailNotifySettings({
globalEnabled: emailGlobalEnabled.value, globalEnabled: emailGlobalEnabled.value,
smtp: { smtp: {
host: smtpHost.value, host: smtpHost.value,
port: smtpPort.value, port: smtpPort.value,
user: smtpUser.value, user: smtpUser.value,
pass: smtpPass.value, pass: smtpPass.value,
secure: smtpSecure.value secure: smtpSecure.value,
} },
templates: {
reply: templateReply.value,
admin: templateAdmin.value,
},
}), }),
]); ]);
showToast(emailRes.message || "保存成功", "success"); showToast(emailRes.message || "保存成功", "success");
@@ -257,29 +423,33 @@ async function testEmail() {
messageType.value = "error"; messageType.value = "error";
return; return;
} }
testingEmail.value = true; testingEmail.value = true;
message.value = ""; message.value = "";
try { try {
const res = await sendTestEmail({ const res = await sendTestEmail({
toEmail: email.value, toEmail: email.value,
smtp: { smtp: {
host: smtpHost.value, host: smtpHost.value,
port: smtpPort.value, port: smtpPort.value,
user: smtpUser.value, user: smtpUser.value,
pass: smtpPass.value, pass: smtpPass.value,
secure: smtpSecure.value secure: smtpSecure.value,
} },
}); });
showToast(res.message || "发送成功,请查收邮件", "success"); showToast(res.message || "发送成功,请查收邮件", "success");
} catch (e: any) { } catch (e: any) {
// 显示详细错误信息 // 显示详细错误信息
console.error(e); console.error(e);
let errorMsg = e.message || "发送失败"; let errorMsg = e.message || "发送失败";
// 针对 QQ 邮箱 535 错误的友好提示 // 针对 QQ 邮箱 535 错误的友好提示
if (errorMsg.includes('535') && (errorMsg.includes('Login fail') || errorMsg.includes('authentication failed'))) { if (
errorMsg = "验证失败 (535):请检查 1. QQ邮箱是否已开启 POP3/SMTP 服务2. 密码栏是否填写了“授权码”非QQ密码。"; errorMsg.includes("535") &&
(errorMsg.includes("Login fail") || errorMsg.includes("authentication failed"))
) {
errorMsg =
"验证失败 (535):请检查 1. QQ 邮箱是否已开启 POP3/SMTP 服务2. 密码栏是否填写了“授权码”(非 QQ 密码)。";
} }
message.value = errorMsg; message.value = errorMsg;
@@ -298,7 +468,10 @@ async function saveComment() {
adminBadge: commentAdminBadge.value, adminBadge: commentAdminBadge.value,
avatarPrefix: avatarPrefix.value, avatarPrefix: avatarPrefix.value,
adminEnabled: commentAdminEnabled.value, adminEnabled: commentAdminEnabled.value,
allowedDomains: allowedDomains.value.split(/[,\n]/).map(d => d.trim()).filter(Boolean) allowedDomains: allowedDomains.value
.split(/[,\n]/)
.map((d) => d.trim())
.filter(Boolean),
}); });
showToast(res.message || "保存成功", "success"); showToast(res.message || "保存成功", "success");
} catch (e: any) { } catch (e: any) {
@@ -466,13 +639,13 @@ onMounted(() => {
} }
.card-button.secondary { .card-button.secondary {
background-color: #f6f8fa; background-color: #f6f8fa;
color: #24292f; color: #24292f;
border: 1px solid #d0d7de; border: 1px solid #d0d7de;
} }
.card-button.secondary:hover { .card-button.secondary:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
border-color: #d0d7de; border-color: #d0d7de;
} }
.card-button:disabled { .card-button:disabled {
@@ -535,10 +708,10 @@ onMounted(() => {
line-height: 1.5; line-height: 1.5;
} }
.form-hint a { .form-hint a {
color: #0969da; color: #0969da;
text-decoration: none; text-decoration: none;
} }
.form-hint a:hover { .form-hint a:hover {
text-decoration: underline; text-decoration: underline;
} }
</style> </style>

View File

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

View File

@@ -184,10 +184,12 @@ app.put('/admin/settings/email-notify', async (c) => {
const globalEnabled = const globalEnabled =
typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined; typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined;
const smtp = body.smtp && typeof body.smtp === 'object' ? body.smtp : 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, { await saveEmailNotificationSettings(c.env, {
globalEnabled, globalEnabled,
smtp smtp,
templates
}); });
return c.json({ message: '保存成功' }); 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_USER_KEY = 'email_smtp_user';
const SMTP_PASS_KEY = 'email_smtp_pass'; const SMTP_PASS_KEY = 'email_smtp_pass';
const SMTP_SECURE_KEY = 'email_smtp_secure'; 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 = { type MailGatewayPayload = {
to: string[]; to: string[];
@@ -27,8 +29,91 @@ export type EmailNotificationSettings = {
pass: string; pass: string;
secure: boolean; 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( async function dispatchMail(
env: Bindings, env: Bindings,
payload: MailGatewayPayload, payload: MailGatewayPayload,
@@ -153,7 +238,9 @@ export async function loadEmailNotificationSettings(
SMTP_PORT_KEY, SMTP_PORT_KEY,
SMTP_USER_KEY, SMTP_USER_KEY,
SMTP_PASS_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( const { results } = await env.CWD_DB.prepare(
@@ -176,12 +263,18 @@ export async function loadEmailNotificationSettings(
port: parseInt(map.get(SMTP_PORT_KEY) || '465', 10), port: parseInt(map.get(SMTP_PORT_KEY) || '465', 10),
user: map.get(SMTP_USER_KEY) || '', user: map.get(SMTP_USER_KEY) || '',
pass: map.get(SMTP_PASS_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 { return {
globalEnabled, globalEnabled,
smtp smtp,
templates
}; };
} }
@@ -190,6 +283,7 @@ export async function saveEmailNotificationSettings(
settings: { settings: {
globalEnabled?: boolean; globalEnabled?: boolean;
smtp?: Partial<EmailNotificationSettings['smtp']>; smtp?: Partial<EmailNotificationSettings['smtp']>;
templates?: Partial<NonNullable<EmailNotificationSettings['templates']>>;
} }
) { ) {
await env.CWD_DB.prepare( 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.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) { for (const entry of entries) {
if (entry.value !== undefined) { if (entry.value !== undefined) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)') await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
@@ -229,7 +328,8 @@ export async function sendCommentReplyNotification(
replyContent: string; replyContent: string;
postUrl: string; postUrl: string;
}, },
smtpSettings?: EmailNotificationSettings['smtp'] smtpSettings?: EmailNotificationSettings['smtp'],
template?: string
) { ) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params; const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
@@ -239,46 +339,15 @@ export async function sendCommentReplyNotification(
postTitle postTitle
}); });
const html = ` const html = replaceTemplate(template || DEFAULT_REPLY_TEMPLATE, {
<div style="background-color:#f4f4f5;padding:24px 0;"> toEmail,
<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;"> toName,
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#2563eb,#4f46e5);"> postTitle,
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">评论回复 - ${postTitle}</h1> parentComment,
<p style="margin:4px 0 0;font-size:12px;color:#e5e7eb;">你在文章下的评论收到了新的回复</p> replyAuthor,
</div> replyContent,
<div style="padding:24px 28px;"> postUrl
<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>
`;
if (!isValidEmail(toEmail)) { if (!isValidEmail(toEmail)) {
console.warn('EmailReplyNotification:invalidRecipient', { toEmail }); console.warn('EmailReplyNotification:invalidRecipient', { toEmail });
@@ -307,46 +376,18 @@ export async function sendCommentNotification(
commentAuthor: string; commentAuthor: string;
commentContent: string; commentContent: string;
}, },
smtpSettings?: EmailNotificationSettings['smtp'] smtpSettings?: EmailNotificationSettings['smtp'],
template?: string
) { ) {
const { postTitle, postUrl, commentAuthor, commentContent } = params; const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env); const toEmail = await getAdminNotifyEmail(env);
const html = ` const html = replaceTemplate(template || DEFAULT_ADMIN_TEMPLATE, {
<div style="background-color:#f4f4f5;padding:24px 0;"> postTitle,
<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;"> postUrl,
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#0f766e,#059669);"> commentAuthor,
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">新评论提醒</h1> commentContent
<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>
`;
if (!isValidEmail(toEmail)) { if (!isValidEmail(toEmail)) {
console.warn('EmailAdminNotification:invalidRecipient', { toEmail }); console.warn('EmailAdminNotification:invalidRecipient', { toEmail });