Browse Source
- Introduced new API endpoints for managing SMTP email configuration (GET, PUT, POST). - Implemented email sending functionality using Nodemailer. - Added middleware to require admin permissions for accessing admin routes. - Updated admin sidebar navigation to conditionally display items based on admin role. - Created a new email configuration page in the admin panel with form validation and test functionality. - Masked sensitive information in API responses. - Enhanced scheduler execution and task management APIs to require admin permissions. - Added a new JSON file for desktop topic titles.acas
34 changed files with 712 additions and 20 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "manual" |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话" |
|||
} |
|||
@ -0,0 +1,396 @@ |
|||
<script setup lang="ts"> |
|||
definePageMeta({ |
|||
layout: false, |
|||
}) |
|||
|
|||
const { $toast } = useNuxtApp() |
|||
|
|||
const form = reactive({ |
|||
host: "", |
|||
port: 587, |
|||
user: "", |
|||
pass: "", |
|||
from: "", |
|||
secure: false, |
|||
}) |
|||
|
|||
const testTo = ref("") |
|||
const loading = ref(false) |
|||
const testing = ref(false) |
|||
const sending = ref(false) |
|||
const testResult = ref<{ success: boolean; message: string } | null>(null) |
|||
|
|||
async function loadConfig() { |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { host: string; port: number; user: string; pass: string; from: string; secure: boolean } }>("/api/admin/email/config") |
|||
if (res.code === 0 && res.data) { |
|||
form.host = res.data.host || "" |
|||
form.port = res.data.port || 587 |
|||
form.user = res.data.user || "" |
|||
form.pass = res.data.pass || "" |
|||
form.from = res.data.from || "" |
|||
form.secure = res.data.secure || false |
|||
} |
|||
} catch (_e) { |
|||
// silently fail on load |
|||
} |
|||
} |
|||
|
|||
async function handleSave() { |
|||
loading.value = true |
|||
try { |
|||
await $fetch("/api/admin/email/config", { |
|||
method: "PUT", |
|||
body: { |
|||
host: form.host, |
|||
port: form.port, |
|||
user: form.user, |
|||
pass: form.pass, |
|||
from: form.from, |
|||
secure: form.secure, |
|||
}, |
|||
}) |
|||
$toast.success("邮件配置已保存") |
|||
} catch (e: any) { |
|||
$toast.error(e?.data?.message || e?.message || "保存失败") |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function handleTestConnection() { |
|||
testing.value = true |
|||
testResult.value = null |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { success: boolean; message: string }; message?: string }>("/api/admin/email/test", { method: "POST" }) |
|||
testResult.value = res.code === 0 ? res.data : { success: false, message: res.message || "测试失败" } |
|||
if (testResult.value?.success) { |
|||
$toast.success("SMTP 连接测试成功") |
|||
} else { |
|||
$toast.error(testResult.value?.message || "连接测试失败") |
|||
} |
|||
} catch (e: any) { |
|||
const msg = e?.data?.message || e?.message || "测试请求失败" |
|||
testResult.value = { success: false, message: msg } |
|||
$toast.error(msg) |
|||
} finally { |
|||
testing.value = false |
|||
} |
|||
} |
|||
|
|||
async function handleSendTest() { |
|||
if (!testTo.value) { |
|||
$toast.warning("请输入测试收件地址") |
|||
return |
|||
} |
|||
sending.value = true |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { success: boolean; message: string }; message?: string }>("/api/admin/email/send", { |
|||
method: "POST", |
|||
body: { to: testTo.value }, |
|||
}) |
|||
if (res.code === 0 && res.data?.success) { |
|||
$toast.success("测试邮件已发送") |
|||
} else { |
|||
$toast.error(res?.data?.message || res?.message || "发送失败") |
|||
} |
|||
} catch (e: any) { |
|||
$toast.error(e?.data?.message || e?.message || "发送失败") |
|||
} finally { |
|||
sending.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
loadConfig() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="email-page"> |
|||
<header class="page-header"> |
|||
<h1 class="page-title">邮件配置</h1> |
|||
<p class="page-subtitle">配置 SMTP 服务器,启用系统邮件发送功能</p> |
|||
</header> |
|||
|
|||
<div class="email-card"> |
|||
<h2 class="section-title">SMTP 服务器设置</h2> |
|||
|
|||
<form class="email-form" @submit.prevent="handleSave"> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">SMTP 服务器地址</label> |
|||
<input |
|||
v-model="form.host" |
|||
type="text" |
|||
class="form-input" |
|||
placeholder="例如 smtp.example.com" |
|||
/> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label class="form-label">端口</label> |
|||
<input |
|||
v-model.number="form.port" |
|||
type="number" |
|||
class="form-input" |
|||
placeholder="587" |
|||
min="1" |
|||
max="65535" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">用户名</label> |
|||
<input |
|||
v-model="form.user" |
|||
type="text" |
|||
class="form-input" |
|||
placeholder="SMTP 用户名" |
|||
/> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label class="form-label">密码</label> |
|||
<input |
|||
v-model="form.pass" |
|||
type="password" |
|||
class="form-input" |
|||
placeholder="SMTP 密码" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">发件人地址</label> |
|||
<input |
|||
v-model="form.from" |
|||
type="email" |
|||
class="form-input" |
|||
placeholder="例如 noreply@example.com" |
|||
/> |
|||
</div> |
|||
|
|||
<div class="form-group form-group-check"> |
|||
<label class="form-label"> </label> |
|||
<label class="form-check-label"> |
|||
<input v-model="form.secure" type="checkbox" class="form-checkbox" /> |
|||
<span>使用 SSL/TLS 加密连接</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-actions"> |
|||
<button type="submit" class="btn-primary" :disabled="loading"> |
|||
{{ loading ? "保存中..." : "保存配置" }} |
|||
</button> |
|||
<button type="button" class="btn-outline" :disabled="testing" @click="handleTestConnection"> |
|||
{{ testing ? "测试中..." : "测试连接" }} |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
<div class="email-card"> |
|||
<h2 class="section-title">发送测试邮件</h2> |
|||
<p class="section-desc">使用当前 SMTP 配置发送一封测试邮件,验证邮件发送功能是否正常。</p> |
|||
|
|||
<div class="test-send-row"> |
|||
<input |
|||
v-model="testTo" |
|||
type="email" |
|||
class="form-input test-input" |
|||
placeholder="输入收件人邮箱地址" |
|||
/> |
|||
<button class="btn-primary" :disabled="sending" @click="handleSendTest"> |
|||
{{ sending ? "发送中..." : "发送测试邮件" }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.email-page { |
|||
padding: 40px; |
|||
max-width: 800px; |
|||
} |
|||
|
|||
.page-header { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-family: var(--font-display); |
|||
font-size: 32px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -0.3px; |
|||
margin: 0 0 8px 0; |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.email-card { |
|||
background: var(--color-surface-card); |
|||
border-radius: 12px; |
|||
padding: 24px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.section-title { |
|||
font-size: 12px; |
|||
font-weight: 600; |
|||
color: var(--color-muted); |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.05em; |
|||
margin: 0 0 20px 0; |
|||
} |
|||
|
|||
.section-desc { |
|||
font-size: 14px; |
|||
color: var(--color-muted); |
|||
margin: 0 0 16px 0; |
|||
} |
|||
|
|||
.email-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.form-row { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.form-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.form-label { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-body-strong); |
|||
} |
|||
|
|||
.form-input { |
|||
width: 100%; |
|||
padding: 12px 16px; |
|||
font-size: 14px; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.form-input:focus { |
|||
outline: none; |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15); |
|||
} |
|||
|
|||
.form-group-check { |
|||
justify-content: flex-end; |
|||
} |
|||
|
|||
.form-check-label { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
font-size: 14px; |
|||
color: var(--color-ink); |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.form-checkbox { |
|||
width: 18px; |
|||
height: 18px; |
|||
accent-color: var(--color-primary); |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.form-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
padding-top: 8px; |
|||
} |
|||
|
|||
.btn-primary { |
|||
padding: 12px 24px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-on-primary); |
|||
background: var(--color-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.15s ease; |
|||
} |
|||
|
|||
.btn-primary:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.btn-primary:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-outline { |
|||
padding: 12px 24px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
background: transparent; |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.btn-outline:hover { |
|||
background: var(--color-surface-soft); |
|||
} |
|||
|
|||
.btn-outline:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.test-send-row { |
|||
display: flex; |
|||
gap: 12px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.test-input { |
|||
flex: 1; |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.email-page { |
|||
padding: 24px; |
|||
} |
|||
|
|||
.form-row { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
|
|||
.test-send-row { |
|||
flex-direction: column; |
|||
align-items: stretch; |
|||
} |
|||
} |
|||
</style> |
|||
Binary file not shown.
@ -0,0 +1,12 @@ |
|||
import { requireAdmin } from "#server/utils/admin-guard"; |
|||
import { getSmtpConfig } from "#server/service/email"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
await requireAdmin(event); |
|||
const config = await getSmtpConfig(); |
|||
// Mask password
|
|||
return R.success({ |
|||
...config, |
|||
pass: config.pass ? "" : "", |
|||
}); |
|||
}); |
|||
@ -0,0 +1,47 @@ |
|||
import { requireAdmin } from "#server/utils/admin-guard"; |
|||
import { setGlobalConfigValue } from "#server/service/config"; |
|||
import { delCache } from "#server/utils/context"; |
|||
|
|||
interface EmailConfigBody { |
|||
host?: string; |
|||
port?: number; |
|||
user?: string; |
|||
pass?: string; |
|||
from?: string; |
|||
secure?: boolean; |
|||
} |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
await requireAdmin(event); |
|||
const body = await readBody<EmailConfigBody>(event); |
|||
|
|||
const updates: Promise<void>[] = []; |
|||
|
|||
if (body.host !== undefined) { |
|||
updates.push(setGlobalConfigValue("smtpHost", body.host)); |
|||
} |
|||
if (body.port !== undefined) { |
|||
updates.push(setGlobalConfigValue("smtpPort", body.port)); |
|||
} |
|||
if (body.user !== undefined) { |
|||
updates.push(setGlobalConfigValue("smtpUser", body.user)); |
|||
} |
|||
if (body.pass !== undefined && body.pass !== "") { |
|||
// Only update password if a new non-empty value is provided
|
|||
updates.push(setGlobalConfigValue("smtpPass", body.pass)); |
|||
} |
|||
if (body.from !== undefined) { |
|||
updates.push(setGlobalConfigValue("smtpFrom", body.from)); |
|||
} |
|||
if (body.secure !== undefined) { |
|||
updates.push(setGlobalConfigValue("smtpSecure", body.secure)); |
|||
} |
|||
|
|||
await Promise.all(updates); |
|||
|
|||
// Invalidate config caches
|
|||
await delCache("config:global:public"); |
|||
await delCache("config:global:admin"); |
|||
|
|||
return R.success({ message: "邮件配置已保存" }); |
|||
}); |
|||
@ -0,0 +1,22 @@ |
|||
import { requireAdmin } from "#server/utils/admin-guard"; |
|||
import { sendMail } from "#server/service/email"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
await requireAdmin(event); |
|||
const body = await readBody<{ to: string; subject?: string; text?: string }>(event); |
|||
|
|||
if (!body.to) { |
|||
throw createError({ statusCode: 400, statusMessage: "收件人地址不能为空" }); |
|||
} |
|||
|
|||
const result = await sendMail({ |
|||
to: body.to, |
|||
subject: body.subject || "测试邮件 - Person Panel", |
|||
text: body.text || "这是一封来自 Person Panel 的测试邮件。如果你收到此邮件,说明邮件配置正确。", |
|||
}); |
|||
|
|||
if (result.success) { |
|||
return R.success(result); |
|||
} |
|||
throw createError({ statusCode: 400, statusMessage: result.message }); |
|||
}); |
|||
@ -0,0 +1,12 @@ |
|||
import { requireAdmin } from "#server/utils/admin-guard"; |
|||
import { getSmtpConfig, testSmtpConnection } from "#server/service/email"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
await requireAdmin(event); |
|||
const config = await getSmtpConfig(); |
|||
const result = await testSmtpConnection(config); |
|||
if (result.success) { |
|||
return R.success(result); |
|||
} |
|||
throw createError({ statusCode: 400, statusMessage: result.message }); |
|||
}); |
|||
@ -0,0 +1,89 @@ |
|||
import nodemailer from "nodemailer"; |
|||
import type { Transporter } from "nodemailer"; |
|||
import { getGlobalConfigValue } from "#server/service/config"; |
|||
import log4js from "logger"; |
|||
|
|||
const logger = log4js.getLogger("email"); |
|||
|
|||
export interface SmtpConfig { |
|||
host: string; |
|||
port: number; |
|||
user: string; |
|||
pass: string; |
|||
from: string; |
|||
secure: boolean; |
|||
} |
|||
|
|||
export async function getSmtpConfig(): Promise<SmtpConfig> { |
|||
const [host, port, user, pass, from, secure] = await Promise.all([ |
|||
getGlobalConfigValue("smtpHost"), |
|||
getGlobalConfigValue("smtpPort"), |
|||
getGlobalConfigValue("smtpUser"), |
|||
getGlobalConfigValue("smtpPass"), |
|||
getGlobalConfigValue("smtpFrom"), |
|||
getGlobalConfigValue("smtpSecure"), |
|||
]); |
|||
return { host, port, user, pass, from, secure }; |
|||
} |
|||
|
|||
export function createTransporter(config: SmtpConfig): Transporter { |
|||
return nodemailer.createTransport({ |
|||
host: config.host, |
|||
port: config.port, |
|||
secure: config.secure, |
|||
auth: config.user |
|||
? { user: config.user, pass: config.pass } |
|||
: undefined, |
|||
}); |
|||
} |
|||
|
|||
export async function testSmtpConnection(config: SmtpConfig): Promise<{ success: boolean; message: string }> { |
|||
if (!config.host || !config.port) { |
|||
return { success: false, message: "SMTP 服务器地址和端口未配置" }; |
|||
} |
|||
try { |
|||
const transporter = createTransporter(config); |
|||
await transporter.verify(); |
|||
return { success: true, message: "SMTP 连接测试成功" }; |
|||
} catch (err: unknown) { |
|||
const message = err instanceof Error ? err.message : "未知错误"; |
|||
logger.error("SMTP connection test failed: %s", message); |
|||
return { success: false, message: `SMTP 连接失败: ${message}` }; |
|||
} |
|||
} |
|||
|
|||
export interface SendMailOptions { |
|||
to: string | string[]; |
|||
subject: string; |
|||
text?: string; |
|||
html?: string; |
|||
} |
|||
|
|||
export async function sendMail(options: SendMailOptions): Promise<{ success: boolean; message: string }> { |
|||
const config = await getSmtpConfig(); |
|||
|
|||
if (!config.host || !config.port) { |
|||
return { success: false, message: "SMTP 服务器未配置,请先在系统设置中配置邮件服务" }; |
|||
} |
|||
|
|||
if (!config.from) { |
|||
return { success: false, message: "发件人地址未配置" }; |
|||
} |
|||
|
|||
try { |
|||
const transporter = createTransporter(config); |
|||
const info = await transporter.sendMail({ |
|||
from: config.from, |
|||
to: Array.isArray(options.to) ? options.to.join(", ") : options.to, |
|||
subject: options.subject, |
|||
text: options.text, |
|||
html: options.html, |
|||
}); |
|||
logger.info("Email sent to %s, messageId: %s", options.to, info.messageId); |
|||
return { success: true, message: "邮件发送成功" }; |
|||
} catch (err: unknown) { |
|||
const message = err instanceof Error ? err.message : "未知错误"; |
|||
logger.error("Failed to send email to %s: %s", options.to, message); |
|||
return { success: false, message: `邮件发送失败: ${message}` }; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue