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

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