166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
// src/lib/monitor/mail-monitor.ts
|
||
import { ImapFlow } from 'imapflow'
|
||
import { simpleParser } from 'mailparser'
|
||
import * as cheerio from 'cheerio'
|
||
import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
|
||
|
||
export class MailMonitor {
|
||
private client: ImapFlow | null = null
|
||
private retryCount = 0
|
||
private readonly RETRY_DELAYS = [5_000, 15_000, 45_000]
|
||
|
||
async connect(config: MonitorConfig['mail']): Promise<void> {
|
||
this.client = new ImapFlow({
|
||
host: config.imap_server,
|
||
port: config.imap_port,
|
||
secure: true,
|
||
auth: { user: config.address, pass: config.password },
|
||
logger: false,
|
||
connectionTimeout: 15_000,
|
||
})
|
||
try {
|
||
await this.client.connect()
|
||
this.retryCount = 0
|
||
} catch (e) {
|
||
this.client = null
|
||
throw e
|
||
}
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
if (this.client) {
|
||
try { await this.client.logout() } catch {}
|
||
this.client = null
|
||
}
|
||
}
|
||
|
||
isConnected(): boolean {
|
||
return this.client !== null
|
||
}
|
||
|
||
async reconnect(config: MonitorConfig['mail']): Promise<void> {
|
||
await this.disconnect()
|
||
const delay = this.RETRY_DELAYS[Math.min(this.retryCount, this.RETRY_DELAYS.length - 1)]
|
||
this.retryCount++
|
||
await new Promise(r => setTimeout(r, delay))
|
||
await this.connect(config)
|
||
}
|
||
|
||
async keepAlive(): Promise<boolean> {
|
||
if (!this.client) return false
|
||
try {
|
||
await this.client.noop()
|
||
return true
|
||
} catch {
|
||
this.client = null
|
||
return false
|
||
}
|
||
}
|
||
|
||
async fetchUnread(config: MonitorConfig): Promise<EmailInfo[]> {
|
||
if (!this.client) return []
|
||
const lock = await this.client.getMailboxLock('INBOX')
|
||
try {
|
||
// 搜索最近 7 天的邮件(避免遗漏昨天未处理的邮件),通过 processed_emails 表去重
|
||
const since = new Date()
|
||
since.setDate(since.getDate() - 7)
|
||
const uids = await this.client.search({ since }, { uid: true })
|
||
if (!uids || uids.length === 0) return []
|
||
|
||
const emails: EmailInfo[] = []
|
||
for (const uid of uids) {
|
||
try {
|
||
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||
const parsed = await simpleParser((msg as any).source as Buffer)
|
||
const emailInfo: EmailInfo = {
|
||
msg_id: parsed.messageId || String(uid),
|
||
subject: parsed.subject || '',
|
||
sender: parsed.from?.text || '',
|
||
date: parsed.date?.toISOString() || '',
|
||
html: parsed.html || null,
|
||
}
|
||
emails.push(emailInfo)
|
||
} catch {}
|
||
}
|
||
return emails
|
||
} finally {
|
||
lock.release()
|
||
}
|
||
}
|
||
|
||
filterEmail(email: EmailInfo, config: MonitorConfig['filter']): { pass: boolean; type: 'oem_diag' | 'oem_repair' | null } {
|
||
// 主题关键词过滤
|
||
const subjectMatch = config.subject_keywords.some(kw => email.subject.includes(kw))
|
||
if (!subjectMatch) return { pass: false, type: null }
|
||
// 发件人过滤
|
||
if (!email.sender.includes(config.sender_email)) return { pass: false, type: null }
|
||
// OEM 类型识别
|
||
const repair = config.oem_repair_keywords
|
||
const diag = config.oem_diag_keywords
|
||
const isRepair = email.subject.includes(repair.base_keyword) && email.subject.includes(repair.type_keyword) && !email.subject.includes(repair.exclude_keyword)
|
||
const isDiag = email.subject.includes(diag.base_keyword) && email.subject.includes(diag.type_keyword) && !email.subject.includes(diag.exclude_keyword)
|
||
if (isRepair) return { pass: true, type: 'oem_repair' }
|
||
if (isDiag) return { pass: true, type: 'oem_diag' }
|
||
return { pass: false, type: null }
|
||
}
|
||
|
||
extractTable(html: string): string[][] | null {
|
||
const $ = cheerio.load(html)
|
||
const tables = $('table')
|
||
if (tables.length === 0) return null
|
||
let targetTable: any = null
|
||
tables.each((_, table) => {
|
||
if ($(table).text().includes('故障信息')) {
|
||
targetTable = table
|
||
return false
|
||
}
|
||
})
|
||
if (!targetTable) targetTable = tables[0]!
|
||
const rows: string[][] = []
|
||
$(targetTable).find('tr').each((_, row) => {
|
||
const cells: string[] = []
|
||
$(row).find('th, td').each((_, cell) => {
|
||
cells.push($(cell).text().trim())
|
||
})
|
||
if (cells.length > 0) rows.push(cells)
|
||
})
|
||
return rows.length > 0 ? rows : null
|
||
}
|
||
|
||
extractFaultInfo(html: string, tableData: string[][] | null): FaultInfo {
|
||
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||
const patterns = {
|
||
fault_time: [/故障(?:发生)?时间[::\s]*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/, /(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/],
|
||
server_sn: [/服务器SN[::\s]*([A-Za-z0-9]+)/, /SN[::\s]*([A-Za-z0-9]+)/, /([A-Za-z0-9]{15,25})/],
|
||
server_ip: [/服务器IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/],
|
||
}
|
||
const result: FaultInfo = { fault_time: null, server_sn: null, server_ip: null, order_number: null, fault_detail: null }
|
||
for (const [key, regexes] of Object.entries(patterns)) {
|
||
for (const regex of regexes) {
|
||
const match = text.match(regex)
|
||
if (match) { result[key as keyof FaultInfo] = match[1] as never; break }
|
||
}
|
||
}
|
||
// 从主题提取工单号
|
||
const orderMatch = html.match(/【服务器故障单】(\d+),/)
|
||
if (orderMatch) result.order_number = orderMatch[1]
|
||
// 从表格提取故障详情
|
||
if (tableData) {
|
||
for (const row of tableData) {
|
||
const idx = row.findIndex(cell => cell.includes('故障信息'))
|
||
if (idx >= 0 && idx + 1 < row.length) {
|
||
result.fault_detail = row[idx + 1]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
extractOrderNumber(subject: string): string | null {
|
||
const match = subject.match(/【服务器故障单】(\d+),/)
|
||
return match?.[1] ?? null
|
||
}
|
||
}
|