'use strict'; /** * 零依赖 IMAP4rev1 客户端(面向 WpywMail,但按 RFC 3501 通用实现)。 * * 为什么自己写而不装库: * 本客户端刻意保持「零 npm 依赖」—— 没有供应链风险、不需要联网安装、 * 在 Windows 上双击即用。IMAP 的核心(行 + {n} 字面量 + 标签应答) * 规模可控,自己实现反而更容易对症下药。 * * 已实现:CAPABILITY / LOGIN / LOGOUT / LIST / STATUS / SELECT / EXAMINE / * UID SEARCH / UID FETCH / UID STORE / UID COPY / EXPUNGE / APPEND / IDLE * * 关键难点是**字面量**:形如 `* 1 FETCH (BODY[] {1234}\r\n<1234 字节裸数据>)` * 的应答不是「一行」,必须先按 CRLF 读到 `{1234}`,再精确读 1234 字节, * 然后继续读同一条逻辑行的剩余部分。本文件用「分段 + 哨兵」的方式处理。 */ const tls = require('node:tls'); const net = require('node:net'); const { EventEmitter } = require('node:events'); const { decodeWords } = require('./mime'); const CRLF = '\r\n'; const LITERAL_SENTINEL = '\u0000LIT'; class ImapError extends Error { constructor(message, info = {}) { super(message); this.name = 'ImapError'; this.status = info.status || null; // 'NO' / 'BAD' / null this.command = info.command || null; this.detail = info.detail || ''; } } /** 把 IMAP 的 quoted string / atom 做转义后包成带引号的字符串 */ function quote(value) { return '"' + String(value).replace(/[\\"]/g, (m) => '\\' + m) + '"'; } // ─────────────────────────────────────────────────────────── 应答解析 /** * 把「分段 + 字面量」拼成一条逻辑行字符串,字面量用 \u0000LIT\u0000 占位。 * 之后就可以对整行做统一分词了。 */ function assembleLogical(parts, literals) { let out = parts[0] || ''; for (let i = 0; i < literals.length; i++) { out += LITERAL_SENTINEL + i + '\u0000'; out += parts[i + 1] || ''; } return out; } /** 分词:括号 / 带引号字符串 / 字面量占位 / 裸原子 */ function tokenize(text) { const tokens = []; let i = 0; const n = text.length; while (i < n) { const c = text[i]; if (c === ' ' || c === '\t') { i++; continue; } if (c === '(') { tokens.push({ t: 'open' }); i++; continue; } if (c === ')') { tokens.push({ t: 'close' }); i++; continue; } if (c === '"') { let j = i + 1; let out = ''; while (j < n) { if (text[j] === '\\') { out += text[j + 1]; j += 2; continue; } if (text[j] === '"') break; out += text[j]; j++; } tokens.push({ t: 'str', v: out }); i = j + 1; continue; } if (c === '\u0000') { const m = /^\u0000LIT(\d+)\u0000/.exec(text.slice(i)); if (m) { tokens.push({ t: 'lit', i: Number(m[1]) }); i += m[0].length; continue; } } let j = i; while (j < n && text[j] !== ' ' && text[j] !== '(' && text[j] !== ')' && text[j] !== '"' && text[j] !== '\u0000') j++; const atom = text.slice(i, j); tokens.push({ t: 'atom', v: atom === 'NIL' ? null : atom }); i = j; } return tokens; } /** 由分词结果构建嵌套数组(NIL → null,字面量 → Buffer) */ function buildTree(tokens, literals) { let pos = 0; function parse() { const out = []; while (pos < tokens.length) { const tk = tokens[pos]; if (tk.t === 'open') { pos++; out.push(parse()); continue; } if (tk.t === 'close') { pos++; return out; } pos++; if (tk.t === 'str' || tk.t === 'atom') out.push(tk.v); else if (tk.t === 'lit') out.push(literals[tk.i]); } return out; } return parse(); } /** 一步到位:逻辑行的分段+字面量 → 树 */ function parseResponse(parts, literals) { return buildTree(tokenize(assembleLogical(parts, literals)), literals); } /** 把 FETCH 的 (KEY VALUE KEY VALUE ...) 扁平列表转成对象,键统一大写 */ function fetchItemsToObject(list) { const out = {}; for (let i = 0; i < list.length - 1; i += 2) { const key = list[i]; if (typeof key !== 'string') continue; out[key.toUpperCase()] = list[i + 1]; } return out; } /** ENVELOPE 地址项 → {name, mailbox, host} */ function addressFrom(raw) { if (!Array.isArray(raw)) return null; const [name, , mailbox, host] = raw; if (!mailbox && !host) return null; const addr = [mailbox, host].filter(Boolean).join('@'); return { name: name || '', address: addr }; } /** ENVELOPE 结构 → 可用的头信息(RFC 3501 第 7.4.2 节,共 10 个字段) */ function parseEnvelope(env) { if (!Array.isArray(env)) return null; const [date, subject, from, sender, replyTo, to, cc, bcc, inReplyTo, messageId] = env; const addrs = (x) => (Array.isArray(x) ? x.map(addressFrom).filter(Boolean) : []); return { date: date || null, subject: subject || '', from: addrs(from), sender: addrs(sender), replyTo: addrs(replyTo), to: addrs(to), cc: addrs(cc), bcc: addrs(bcc), inReplyTo: inReplyTo || null, messageId: messageId || null, }; } // ─────────────────────────────────────────────────────────── 客户端 class ImapClient extends EventEmitter { /** * @param {object} opts * @param {string} opts.host * @param {number} [opts.port=993] * @param {string} opts.user * @param {string} opts.password * @param {boolean} [opts.implicitTls=true] true=993 直接 TLS;false=143 明文(可再 STARTTLS) * @param {boolean} [opts.startTls=false] implicitTls=false 时是否发 STARTTLS * @param {number} [opts.timeoutMs=30000] * @param {boolean} [opts.debug=false] */ constructor(opts) { super(); this.opts = Object.assign( { port: 993, implicitTls: true, startTls: false, timeoutMs: 30000, debug: false }, opts ); this.socket = null; this.buffer = Buffer.alloc(0); this.literals = []; // 当前逻辑行收集到的字面量 this.parts = []; // 当前逻辑行收集到的文本分段 this.tagSeq = 0; this.pending = new Map(); // tag -> {resolve, reject, untagged, command, timer} this.current = null; // 最近一次已发出、尚未完成的命令(用于归集未标记应答) this.continuation = null; // 等待 "+" 的 promise resolver(IDLE / APPEND / AUTHENTICATE) this.capabilities = []; this.selected = null; this.exists = 0; this.closed = false; this._lineCount = 0; this.on('error', () => {}); // 避免未处理的 error 事件把进程带崩 } get connected() { return !!this.socket && !this.socket.destroyed; } _log(dir, text) { if (!this.opts.debug) return; const one = String(text).replace(/\r?\n/g, '\\n').slice(0, 300); process.stderr.write(`[imap ${dir}] ${one}\n`); } async connect() { const { host, port, implicitTls, timeoutMs } = this.opts; this.socket = await new Promise((resolve, reject) => { const onError = (err) => reject(new ImapError(`连接 ${host}:${port} 失败:${err.message}`)); const sock = implicitTls ? tls.connect({ host, port, servername: host }, () => resolve(sock)) : net.connect({ host, port }, () => resolve(sock)); sock.setNoDelay(true); sock.once('error', onError); sock.setTimeout(timeoutMs, () => reject(new ImapError(`连接 ${host}:${port} 超时`))); }); this.socket.setTimeout(0); this.socket.on('data', (chunk) => this._onData(chunk)); this.socket.on('error', (err) => this._failAll(err)); this.socket.on('close', () => { this.closed = true; this._failAll(new ImapError('连接已被对端关闭')); this.emit('close'); }); // 服务器问候语(* OK [CAPABILITY ...] ...)——等它到达并解析 const greeting = await this._waitGreeting(); this._log('<', greeting); if (!implicitTls && this.opts.startTls) { await this._command('STARTTLS'); await this._upgradeToTls(); const caps = await this._command('CAPABILITY'); this.capabilities = (caps.untaggedText().find((l) => /CAPABILITY/i.test(l)) || '') .replace(/^\*\s*CAPABILITY\s*/i, '') .split(/\s+/) .filter(Boolean); } return greeting; } _waitGreeting() { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new ImapError('等待服务器问候语超时')), this.opts.timeoutMs); this._greetResolve = (line) => { clearTimeout(timer); resolve(line); }; }); } _upgradeToTls() { const { host } = this.opts; return new Promise((resolve, reject) => { this.socket.removeAllListeners('data'); const secured = tls.connect({ socket: this.socket, servername: host }, () => { this.socket = secured; this.buffer = Buffer.alloc(0); this.parts = []; this.literals = []; secured.setNoDelay(true); secured.on('data', (chunk) => this._onData(chunk)); secured.on('error', (err) => this._failAll(err)); secured.on('close', () => { this.closed = true; this._failAll(new ImapError('连接已被对端关闭')); }); resolve(); }); secured.once('error', reject); }); } // ───────────────────────────────────────── 数据接收与逻辑行组装 _onData(chunk) { this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk; this._drain(); } /** * 从缓冲区里不断提取「完整逻辑行」。 * 一条逻辑行可能因为 {n} 字面量而被切成多段,这里用 parts/literals 累积。 */ _drain() { for (;;) { const idx = this.buffer.indexOf(CRLF); if (idx < 0) { // 没有 CRLF,且缓冲区已经很大 → 可能是异常数据,保护一下 if (this.buffer.length > 64 * 1024 * 1024) { this._failAll(new ImapError('单行缓冲超过 64MB,疑似协议异常')); this.socket.destroy(); } return; } const lineBuf = this.buffer.slice(0, idx); const line = lineBuf.toString('latin1'); const m = /\{(\d+)\}$/.exec(line); if (m) { const need = Number(m[1]); const after = idx + 2; if (this.buffer.length < after + need) return; // 字面量还没到齐 const literal = this.buffer.slice(after, after + need); this.parts.push(line.slice(0, m.index)); this.literals.push(literal); this.buffer = this.buffer.slice(after + need); continue; // 继续读这条逻辑行后面的内容 } // 完整逻辑行 this.parts.push(line); const lineParts = this.parts; const lineLiterals = this.literals; this.parts = []; this.literals = []; this.buffer = this.buffer.slice(idx + 2); this._lineCount++; this._handleLine(lineParts, lineLiterals); } } _handleLine(parts, literals) { const head = parts.join('\u0000LIT\u0000').replace(/\u0000LIT\d+\u0000/g, '').trim(); this._log('<', head.slice(0, 400) + (literals.length ? ` [+${literals.length} 字面量]` : '')); // 1) 续行请求 "+ ..." if (head.startsWith('+')) { const cb = this.continuation; this.continuation = null; if (cb) cb(null, head.slice(1).trim()); return; } // 2) 带标签的完成应答 const tm = /^(\S+)\s+(OK|NO|BAD)\b([\s\S]*)$/i.exec(head); if (tm && this.pending.has(tm[1])) { const tag = tm[1]; const entry = this.pending.get(tag); this.pending.delete(tag); clearTimeout(entry.timer); if (this.current === entry) this.current = null; const status = tm[2].toUpperCase(); const text = tm[3].trim(); if (status === 'OK') { // 把 entry 上的辅助方法一并挂到返回值上,调用方才能用 res.untaggedText() / res.tree() entry.resolve({ status, text, untagged: entry.untagged, lines: entry.untagged, untaggedText: entry.untaggedText, tree: entry.tree, response: entry, }); } else { // 顺手把 [ ... ] 里的状态码也带进错误信息,便于排查 entry.reject(new ImapError(`${entry.command} 被拒绝:${status} ${text}`, { status, command: entry.command, detail: text, })); } return; } // 3) 问候语 if (/^\*\s+(OK|PREAUTH)/i.test(head) && this._greetResolve) { const r = this._greetResolve; this._greetResolve = null; const cm = /\[CAPABILITY\s+([^\]]+)\]/i.exec(head); if (cm) this.capabilities = cm[1].trim().split(/\s+/); r(head); this._dispatchUntagged(parts, literals, head); return; } // 4) 未标记应答 this._dispatchUntagged(parts, literals, head); } _dispatchUntagged(parts, literals, head) { const tree = parseResponse(parts, literals); // 已选邮箱的状态变化 → 主动广播给上层(IDLE 期间尤其重要) const existsMatch = /^\*\s+(\d+)\s+EXISTS/i.exec(head); if (existsMatch) { this.exists = Number(existsMatch[1]); this.emit('exists', this.exists); } if (/^\*\s+\d+\s+EXPUNGE/i.test(head)) this.emit('expunge', Number(head.split(/\s+/)[1])); if (/^\*\s+BYE/i.test(head)) this.emit('bye', head); if (this.current) this.current.untagged.push({ head, tree, parts, literals }); else this.emit('untagged', { head, tree }); } _failAll(err) { for (const [, entry] of this.pending) { clearTimeout(entry.timer); entry.reject(err); } this.pending.clear(); this.current = null; if (this.continuation) { const cb = this.continuation; this.continuation = null; cb(err); } this.emit('error', err); } // ───────────────────────────────────────── 命令 _write(text) { this._log('>', text); this.socket.write(text); } /** 发一条命令并等它的带标签应答 */ _command(command, { collect = true, timeoutMs = null } = {}) { if (!this.connected) return Promise.reject(new ImapError('连接不可用')); const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); return new Promise((resolve, reject) => { const entry = { command, untagged: [], resolve, reject, tag, untaggedText: () => entry.untagged.map((u) => u.head), tree: () => entry.untagged.map((u) => u.tree), }; const timer = setTimeout(() => { this.pending.delete(tag); if (this.current === entry) this.current = null; reject(new ImapError(`${command} 超时(${(timeoutMs || this.opts.timeoutMs) / 1000}s)`, { command })); }, timeoutMs || this.opts.timeoutMs); entry.timer = timer; this.pending.set(tag, entry); if (collect) this.current = entry; this._write(`${tag} ${command}${CRLF}`); }); } /** 等服务器发 "+"(IDLE / APPEND 用) */ _waitContinuation(timeoutMs = this.opts.timeoutMs) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.continuation = null; reject(new ImapError('等待续行(+)超时')); }, timeoutMs); this.continuation = (err, text) => { clearTimeout(timer); if (err) reject(err); else resolve(text); }; }); } /** 开始一条命令但不等待完成,返回 {tag, done};用于需要等 "+" 的多步命令 */ _beginCommand(command, timeoutMs = this.opts.timeoutMs) { const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); const done = new Promise((resolve, reject) => { const entry = { command, untagged: [], resolve, reject, tag, untaggedText: () => entry.untagged.map((u) => u.head), tree: () => entry.untagged.map((u) => u.tree), }; entry.timer = setTimeout(() => { this.pending.delete(tag); if (this.current === entry) this.current = null; reject(new ImapError(`${command} 超时(${timeoutMs / 1000}s)`, { command })); }, timeoutMs); this.pending.set(tag, entry); this.current = entry; }); this._write(`${tag} ${command}${CRLF}`); return { tag, done }; } async capability() { const res = await this._command('CAPABILITY'); const line = res.untaggedText().find((l) => /CAPABILITY/i.test(l)) || ''; this.capabilities = line.replace(/^\*\s*CAPABILITY\s*/i, '').trim().split(/\s+/).filter(Boolean); return this.capabilities; } /** * 登录。策略(按兼容性从高到低): * 1. LOGIN —— 最通用,WpywMail 支持 * 2. AUTHENTICATE PLAIN 两步式(先命令、等 "+"、再发 base64)—— 应对禁用了 LOGIN 的服务器 * 3. AUTHENTICATE PLAIN 行内初始应答 —— 需要 SASL-IR,实测本服务器不接受,放最后 * 返回实际使用的方式,便于排查。 */ async login() { const { user, password } = this.opts; if (!user) throw new ImapError('缺少用户名'); const plain = Buffer.from(`\u0000${user}\u0000${password}`, 'utf8').toString('base64'); const canPlain = (this.capabilities || []).some((c) => /^AUTH=PLAIN$/i.test(c)); try { await this._command(`LOGIN ${quote(user)} ${quote(password)}`); this.authMethod = 'LOGIN'; return 'LOGIN'; } catch (err) { if (!canPlain) throw err; // 服务器不支持 PLAIN,原始错误直接抛出更有用 this._loginError = err; } try { const { done } = this._beginCommand('AUTHENTICATE PLAIN'); await this._waitContinuation(30000); this._write(`${plain}${CRLF}`); await done; this.authMethod = 'AUTHENTICATE PLAIN(两步式)'; return this.authMethod; } catch (err) { this._loginError = err; } await this._command(`AUTHENTICATE PLAIN ${plain}`); this.authMethod = 'AUTHENTICATE PLAIN(行内)'; return this.authMethod; } /** LIST "" "*" → [{flags, delimiter, name, raw}] */ async list(reference = '', pattern = '*') { const res = await this._command(`LIST ${quote(reference)} ${quote(pattern)}`); const boxes = []; for (const u of res.untagged) { const m = /^\*\s+LIST\s+\((.*?)\)\s+(NIL|"[^"]*")\s+(.+)$/i.exec(u.head); if (!m) continue; const flags = m[1].trim() ? m[1].trim().split(/\s+/) : []; const delim = m[2] === 'NIL' ? null : m[2].slice(1, -1); let name = m[3].trim(); if (name.startsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, '$1'); boxes.push({ flags, delimiter: delim, name, raw: u.head }); } return boxes; } /** STATUS "INBOX" (MESSAGES UNSEEN RECENT UIDNEXT UIDVALIDITY) */ async status(mailbox, items = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']) { const res = await this._command(`STATUS ${quote(mailbox)} (${items.join(' ')})`); const out = {}; for (const u of res.untagged) { if (!/^\*\s+STATUS/i.test(u.head)) continue; const open = u.head.indexOf('('); const close = u.head.lastIndexOf(')'); if (open < 0 || close < 0) continue; const body = u.head.slice(open + 1, close).trim(); const toks = body.split(/\s+/); for (let i = 0; i < toks.length - 1; i += 2) { out[toks[i].toUpperCase()] = Number(toks[i + 1]); } } return out; } /** SELECT / EXAMINE */ async select(mailbox, { readonly = false } = {}) { const res = await this._command(`${readonly ? 'EXAMINE' : 'SELECT'} ${quote(mailbox)}`, { timeoutMs: 60000, }); const info = { mailbox, readonly, exists: 0, recent: 0, unseen: null, uidValidity: null, uidNext: null, flags: [] }; for (const u of res.untagged) { let m; if ((m = /^\*\s+(\d+)\s+EXISTS/i.exec(u.head))) info.exists = Number(m[1]); else if ((m = /^\*\s+(\d+)\s+RECENT/i.exec(u.head))) info.recent = Number(m[1]); else if ((m = /^\*\s+FLAGS\s+\((.*?)\)/i.exec(u.head))) info.flags = m[1].trim().split(/\s+/).filter(Boolean); else if ((m = /\[UNSEEN\s+(\d+)\]/i.exec(u.head))) info.unseen = Number(m[1]); else if ((m = /\[UIDVALIDITY\s+(\d+)\]/i.exec(u.head))) info.uidValidity = Number(m[1]); else if ((m = /\[UIDNEXT\s+(\d+)\]/i.exec(u.head))) info.uidNext = Number(m[1]); } this.selected = mailbox; this.exists = info.exists; return info; } /** UID SEARCH [criteria...] → UID 数组 */ async searchUid(criteria = ['ALL']) { const res = await this._command(`UID SEARCH ${criteria.join(' ')}`, { timeoutMs: 120000 }); const line = res.untaggedText().find((l) => /^\*\s+SEARCH/i.test(l)) || ''; const body = line.replace(/^\*\s+SEARCH\s*/i, '').trim(); if (!body) return []; return body.split(/\s+/).map(Number).filter((x) => Number.isFinite(x)); } /** * UID FETCH:取摘要(信封/旗标/大小/时间)。 * @param {number[]|string} uids */ async fetchSummaries(uids, { chunkSize = 200 } = {}) { const list = Array.isArray(uids) ? uids : [uids]; const out = []; for (let i = 0; i < list.length; i += chunkSize) { const chunk = list.slice(i, i + chunkSize); if (!chunk.length) continue; const set = chunk.join(','); const res = await this._command( `UID FETCH ${set} (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE)`, { timeoutMs: 180000 } ); for (const u of res.untagged) { if (!/FETCH/i.test(u.head)) continue; const tree = u.tree; const idx = tree.findIndex((x) => x === 'FETCH'); if (idx < 0 || !Array.isArray(tree[idx + 1])) continue; const items = fetchItemsToObject(tree[idx + 1]); out.push(this._summarize(items, u.literals)); } } return out; } _summarize(items, literals) { const env = parseEnvelope(items.ENVELOPE); // RFC 3501:ENVELOPE 里的字符串是「与报文中一致的原文」,编码字不会被服务器解码, // 所以客户端必须自己解 RFC 2047,否则列表里会显示 =?UTF-8?B?...?= if (env) { env.subjectRaw = env.subject; env.subject = decodeWords(env.subject || ''); for (const key of ['from', 'to', 'cc', 'replyTo', 'sender']) { if (Array.isArray(env[key])) { for (const a of env[key]) if (a && a.name) a.name = decodeWords(a.name); } } } const flags = Array.isArray(items.FLAGS) ? items.FLAGS : []; // BODY[] / BODY[HEADER] 这类键的值就是字面量 let raw = null; for (const k of Object.keys(items)) { if (/^BODY(\[.*\])?$/.test(k) || k === 'RFC822') { if (Buffer.isBuffer(items[k])) raw = items[k]; } } if (raw == null && literals && literals.length) raw = literals[0]; return { uid: Number(items.UID), flags, size: Number(items['RFC822.SIZE'] || 0), internalDate: items.INTERNALDATE ? String(items.INTERNALDATE) : null, envelope: env, seen: flags.some((f) => /\\Seen/i.test(f)), flagged: flags.some((f) => /\\Flagged/i.test(f)), answered: flags.some((f) => /\\Answered/i.test(f)), draft: flags.some((f) => /\\Draft/i.test(f)), raw, }; } /** UID FETCH 整封原始报文(BODY.PEEK[]:不置 \Seen) */ async fetchRaw(uid, { peek = true } = {}) { const item = peek ? 'BODY.PEEK[]' : 'BODY[]'; const res = await this._command(`UID FETCH ${uid} (UID FLAGS ${item})`, { timeoutMs: 180000 }); for (const u of res.untagged) { if (!/FETCH/i.test(u.head)) continue; const tree = u.tree; const idx = tree.findIndex((x) => x === 'FETCH'); if (idx < 0 || !Array.isArray(tree[idx + 1])) continue; const items = fetchItemsToObject(tree[idx + 1]); const buf = this._extractBody(items, u.literals); if (buf) { const flags = Array.isArray(items.FLAGS) ? items.FLAGS : []; return { uid, raw: buf, flags }; } } return null; } _extractBody(items, literals) { for (const k of Object.keys(items)) { if (/^BODY(\[.*\])?$/i.test(k) || /^RFC822(\.TEXT)?$/i.test(k)) { if (Buffer.isBuffer(items[k])) return items[k]; } } return literals && literals.length ? literals[literals.length - 1] : null; } /** UID STORE:mode 为 '+FLAGS' / '-FLAGS' / 'FLAGS';flags 例如 ['\\Seen'] */ async storeFlags(uid, mode, flags) { const uids = Array.isArray(uid) ? uid.join(',') : String(uid); const list = flags.length ? '(' + flags.join(' ') + ')' : '()'; const res = await this._command(`UID STORE ${uids} ${mode} ${list}`, { timeoutMs: 60000 }); const updated = []; for (const u of res.untagged) { if (!/FETCH/i.test(u.head)) continue; const tree = u.tree; const idx = tree.findIndex((x) => x === 'FETCH'); if (idx >= 0 && Array.isArray(tree[idx + 1])) updated.push(this._summarize(fetchItemsToObject(tree[idx + 1]), u.literals)); } return updated; } /** UID COPY:移动的第一步(IMAP 没有 MOVE,服务器也没广告 MOVE) */ async copy(uid, mailbox) { const uids = Array.isArray(uid) ? uid.join(',') : String(uid); await this._command(`UID COPY ${uids} ${quote(mailbox)}`, { timeoutMs: 120000 }); return true; } /** 把邮件标为删除并 EXPUNGE(真正的「移动」= COPY + 标记删除 + EXPUNGE) */ async deleteUid(uid, { expunge = true } = {}) { await this.storeFlags(uid, '+FLAGS', ['\\Deleted']); if (expunge) await this.expunge(); return true; } async expunge() { await this._command('EXPUNGE', { timeoutMs: 120000 }); return true; } /** APPEND 一封邮件到指定邮箱 */ async append(mailbox, rawBuffer, { flags = [], date = null } = {}) { const size = rawBuffer.length; const flagPart = flags.length ? `(${flags.join(' ')}) ` : ''; const datePart = date ? `${quote(date)} ` : ''; // 先发命令头,等 "+",再发字面量本体 const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); const wait = this._waitContinuation(60000); const done = new Promise((resolve, reject) => { const entry = { command: `APPEND ${mailbox}`, untagged: [], resolve, reject, tag, untaggedText: () => entry.untagged.map((u) => u.head), tree: () => entry.untagged.map((u) => u.tree), }; entry.timer = setTimeout(() => { this.pending.delete(tag); reject(new ImapError('APPEND 超时')); }, 180000); this.pending.set(tag, entry); this.current = entry; }); this._write(`${tag} APPEND ${quote(mailbox)} ${flagPart}${datePart}{${size}}${CRLF}`); await wait; this._log('>', `<${size} 字节字面量>`); this.socket.write(rawBuffer); this._write(CRLF); await done; return true; } /** * IDLE:服务器有变化时会触发 'exists' / 'expunge' / 'bye' 事件。 * 返回一个 stop() 用于结束 IDLE(内部会发 DONE 并等 tagged OK)。 */ async idle({ maxMs = 9 * 60 * 1000 } = {}) { const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); const finished = new Promise((resolve, reject) => { const entry = { command: 'IDLE', untagged: [], resolve, reject, tag, untaggedText: () => entry.untagged.map((u) => u.head), tree: () => entry.untagged.map((u) => u.tree), }; entry.timer = setTimeout(() => { this.pending.delete(tag); reject(new ImapError('IDLE 超时')); }, maxMs); this.pending.set(tag, entry); this.current = entry; }); const cont = this._waitContinuation(30000); this._write(`${tag} IDLE${CRLF}`); await cont; let stopped = false; const stop = async () => { if (stopped) return; stopped = true; this._write(`DONE${CRLF}`); try { await finished; } catch { /* 忽略停止时的噪音 */ } }; // 让调用方既能 await idle() 拿到 stop,也方便忘记停止时自动收尾 return stop; } /** 优雅退出 */ async logout() { try { if (this.connected) await this._command('LOGOUT', { timeoutMs: 8000 }); } catch { /* 忽略 */ } this._destroy(); } _destroy() { try { this.socket?.destroy(); } catch { /* 忽略 */ } this.closed = true; } } module.exports = { ImapClient, ImapError, // 导出解析器,便于单测与客户端侧复用 parseResponse, parseEnvelope, tokenize, buildTree, fetchItemsToObject, };