Browse Source

feat(config): add admin comment email test send flow

Add an admin-only test email endpoint with SMTP config and admin email validation, plus a config-page action button to trigger test sends and minimal service tests for key 400 failure paths.

Made-with: Cursor
main
npmrun 3 weeks ago
parent
commit
60ca751fa9
  1. 27
      app/pages/me/admin/config/index.vue
  2. 23
      bun.lock
  3. 1
      package.json
  4. 49
      server/api/config/global/comment-email-test.post.ts
  5. 26
      server/service/comment-email/test-mail.test.ts
  6. 71
      server/service/comment-email/test-mail.ts

27
app/pages/me/admin/config/index.vue

@ -26,6 +26,7 @@ const { refresh: refreshGlobalConfig } = useGlobalConfig()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const testingEmail = ref(false)
const siteName = ref('') const siteName = ref('')
const allowRegister = ref(true) const allowRegister = ref(true)
const mediaOrphanAutoSweepEnabled = ref(false) const mediaOrphanAutoSweepEnabled = ref(false)
@ -101,6 +102,21 @@ async function save() {
saving.value = false saving.value = false
} }
} }
async function sendTestEmail() {
testingEmail.value = true
try {
const result = await fetchData<{ message: string }>('/api/config/global/comment-email-test', {
method: 'POST',
})
toast.add({ title: result.message || '测试邮件发送成功,请检查邮箱', color: 'success' })
} catch (error) {
const message = error instanceof Error ? error.message : '测试邮件发送失败'
toast.add({ title: message, color: 'error' })
} finally {
testingEmail.value = false
}
}
</script> </script>
<template> <template>
@ -209,9 +225,14 @@ async function save() {
> >
<UInput v-model="commentSmtpPass" type="password" placeholder="smtp password" /> <UInput v-model="commentSmtpPass" type="password" placeholder="smtp password" />
</UFormField> </UFormField>
<UButton :loading="saving" @click="save"> <div class="flex gap-3">
保存 <UButton :loading="saving" @click="save">
</UButton> 保存
</UButton>
<UButton color="neutral" variant="soft" :loading="testingEmail" @click="sendTestEmail">
发送测试邮件
</UButton>
</div>
</div> </div>
</UCard> </UCard>
</UContainer> </UContainer>

23
bun.lock

@ -7,39 +7,40 @@
"dependencies": { "dependencies": {
"@nuxt/ui": "4.6.1", "@nuxt/ui": "4.6.1",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "12.9.0",
"dotenv": "17.4.1", "dotenv": "17.4.1",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"drizzle-pkg": "workspace:*", "drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1", "drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3", "drizzle-zod": "0.8.3",
"fast-xml-parser": "^5.7.0", "fast-xml-parser": "5.7.0",
"isomorphic-dompurify": "^3.9.0", "isomorphic-dompurify": "3.9.0",
"log4js": "6.9.1", "log4js": "6.9.1",
"logger": "workspace:*", "logger": "workspace:*",
"markdown-it": "^14.1.1", "markdown-it": "14.1.1",
"md-editor-v3": "6.4.2", "md-editor-v3": "6.4.2",
"mime": "4.1.0", "mime": "4.1.0",
"multer": "2.1.1", "multer": "2.1.1",
"nodemailer": "^8.0.5",
"nuxt": "4.4.2", "nuxt": "4.4.2",
"pg": "8.20.0", "pg": "8.20.0",
"sharp": "^0.34.5", "sharp": "0.34.5",
"svg-captcha": "^1.4.0", "svg-captcha": "1.4.0",
"tailwindcss": "^4.2.2", "tailwindcss": "4.2.2",
"ufo": "1.6.3", "ufo": "1.6.3",
"vue": "3.5.32", "vue": "3.5.32",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "2.8.9",
"vue-router": "5.0.4", "vue-router": "5.0.4",
"zod": "4.3.6", "zod": "4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "14.1.2",
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"bun-types": "1.3.12", "bun-types": "1.3.12",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"sass": "^1.99.0", "sass": "1.99.0",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2", "typescript": "6.0.2",
}, },
@ -1590,6 +1591,8 @@
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"nodemailer": ["nodemailer@8.0.5", "", {}, "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],

1
package.json

@ -35,6 +35,7 @@
"md-editor-v3": "6.4.2", "md-editor-v3": "6.4.2",
"mime": "4.1.0", "mime": "4.1.0",
"multer": "2.1.1", "multer": "2.1.1",
"nodemailer": "^8.0.5",
"nuxt": "4.4.2", "nuxt": "4.4.2",
"pg": "8.20.0", "pg": "8.20.0",
"sharp": "0.34.5", "sharp": "0.34.5",

49
server/api/config/global/comment-email-test.post.ts

@ -0,0 +1,49 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";
import { requireAdmin } from "#server/utils/admin-guard";
import {
CommentEmailTestValidationError,
sendCommentEmailTestMail,
} from "#server/service/comment-email/test-mail";
export default defineWrappedResponseHandler(async (event) => {
const admin = await requireAdmin(event);
const [adminRow] = await dbGlobal
.select({
email: users.email,
})
.from(users)
.where(eq(users.id, admin.id))
.limit(1);
try {
await sendCommentEmailTestMail({
toEmail: adminRow?.email ?? "",
requestedBy: admin.username,
config: {
enabled: await event.context.config.getGlobal("commentEmailNotifyEnabled"),
fromEmail: await event.context.config.getGlobal("commentMailFromEmail"),
smtpHost: await event.context.config.getGlobal("commentSmtpHost"),
smtpPort: await event.context.config.getGlobal("commentSmtpPort"),
smtpSecure: await event.context.config.getGlobal("commentSmtpSecure"),
smtpUser: await event.context.config.getGlobal("commentSmtpUser"),
smtpPass: await event.context.config.getGlobal("commentSmtpPass"),
},
});
return R.success({
message: "测试邮件发送成功,请检查管理员邮箱",
});
} catch (error) {
if (error instanceof CommentEmailTestValidationError) {
throw createError({
statusCode: 400,
statusMessage: error.message,
});
}
throw createError({
statusCode: 502,
statusMessage: "测试邮件发送失败,请检查 SMTP 配置或稍后重试",
});
}
});

26
server/service/comment-email/test-mail.test.ts

@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import {
assertAdminEmailReady,
assertCommentEmailConfigReadyForTest,
CommentEmailTestValidationError,
} from "./test-mail";
describe("comment email test mail validation", () => {
test("returns 400-equivalent validation error when smtp config is incomplete", () => {
expect(() =>
assertCommentEmailConfigReadyForTest({
enabled: true,
fromEmail: "",
smtpHost: "smtp.example.com",
smtpPort: 465,
smtpSecure: true,
smtpUser: "user",
smtpPass: "pass",
}),
).toThrow(CommentEmailTestValidationError);
});
test("returns 400-equivalent validation error when admin email is empty", () => {
expect(() => assertAdminEmailReady("")).toThrow(CommentEmailTestValidationError);
});
});

71
server/service/comment-email/test-mail.ts

@ -0,0 +1,71 @@
import log4js from "logger";
import nodemailer from "nodemailer";
type CommentEmailConfig = {
enabled: boolean;
fromEmail: string;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPass: string;
};
const logger = log4js.getLogger("COMMENT_EMAIL_TEST");
export class CommentEmailTestValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "CommentEmailTestValidationError";
}
}
function hasValue(value: string): boolean {
return value.trim().length > 0;
}
export function assertCommentEmailConfigReadyForTest(config: CommentEmailConfig): void {
if (!config.enabled) {
throw new CommentEmailTestValidationError("评论邮件通知总开关未开启");
}
if (!hasValue(config.fromEmail) || !hasValue(config.smtpHost) || !hasValue(config.smtpUser) || !hasValue(config.smtpPass)) {
throw new CommentEmailTestValidationError("邮件配置不完整,请先填写发件人、SMTP 主机、用户名和密码");
}
if (!Number.isInteger(config.smtpPort) || config.smtpPort < 1 || config.smtpPort > 65535) {
throw new CommentEmailTestValidationError("SMTP 端口不合法");
}
}
export function assertAdminEmailReady(adminEmail: string | null | undefined): asserts adminEmail is string {
if (!adminEmail || adminEmail.trim().length === 0) {
throw new CommentEmailTestValidationError("当前管理员账号未配置邮箱,无法接收测试邮件");
}
}
export async function sendCommentEmailTestMail(input: {
toEmail: string;
requestedBy: string;
config: CommentEmailConfig;
}) {
assertCommentEmailConfigReadyForTest(input.config);
assertAdminEmailReady(input.toEmail);
const transporter = nodemailer.createTransport({
host: input.config.smtpHost,
port: input.config.smtpPort,
secure: input.config.smtpSecure,
auth: {
user: input.config.smtpUser,
pass: input.config.smtpPass,
},
});
await transporter.sendMail({
from: input.config.fromEmail,
to: input.toEmail.trim(),
subject: "[Person Panel] 评论邮件配置测试",
text: `你好,${input.requestedBy}\n\n这是一封评论邮件配置测试邮件,若你收到该邮件,说明 SMTP 配置可用。\n\n发送时间:${new Date().toISOString()}\n`,
});
logger.info("comment email test message sent to admin account");
}
Loading…
Cancel
Save