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

View File

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

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 });