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