feat(邮件通知): 添加SMTP邮件发送支持并完善通知设置

- 新增nodemailer依赖实现SMTP邮件发送功能
- 扩展邮件通知设置界面,支持SMTP服务器配置
- 实现SMTP与原有邮件网关的自动切换逻辑
- 优化邮件通知相关API接口和数据库存储结构
This commit is contained in:
anghunk
2026-01-20 11:59:52 +08:00
parent 398daa9a11
commit 6d3bdb2b11
6 changed files with 214 additions and 37 deletions

View File

@@ -42,6 +42,13 @@ export type CommentSettingsResponse = {
export type EmailNotifySettingsResponse = { export type EmailNotifySettingsResponse = {
globalEnabled: boolean; globalEnabled: boolean;
smtp?: {
host: string;
port: number;
user: string;
pass: string;
secure: boolean;
};
}; };
export async function loginAdmin(name: string, password: string): Promise<string> { export async function loginAdmin(name: string, password: string): Promise<string> {
@@ -81,6 +88,13 @@ export function fetchEmailNotifySettings(): Promise<EmailNotifySettingsResponse>
export function saveEmailNotifySettings(data: { export function saveEmailNotifySettings(data: {
globalEnabled?: boolean; globalEnabled?: boolean;
smtp?: {
host?: string;
port?: number;
user?: string;
pass?: string;
secure?: boolean;
};
}): 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

@@ -40,7 +40,7 @@
</div> </div>
<div class="card"> <div class="card">
<h3 class="card-title">通知邮箱设置测试中暂未开放</h3> <h3 class="card-title">通知邮箱设置</h3>
<div class="form-item"> <div class="form-item">
<label class="form-label">开启邮件通知</label> <label class="form-label">开启邮件通知</label>
<label class="switch"> <label class="switch">
@@ -50,8 +50,47 @@
</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" /> <input v-model="email" class="form-input" type="email" placeholder="接收新评论提醒的邮箱" />
</div> </div>
<div class="divider"></div>
<h4 class="card-subtitle">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>
</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>
<div class="form-item">
<label class="form-label">发件邮箱账号</label>
<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邮箱请使用授权码" />
</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"
@@ -61,7 +100,7 @@
<div class="card-actions"> <div class="card-actions">
<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>
</button> </button>
</div> </div>
</div> </div>
@@ -95,6 +134,21 @@ 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 smtpHost = ref('smtp.qq.com');
const smtpPort = ref(465);
const smtpUser = ref('');
const smtpPass = ref('');
const smtpSecure = ref(true);
function onProviderChange() {
if (smtpProvider.value === 'qq') {
smtpHost.value = 'smtp.qq.com';
smtpPort.value = 465;
smtpSecure.value = true;
}
}
function showToast(msg: string, type: "success" | "error" = "success") { function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg; toastMessage.value = msg;
toastType.value = type; toastType.value = type;
@@ -118,6 +172,20 @@ async function load() {
avatarPrefix.value = commentRes.avatarPrefix || ""; avatarPrefix.value = commentRes.avatarPrefix || "";
commentAdminEnabled.value = !!commentRes.adminEnabled; commentAdminEnabled.value = !!commentRes.adminEnabled;
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled; emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
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';
}
}
} catch (e: any) { } catch (e: any) {
message.value = e.message || "加载失败"; message.value = e.message || "加载失败";
messageType.value = "error"; messageType.value = "error";
@@ -139,6 +207,13 @@ async function saveEmail() {
saveAdminEmail(email.value), saveAdminEmail(email.value),
saveEmailNotifySettings({ saveEmailNotifySettings({
globalEnabled: emailGlobalEnabled.value, globalEnabled: emailGlobalEnabled.value,
smtp: {
host: smtpHost.value,
port: smtpPort.value,
user: smtpUser.value,
pass: smtpPass.value,
secure: smtpSecure.value
}
}), }),
]); ]);
showToast(emailRes.message || "保存成功", "success"); showToast(emailRes.message || "保存成功", "success");
@@ -201,6 +276,19 @@ onMounted(() => {
font-size: 15px; font-size: 15px;
} }
.card-subtitle {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: #24292f;
}
.divider {
height: 1px;
background-color: #d0d7de;
margin: 16px 0;
}
.form-item { .form-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -11,6 +11,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.19", "@cloudflare/vitest-pool-workers": "^0.8.19",
"@types/nodemailer": "^7.0.5",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"vitest": "~3.2.0", "vitest": "~3.2.0",
@@ -18,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"hono": "^4.11.3", "hono": "^4.11.3",
"nodemailer": "^7.0.12",
"ua-parser-js": "^2.0.7" "ua-parser-js": "^2.0.7"
} }
} }

