diff --git a/cwd-comments-admin/src/api/admin.ts b/cwd-comments-admin/src/api/admin.ts index fedf858..c635979 100644 --- a/cwd-comments-admin/src/api/admin.ts +++ b/cwd-comments-admin/src/api/admin.ts @@ -42,6 +42,13 @@ export type CommentSettingsResponse = { export type EmailNotifySettingsResponse = { globalEnabled: boolean; + smtp?: { + host: string; + port: number; + user: string; + pass: string; + secure: boolean; + }; }; export async function loginAdmin(name: string, password: string): Promise { @@ -81,6 +88,13 @@ export function fetchEmailNotifySettings(): Promise export function saveEmailNotifySettings(data: { globalEnabled?: boolean; + smtp?: { + host?: string; + port?: number; + user?: string; + pass?: string; + secure?: boolean; + }; }): Promise<{ message: string }> { return put<{ message: string }>('/admin/settings/email-notify', data); } diff --git a/cwd-comments-admin/src/views/SettingsView.vue b/cwd-comments-admin/src/views/SettingsView.vue index c6c3371..c16013c 100644 --- a/cwd-comments-admin/src/views/SettingsView.vue +++ b/cwd-comments-admin/src/views/SettingsView.vue @@ -40,7 +40,7 @@
-

通知邮箱设置(测试中,暂未开放)

+

通知邮箱设置

- +
+ +
+

SMTP 发件配置

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
@@ -95,6 +134,21 @@ const toastMessage = ref(""); const toastType = ref<"success" | "error">("success"); 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") { toastMessage.value = msg; toastType.value = type; @@ -118,6 +172,20 @@ async function load() { avatarPrefix.value = commentRes.avatarPrefix || ""; commentAdminEnabled.value = !!commentRes.adminEnabled; 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) { message.value = e.message || "加载失败"; messageType.value = "error"; @@ -139,6 +207,13 @@ async function saveEmail() { saveAdminEmail(email.value), saveEmailNotifySettings({ globalEnabled: emailGlobalEnabled.value, + smtp: { + host: smtpHost.value, + port: smtpPort.value, + user: smtpUser.value, + pass: smtpPass.value, + secure: smtpSecure.value + } }), ]); showToast(emailRes.message || "保存成功", "success"); @@ -201,6 +276,19 @@ onMounted(() => { 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 { display: flex; flex-direction: column; diff --git a/cwd-comments-api/package.json b/cwd-comments-api/package.json index c098500..af4ed4b 100644 --- a/cwd-comments-api/package.json +++ b/cwd-comments-api/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.19", + "@types/nodemailer": "^7.0.5", "@types/ua-parser-js": "^0.7.39", "typescript": "^5.5.2", "vitest": "~3.2.0", @@ -18,6 +19,7 @@ }, "dependencies": { "hono": "^4.11.3", + "nodemailer": "^7.0.12", "ua-parser-js": "^2.0.7" } } diff --git a/cwd-comments-api/src/api/public/postComment.ts b/cwd-comments-api/src/api/public/postComment.ts index d590fd3..95bcfe4 100644 --- a/cwd-comments-api/src/api/public/postComment.ts +++ b/cwd-comments-api/src/api/public/postComment.ts @@ -167,7 +167,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => { replyAuthor: author, replyContent: content, postUrl: data.post_url, - }); + }, notifySettings.smtp); 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(); @@ -188,7 +188,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => { postUrl: data.post_url, commentAuthor: author, commentContent: content - }); + }, notifySettings.smtp); await c.env.CWD_DB.prepare( "INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)" ).bind('admin', 'admin-notify', ip, new Date().toISOString()).run(); diff --git a/cwd-comments-api/src/index.ts b/cwd-comments-api/src/index.ts index 88b4f22..392f369 100644 --- a/cwd-comments-api/src/index.ts +++ b/cwd-comments-api/src/index.ts @@ -164,9 +164,11 @@ app.put('/admin/settings/email-notify', async (c) => { const body = await c.req.json(); const globalEnabled = typeof body.globalEnabled === 'boolean' ? body.globalEnabled : undefined; + const smtp = body.smtp && typeof body.smtp === 'object' ? body.smtp : undefined; await saveEmailNotificationSettings(c.env, { - globalEnabled + globalEnabled, + smtp }); return c.json({ message: '保存成功' }); diff --git a/cwd-comments-api/src/utils/email.ts b/cwd-comments-api/src/utils/email.ts index 32a6828..f8cddd7 100644 --- a/cwd-comments-api/src/utils/email.ts +++ b/cwd-comments-api/src/utils/email.ts @@ -1,10 +1,16 @@ import { Bindings } from '../bindings'; +import { createTransport } from 'nodemailer'; export function isValidEmail(email: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } 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 = { to: string[]; @@ -12,9 +18,57 @@ type MailGatewayPayload = { 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) { - console.error('MailGateway:missingUrl'); + if (!smtpSettings?.user) { + console.error('MailGateway:missingUrlAndSmtp'); + } return; } @@ -40,10 +94,6 @@ async function dispatchMail(env: Bindings, payload: MailGatewayPayload) { } } -export type EmailNotificationSettings = { - globalEnabled: boolean; -}; - function parseEnabled(raw: string | undefined, defaultValue: boolean) { if (raw === undefined) return defaultValue; 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)' ).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( - '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 }>(); const map = new Map(); @@ -70,9 +129,18 @@ export async function loadEmailNotificationSettings( } 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 { - globalEnabled + globalEnabled, + smtp }; } @@ -80,23 +148,32 @@ export async function saveEmailNotificationSettings( env: Bindings, settings: { globalEnabled?: boolean; + smtp?: Partial; } ) { await env.CWD_DB.prepare( 'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)' ).run(); - const value = - typeof settings.globalEnabled === 'boolean' - ? settings.globalEnabled - ? '1' - : '0' - : undefined; + const entries: { key: string; value: string | undefined }[] = []; - if (value !== undefined) { - await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)') - .bind(EMAIL_NOTIFY_GLOBAL_KEY, value) - .run(); + 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 (?, ?)') + .bind(entry.key, entry.value) + .run(); + } } } @@ -110,7 +187,8 @@ export async function sendCommentReplyNotification( replyAuthor: string; replyContent: string; postUrl: string; - } + }, + smtpSettings?: EmailNotificationSettings['smtp'] ) { const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params; @@ -170,7 +248,7 @@ export async function sendCommentReplyNotification( to: [toEmail], subject: `评论回复 - ${postTitle}`, html - }); + }, smtpSettings); console.log('EmailReplyNotification:sent', { toEmail @@ -187,7 +265,8 @@ export async function sendCommentNotification( postUrl: string; commentAuthor: string; commentContent: string; - } + }, + smtpSettings?: EmailNotificationSettings['smtp'] ) { const { postTitle, postUrl, commentAuthor, commentContent } = params; const toEmail = await getAdminNotifyEmail(env); @@ -237,7 +316,7 @@ export async function sendCommentNotification( to: [toEmail], subject: `新评论提醒 - ${postTitle}`, html - }); + }, smtpSettings); console.log('EmailAdminNotification:sent', { toEmail @@ -253,15 +332,7 @@ export async function getAdminNotifyEmail(env: Bindings): Promise { .first<{ value: string }>(); if (row?.value && isValidEmail(row.value)) { const cleanEmail = row.value.trim(); - console.log('EmailAdminNotification:useDbEmail', { - email: cleanEmail, - originalLength: row.value.length, - cleanLength: cleanEmail.length - }); return cleanEmail; } - console.error('EmailAdminNotification:noAdminEmail', { - dbValue: row?.value - }); throw new Error('未配置管理员通知邮箱或格式不正确'); }