issue-ai/src/lib/monitor/mail-monitor.ts

166 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}
}