View File

@@ -167,7 +167,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
replyAuthor: author, replyAuthor: author,
replyContent: content, replyContent: content,
postUrl: data.post_url, postUrl: data.post_url,
}); }, notifySettings.smtp);
await c.env.CWD_DB.prepare( await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)" "INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run(); ).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run();
@@ -188,7 +188,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
postUrl: data.post_url, postUrl: data.post_url,
commentAuthor: author, commentAuthor: author,
commentContent: content commentContent: content
}); }, notifySettings.smtp);
await c.env.CWD_DB.prepare( await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)" "INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind('admin', 'admin-notify', ip, new Date().toISOString()).run(); ).bind('admin', 'admin-notify', ip, new Date().toISOString()).run();

View File

@@ -164,9 +164,11 @@ app.put('/admin/settings/email-notify', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
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;
await saveEmailNotificationSettings(c.env, { await saveEmailNotificationSettings(c.env, {
globalEnabled globalEnabled,
smtp
}); });
return c.json({ message: '保存成功' }); return c.json({ message: '保存成功' });

View File

@@ -1,10 +1,16 @@
import { Bindings } from '../bindings'; import { Bindings } from '../bindings';
import { createTransport } from 'nodemailer';
export function isValidEmail(email: string) { export function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }
const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled'; const EMAIL_NOTIFY_GLOBAL_KEY = 'email_notify_enabled';
const SMTP_HOST_KEY = 'email_smtp_host';
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';
type MailGatewayPayload = { type MailGatewayPayload = {
to: string[]; to: string[];
@@ -12,9 +18,57 @@ type MailGatewayPayload = {
html: string; html: string;
}; };
async function dispatchMail(env: Bindings, payload: MailGatewayPayload) { export type EmailNotificationSettings = {
globalEnabled: boolean;
smtp?: {
host: string;
port: number;
user: string;
pass: string;
secure: boolean;
};
};
async function dispatchMail(
env: Bindings,
payload: MailGatewayPayload,
smtpSettings?: EmailNotificationSettings['smtp']
) {
// 1. Try SMTP
if (smtpSettings && smtpSettings.user && smtpSettings.pass) {
try {
console.log('MailDispatch:SMTP:start', { host: smtpSettings.host, user: smtpSettings.user });
const transporter = createTransport({
host: smtpSettings.host || 'smtp.qq.com',
port: smtpSettings.port || 465,
secure: smtpSettings.secure ?? true,
auth: {
user: smtpSettings.user,
pass: smtpSettings.pass,
},
});
await transporter.sendMail({
from: `"评论通知" <${smtpSettings.user}>`,
to: payload.to.join(', '),
subject: payload.subject,
html: payload.html,
});
console.log('MailDispatch:SMTP:success', { to: payload.to });
return;
} catch (e: any) {
console.error('MailDispatch:SMTP:error', {
message: e?.message || String(e),
});
// Fallback to gateway?
}
}
if (!env.MAIL_GATEWAY_URL) { if (!env.MAIL_GATEWAY_URL) {
console.error('MailGateway:missingUrl'); if (!smtpSettings?.user) {
console.error('MailGateway:missingUrlAndSmtp');
}
return; return;
} }
@@ -40,10 +94,6 @@ async function dispatchMail(env: Bindings, payload: MailGatewayPayload) {
} }
} }
export type EmailNotificationSettings = {
globalEnabled: boolean;
};
function parseEnabled(raw: string | undefined, defaultValue: boolean) { function parseEnabled(raw: string | undefined, defaultValue: boolean) {
if (raw === undefined) return defaultValue; if (raw === undefined) return defaultValue;
return raw === '1'; return raw === '1';
@@ -56,10 +106,19 @@ export async function loadEmailNotificationSettings(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)' 'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run(); ).run();
const keys = [
EMAIL_NOTIFY_GLOBAL_KEY,
SMTP_HOST_KEY,
SMTP_PORT_KEY,
SMTP_USER_KEY,
SMTP_PASS_KEY,
SMTP_SECURE_KEY
];
const { results } = await env.CWD_DB.prepare( const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key = ?' `SELECT key, value FROM Settings WHERE key IN (${keys.map(() => '?').join(',')})`
) )
.bind(EMAIL_NOTIFY_GLOBAL_KEY) .bind(...keys)
.all<{ key: string; value: string }>(); .all<{ key: string; value: string }>();
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -71,8 +130,17 @@ export async function loadEmailNotificationSettings(
const globalEnabled = parseEnabled(map.get(EMAIL_NOTIFY_GLOBAL_KEY), true); const globalEnabled = parseEnabled(map.get(EMAIL_NOTIFY_GLOBAL_KEY), true);
const smtp: EmailNotificationSettings['smtp'] = {
host: map.get(SMTP_HOST_KEY) || 'smtp.qq.com',
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
};
return { return {
globalEnabled globalEnabled,
smtp
}; };
} }
@@ -80,25 +148,34 @@ export async function saveEmailNotificationSettings(
env: Bindings, env: Bindings,
settings: { settings: {
globalEnabled?: boolean; globalEnabled?: boolean;
smtp?: Partial<EmailNotificationSettings['smtp']>;
} }
) { ) {
await env.CWD_DB.prepare( await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)' 'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run(); ).run();
const value = const entries: { key: string; value: string | undefined }[] = [];
typeof settings.globalEnabled === 'boolean'
? settings.globalEnabled
? '1'
: '0'
: undefined;
if (value !== undefined) { if (settings.globalEnabled !== undefined) {
entries.push({ key: EMAIL_NOTIFY_GLOBAL_KEY, value: settings.globalEnabled ? '1' : '0' });
}
if (settings.smtp) {
if (settings.smtp.host !== undefined) entries.push({ key: SMTP_HOST_KEY, value: settings.smtp.host });
if (settings.smtp.port !== undefined) entries.push({ key: SMTP_PORT_KEY, value: String(settings.smtp.port) });
if (settings.smtp.user !== undefined) entries.push({ key: SMTP_USER_KEY, value: settings.smtp.user });
if (settings.smtp.pass !== undefined) entries.push({ key: SMTP_PASS_KEY, value: settings.smtp.pass });
if (settings.smtp.secure !== undefined) entries.push({ key: SMTP_SECURE_KEY, value: settings.smtp.secure ? '1' : '0' });
}
for (const entry of entries) {
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 (?, ?)')
.bind(EMAIL_NOTIFY_GLOBAL_KEY, value) .bind(entry.key, entry.value)
.run(); .run();
} }
} }
}
export async function sendCommentReplyNotification( export async function sendCommentReplyNotification(
env: Bindings, env: Bindings,
@@ -110,7 +187,8 @@ export async function sendCommentReplyNotification(
replyAuthor: string; replyAuthor: string;
replyContent: string; replyContent: string;
postUrl: string; postUrl: string;
} },
smtpSettings?: EmailNotificationSettings['smtp']
) { ) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params; const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
@@ -170,7 +248,7 @@ export async function sendCommentReplyNotification(
to: [toEmail], to: [toEmail],
subject: `评论回复 - ${postTitle}`, subject: `评论回复 - ${postTitle}`,
html html
}); }, smtpSettings);
console.log('EmailReplyNotification:sent', { console.log('EmailReplyNotification:sent', {
toEmail toEmail
@@ -187,7 +265,8 @@ export async function sendCommentNotification(
postUrl: string; postUrl: string;
commentAuthor: string; commentAuthor: string;
commentContent: string; commentContent: string;
} },
smtpSettings?: EmailNotificationSettings['smtp']
) { ) {
const { postTitle, postUrl, commentAuthor, commentContent } = params; const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env); const toEmail = await getAdminNotifyEmail(env);
@@ -237,7 +316,7 @@ export async function sendCommentNotification(
to: [toEmail], to: [toEmail],
subject: `新评论提醒 - ${postTitle}`, subject: `新评论提醒 - ${postTitle}`,
html html
}); }, smtpSettings);
console.log('EmailAdminNotification:sent', { console.log('EmailAdminNotification:sent', {
toEmail toEmail
@@ -253,15 +332,7 @@ export async function getAdminNotifyEmail(env: Bindings): Promise<string> {
.first<{ value: string }>(); .first<{ value: string }>();
if (row?.value && isValidEmail(row.value)) { if (row?.value && isValidEmail(row.value)) {
const cleanEmail = row.value.trim(); const cleanEmail = row.value.trim();
console.log('EmailAdminNotification:useDbEmail', {
email: cleanEmail,
originalLength: row.value.length,
cleanLength: cleanEmail.length
});
return cleanEmail; return cleanEmail;
} }
console.error('EmailAdminNotification:noAdminEmail', {
dbValue: row?.value
});
throw new Error('未配置管理员通知邮箱或格式不正确'); throw new Error('未配置管理员通知邮箱或格式不正确');
} }