feat(邮件通知): 添加SMTP邮件发送支持并完善通知设置
- 新增nodemailer依赖实现SMTP邮件发送功能 - 扩展邮件通知设置界面,支持SMTP服务器配置 - 实现SMTP与原有邮件网关的自动切换逻辑 - 优化邮件通知相关API接口和数据库存储结构
This commit is contained in:
@@ -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<string> {
|
||||
@@ -81,6 +88,13 @@ export function fetchEmailNotifySettings(): Promise<EmailNotifySettingsResponse>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">通知邮箱设置(测试中,暂未开放)</h3>
|
||||
<h3 class="card-title">通知邮箱设置</h3>
|
||||
<div class="form-item">
|
||||
<label class="form-label">开启邮件通知</label>
|
||||
<label class="switch">
|
||||
@@ -50,8 +50,47 @@
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<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 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
|
||||
v-if="message && messageType === 'error'"
|
||||
class="form-message form-message-error"
|
||||
@@ -61,7 +100,7 @@
|
||||
<div class="card-actions">
|
||||
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
|
||||
<span v-if="savingEmail">保存中...</span>
|
||||
<span v-else>保存</span>
|
||||
<span v-else>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: '保存成功' });
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -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<EmailNotificationSettings['smtp']>;
|
||||
}
|
||||
) {
|
||||
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<string> {
|
||||
.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('未配置管理员通知邮箱或格式不正确');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user