Skip to main content
Glama
imap-client.ts18.7 kB
import Imap from 'imap'; import { EventEmitter } from 'events'; import { simpleParser } from 'mailparser'; export interface IMAPConfig { host: string; port: number; username: string; password: string; tls?: boolean; connTimeout?: number; authTimeout?: number; keepalive?: boolean; } export interface EmailMessage { uid: number; id?: number; flags: string[]; date: string; // 改为字符串格式,使用中国东八区时间 size: number; // 使用解析后的内容作为主要字段 subject: string; from: string; to: string; cc?: string; bcc?: string; text?: string; html?: string; } export interface MailboxInfo { name: string; messages: { total: number; new: number; unseen: number; }; permFlags: string[]; uidvalidity: number; uidnext: number; } export class IMAPClient extends EventEmitter { private imap: Imap | null = null; private config: IMAPConfig; private connected = false; private authenticated = false; private currentBox: string | null = null; constructor(config: IMAPConfig) { super(); this.config = config; } async connect(): Promise<void> { return new Promise((resolve, reject) => { console.error(`[IMAP] Connecting to ${this.config.host}:${this.config.port} (TLS: ${this.config.tls})`); const imapConfig: Imap.Config = { user: this.config.username, password: this.config.password, host: this.config.host, port: this.config.port, tls: this.config.tls || false, tlsOptions: { rejectUnauthorized: false, servername: this.config.host }, connTimeout: this.config.connTimeout || 60000, authTimeout: this.config.authTimeout || 30000, keepalive: this.config.keepalive !== false }; this.imap = new Imap(imapConfig); this.imap.once('ready', async () => { console.error('[IMAP] Connection ready'); this.connected = true; this.authenticated = true; // 自动打开收件箱 try { await this.openBox('INBOX', true); // 只读方式打开 console.error('[IMAP] Auto-opened INBOX'); } catch (error) { console.error('[IMAP] Failed to auto-open INBOX:', error instanceof Error ? error.message : String(error)); } resolve(); }); this.imap.once('error', (error: Error) => { console.error('[IMAP] Connection error:', error.message); reject(new Error(`IMAP connection failed: ${error.message}`)); }); this.imap.once('end', () => { console.error('[IMAP] Connection ended'); this.connected = false; this.authenticated = false; this.currentBox = null; }); this.imap.connect(); }); } async openBox(boxName: string = 'INBOX', readOnly: boolean = false): Promise<MailboxInfo> { if (!this.imap || !this.authenticated) { throw new Error('Not connected or authenticated'); } return new Promise((resolve, reject) => { this.imap!.openBox(boxName, readOnly, (error, box) => { if (error) { console.error(`[IMAP] Failed to open box ${boxName}:`, error.message); reject(new Error(`Failed to open mailbox: ${error.message}`)); return; } console.error(`[IMAP] Opened box ${boxName}`); this.currentBox = boxName; const mailboxInfo: MailboxInfo = { name: boxName, messages: { total: box.messages.total, new: box.messages.new, unseen: box.messages.unseen }, permFlags: box.permFlags, uidvalidity: box.uidvalidity, uidnext: box.uidnext }; resolve(mailboxInfo); }); }); } async getBoxes(): Promise<any> { if (!this.imap || !this.authenticated) { throw new Error('Not connected or authenticated'); } return new Promise((resolve, reject) => { this.imap!.getBoxes((error, boxes) => { if (error) { reject(new Error(`Failed to get boxes: ${error.message}`)); return; } resolve(boxes); }); }); } async search(criteria: any[] = ['ALL']): Promise<number[]> { if (!this.imap) { throw new Error('Not connected to IMAP server'); } // 如果没有打开邮箱,自动打开收件箱 if (!this.currentBox) { await this.openBox('INBOX', true); } return new Promise((resolve, reject) => { this.imap!.search([criteria], (error, results) => { if (error) { console.error('[IMAP] Search failed:', error.message); reject(new Error(`Search failed: ${error.message}`)); return; } console.error(`[IMAP] Search found ${results.length} messages`); resolve(results); }); }); } async fetchMessages(uids: number[], options: any = {}): Promise<EmailMessage[]> { if (!this.imap) { throw new Error('Not connected to IMAP server'); } // 如果没有打开邮箱,自动打开收件箱 if (!this.currentBox) { await this.openBox('INBOX', true); } const fetchOptions = { bodies: options.bodies || ['HEADER', 'TEXT'], struct: options.struct !== false, envelope: options.envelope !== false, markSeen: options.markSeen || false, ...options }; return new Promise((resolve, reject) => { const messages: EmailMessage[] = []; const pendingMessages: Map<number, { message: Partial<EmailMessage>; headers: Record<string, string>; body: string; rawBuffer: Buffer; }> = new Map(); if (uids.length === 0) { resolve(messages); return; } const fetch = this.imap!.fetch(uids, fetchOptions); fetch.on('message', (msg, seqno) => { console.error(`[IMAP] Processing message ${seqno}`); let headers: Record<string, string> = {}; let body = ''; const rawChunks: Buffer[] = []; const message: Partial<EmailMessage> = { uid: 0, id: seqno, flags: [], date: '', size: 0 }; msg.on('body', (stream, info) => { const chunks: Buffer[] = []; stream.on('data', (chunk: Buffer) => { chunks.push(chunk); rawChunks.push(chunk); // 保存所有原始数据 }); stream.once('end', () => { const buffer = Buffer.concat(chunks); if (info.which === 'HEADER') { // 头部需要字符串处理来解析 const bufferString = buffer.toString('utf8'); headers = this.parseHeaders(bufferString); } else if (info.which === 'TEXT') { // 正文暂时保留字符串版本(备用) body = buffer.toString('utf8'); } }); }); msg.once('attributes', (attrs) => { message.uid = attrs.uid; message.flags = attrs.flags || []; // 转换为中国东八区时间格式 const date = attrs.date || new Date(); message.date = date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); message.size = attrs.size || 0; }); msg.once('end', () => { console.error(`[IMAP] Message ${seqno} processed, preparing for parse`); pendingMessages.set(seqno, { message, headers, body, rawBuffer: Buffer.concat(rawChunks) }); }); }); fetch.once('error', (error) => { console.error('[IMAP] Fetch error:', error.message); reject(new Error(`Fetch failed: ${error.message}`)); }); fetch.once('end', async () => { console.error(`[IMAP] Fetch completed, parsing ${pendingMessages.size} messages`); // 解析所有待处理的消息 for (const [seqno, data] of pendingMessages) { try { // 使用 mailparser 解析完整的邮件原始Buffer,让mailparser自动处理编码 const parsedMail = await simpleParser(data.rawBuffer); // 提取纯邮箱地址的辅助函数 const extractEmailAddress = (addressObj: any): string => { if (!addressObj) return ''; // 处理数组情况 if (Array.isArray(addressObj)) { return addressObj.map(addr => extractSingleEmail(addr)).filter(Boolean).join(', '); } return extractSingleEmail(addressObj); }; // 从单个地址对象中提取邮箱地址 const extractSingleEmail = (addr: any): string => { if (!addr) return ''; // 如果是字符串,尝试从中提取邮箱 if (typeof addr === 'string') { // 匹配 "name" <email@domain.com> 或 email@domain.com 格式 const emailMatch = addr.match(/<([^>]+)>/) || addr.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/); return emailMatch ? emailMatch[1] : addr; } // 如果是对象,优先取 address 属性 if (addr && typeof addr === 'object') { if (addr.address) return addr.address; if (addr.text) { // 从 text 中提取邮箱 const emailMatch = addr.text.match(/<([^>]+)>/) || addr.text.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/); return emailMatch ? emailMatch[1] : addr.text; } } return ''; }; messages.push({ ...data.message, subject: parsedMail.subject || 'No Subject', from: extractEmailAddress(parsedMail.from), to: extractEmailAddress(parsedMail.to), cc: extractEmailAddress(parsedMail.cc) || undefined, bcc: extractEmailAddress(parsedMail.bcc) || undefined, text: parsedMail.text, html: parsedMail.html } as EmailMessage); } catch (error) { console.error(`[IMAP] Failed to parse message ${seqno}:`, error); // 如果解析失败,返回基本信息和原始内容 messages.push({ ...data.message, subject: data.headers['subject'] || 'Parse Failed', from: data.headers['from'] || '', to: data.headers['to'] || '', cc: data.headers['cc'] || undefined, bcc: data.headers['bcc'] || undefined, text: data.body.trim() } as EmailMessage); } } console.error(`[IMAP] All messages parsed, returning ${messages.length} messages`); resolve(messages); }); }); } async getMessage(uid: number): Promise<EmailMessage> { const messages = await this.fetchMessages([uid]); if (messages.length === 0) { throw new Error(`Message with UID ${uid} not found`); } return messages[0]; } async deleteMessage(uid: number): Promise<void> { if (!this.imap) { throw new Error('Not connected to IMAP server'); } // 如果没有打开邮箱,自动打开收件箱 if (!this.currentBox) { await this.openBox('INBOX', false); // 需要写权限来删除 } return new Promise((resolve, reject) => { this.imap!.addFlags(uid, ['\\Deleted'], (error) => { if (error) { console.error(`[IMAP] Failed to mark message ${uid} as deleted:`, error.message); reject(new Error(`Failed to delete message: ${error.message}`)); return; } console.error(`[IMAP] Message ${uid} marked for deletion`); // 执行 expunge 来真正删除消息 this.imap!.expunge((expungeError) => { if (expungeError) { console.error('[IMAP] Failed to expunge:', expungeError.message); reject(new Error(`Failed to expunge deleted messages: ${expungeError.message}`)); return; } console.error(`[IMAP] Message ${uid} deleted successfully`); resolve(); }); }); }); } async getMessageCount(): Promise<number> { if (!this.currentBox) { await this.openBox('INBOX', true); } const uids = await this.search(['ALL']); return uids.length; } async getUnseenMessages(): Promise<EmailMessage[]> { const unseenUids = await this.search(['UNSEEN']); return this.fetchMessages(unseenUids); } async getRecentMessages(): Promise<EmailMessage[]> { const recentUids = await this.search(['RECENT']); return this.fetchMessages(recentUids); } private parseHeaders(headerText: string): Record<string, string> { const headers: Record<string, string> = {}; const lines = headerText.split('\r\n'); let currentHeader = ''; let currentValue = ''; for (const line of lines) { if (line.match(/^\s/) && currentHeader) { // 继续上一个头部 currentValue += ' ' + line.trim(); } else { // 保存上一个头部 if (currentHeader) { headers[currentHeader.toLowerCase()] = currentValue.trim(); } // 开始新的头部 const colonIndex = line.indexOf(':'); if (colonIndex > -1) { currentHeader = line.substring(0, colonIndex).trim(); currentValue = line.substring(colonIndex + 1).trim(); } else { currentHeader = ''; currentValue = ''; } } } // 保存最后一个头部 if (currentHeader) { headers[currentHeader.toLowerCase()] = currentValue.trim(); } return headers; } async disconnect(): Promise<void> { if (!this.imap) { return; // 已经没有连接对象 } if (!this.connected) { // 如果状态显示未连接,直接清理 this.imap = null; this.authenticated = false; this.currentBox = null; return; } return new Promise((resolve) => { const timeout = setTimeout(() => { console.error('[IMAP] Disconnect timeout, forcing cleanup'); this.connected = false; this.authenticated = false; this.currentBox = null; this.imap = null; resolve(); }, 5000); // 5秒超时 this.imap!.once('end', () => { clearTimeout(timeout); console.error('[IMAP] Disconnected'); this.connected = false; this.authenticated = false; this.currentBox = null; this.imap = null; resolve(); }); this.imap!.once('error', (error: Error) => { clearTimeout(timeout); console.error('[IMAP] Disconnect error:', error.message); this.connected = false; this.authenticated = false; this.currentBox = null; this.imap = null; resolve(); // 即使有错误也要resolve,因为目标是断开连接 }); try { this.imap!.end(); } catch (error) { clearTimeout(timeout); console.error('[IMAP] Error calling end():', error); this.connected = false; this.authenticated = false; this.currentBox = null; this.imap = null; resolve(); } }); } isConnected(): boolean { return this.connected && this.authenticated; } getCurrentBox(): string | null { return this.currentBox; } getCurrentUsername(): string | null { return this.config?.username || null; } // 保存邮件到指定文件夹(用于已发送邮件) async saveMessageToFolder(messageContent: string, folderName: string = 'INBOX.Sent'): Promise<void> { if (!this.connected) { throw new Error('IMAP client is not connected'); } return new Promise((resolve, reject) => { // 尝试打开目标文件夹 this.imap!.openBox(folderName, false, (err) => { if (err) { // 如果文件夹不存在,尝试创建 console.warn(`[IMAP] Folder ${folderName} not found, trying to create it`); this.imap!.addBox(folderName, (createErr) => { if (createErr) { console.error(`[IMAP] Failed to create folder ${folderName}:`, createErr.message); // 如果创建失败,尝试其他常见的发件箱名称 this.trySaveToAlternateSentFolders(messageContent, resolve, reject); return; } // 创建成功后再次尝试打开并保存 this.saveToOpenedFolder(messageContent, folderName, resolve, reject); }); } else { // 文件夹存在,直接保存 this.saveToOpenedFolder(messageContent, folderName, resolve, reject); } }); }); } private saveToOpenedFolder(messageContent: string, folderName: string, resolve: () => void, reject: (error: Error) => void): void { this.imap!.append(messageContent, { mailbox: folderName }, (err) => { if (err) { console.error(`[IMAP] Failed to save message to ${folderName}:`, err.message); reject(new Error(`Failed to save message to ${folderName}: ${err.message}`)); } else { console.log(`[IMAP] Message successfully saved to ${folderName}`); resolve(); } }); } private trySaveToAlternateSentFolders(messageContent: string, resolve: () => void, reject: (error: Error) => void): void { const alternateFolders = ['Sent', 'SENT', 'Sent Items', 'Sent Messages', '已发送']; let currentIndex = 0; const tryNext = () => { if (currentIndex >= alternateFolders.length) { console.warn('[IMAP] All sent folder attempts failed, message not saved to sent folder'); resolve(); // 不抛出错误,因为邮件已经发送成功 return; } const folderName = alternateFolders[currentIndex++]; this.imap!.openBox(folderName, false, (err) => { if (err) { tryNext(); // 尝试下一个文件夹 } else { this.saveToOpenedFolder(messageContent, folderName, resolve, reject); } }); }; tryNext(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yunfeizhu/mcp-mail-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server