Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 邮件服务器「账号接口」客户端(对接服务端 v2.2.0 的账号体系)。
|
||||
*
|
||||
* 走的是服务器上**只放账号类接口**的公网 HTTPS 入口(默认 https://mail.example.com:9443):
|
||||
* 注册、邮箱验证码、找回密码、登录换 token、改密码、会话与资料。
|
||||
* 邮件读写(读信/发件/队列)**不经过这里**,仍然走 IMAP 993 / SMTP 587 ——
|
||||
* 那个入口对邮件与管理接口一律返回 404(可在服务器上用 /api/messages 验证)。
|
||||
*
|
||||
* 零依赖:用 Node 内置的全局 fetch(Node 18+)。
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE = 'https://mail.example.com:9443';
|
||||
|
||||
/** 服务器给的错误信息本身就是中文说明,直接透传给 UI;只补上网络层失败的可读提示。 */
|
||||
async function call(base, path, options = {}) {
|
||||
const { method = 'GET', body, token, timeoutMs = 20000 } = options;
|
||||
const root = String(base || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const url = root + path;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json; charset=utf-8' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { error: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error) || `账号接口返回 HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err && err.status) throw err;
|
||||
if (err && err.name === 'AbortError') throw new Error(`账号接口超时:${url}`);
|
||||
throw new Error(`连不上账号接口 ${url}:${err && err.message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountApi(base) {
|
||||
const apiBase = String(base || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
return {
|
||||
apiBase,
|
||||
policy: () => call(apiBase, '/api/auth/policy'),
|
||||
register: (body) => call(apiBase, '/api/register', { method: 'POST', body }),
|
||||
verifyRegistration: (body) => call(apiBase, '/api/register/verify', { method: 'POST', body }),
|
||||
resendCode: (body) => call(apiBase, '/api/register/resend', { method: 'POST', body }),
|
||||
forgot: (email) => call(apiBase, '/api/auth/forgot', { method: 'POST', body: { email } }),
|
||||
reset: (body) => call(apiBase, '/api/auth/reset', { method: 'POST', body }),
|
||||
login: (email, password) => call(apiBase, '/api/login', { method: 'POST', body: { email, password } }),
|
||||
logout: (token) => call(apiBase, '/api/logout', { method: 'POST', body: {}, token }),
|
||||
changePassword: (token, body) => call(apiBase, '/api/account/password', { method: 'POST', body, token }),
|
||||
profile: (token) => call(apiBase, '/api/me', { token }),
|
||||
updateProfile: (token, displayName) =>
|
||||
call(apiBase, '/api/account/profile', { method: 'PATCH', body: { displayName }, token }),
|
||||
sessions: (token) => call(apiBase, '/api/account/sessions', { token }),
|
||||
revokeSessions: (token, body) => call(apiBase, '/api/account/sessions/revoke', { method: 'POST', body, token }),
|
||||
audit: (token, limit = 30) => call(apiBase, `/api/account/audit?limit=${limit}`, { token }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_API_BASE, createAccountApi };
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 账户配置的读取与保存。
|
||||
*
|
||||
* 位置优先级:
|
||||
* 1. 环境变量 WPYWMAIL_ACCOUNT_FILE 指定的文件(自测/多账户时用)
|
||||
* 2. %LOCALAPPDATA%\WpywMailClient\account.json(默认,跟着用户走)
|
||||
*
|
||||
* 安全说明(不粉饰):密码以明文存在这个文件里。这是自研客户端的常见做法
|
||||
* (Thunderbird 之类会用系统凭据库/主密码,那需要额外依赖)。本文件只放在
|
||||
* 当前用户自己的 profile 目录下,不做任何网络传输;若在意,可把
|
||||
* account.savePassword 设为 false,每次启动时手动输入密码。
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULTS = {
|
||||
host: 'mail.example.com',
|
||||
imapPort: 993,
|
||||
smtpPort: 587,
|
||||
implicitTls: true, // 993 隐式 TLS
|
||||
startTls: true, // 587 先 STARTTLS 再 AUTH
|
||||
user: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
domain: 'wpy.email',
|
||||
savePassword: true,
|
||||
// 服务器「账号接口」入口(注册 / 找回密码 / 会话与资料)。
|
||||
// 只放账号类接口,邮件读写仍然走 IMAP/SMTP —— 这里看不到任何邮件数据。
|
||||
apiBase: 'https://mail.example.com:9443',
|
||||
// 界面默认值
|
||||
pageSize: 50,
|
||||
folders: { inbox: 'INBOX', sent: 'Sent', drafts: 'Drafts', archive: 'Archive', trash: 'Trash', junk: 'Junk' },
|
||||
};
|
||||
|
||||
function defaultDir() {
|
||||
if (process.env.WPYWMAIL_CONFIG_DIR) return process.env.WPYWMAIL_CONFIG_DIR;
|
||||
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
return path.join(base, 'WpywMailClient');
|
||||
}
|
||||
|
||||
function accountFile() {
|
||||
if (process.env.WPYWMAIL_ACCOUNT_FILE) return process.env.WPYWMAIL_ACCOUNT_FILE;
|
||||
return path.join(defaultDir(), 'account.json');
|
||||
}
|
||||
|
||||
function loadAccount() {
|
||||
const file = accountFile();
|
||||
let data = {};
|
||||
try {
|
||||
if (fs.existsSync(file)) data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`账户配置读取失败(${file}):${err.message}`);
|
||||
}
|
||||
return Object.assign({}, DEFAULTS, data, { _file: file });
|
||||
}
|
||||
|
||||
function saveAccount(account) {
|
||||
const file = accountFile();
|
||||
const clean = Object.assign({}, account);
|
||||
delete clean._file;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(clean, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 供 UI 与自测复用:把配置变成两个客户端 */
|
||||
function connectionOptions(account) {
|
||||
return {
|
||||
imap: {
|
||||
host: account.host,
|
||||
port: account.imapPort,
|
||||
user: account.user,
|
||||
password: account.password,
|
||||
implicitTls: !!account.implicitTls,
|
||||
startTls: !!account.startTls,
|
||||
},
|
||||
smtp: {
|
||||
host: account.host,
|
||||
port: account.smtpPort,
|
||||
user: account.user,
|
||||
password: account.password,
|
||||
startTls: !!account.startTls,
|
||||
heloName: account.heloName || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { DEFAULTS, accountFile, defaultDir, loadAccount, saveAccount, connectionOptions };
|
||||
+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,
|
||||
};
|
||||
+730
@@ -0,0 +1,730 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WpywMail 客户端本地服务。
|
||||
*
|
||||
* 职责:① 保持一条到 mail.example.com 的 IMAP 会话;② 把 IMAP 能力包成 JSON API;
|
||||
* ③ 托管界面静态文件。界面在浏览器里跑,因此不需要任何原生壳。
|
||||
*
|
||||
* 设计要点:
|
||||
* - **一条连接、串行化**:IMAP 是单连接状态机,多个请求并发会互相踩。
|
||||
* 所有 IMAP 操作都过 _queue(),保证同一时刻只有一条命令在飞。
|
||||
* - **按需重连**:连接掉了自动重建一次,失败则把错误抛给界面(界面显示离线态)。
|
||||
* - 只监听 127.0.0.1,绝不对外暴露。
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const { ImapClient, ImapError } = require('./imap');
|
||||
const { SmtpClient } = require('./smtp');
|
||||
const mime = require('./mime');
|
||||
const { loadAccount, saveAccount, connectionOptions } = require('./account');
|
||||
const { createAccountApi, DEFAULT_API_BASE } = require('./account-api');
|
||||
const { sanitizeHtml, buildDocument } = require('./sanitize');
|
||||
|
||||
const HOST = process.env.WPYWMAIL_HOST || '127.0.0.1';
|
||||
const PORT = Number(process.env.WPYWMAIL_PORT || 8788);
|
||||
const WEB_DIR = path.join(__dirname, '..', 'web');
|
||||
// 新界面(shadcn/ui + Vite)的构建产物;存在就优先发它,否则回退到旧的纯手写界面
|
||||
const UI_DIST = path.join(__dirname, '..', 'ui', 'dist');
|
||||
const SITE_DIR = fs.existsSync(path.join(UI_DIST, 'index.html')) ? UI_DIST : WEB_DIR;
|
||||
|
||||
// ─────────────────────────────────────────────────────────── IMAP 会话
|
||||
|
||||
class Session {
|
||||
constructor(account) {
|
||||
this.account = account;
|
||||
this.options = connectionOptions(account);
|
||||
this.imap = null;
|
||||
this.connecting = null;
|
||||
this.tail = Promise.resolve(); // 串行化队列
|
||||
this.cache = { folders: null, foldersAt: 0, summaries: new Map() };
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
/** 把任意异步操作串到队列尾部,保证 IMAP 上一时刻只有一条命令 */
|
||||
_queue(fn) {
|
||||
const run = this.tail.then(fn, fn);
|
||||
// 队列本身不因单次失败而中断
|
||||
this.tail = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
async connect({ force = false } = {}) {
|
||||
if (this.imap && this.imap.connected && !force) return this.imap;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = (async () => {
|
||||
const c = new ImapClient(this.options.imap);
|
||||
try {
|
||||
await c.connect();
|
||||
await c.capability();
|
||||
await c.login();
|
||||
c.on('error', (err) => { this.lastError = err.message; });
|
||||
this.imap = c;
|
||||
this.lastError = null;
|
||||
this.cache.folders = null;
|
||||
return c;
|
||||
} catch (err) {
|
||||
try { c._destroy(); } catch { /* 忽略 */ }
|
||||
this.imap = null;
|
||||
this.lastError = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
this.connecting = null;
|
||||
}
|
||||
})();
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
/** 带一次自动重连的操作包装 */
|
||||
async withImap(fn) {
|
||||
return this._queue(async () => {
|
||||
let client = await this.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
// 连接类错误 → 重连一次再试;业务类错误(NO/BAD)直接抛
|
||||
const retriable = err instanceof ImapError
|
||||
? !/被拒绝/.test(err.message)
|
||||
: true;
|
||||
if (!retriable) throw err;
|
||||
client = await this.connect({ force: true });
|
||||
return fn(client);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async folders({ refresh = false, maxAgeMs = 15000 } = {}) {
|
||||
const now = Date.now();
|
||||
if (!refresh && this.cache.folders && now - this.cache.foldersAt < maxAgeMs) {
|
||||
return this.cache.folders;
|
||||
}
|
||||
return this.withImap(async (client) => {
|
||||
const boxes = await client.list();
|
||||
const out = [];
|
||||
for (const b of boxes) {
|
||||
const st = await client.status(b.name).catch(() => ({}));
|
||||
out.push({
|
||||
name: b.name,
|
||||
flags: b.flags,
|
||||
messages: st.MESSAGES || 0,
|
||||
unseen: st.UNSEEN || 0,
|
||||
recent: st.RECENT || 0,
|
||||
});
|
||||
}
|
||||
// 常见顺序:收件箱最前,其次草稿/已发送,最后是归档/垃圾/废纸篓
|
||||
const order = ['INBOX', 'Drafts', 'Sent', 'Archive', 'Junk', 'Trash'];
|
||||
out.sort((a, b) => {
|
||||
const ia = order.indexOf(a.name.toUpperCase());
|
||||
const ib = order.indexOf(b.name.toUpperCase());
|
||||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
|
||||
});
|
||||
this.cache.folders = out;
|
||||
this.cache.foldersAt = Date.now();
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
async messageList({ folder, limit = 50, offset = 0, query = '' }) {
|
||||
return this.withImap(async (client) => {
|
||||
await client.select(folder, { readonly: false });
|
||||
const allUids = await client.searchUid(['ALL']);
|
||||
|
||||
// ── 搜索
|
||||
// 为什么不用服务器端 SEARCH:WpywMail 的 SEARCH 只实现了 SEEN/UNSEEN/FLAGGED/
|
||||
// UNFLAGGED/DELETED/FROM/SUBJECT/TEXT/UID,**没有 BODY、没有 OR**(未知条件被静默忽略),
|
||||
// 而且它的命令行是按 Encoding.ASCII 解码的 —— 中文检索词到了服务端会变成 "??",
|
||||
// 永远匹配不到。所以这里改为:取回摘要(已解码 RFC 2047)后在本地过滤。
|
||||
// 代价是搜索窗口有上限(下面 500 封);个人邮箱足够,且永远正确。
|
||||
if (query && query.trim()) {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const window = allUids.slice().sort((a, b) => b - a).slice(0, 500);
|
||||
const summaries = window.length ? await client.fetchSummaries(window) : [];
|
||||
const hit = summaries.filter((s) => {
|
||||
const env = s.envelope || {};
|
||||
const hay = [
|
||||
env.subject || '',
|
||||
...(env.from || []).map((a) => `${a.name} ${a.address}`),
|
||||
...(env.to || []).map((a) => `${a.name} ${a.address}`),
|
||||
...(env.cc || []).map((a) => `${a.name} ${a.address}`),
|
||||
].join('\n').toLowerCase();
|
||||
if (hay.includes(needle)) return true;
|
||||
// 支持直接按 UID 找
|
||||
return /^\d+$/.test(needle) && String(s.uid) === needle;
|
||||
});
|
||||
const total = hit.length;
|
||||
return {
|
||||
folder, total, offset, limit, query: query.trim(),
|
||||
messages: hit.slice(offset, offset + limit).map(mapSummary),
|
||||
};
|
||||
}
|
||||
|
||||
const total = allUids.length;
|
||||
const newestFirst = allUids.slice().sort((a, b) => b - a);
|
||||
const page = newestFirst.slice(offset, offset + limit);
|
||||
const summaries = page.length ? await client.fetchSummaries(page) : [];
|
||||
const byUid = new Map(summaries.map((s) => [s.uid, s]));
|
||||
const ordered = page.map((u) => byUid.get(u)).filter(Boolean);
|
||||
return { folder, total, offset, limit, messages: ordered.map(mapSummary) };
|
||||
});
|
||||
}
|
||||
|
||||
async message(uid, folder) {
|
||||
return this.withImap(async (client) => {
|
||||
await client.select(folder, { readonly: false });
|
||||
const raw = await client.fetchRaw(uid, { peek: true });
|
||||
if (!raw) throw new Error(`找不到 UID ${uid}`);
|
||||
const parsed = mime.parseMessage(raw.raw);
|
||||
return {
|
||||
uid,
|
||||
folder,
|
||||
subject: parsed.subject,
|
||||
from: parsed.from,
|
||||
to: parsed.to,
|
||||
cc: parsed.cc,
|
||||
replyTo: parsed.replyTo,
|
||||
date: parsed.date,
|
||||
messageId: parsed.messageId,
|
||||
inReplyTo: parsed.inReplyTo,
|
||||
references: parsed.references,
|
||||
text: parsed.text,
|
||||
html: parsed.html,
|
||||
size: parsed.size,
|
||||
seen: raw.flags.some((f) => /\\Seen/i.test(f)),
|
||||
flagged: raw.flags.some((f) => /\\Flagged/i.test(f)),
|
||||
flags: raw.flags,
|
||||
attachments: parsed.attachments.map((a, i) => ({
|
||||
index: i,
|
||||
filename: a.filename,
|
||||
contentType: a.contentType,
|
||||
size: a.size,
|
||||
inline: a.inline,
|
||||
contentId: a.contentId,
|
||||
})),
|
||||
_attachments: parsed.attachments, // 仅服务端内部使用,不外发
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async attachment(uid, index, folder) {
|
||||
const msg = await this.message(uid, folder);
|
||||
const att = msg._attachments[index];
|
||||
if (!att) throw new Error('附件不存在');
|
||||
return att;
|
||||
}
|
||||
|
||||
async setFlags(uid, folder, { seen, flagged, answered }) {
|
||||
return this.withImap(async (client) => {
|
||||
await client.select(folder, { readonly: false });
|
||||
const ops = [];
|
||||
if (typeof seen === 'boolean') ops.push([seen ? '+FLAGS' : '-FLAGS', ['\\Seen']]);
|
||||
if (typeof flagged === 'boolean') ops.push([flagged ? '+FLAGS' : '-FLAGS', ['\\Flagged']]);
|
||||
if (typeof answered === 'boolean') ops.push([answered ? '+FLAGS' : '-FLAGS', ['\\Answered']]);
|
||||
let last = null;
|
||||
for (const [mode, flags] of ops) last = await client.storeFlags(uid, mode, flags);
|
||||
this.cache.folders = null;
|
||||
return last;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMessage(uid, folder, { permanent = false } = {}) {
|
||||
const trash = this.account.folders.trash || 'Trash';
|
||||
return this.withImap(async (client) => {
|
||||
await client.select(folder, { readonly: false });
|
||||
const isTrash = folder.toUpperCase() === String(trash).toUpperCase();
|
||||
if (!permanent && !isTrash) {
|
||||
try {
|
||||
await client.copy(uid, trash);
|
||||
} catch (err) {
|
||||
// 垃圾箱不存在就直接原地删除
|
||||
if (!/不存在|NONEXISTENT|NO/i.test(err.message)) throw err;
|
||||
}
|
||||
}
|
||||
await client.deleteUid(uid, { expunge: true });
|
||||
this.cache.folders = null;
|
||||
return { moved: !permanent && !isTrash, folder: isTrash || permanent ? null : trash };
|
||||
});
|
||||
}
|
||||
|
||||
async moveMessage(uid, from, to) {
|
||||
return this.withImap(async (client) => {
|
||||
await client.select(from, { readonly: false });
|
||||
await client.copy(uid, to);
|
||||
await client.deleteUid(uid, { expunge: true });
|
||||
this.cache.folders = null;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async saveDraft({ to, subject, text, html }) {
|
||||
const drafts = this.account.folders.drafts || 'Drafts';
|
||||
const raw = mime.buildMessage({
|
||||
from: { name: this.account.displayName || '', address: this.account.user },
|
||||
to: to ? [{ name: '', address: to }] : [],
|
||||
subject, text, html, domain: this.account.domain,
|
||||
});
|
||||
return this.withImap(async (client) => {
|
||||
await client.append(drafts, raw, { flags: ['\\Draft'] });
|
||||
this.cache.folders = null;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async send({ to, cc, bcc, subject, text, html, inReplyTo, references, attachments }) {
|
||||
const list = (v) => String(v || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
||||
const toList = list(to);
|
||||
if (!toList.length) throw new Error('至少需要一个收件人');
|
||||
const ccList = list(cc);
|
||||
const bccList = list(bcc);
|
||||
|
||||
const raw = mime.buildMessage({
|
||||
from: { name: this.account.displayName || '', address: this.account.user },
|
||||
to: toList.map((a) => ({ name: '', address: a })),
|
||||
cc: ccList.map((a) => ({ name: '', address: a })),
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo: inReplyTo || undefined,
|
||||
references: references || undefined,
|
||||
domain: this.account.domain,
|
||||
attachments: (attachments || []).map((a) => ({
|
||||
filename: a.filename,
|
||||
contentType: a.contentType || 'application/octet-stream',
|
||||
content: Buffer.from(a.base64 || '', 'base64'),
|
||||
})),
|
||||
});
|
||||
|
||||
const smtp = new SmtpClient(this.options.smtp);
|
||||
// 家用/运营商 DNS 偶尔会瞬时解析失败(EAI_AGAIN);发信是用户主动动作,
|
||||
// 这种瞬时错误重试一次比直接报错体验好得多。
|
||||
const transient = (err) => /EAI_AGAIN|EAI_NODATA|EAI_FAIL|ETIMEDOUT|ECONNRESET|socket hang up/i.test(err && err.message || '');
|
||||
const attempt = async () => {
|
||||
await smtp.connect();
|
||||
await smtp.hello();
|
||||
await smtp.auth();
|
||||
await smtp.sendMail(this.account.user, [...toList, ...ccList, ...bccList], raw);
|
||||
};
|
||||
try {
|
||||
try {
|
||||
await attempt();
|
||||
} catch (err) {
|
||||
if (!transient(err)) throw err;
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
await attempt();
|
||||
}
|
||||
} finally {
|
||||
try { await smtp.quit(); } catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// 自发自收时服务器会投递到收件箱;同时把副本放到「已发送」
|
||||
const sent = this.account.folders.sent || 'Sent';
|
||||
try {
|
||||
await this.withImap(async (client) => {
|
||||
await client.append(sent, raw);
|
||||
});
|
||||
} catch { /* 存副本失败不影响发送结果 */ }
|
||||
this.cache.folders = null;
|
||||
return { ok: true, bytes: raw.length, recipients: [...toList, ...ccList, ...bccList] };
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.imap) {
|
||||
const c = this.imap;
|
||||
this.imap = null;
|
||||
try { await c.logout(); } catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function quoteStr(s) {
|
||||
return '"' + String(s).replace(/[\\"]/g, (m) => '\\' + m) + '"';
|
||||
}
|
||||
|
||||
/** IMAP 摘要 → 列表项(统一字段名,界面直接用) */
|
||||
function mapSummary(s) {
|
||||
return {
|
||||
uid: s.uid,
|
||||
subject: s.envelope ? s.envelope.subject : '',
|
||||
from: s.envelope ? s.envelope.from : [],
|
||||
to: s.envelope ? s.envelope.to : [],
|
||||
date: s.envelope ? s.envelope.date : null,
|
||||
internalDate: s.internalDate,
|
||||
size: s.size,
|
||||
seen: s.seen,
|
||||
flagged: s.flagged,
|
||||
answered: s.answered,
|
||||
draft: s.draft,
|
||||
flags: s.flags,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── HTTP
|
||||
|
||||
let session = null;
|
||||
|
||||
function json(res, code, body) {
|
||||
const data = Buffer.from(JSON.stringify(body), 'utf8');
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
function readBody(req, limit = 60 * 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (!buf.length) return resolve({});
|
||||
try { resolve(JSON.parse(buf.toString('utf8'))); } catch (e) { reject(new Error('请求体不是合法 JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
function serveStatic(req, res, urlPath) {
|
||||
// 浏览器会主动请求 /favicon.ico:直接回 204,避免日志和验收里出现无意义的 404
|
||||
if (urlPath === '/favicon.ico') { res.writeHead(204); res.end(); return; }
|
||||
const rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, '');
|
||||
const target = path.join(SITE_DIR, rel);
|
||||
// 目录穿越保护
|
||||
if (!target.startsWith(SITE_DIR)) { res.writeHead(403); res.end('forbidden'); return; }
|
||||
fs.stat(target, (err, st) => {
|
||||
if (err || !st.isFile()) {
|
||||
// SPA 回退:没有扩展名的路径一律回 index.html(前端路由)
|
||||
if (!path.extname(rel)) {
|
||||
const indexFile = path.join(SITE_DIR, 'index.html');
|
||||
fs.stat(indexFile, (e2, st2) => {
|
||||
if (e2 || !st2.isFile()) { res.writeHead(404); res.end('404'); return; }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': st2.size,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
fs.createReadStream(indexFile).pipe(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('404');
|
||||
return;
|
||||
}
|
||||
const type = MIME_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream';
|
||||
const isHtml = path.extname(target).toLowerCase() === '.html';
|
||||
res.writeHead(200, {
|
||||
'Content-Type': type,
|
||||
'Content-Length': st.size,
|
||||
'Cache-Control': isHtml ? 'no-store' : 'public, max-age=3600',
|
||||
});
|
||||
fs.createReadStream(target).pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const p = url.pathname;
|
||||
const q = url.searchParams;
|
||||
|
||||
// ── 状态
|
||||
if (p === '/api/state' && req.method === 'GET') {
|
||||
const acc = session ? session.account : loadAccount();
|
||||
let folders = null;
|
||||
let error = null;
|
||||
let connected = false;
|
||||
if (session) {
|
||||
try {
|
||||
folders = await session.folders();
|
||||
connected = true;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
return json(res, 200, {
|
||||
account: {
|
||||
host: acc.host, imapPort: acc.imapPort, smtpPort: acc.smtpPort,
|
||||
user: acc.user, displayName: acc.displayName, domain: acc.domain,
|
||||
hasPassword: !!acc.password, accountFile: acc._file || null,
|
||||
},
|
||||
connected, folders, error,
|
||||
capabilities: session && session.imap ? session.imap.capabilities : [],
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 登录 / 登出(客户端本地会话)
|
||||
if (p === '/api/login' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const acc = loadAccount();
|
||||
if (body.host) acc.host = body.host;
|
||||
if (body.imapPort) acc.imapPort = Number(body.imapPort);
|
||||
if (body.smtpPort) acc.smtpPort = Number(body.smtpPort);
|
||||
if (body.user) acc.user = body.user;
|
||||
if (body.password) acc.password = body.password;
|
||||
if (body.displayName != null) acc.displayName = body.displayName;
|
||||
if (body.save !== false) saveAccount(acc);
|
||||
if (session) await session.close();
|
||||
session = new Session(acc);
|
||||
await session.connect();
|
||||
const folders = await session.folders({ refresh: true });
|
||||
return json(res, 200, { ok: true, folders });
|
||||
}
|
||||
|
||||
if (p === '/api/logout' && req.method === 'POST') {
|
||||
if (session) await session.close();
|
||||
session = null;
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// ── 账号体系(无需登录:注册 / 邮箱验证码 / 找回密码)
|
||||
// 走服务器的公网账号入口,和 IMAP/SMTP 是两条独立通道。
|
||||
const accApi = (acc) => createAccountApi((acc && acc.apiBase) || DEFAULT_API_BASE);
|
||||
const accountRoute = async (res, fn) => {
|
||||
try {
|
||||
return json(res, 200, await fn());
|
||||
} catch (err) {
|
||||
const status = err && err.status ? err.status : 502;
|
||||
return json(res, status, {
|
||||
error: (err && err.message) || String(err),
|
||||
pendingVerification: !!(err && err.data && err.data.pendingVerification),
|
||||
retryAfterSeconds: (err && err.data && err.data.retryAfterSeconds) || null,
|
||||
});
|
||||
}
|
||||
};
|
||||
const currentAccount = () => (session ? session.account : loadAccount());
|
||||
|
||||
if (p === '/api/account/policy' && req.method === 'GET') {
|
||||
return accountRoute(res, async () => {
|
||||
const api = accApi(currentAccount());
|
||||
const policy = await api.policy();
|
||||
return { policy, apiBase: api.apiBase };
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/account/register' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(currentAccount()).register({
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
displayName: body.displayName || '',
|
||||
inviteCode: body.inviteCode || '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (p === '/api/account/verify' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(currentAccount()).verifyRegistration({ email: body.email, code: body.code }));
|
||||
}
|
||||
|
||||
if (p === '/api/account/resend' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(currentAccount()).resendCode({ email: body.email, purpose: body.purpose || 'register' }));
|
||||
}
|
||||
|
||||
if (p === '/api/account/forgot' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(currentAccount()).forgot(body.email));
|
||||
}
|
||||
|
||||
if (p === '/api/account/reset' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(currentAccount()).reset({ email: body.email, code: body.code, password: body.password }));
|
||||
}
|
||||
|
||||
if (!session) return json(res, 409, { error: '尚未登录' });
|
||||
|
||||
// ── 账号体系(需登录:资料 / 会话 / 改密码 / 审计)
|
||||
// 客户端自己只持 IMAP 凭据,这里按需换一个账号接口 token(换不到就只报错,不影响收发信)。
|
||||
async function accountToken() {
|
||||
if (session.apiToken) return session.apiToken;
|
||||
const acc = session.account;
|
||||
if (!acc || !acc.user || !acc.password) throw new Error('本机没有保存密码,无法管理账号设置');
|
||||
const r = await accApi(acc).login(acc.user, acc.password);
|
||||
session.apiToken = (r && r.token) || null;
|
||||
if (!session.apiToken) throw new Error('账号接口没有返回登录令牌');
|
||||
return session.apiToken;
|
||||
}
|
||||
|
||||
if (p === '/api/account/overview' && req.method === 'GET') {
|
||||
return accountRoute(res, async () => {
|
||||
const api = accApi(session.account);
|
||||
const token = await accountToken();
|
||||
const [me, sessions, audit] = await Promise.all([api.profile(token), api.sessions(token), api.audit(token, 20)]);
|
||||
return { profile: me && me.user ? me.user : null, sessions: (sessions && sessions.sessions) || [], audit: (audit && audit.events) || [] };
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/account/profile' && req.method === 'PATCH') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => {
|
||||
const api = accApi(session.account);
|
||||
const r = await api.updateProfile(await accountToken(), body.displayName || '');
|
||||
// 本地也更新一份,界面/发件显示名立刻跟上
|
||||
session.account.displayName = r && r.user ? r.user.displayName : session.account.displayName;
|
||||
saveAccount(session.account);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/account/password' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => {
|
||||
const api = accApi(session.account);
|
||||
const r = await api.changePassword(await accountToken(), { currentPassword: body.currentPassword, password: body.password });
|
||||
// 改完密码要把本机保存的密码一起换掉,否则下次登录还用旧密码
|
||||
if (body.password) {
|
||||
session.account.password = body.password;
|
||||
saveAccount(session.account);
|
||||
}
|
||||
session.apiToken = null; // 旧 token 已被服务器保留(当前会话),但重新换一个更保险
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
if (p === '/api/account/sessions/revoke' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return accountRoute(res, async () => accApi(session.account).revokeSessions(await accountToken(), body || {}));
|
||||
}
|
||||
|
||||
// ── 文件夹
|
||||
if (p === '/api/folders' && req.method === 'GET') {
|
||||
const folders = await session.folders({ refresh: q.get('refresh') === '1' });
|
||||
return json(res, 200, { folders });
|
||||
}
|
||||
|
||||
// ── 邮件列表
|
||||
if (p === '/api/messages' && req.method === 'GET') {
|
||||
const folder = q.get('folder') || session.account.folders.inbox;
|
||||
const limit = Math.min(Number(q.get('limit') || session.account.pageSize || 50), 300);
|
||||
const offset = Math.max(Number(q.get('offset') || 0), 0);
|
||||
const query = q.get('q') || '';
|
||||
const out = await session.messageList({ folder, limit, offset, query });
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
// ── 单封详情 / 附件
|
||||
let m;
|
||||
if ((m = /^\/api\/messages\/(\d+)\/attachments\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||||
const att = await session.attachment(Number(m[1]), Number(m[2]), q.get('folder') || session.account.folders.inbox);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': att.contentType || 'application/octet-stream',
|
||||
'Content-Length': att.content.length,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(att.filename)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
return res.end(att.content);
|
||||
}
|
||||
|
||||
if ((m = /^\/api\/messages\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||||
const msg = await session.message(Number(m[1]), q.get('folder') || session.account.folders.inbox);
|
||||
delete msg._attachments;
|
||||
// HTML 正文是**别人写的代码**:这里做第一层净化(界面还会把它放进不带 allow-scripts 的沙箱 iframe)
|
||||
if (msg.html) {
|
||||
const clean = sanitizeHtml(msg.html, { allowRemoteImages: q.get('images') === '1' });
|
||||
msg.html = clean.html;
|
||||
msg.htmlDocument = buildDocument(clean.html, { theme: q.get('theme') === 'dark' ? 'dark' : 'light' });
|
||||
msg.blockedImages = clean.blockedImages;
|
||||
msg.sanitized = clean.removed;
|
||||
}
|
||||
return json(res, 200, msg);
|
||||
}
|
||||
|
||||
// ── 旗标
|
||||
if (p === '/api/flags' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
await session.setFlags(Number(body.uid), body.folder || session.account.folders.inbox, body);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// ── 删除 / 移动
|
||||
if (p === '/api/delete' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const out = await session.deleteMessage(Number(body.uid), body.folder || session.account.folders.inbox,
|
||||
{ permanent: !!body.permanent });
|
||||
return json(res, 200, Object.assign({ ok: true }, out));
|
||||
}
|
||||
|
||||
if (p === '/api/move' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
await session.moveMessage(Number(body.uid), body.folder || session.account.folders.inbox, body.target);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// ── 发送 / 存草稿
|
||||
if (p === '/api/send' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const out = await session.send(body);
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
if (p === '/api/drafts' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
await session.saveDraft(body);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
return json(res, 404, { error: '未知的 API 路径:' + p });
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || HOST}`);
|
||||
if (!url.pathname.startsWith('/api/')) return serveStatic(req, res, url.pathname);
|
||||
handleApi(req, res, url).catch((err) => {
|
||||
const code = err && /超时/.test(err.message) ? 504 : 500;
|
||||
json(res, code, { error: err.message, name: err.name || 'Error' });
|
||||
});
|
||||
});
|
||||
|
||||
async function main() {
|
||||
// 启动即有账户配置就自动连一次(失败不阻止启动,界面会显示离线态与错误)
|
||||
try {
|
||||
const acc = loadAccount();
|
||||
if (acc.user && acc.password) {
|
||||
session = new Session(acc);
|
||||
await session.connect();
|
||||
const folders = await session.folders({ refresh: true });
|
||||
process.stdout.write(`[ok] 已连接 ${acc.host} 账号 ${acc.user},文件夹 ${folders.length} 个\n`);
|
||||
} else {
|
||||
process.stdout.write('[warn] 账户未配置(缺账号或密码),界面会要求登录\n');
|
||||
}
|
||||
} catch (err) {
|
||||
session = session || new Session(loadAccount());
|
||||
process.stdout.write(`[warn] 启动自动连接失败:${err.message}\n`);
|
||||
}
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
process.stdout.write(`WpywMail 客户端已启动:http://${HOST}:${PORT}/\n`);
|
||||
process.stdout.write('(关闭此窗口即退出客户端)\n');
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write('启动失败:' + (err.stack || err) + '\n');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { server, Session };
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* MIME 解析与组装(RFC 5322 / 2047 / 2231 / 2045-2049)。
|
||||
*
|
||||
* 这一层决定了「中文对不对」。生产环境的教训:
|
||||
* - 头部必须能解 RFC 2047 编码字(否则主题显示成 =?utf-8?B?...?=)
|
||||
* - 头部若被写成裸 UTF-8,必须按 UTF-8 优先解(按 Latin-1 解会得到 [æµè¯])
|
||||
* - 正文要按声明的 charset 解,且要能回退到 GBK/Big5(老邮件)
|
||||
* - 组装时要主动用 RFC 2047 编码非 ASCII 头部,不能把裸中文写进 Subject
|
||||
*/
|
||||
|
||||
const { TextDecoder } = require('node:util');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 字符集
|
||||
|
||||
const DECODERS = new Map();
|
||||
function decoderFor(charset) {
|
||||
const key = String(charset || 'utf-8').trim().toLowerCase().replace(/^["']|["']$/g, '');
|
||||
if (DECODERS.has(key)) return DECODERS.get(key);
|
||||
const aliases = {
|
||||
'utf8': 'utf-8',
|
||||
'utf-8': 'utf-8',
|
||||
'gb2312': 'gbk',
|
||||
'gb18030': 'gbk',
|
||||
'gbk': 'gbk',
|
||||
'big5': 'big5',
|
||||
'big5-hkscs': 'big5',
|
||||
'shift_jis': 'shift_jis',
|
||||
'shift-jis': 'shift_jis',
|
||||
'sjis': 'shift_jis',
|
||||
'euc-kr': 'euc-kr',
|
||||
'ks_c_5601-1987': 'euc-kr',
|
||||
'latin1': 'windows-1252',
|
||||
'iso-8859-1': 'windows-1252',
|
||||
'us-ascii': 'utf-8',
|
||||
'ascii': 'utf-8',
|
||||
'windows-1252': 'windows-1252',
|
||||
};
|
||||
const label = aliases[key] || key;
|
||||
let dec = null;
|
||||
try {
|
||||
dec = new TextDecoder(label, { fatal: false });
|
||||
} catch {
|
||||
dec = null;
|
||||
}
|
||||
DECODERS.set(key, dec);
|
||||
return dec;
|
||||
}
|
||||
|
||||
/** 按 charset 解码字节;失败则依次尝试 utf-8 / gbk */
|
||||
function decodeCharset(buffer, charset) {
|
||||
if (!buffer || !buffer.length) return '';
|
||||
const dec = decoderFor(charset);
|
||||
if (dec) {
|
||||
try {
|
||||
return dec.decode(buffer);
|
||||
} catch { /* 落到下面的回退 */ }
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(buffer);
|
||||
} catch {
|
||||
return buffer.toString('latin1');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 传输编码
|
||||
|
||||
/** quoted-printable → 原始字节(含软换行合并) */
|
||||
function decodeQuotedPrintable(buffer) {
|
||||
const text = buffer.toString('latin1');
|
||||
const out = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '=') {
|
||||
const n1 = text[i + 1];
|
||||
const n2 = text[i + 2];
|
||||
if (n1 === '\r' && n2 === '\n') { i += 2; continue; }
|
||||
if (n1 === '\n') { i += 1; continue; }
|
||||
if (/[0-9A-Fa-f]{2}/.test((n1 || '') + (n2 || ''))) {
|
||||
out.push(parseInt(n1 + n2, 16));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c.charCodeAt(0) & 0xff);
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function decodeTransfer(buffer, encoding) {
|
||||
const enc = String(encoding || '').trim().toLowerCase();
|
||||
if (enc === 'base64') {
|
||||
const clean = buffer.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
try { return Buffer.from(clean, 'base64'); } catch { return buffer; }
|
||||
}
|
||||
if (enc === 'quoted-printable') return decodeQuotedPrintable(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── RFC 2047
|
||||
|
||||
function decodeOneWord(charset, encoding, text) {
|
||||
let bytes;
|
||||
if (encoding.toUpperCase() === 'B') {
|
||||
try { bytes = Buffer.from(text.replace(/\s+/g, ''), 'base64'); } catch { return text; }
|
||||
} else {
|
||||
// Q:下划线代表空格,=XX 代表字节
|
||||
const bytesArr = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '_') { bytesArr.push(0x20); continue; }
|
||||
if (c === '=' && /[0-9A-Fa-f]{2}/.test(text.substr(i + 1, 2))) {
|
||||
bytesArr.push(parseInt(text.substr(i + 1, 2), 16));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
bytesArr.push(c.charCodeAt(0) & 0xff);
|
||||
}
|
||||
bytes = Buffer.from(bytesArr);
|
||||
}
|
||||
return decodeCharset(bytes, charset);
|
||||
}
|
||||
|
||||
/** 解码头部里的 RFC 2047 编码字;相邻编码字之间的空白会被丢弃(RFC 2047 规定) */
|
||||
function decodeWords(input) {
|
||||
const s = String(input == null ? '' : input);
|
||||
if (!s.includes('=?')) return s;
|
||||
const re = /=\?([^?\s]+)\?([BbQq])\?([^?]*)\?=/g;
|
||||
let out = '';
|
||||
let last = 0;
|
||||
let m;
|
||||
let prevWasWord = false;
|
||||
while ((m = re.exec(s)) !== null) {
|
||||
let between = s.slice(last, m.index);
|
||||
if (prevWasWord && /^\s*$/.test(between)) between = '';
|
||||
out += between + decodeOneWord(m[1], m[2], m[3]);
|
||||
prevWasWord = true;
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
out += s.slice(last);
|
||||
return out.replace(/[\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** 把可能是裸 UTF-8 的头部值还原成可读文本 */
|
||||
function smartDecodeHeader(rawValue) {
|
||||
const s = String(rawValue == null ? '' : rawValue);
|
||||
if (s.includes('=?')) return decodeWords(s);
|
||||
// 全是 ASCII 直接返回
|
||||
if (!/[\u0080-\u00ff]/.test(s)) return s.trim();
|
||||
// 含高位字节:优先按 UTF-8 解,失败再按 Latin-1
|
||||
const bytes = Buffer.from(s, 'latin1');
|
||||
const utf8 = decodeCharset(bytes, 'utf-8');
|
||||
if (!utf8.includes('\ufffd')) return utf8.trim();
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** RFC 2231(filename*=UTF-8''...)与普通 filename 参数 */
|
||||
function decodeParameter(value) {
|
||||
if (value == null) return '';
|
||||
const v = String(value).trim();
|
||||
const m = /^([^']*)'([^']*)'(.*)$/s.exec(v);
|
||||
if (m) {
|
||||
const charset = m[1] || 'utf-8';
|
||||
const encoded = m[3];
|
||||
// 关键:直接把 %XX 还原成**字节**,不要先 decodeURIComponent 成字符串。
|
||||
// 先转字符串再用 Buffer.from(str,'binary'/'latin1') 会把多字节字符按单字节截断,
|
||||
// 中文文件名会变成一串 U+FFFD(这是在真机上踩到的坑)。
|
||||
const bytes = [];
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
const ch = encoded[i];
|
||||
if (ch === '%' && /^[0-9A-Fa-f]{2}$/.test(encoded.substr(i + 1, 2))) {
|
||||
bytes.push(parseInt(encoded.substr(i + 1, 2), 16));
|
||||
i += 2;
|
||||
} else {
|
||||
bytes.push(ch.charCodeAt(0) & 0xff);
|
||||
}
|
||||
}
|
||||
return decodeCharset(Buffer.from(bytes), charset);
|
||||
}
|
||||
return smartDecodeHeader(v.replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 头部
|
||||
|
||||
/** 拆出头部与正文(按字节,保持忠实) */
|
||||
function splitHeadBody(buffer) {
|
||||
const idx = buffer.indexOf('\r\n\r\n');
|
||||
if (idx >= 0) return { head: buffer.subarray(0, idx), body: buffer.subarray(idx + 4) };
|
||||
const idx2 = buffer.indexOf('\n\n');
|
||||
if (idx2 >= 0) return { head: buffer.subarray(0, idx2), body: buffer.subarray(idx2 + 2) };
|
||||
return { head: buffer, body: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
/** 解析头部为 [{name, value}](折行拼接) */
|
||||
function parseHeaders(headBuffer) {
|
||||
const text = headBuffer.toString('latin1');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const headers = [];
|
||||
let cur = null;
|
||||
for (const line of lines) {
|
||||
if (/^[ \t]/.test(line) && cur) {
|
||||
cur.value += CRLF + line;
|
||||
continue;
|
||||
}
|
||||
const i = line.indexOf(':');
|
||||
if (i <= 0) { cur = null; continue; }
|
||||
cur = { name: line.slice(0, i).trim(), value: line.slice(i + 1) };
|
||||
headers.push(cur);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Content-Type 解析:mime 类型 + 参数表 */
|
||||
function parseContentType(value) {
|
||||
const s = String(value || 'text/plain');
|
||||
const semi = s.indexOf(';');
|
||||
const mime = (semi < 0 ? s : s.slice(0, semi)).trim().toLowerCase() || 'text/plain';
|
||||
const params = {};
|
||||
if (semi >= 0) {
|
||||
const rest = s.slice(semi + 1);
|
||||
// 参数可能带引号并含分号,用状态机切分
|
||||
const chunks = [];
|
||||
let buf = '';
|
||||
let inQuote = false;
|
||||
for (const ch of rest) {
|
||||
if (ch === '"') { inQuote = !inQuote; buf += ch; continue; }
|
||||
if (ch === ';' && !inQuote) { chunks.push(buf); buf = ''; continue; }
|
||||
buf += ch;
|
||||
}
|
||||
if (buf) chunks.push(buf);
|
||||
for (const chunk of chunks) {
|
||||
const eq = chunk.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = chunk.slice(0, eq).trim().toLowerCase();
|
||||
const v = chunk.slice(eq + 1).trim();
|
||||
params[k] = v;
|
||||
}
|
||||
}
|
||||
// RFC 2231 的分段参数:filename*0*=utf-8''%E9%AA%8C; filename*1*=... → 合并成 filename*
|
||||
const keys = Object.keys(params);
|
||||
const groups = new Map();
|
||||
for (const k of keys) {
|
||||
const m = /^([a-z0-9_-]+)\*(\d+)(\*?)$/.exec(k);
|
||||
if (!m) continue;
|
||||
if (!groups.has(m[1])) groups.set(m[1], []);
|
||||
groups.get(m[1]).push({ idx: Number(m[2]), encoded: m[3] === '*', key: k });
|
||||
}
|
||||
for (const [base, parts] of groups) {
|
||||
if (parts.length < 2 && !String(parts[0].key).endsWith('*')) continue;
|
||||
parts.sort((a, b) => a.idx - b.idx);
|
||||
let joined = '';
|
||||
let allEncoded = true;
|
||||
for (const p of parts) {
|
||||
const raw = params[p.key].replace(/^"|"$/g, '');
|
||||
joined += raw;
|
||||
if (!p.encoded) allEncoded = false;
|
||||
}
|
||||
params[base + (allEncoded ? '*' : '')] = joined;
|
||||
for (const p of parts) delete params[p.key];
|
||||
}
|
||||
return { mime, params };
|
||||
}
|
||||
|
||||
/** 从 Content-Disposition 里取文件名 */
|
||||
function filenameFrom(headers) {
|
||||
const disp = getHeader(headers, 'content-disposition');
|
||||
if (disp) {
|
||||
const { params } = parseContentType(disp);
|
||||
if (params['filename*']) return decodeParameter(params['filename*']);
|
||||
if (params.filename) return decodeParameter(params.filename);
|
||||
}
|
||||
const ct = parseContentType(getHeader(headers, 'content-type'));
|
||||
if (ct.params['name*']) return decodeParameter(ct.params['name*']);
|
||||
if (ct.params.name) return decodeParameter(ct.params.name);
|
||||
return '';
|
||||
}
|
||||
|
||||
function getHeader(headers, name) {
|
||||
const lower = name.toLowerCase();
|
||||
const hit = headers.find((h) => h.name.toLowerCase() === lower);
|
||||
return hit ? hit.value : null;
|
||||
}
|
||||
|
||||
/** 地址列表解析:`"张三" <[email protected]>, [email protected]` */
|
||||
function parseAddressList(rawValue) {
|
||||
const s = smartDecodeHeader(rawValue || '');
|
||||
if (!s.trim()) return [];
|
||||
const parts = [];
|
||||
let buf = '';
|
||||
let inQuote = false;
|
||||
let inAngle = false;
|
||||
for (const ch of s) {
|
||||
if (ch === '"') inQuote = !inQuote;
|
||||
if (ch === '<' && !inQuote) inAngle = true;
|
||||
if (ch === '>' && !inQuote) inAngle = false;
|
||||
if (ch === ',' && !inQuote && !inAngle) { parts.push(buf); buf = ''; continue; }
|
||||
buf += ch;
|
||||
}
|
||||
if (buf.trim()) parts.push(buf);
|
||||
return parts.map((p) => {
|
||||
const t = p.trim();
|
||||
const m = /^(.*?)<([^>]+)>\s*$/.exec(t);
|
||||
if (m) {
|
||||
const name = m[1].trim().replace(/^"|"$/g, '').trim();
|
||||
return { name, address: m[2].trim() };
|
||||
}
|
||||
return { name: '', address: t.replace(/^"|"$/g, '') };
|
||||
}).filter((a) => a.address);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 正文解析
|
||||
|
||||
/** 粗略地从 HTML 提取纯文本(没有 text/plain 部分时的回退) */
|
||||
function htmlToText(html) {
|
||||
return String(html)
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/</gi, '<').replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"').replace(/'/gi, "'")
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归解析一个 MIME 实体。
|
||||
* @returns {{headers, mime, params, text, html, attachments, children}}
|
||||
*/
|
||||
function parseEntity(headBuffer, bodyBuffer, depth = 0) {
|
||||
const headers = parseHeaders(headBuffer);
|
||||
const ct = parseContentType(getHeader(headers, 'content-type'));
|
||||
const transfer = (getHeader(headers, 'content-transfer-encoding') || '').trim();
|
||||
const node = {
|
||||
headers, mime: ct.mime, params: ct.params,
|
||||
text: null, html: null, attachments: [], children: [],
|
||||
};
|
||||
|
||||
if (ct.mime.startsWith('multipart/') && (ct.params.boundary || ct.params.boundary === '')) {
|
||||
const boundary = String(ct.params.boundary || '').replace(/^"|"$/g, '');
|
||||
const parts = splitMultipart(bodyBuffer, boundary);
|
||||
for (const p of parts) {
|
||||
if (depth > 8) break;
|
||||
const child = parseEntity(p.head, p.body, depth + 1);
|
||||
node.children.push(child);
|
||||
if (child.text != null && node.text == null) node.text = child.text;
|
||||
if (child.html != null && node.html == null) node.html = child.html;
|
||||
node.attachments.push(...child.attachments);
|
||||
}
|
||||
// 有些坑爹邮件把文本塞在 multipart 的 preamble 里
|
||||
return node;
|
||||
}
|
||||
|
||||
const decoded = decodeTransfer(bodyBuffer, transfer);
|
||||
const charset = ct.params.charset;
|
||||
const disposition = (parseContentType(getHeader(headers, 'content-disposition')).mime || '');
|
||||
const filename = filenameFrom(headers);
|
||||
const isAttachment =
|
||||
disposition === 'attachment' ||
|
||||
(!!filename && !/^text\/(plain|html)$/i.test(ct.mime)) ||
|
||||
/^(image|application|audio|video)\//i.test(ct.mime) && !!filename;
|
||||
|
||||
if (!isAttachment && ct.mime === 'text/plain') {
|
||||
node.text = decodeCharset(decoded, charset).replace(/\r\n/g, '\n');
|
||||
return node;
|
||||
}
|
||||
if (!isAttachment && ct.mime === 'text/html') {
|
||||
node.html = decodeCharset(decoded, charset).replace(/\r\n/g, '\n');
|
||||
return node;
|
||||
}
|
||||
if (ct.mime === 'message/rfc822') {
|
||||
const inner = parseEntityFromBuffer(decoded, depth + 1);
|
||||
node.children.push(inner);
|
||||
if (inner.text != null && node.text == null) node.text = inner.text;
|
||||
if (inner.html != null && node.html == null) node.html = inner.html;
|
||||
node.attachments.push(...inner.attachments);
|
||||
return node;
|
||||
}
|
||||
|
||||
// 其余一律当附件(含无文件名的内联图)
|
||||
node.attachments.push({
|
||||
filename: filename || '(未命名)',
|
||||
contentType: ct.mime,
|
||||
contentId: (getHeader(headers, 'content-id') || '').replace(/^<|>$/g, '') || null,
|
||||
inline: disposition === 'inline',
|
||||
size: decoded.length,
|
||||
content: decoded,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function parseEntityFromBuffer(buffer, depth = 0) {
|
||||
const { head, body } = splitHeadBody(buffer);
|
||||
return parseEntity(head, body, depth);
|
||||
}
|
||||
|
||||
/** 按 boundary 切分 multipart 正文 */
|
||||
function splitMultipart(bodyBuffer, boundary) {
|
||||
const delim = Buffer.from('--' + boundary);
|
||||
const parts = [];
|
||||
let start = bodyBuffer.indexOf(delim);
|
||||
if (start < 0) return parts;
|
||||
start += delim.length;
|
||||
// 跳过紧随其后的 CRLF
|
||||
if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2;
|
||||
else if (bodyBuffer[start] === 0x0a) start += 1;
|
||||
|
||||
for (;;) {
|
||||
const next = bodyBuffer.indexOf(delim, start);
|
||||
if (next < 0) break;
|
||||
let end = next;
|
||||
// 去掉分隔符前的 CRLF
|
||||
if (end >= 2 && bodyBuffer[end - 2] === 0x0d && bodyBuffer[end - 1] === 0x0a) end -= 2;
|
||||
else if (end >= 1 && bodyBuffer[end - 1] === 0x0a) end -= 1;
|
||||
const chunk = bodyBuffer.subarray(start, end);
|
||||
const { head, body } = splitHeadBody(chunk);
|
||||
parts.push({ head, body });
|
||||
// 判断是否为结束分隔符 "--boundary--"
|
||||
const afterDelim = next + delim.length;
|
||||
if (bodyBuffer[afterDelim] === 0x2d && bodyBuffer[afterDelim + 1] === 0x2d) break;
|
||||
start = afterDelim;
|
||||
if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2;
|
||||
else if (bodyBuffer[start] === 0x0a) start += 1;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 顶级解析:给出客户端 UI 需要的全部字段 */
|
||||
function parseMessage(rawBuffer) {
|
||||
const buffer = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.from(rawBuffer);
|
||||
const root = parseEntityFromBuffer(buffer, 0);
|
||||
const headers = root.headers;
|
||||
const subject = smartDecodeHeader(getHeader(headers, 'subject') || '');
|
||||
const from = parseAddressList(getHeader(headers, 'from'));
|
||||
const to = parseAddressList(getHeader(headers, 'to'));
|
||||
const cc = parseAddressList(getHeader(headers, 'cc'));
|
||||
const replyTo = parseAddressList(getHeader(headers, 'reply-to'));
|
||||
const text = root.text != null ? root.text : (root.html ? htmlToText(root.html) : '');
|
||||
return {
|
||||
headers,
|
||||
headerMap: headers.reduce((m, h) => { m[h.name.toLowerCase()] = h.value; return m; }, {}),
|
||||
subject,
|
||||
from, to, cc, replyTo,
|
||||
date: getHeader(headers, 'date') ? smartDecodeHeader(getHeader(headers, 'date')) : null,
|
||||
messageId: (getHeader(headers, 'message-id') || '').trim() || null,
|
||||
inReplyTo: (getHeader(headers, 'in-reply-to') || '').trim() || null,
|
||||
references: (getHeader(headers, 'references') || '').trim() || null,
|
||||
text,
|
||||
html: root.html || null,
|
||||
attachments: root.attachments,
|
||||
size: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 组装(发信)
|
||||
|
||||
/** 非 ASCII 头部值 → RFC 2047 编码字(按 45 字节分块,保证整词 ≤75 字符) */
|
||||
function encodeHeaderValue(value) {
|
||||
const s = String(value == null ? '' : value).replace(/[\r\n]+/g, ' ');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x20-\x7e]*$/.test(s)) return s;
|
||||
const bytes = Buffer.from(s, 'utf8');
|
||||
const chunks = [];
|
||||
let cur = [];
|
||||
let curLen = 0;
|
||||
for (const ch of s) {
|
||||
const b = Buffer.byteLength(ch, 'utf8');
|
||||
if (curLen + b > 45) { chunks.push(Buffer.from(cur.join(''), 'utf8')); cur = []; curLen = 0; }
|
||||
cur.push(ch);
|
||||
curLen += b;
|
||||
}
|
||||
if (cur.length) chunks.push(Buffer.from(cur.join(''), 'utf8'));
|
||||
return chunks.map((b) => `=?UTF-8?B?${b.toString('base64')}?=`).join(CRLF + ' ');
|
||||
}
|
||||
|
||||
/** 组装地址头 */
|
||||
function formatAddress(addr) {
|
||||
const name = addr.name ? encodeHeaderValue(addr.name) : '';
|
||||
if (!name) return addr.address;
|
||||
const needsQuote = !/^[\x20-\x7e]*$/.test(name) || /[",;:<>@\\()\[\]]/.test(name);
|
||||
return `${needsQuote && !name.startsWith('=?') ? `"${name.replace(/"/g, '\\"')}"` : name} <${addr.address}>`;
|
||||
}
|
||||
|
||||
function wrap76(base64) {
|
||||
const lines = [];
|
||||
for (let i = 0; i < base64.length; i += 76) lines.push(base64.slice(i, i + 76));
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
|
||||
function generateMessageId(domain) {
|
||||
const rnd = crypto.randomBytes(12).toString('hex');
|
||||
return `<${Date.now()}.${rnd}@${domain || 'localhost'}>`;
|
||||
}
|
||||
|
||||
function rfc2822Date(d = new Date()) {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const off = -d.getTimezoneOffset();
|
||||
const sign = off >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(off);
|
||||
return `${days[d.getDay()]}, ${pad(d.getDate())} ${months[d.getMonth()]} ${d.getFullYear()} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ` +
|
||||
`${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一封可发送的 MIME 报文(返回 UTF-8 Buffer,CRLF 行尾)。
|
||||
* @param {object} msg {from:{name,address}, to:[], cc:[], subject, text, html,
|
||||
* attachments:[{filename,contentType,content}], inReplyTo,
|
||||
* references, domain, date}
|
||||
*/
|
||||
function buildMessage(msg) {
|
||||
const domain = msg.domain || (msg.from && msg.from.address ? String(msg.from.address).split('@')[1] : 'localhost');
|
||||
const headers = [];
|
||||
headers.push(['From', formatAddress(msg.from)]);
|
||||
if (msg.to && msg.to.length) headers.push(['To', msg.to.map(formatAddress).join(', ')]);
|
||||
if (msg.cc && msg.cc.length) headers.push(['Cc', msg.cc.map(formatAddress).join(', ')]);
|
||||
headers.push(['Subject', encodeHeaderValue(msg.subject || '')]);
|
||||
headers.push(['Date', msg.date || rfc2822Date()]);
|
||||
headers.push(['Message-ID', msg.messageId || generateMessageId(domain)]);
|
||||
if (msg.inReplyTo) headers.push(['In-Reply-To', msg.inReplyTo]);
|
||||
if (msg.references) headers.push(['References', msg.references]);
|
||||
headers.push(['MIME-Version', '1.0']);
|
||||
|
||||
const attachments = msg.attachments || [];
|
||||
const useHtml = !!msg.html;
|
||||
const textPart = (body, mime, charset = 'UTF-8') => {
|
||||
const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(body);
|
||||
const enc = isAscii ? '7bit' : 'base64';
|
||||
const payload = isAscii ? body.replace(/\r?\n/g, CRLF) : wrap76(Buffer.from(body, 'utf8').toString('base64'));
|
||||
return [
|
||||
`Content-Type: ${mime}; charset=${charset}`,
|
||||
`Content-Transfer-Encoding: ${enc}`,
|
||||
'',
|
||||
payload,
|
||||
].join(CRLF);
|
||||
};
|
||||
|
||||
let bodyText;
|
||||
if (attachments.length === 0 && !useHtml) {
|
||||
headers.push(['Content-Type', 'text/plain; charset=UTF-8']);
|
||||
const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(msg.text || '');
|
||||
headers.push(['Content-Transfer-Encoding', isAscii ? '7bit' : 'base64']);
|
||||
bodyText = isAscii
|
||||
? (msg.text || '').replace(/\r?\n/g, CRLF)
|
||||
: wrap76(Buffer.from(msg.text || '', 'utf8').toString('base64'));
|
||||
} else if (attachments.length === 0 && useHtml) {
|
||||
const alt = 'alt-' + crypto.randomBytes(10).toString('hex');
|
||||
headers.push(['Content-Type', `multipart/alternative; boundary="${alt}"`]);
|
||||
bodyText = [
|
||||
`--${alt}`, textPart(msg.text || '', 'text/plain'), '',
|
||||
`--${alt}`, textPart(msg.html, 'text/html'), '',
|
||||
`--${alt}--`, '',
|
||||
].join(CRLF);
|
||||
} else {
|
||||
const mixed = 'mix-' + crypto.randomBytes(10).toString('hex');
|
||||
headers.push(['Content-Type', `multipart/mixed; boundary="${mixed}"`]);
|
||||
const chunks = [];
|
||||
if (useHtml) {
|
||||
const alt = 'alt-' + crypto.randomBytes(10).toString('hex');
|
||||
chunks.push(`--${mixed}`, `Content-Type: multipart/alternative; boundary="${alt}"`, '');
|
||||
chunks.push(`--${alt}`, textPart(msg.text || '', 'text/plain'), '');
|
||||
chunks.push(`--${alt}`, textPart(msg.html, 'text/html'), '');
|
||||
chunks.push(`--${alt}--`, '');
|
||||
} else {
|
||||
chunks.push(`--${mixed}`, textPart(msg.text || '', 'text/plain'), '');
|
||||
}
|
||||
for (const att of attachments) {
|
||||
const content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content || '', 'base64');
|
||||
const name = encodeHeaderValue(att.filename || 'attachment');
|
||||
chunks.push(`--${mixed}`);
|
||||
chunks.push(`Content-Type: ${att.contentType || 'application/octet-stream'}; name="${name}"`);
|
||||
chunks.push('Content-Transfer-Encoding: base64');
|
||||
chunks.push(`Content-Disposition: attachment; filename="${name}"`);
|
||||
chunks.push('');
|
||||
chunks.push(wrap76(content.toString('base64')));
|
||||
chunks.push('');
|
||||
}
|
||||
chunks.push(`--${mixed}--`, '');
|
||||
bodyText = chunks.join(CRLF);
|
||||
}
|
||||
|
||||
const head = headers.map(([k, v]) => `${k}: ${v}`).join(CRLF);
|
||||
return Buffer.from(head + CRLF + CRLF + bodyText, 'utf8');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMessage,
|
||||
buildMessage,
|
||||
parseHeaders,
|
||||
parseAddressList,
|
||||
parseContentType,
|
||||
decodeWords,
|
||||
decodeParameter,
|
||||
smartDecodeHeader,
|
||||
decodeCharset,
|
||||
decodeQuotedPrintable,
|
||||
decodeTransfer,
|
||||
encodeHeaderValue,
|
||||
htmlToText,
|
||||
splitMultipart,
|
||||
filenameFrom,
|
||||
getHeader,
|
||||
rfc2822Date,
|
||||
generateMessageId,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* HTML 邮件净化。
|
||||
*
|
||||
* 为什么需要它:邮件正文是**别人**写的代码,直接塞进页面等于把我们的界面交给发件人。
|
||||
* 这里做第一层(去脚本/事件/危险协议 + 默认不加载远程图片),
|
||||
* 第二层在界面上:正文渲染在 `<iframe sandbox>` 里(**不给 allow-scripts**),
|
||||
* 就算这里漏掉什么,脚本也执行不起来。
|
||||
*
|
||||
* 说明(不粉饰):这是基于正则的净化,不是完整的 HTML 解析器。
|
||||
* 之所以接受这个折中:① 客户端是零依赖的;② 真正的安全边界是沙箱 iframe,
|
||||
* 这一层只负责把「不该出现的东西」清掉,让渲染更干净、也不至于一打开就连外网。
|
||||
*/
|
||||
|
||||
/** 整段删掉的标签(含内容)。 */
|
||||
const DROP_WITH_CONTENT = ['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'noscript', 'template', 'svg', 'math'];
|
||||
|
||||
/** 自闭合/空标签,直接删标签本身。 */
|
||||
const DROP_TAGS = ['meta', 'link', 'base', 'input', 'button', 'textarea', 'select', 'option'];
|
||||
|
||||
const URL_ATTRIBUTES = ['href', 'src', 'action', 'formaction', 'background', 'poster', 'xlink:href'];
|
||||
|
||||
function dropBlocks(html, tag) {
|
||||
const paired = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, 'gi');
|
||||
let out = html.replace(paired, '');
|
||||
// 没有闭合的(被截断的)也要清掉
|
||||
out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*$`, 'gi'), '');
|
||||
out = out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html 原始 HTML 正文
|
||||
* @param {{allowRemoteImages?: boolean}} options
|
||||
* @returns {{html: string, blockedImages: string[], removed: string[]}}
|
||||
*/
|
||||
function sanitizeHtml(html, options = {}) {
|
||||
const allowRemoteImages = !!options.allowRemoteImages;
|
||||
const removed = [];
|
||||
const blockedImages = [];
|
||||
let out = String(html || '');
|
||||
|
||||
for (const tag of DROP_WITH_CONTENT) {
|
||||
const before = out;
|
||||
out = dropBlocks(out, tag);
|
||||
if (out !== before) removed.push(tag);
|
||||
}
|
||||
for (const tag of DROP_TAGS) {
|
||||
const before = out;
|
||||
out = out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '');
|
||||
if (out !== before) removed.push(tag);
|
||||
}
|
||||
|
||||
// 事件处理器:on* 属性(onclick、onerror…)
|
||||
out = out.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m) => {
|
||||
if (!removed.includes('事件处理器')) removed.push('事件处理器');
|
||||
return '';
|
||||
});
|
||||
|
||||
// 危险协议
|
||||
out = out.replace(new RegExp(`(${URL_ATTRIBUTES.join('|')})\\s*=\\s*("|')?\\s*(javascript|vbscript|data:text\\/html)[^"'>\\s]*\\2?`, 'gi'), (m, attr) => {
|
||||
if (!removed.includes('危险链接')) removed.push('危险链接');
|
||||
return `${attr}="#"`;
|
||||
});
|
||||
|
||||
// 远程图片:默认拦掉(否则一打开邮件就把你的 IP、打开时间、邮件 id 告诉发件人)
|
||||
if (!allowRemoteImages) {
|
||||
out = out.replace(/<img\b[^>]*>/gi, (tag) => {
|
||||
const srcMatch = tag.match(/\ssrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||
const src = srcMatch ? (srcMatch[2] || srcMatch[3] || srcMatch[4] || '') : '';
|
||||
if (!/^(https?:)?\/\//i.test(src)) return tag; // cid:/data: 的图片保留(一般是内嵌附件)
|
||||
blockedImages.push(src);
|
||||
const alt = (tag.match(/\salt\s*=\s*("([^"]*)"|'([^']*)')/i) || [])[2] || '';
|
||||
return `<span style="display:inline-block;border:1px dashed #bbb;padding:4px 8px;border-radius:6px;`
|
||||
+ `font:12px/1.4 sans-serif;color:#888">🖼 已拦截远程图片${alt ? `(${escapeAttr(alt)})` : ''}</span>`;
|
||||
});
|
||||
if (blockedImages.length > 0 && !removed.includes('远程图片')) removed.push('远程图片');
|
||||
}
|
||||
|
||||
// 只留安全的 style 属性(去掉 expression/behavior/url(javascript:))
|
||||
out = out.replace(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, _all, dq, sq) => {
|
||||
const value = dq != null ? dq : sq || '';
|
||||
if (/expression\s*\(|behavior\s*:|javascript:/i.test(value)) {
|
||||
if (!removed.includes('危险样式')) removed.push('危险样式');
|
||||
return '';
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
return { html: out, blockedImages, removed: [...new Set(removed)] };
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把净化的 HTML 包成一个自带样式的完整文档,用于 iframe srcDoc。
|
||||
* 关键点:
|
||||
* - CSP 里禁掉脚本与远程加载(第一层保险)
|
||||
* - 图片被拦时给出提示条 + 「显示图片」按钮(按钮由界面在 iframe 外提供,这里只放占位)
|
||||
*/
|
||||
function buildDocument(bodyHtml, options = {}) {
|
||||
const theme = options.theme === 'dark' ? 'dark' : 'light';
|
||||
const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
|
||||
const fg = theme === 'dark' ? '#e5e5e5' : '#111111';
|
||||
const link = theme === 'dark' ? '#7cc4ff' : '#0b62d0';
|
||||
return `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'; font-src data:; form-action 'none'; base-uri 'none'">
|
||||
<style>
|
||||
:root { color-scheme: ${theme}; }
|
||||
html, body { margin: 0; padding: 0; background: ${bg}; color: ${fg}; }
|
||||
body { font: 14px/1.7 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; padding: 16px 20px; word-break: break-word; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
table { max-width: 100%; }
|
||||
blockquote { margin: 0 0 0 12px; padding-left: 12px; border-left: 2px solid #8884; color: #8888; }
|
||||
a { color: ${link}; }
|
||||
pre { white-space: pre-wrap; }
|
||||
</style>
|
||||
</head><body>${bodyHtml}</body></html>`;
|
||||
}
|
||||
|
||||
module.exports = { sanitizeHtml, buildDocument };
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 零依赖 SMTP 提交客户端(587 + STARTTLS + AUTH PLAIN)。
|
||||
*
|
||||
* 与服务器端实现相同的关键纪律:
|
||||
* **DATA 段必须按字节写出,且除 dot-stuffing 外不得改动任何字节。**
|
||||
* 中文乱码的历史根因就是这里用了 ASCII 编码写 DATA —— 本实现只做:
|
||||
* ① 行尾统一成 CRLF;② 行首的点做 dot-stuffing;③ 结尾补 CRLF + "." + CRLF。
|
||||
* 绝不碰正文的其它字节,也绝不重新编码。
|
||||
*/
|
||||
|
||||
const tls = require('node:tls');
|
||||
const net = require('node:net');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
class SmtpError extends Error {
|
||||
constructor(message, info = {}) {
|
||||
super(message);
|
||||
this.name = 'SmtpError';
|
||||
this.code = info.code || null;
|
||||
this.stage = info.stage || null;
|
||||
this.detail = info.detail || '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 行尾统一为 CRLF(不动其它字节,UTF-8 原样保留) */
|
||||
function normalizeCrlf(buffer) {
|
||||
const out = Buffer.alloc(buffer.length + 16);
|
||||
let n = 0;
|
||||
let i = 0;
|
||||
while (i < buffer.length) {
|
||||
const c = buffer[i];
|
||||
if (c === 0x0d) {
|
||||
out[n++] = 0x0d; out[n++] = 0x0a;
|
||||
i += (i + 1 < buffer.length && buffer[i + 1] === 0x0a) ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (c === 0x0a) { out[n++] = 0x0d; out[n++] = 0x0a; i++; continue; }
|
||||
out[n++] = c; i++;
|
||||
}
|
||||
let bytes = out.subarray(0, n);
|
||||
if (!(bytes.length >= 2 && bytes[bytes.length - 2] === 0x0d && bytes[bytes.length - 1] === 0x0a)) {
|
||||
bytes = Buffer.concat([bytes, Buffer.from('\r\n')]);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** dot-stuffing:仅当行首是 "." 时补一个点 */
|
||||
function dotStuff(normalized) {
|
||||
const parts = [Buffer.from('')];
|
||||
let start = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
const atLineStart = i === 0 || normalized[i - 1] === 0x0a;
|
||||
if (atLineStart && normalized[i] === 0x2e) {
|
||||
parts.push(normalized.subarray(start, i));
|
||||
parts.push(Buffer.from('.'));
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
parts.push(normalized.subarray(start));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** 生成 DATA 段要写出的完整字节 */
|
||||
function encodeData(raw) {
|
||||
const normalized = normalizeCrlf(raw);
|
||||
const stuffed = dotStuff(normalized);
|
||||
return Buffer.concat([stuffed, Buffer.from('.\r\n')]);
|
||||
}
|
||||
|
||||
class SmtpClient extends EventEmitter {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.host
|
||||
* @param {number} [opts.port=587]
|
||||
* @param {string} opts.user
|
||||
* @param {string} opts.password
|
||||
* @param {boolean} [opts.startTls=true]
|
||||
* @param {string} [opts.heloName] 默认取本机主机名
|
||||
* @param {number} [opts.timeoutMs=30000]
|
||||
* @param {boolean} [opts.debug=false]
|
||||
*/
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = Object.assign({ port: 587, startTls: true, timeoutMs: 30000, debug: false }, opts);
|
||||
this.socket = null;
|
||||
this.buffer = '';
|
||||
this.waiters = [];
|
||||
this.capabilities = [];
|
||||
this.on('error', () => {});
|
||||
}
|
||||
|
||||
_log(dir, text) {
|
||||
if (!this.opts.debug) return;
|
||||
process.stderr.write(`[smtp ${dir}] ${String(text).replace(/\r?\n/g, '\\n').slice(0, 300)}\n`);
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const { host, port, timeoutMs } = this.opts;
|
||||
this.socket = await new Promise((resolve, reject) => {
|
||||
const sock = net.connect({ host, port }, () => resolve(sock));
|
||||
sock.setTimeout(timeoutMs, () => reject(new SmtpError(`连接 ${host}:${port} 超时`)));
|
||||
sock.once('error', (err) => reject(new SmtpError(`连接 ${host}:${port} 失败:${err.message}`)));
|
||||
});
|
||||
this.socket.setTimeout(0);
|
||||
this._attach(this.socket);
|
||||
const greeting = await this.readResponse();
|
||||
if (greeting.code !== 220) throw new SmtpError(`问候语异常:${greeting.text}`, { code: greeting.code });
|
||||
return greeting;
|
||||
}
|
||||
|
||||
_attach(sock) {
|
||||
sock.setNoDelay(true);
|
||||
sock.on('data', (chunk) => this._onData(chunk));
|
||||
sock.on('error', (err) => this._failAll(err));
|
||||
sock.on('close', () => { this.closed = true; this._failAll(new SmtpError('连接已关闭', { stage: 'close' })); });
|
||||
}
|
||||
|
||||
_onData(chunk) {
|
||||
this.buffer += chunk.toString('latin1');
|
||||
this._drain();
|
||||
}
|
||||
|
||||
_drain() {
|
||||
for (;;) {
|
||||
// 找到「最后一行」:形如 "250 xxx\r\n"
|
||||
const idx = this.buffer.indexOf('\r\n');
|
||||
if (idx < 0) return;
|
||||
const line = this.buffer.slice(0, idx);
|
||||
this.buffer = this.buffer.slice(idx + 2);
|
||||
if (!this.curLines) this.curLines = [];
|
||||
this.curLines.push(line);
|
||||
this._log('<', line);
|
||||
if (/^\d{3} /.test(line) || !/^\d{3}-/.test(line)) {
|
||||
const lines = this.curLines;
|
||||
this.curLines = null;
|
||||
const code = Number(lines[0].slice(0, 3));
|
||||
const w = this.waiters.shift();
|
||||
const resp = { code, lines, text: lines.map((l) => l.slice(4)).join('\n') };
|
||||
if (w) w.resolve(resp);
|
||||
else this.emit('unsolicited', resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_failAll(err) {
|
||||
const ws = this.waiters;
|
||||
this.waiters = [];
|
||||
for (const w of ws) { clearTimeout(w.timer); w.reject(err); }
|
||||
}
|
||||
|
||||
readResponse(timeoutMs = this.opts.timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const i = this.waiters.findIndex((w) => w.resolve === resolve);
|
||||
if (i >= 0) this.waiters.splice(i, 1);
|
||||
reject(new SmtpError('等待服务器应答超时', { stage: 'read' }));
|
||||
}, timeoutMs);
|
||||
this.waiters.push({ resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
async command(text, expectCodes, stage) {
|
||||
this._log('>', text);
|
||||
this.socket.write(text + '\r\n');
|
||||
const resp = await this.readResponse();
|
||||
if (expectCodes && !expectCodes.includes(resp.code)) {
|
||||
throw new SmtpError(`${stage || text.split(' ')[0]} 失败:${resp.code} ${resp.text}`,
|
||||
{ code: resp.code, stage: stage || text.split(' ')[0], detail: resp.text });
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
_parseEhlo(resp) {
|
||||
const caps = [];
|
||||
for (const line of resp.lines.slice(1)) {
|
||||
const t = line.slice(4).trim();
|
||||
if (t) caps.push(t);
|
||||
}
|
||||
this.capabilities = caps;
|
||||
return caps;
|
||||
}
|
||||
|
||||
/** EHLO → STARTTLS → EHLO → AUTH PLAIN */
|
||||
async hello() {
|
||||
const hostname = this.opts.heloName || require('node:os').hostname();
|
||||
let resp = await this.command(`EHLO ${hostname}`, [250], 'EHLO');
|
||||
this._parseEhlo(resp);
|
||||
|
||||
if (this.opts.startTls) {
|
||||
const hasStartTls = this.capabilities.some((c) => /^STARTTLS/i.test(c));
|
||||
if (!hasStartTls) throw new SmtpError('服务器未广告 STARTTLS', { stage: 'STARTTLS' });
|
||||
await this.command('STARTTLS', [220], 'STARTTLS');
|
||||
await this._upgrade();
|
||||
resp = await this.command(`EHLO ${hostname}`, [250], 'EHLO');
|
||||
this._parseEhlo(resp);
|
||||
}
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
_upgrade() {
|
||||
const { host } = this.opts;
|
||||
return new Promise((resolve, reject) => {
|
||||
const secured = tls.connect({ socket: this.socket, servername: host }, () => {
|
||||
this.socket = secured;
|
||||
this.buffer = '';
|
||||
this.curLines = null;
|
||||
secured.removeAllListeners('data');
|
||||
secured.removeAllListeners('error');
|
||||
secured.removeAllListeners('close');
|
||||
this._attach(secured);
|
||||
resolve();
|
||||
});
|
||||
secured.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async auth() {
|
||||
const { user, password } = this.opts;
|
||||
if (!user) return false;
|
||||
const token = Buffer.from(`\u0000${user}\u0000${password}`, 'utf8').toString('base64');
|
||||
const resp = await this.command(`AUTH PLAIN ${token}`, [235], 'AUTH');
|
||||
return resp.code === 235;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发一封信。
|
||||
* @param {string} from 信封发件人(通常与 From 头一致)
|
||||
* @param {string[]} to 收件人(含抄送/密送,密送不进头部)
|
||||
* @param {Buffer} rawMessage 完整 MIME 报文(UTF-8 字节)
|
||||
*/
|
||||
async sendMail(from, to, rawMessage) {
|
||||
await this.command(`MAIL FROM:<${from}>`, [250, 251], 'MAIL FROM');
|
||||
for (const rcpt of to) {
|
||||
await this.command(`RCPT TO:<${rcpt}>`, [250, 251], `RCPT TO ${rcpt}`);
|
||||
}
|
||||
await this.command('DATA', [354], 'DATA');
|
||||
const payload = encodeData(rawMessage);
|
||||
this._log('>', `<DATA ${payload.length} 字节>`);
|
||||
this.socket.write(payload);
|
||||
const resp = await this.readResponse(120000);
|
||||
this._log('<', `${resp.code} ${resp.text}`);
|
||||
if (resp.code !== 250) {
|
||||
throw new SmtpError(`邮件正文被拒:${resp.code} ${resp.text}`, { code: resp.code, stage: 'DATA', detail: resp.text });
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
async quit() {
|
||||
try {
|
||||
if (this.socket && !this.socket.destroyed) await this.command('QUIT', [221], 'QUIT');
|
||||
} catch { /* 忽略 */ }
|
||||
try { this.socket?.destroy(); } catch { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SmtpClient, SmtpError, encodeData, normalizeCrlf, dotStuff };
|
||||
Reference in New Issue
Block a user