Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+791
View File
@@ -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,
};