diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx
index c1c1251..7cb2946 100644
--- a/src/app/(app)/settings/monitor/page.tsx
+++ b/src/app/(app)/settings/monitor/page.tsx
@@ -32,7 +32,7 @@ interface WebhookConfig {
interface MonitorConfig {
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
- mail: { address: string; imap_server: string; imap_port: number; smtp_server: string; smtp_port: number; password: string }
+ mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
wechat: { webhooks: WebhookConfig[] }
}
@@ -694,6 +694,14 @@ export default function MonitorSettingsPage() {
setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
+
+
+ setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
+
+
+
+ setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
+
setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
diff --git a/src/app/api/monitor/scan-emails/route.ts b/src/app/api/monitor/scan-emails/route.ts
index d1ae5bd..1100115 100644
--- a/src/app/api/monitor/scan-emails/route.ts
+++ b/src/app/api/monitor/scan-emails/route.ts
@@ -315,7 +315,7 @@ async function scanEmails(
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
try {
- pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
+ pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
} catch (pop3Err) {
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : pop3Err)
}
@@ -637,6 +637,10 @@ function saveScanHistory(scanState: ScanResult, userId: number): void {
JSON.stringify(scanState.details),
userId
)
+ // 保留最近 50 条扫描历史,删除更早的(用 id 单调排序,避免 created_at 同秒重复)
+ db.prepare(`DELETE FROM scan_history WHERE id NOT IN (
+ SELECT id FROM scan_history ORDER BY id DESC LIMIT 50
+ )`).run()
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
} catch (err) {
console.error('[Scan] 保存扫描历史失败:', err)
diff --git a/src/app/api/monitor/scan-history/route.ts b/src/app/api/monitor/scan-history/route.ts
index 6196809..6fa9c43 100644
--- a/src/app/api/monitor/scan-history/route.ts
+++ b/src/app/api/monitor/scan-history/route.ts
@@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
+import { writeAuditLog, getClientIP } from '@/lib/audit'
interface ScanHistoryItem {
id: number
@@ -84,6 +85,8 @@ export async function DELETE(request: NextRequest) {
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
`).run(-keepDays)
+ writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
+
return NextResponse.json({
success: true,
deleted: result.changes,
diff --git a/src/app/api/monitor/settings/route.ts b/src/app/api/monitor/settings/route.ts
index 54e088b..b9d4861 100644
--- a/src/app/api/monitor/settings/route.ts
+++ b/src/app/api/monitor/settings/route.ts
@@ -6,6 +6,11 @@ import { hasPermission } from '@/lib/permissions'
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
+// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符,拒绝 URL/路径/空格等,
+// 防止误配或配置被篡改后连接到任意主机(SSRF 面收窄)
+const isValidHost = (v: unknown): boolean =>
+ typeof v === 'string' && v.length > 0 && v.length <= 253 && /^[a-zA-Z0-9.-]+$/.test(v)
+
const VALIDATORS: Record boolean> = {
'monitor.enabled': (v) => typeof v === 'boolean',
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
@@ -13,7 +18,11 @@ const VALIDATORS: Record boolean> = {
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
+ 'mail.imap_server': isValidHost,
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
+ 'mail.pop3_server': isValidHost,
+ 'mail.pop3_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
+ 'mail.smtp_server': isValidHost,
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
}
diff --git a/src/app/api/monitor/status/route.ts b/src/app/api/monitor/status/route.ts
index f811e84..8fb4fea 100644
--- a/src/app/api/monitor/status/route.ts
+++ b/src/app/api/monitor/status/route.ts
@@ -22,7 +22,18 @@ export async function GET(request: NextRequest) {
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
- const errorCount = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'error'").get() as { c: number }).c
+ // 最近一次 tick 的错误状态:action='error' → 1;tick_complete → 该 tick 的 errors 数;无记录 → 0
+ const lastLog = db.prepare(
+ "SELECT action, details FROM monitor_logs WHERE action IN ('error', 'tick_complete') ORDER BY id DESC LIMIT 1"
+ ).get() as { action: string; details: string | null } | undefined
+ let errorCount = 0
+ if (lastLog) {
+ if (lastLog.action === 'error') {
+ errorCount = 1
+ } else {
+ try { errorCount = JSON.parse(lastLog.details || '{}').errors || 0 } catch { errorCount = 0 }
+ }
+ }
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
// 获取最近的活动(tick_complete 或 error)
diff --git a/src/app/api/monitor/test-mail/route.ts b/src/app/api/monitor/test-mail/route.ts
index 6464fcd..be3a6ce 100644
--- a/src/app/api/monitor/test-mail/route.ts
+++ b/src/app/api/monitor/test-mail/route.ts
@@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
+import { writeAuditLog, getClientIP } from '@/lib/audit'
import { ImapFlow } from 'imapflow'
export async function POST(request: NextRequest) {
@@ -21,8 +22,10 @@ export async function POST(request: NextRequest) {
const lock = await client.getMailboxLock('INBOX')
lock.release()
await client.logout()
+ writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: true, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
} catch (e) {
+ writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: false, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
} finally {
try { await client.logout() } catch {}
diff --git a/src/app/api/monitor/test-wechat/route.ts b/src/app/api/monitor/test-wechat/route.ts
index 4cabda7..8c403a3 100644
--- a/src/app/api/monitor/test-wechat/route.ts
+++ b/src/app/api/monitor/test-wechat/route.ts
@@ -37,8 +37,10 @@ export async function POST(request: NextRequest) {
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '测试消息发送成功' })
}
+ writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送失败', error: result.errmsg || '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
} catch (e) {
+ writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送异常', error: e instanceof Error ? e.message : '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
}
}
diff --git a/src/lib/monitor/mail-monitor.ts b/src/lib/monitor/mail-monitor.ts
index 5f86b49..1dd86ab 100644
--- a/src/lib/monitor/mail-monitor.ts
+++ b/src/lib/monitor/mail-monitor.ts
@@ -84,7 +84,7 @@ export class MailMonitor {
if (!source && !pop3Attempted) {
pop3Attempted = true
try {
- pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
+ pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
} catch (e) {
console.error(`[Worker] POP3 fallback 失败: ${e instanceof Error ? e.message : e}`)
}
diff --git a/src/lib/monitor/pop3-fetcher.ts b/src/lib/monitor/pop3-fetcher.ts
index fbb57c1..94abdd3 100644
--- a/src/lib/monitor/pop3-fetcher.ts
+++ b/src/lib/monitor/pop3-fetcher.ts
@@ -25,10 +25,10 @@ export function extractMessageIdFromRaw(buf: Buffer): string | null {
export async function fetchPOP3Emails(
mailAddress: string,
mailPassword: string,
- count: number
+ count: number,
+ host: string,
+ port: number,
): Promise