Files
wpywmail-client/server/imap.js
T

792 lines
28 KiB
JavaScript
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.
'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<i>\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,
};