// 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 { 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 { if (this.client) { try { await this.client.logout() } catch {} this.client = null } } isConnected(): boolean { return this.client !== null } async reconnect(config: MonitorConfig['mail']): Promise { 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 { if (!this.client) return false try { await this.client.noop() return true } catch { this.client = null return false } } async fetchUnread(config: MonitorConfig): Promise { 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 } }