Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
+791
@@ -0,0 +1,791 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user