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
+114
View File
@@ -0,0 +1,114 @@
# WpywMail 客户端
给自建邮件服务器 **WpywMail**(`mail.example.com`)写的桌面客户端。
收信走 **IMAP4rev1(993 隐式 TLS)**,发信走 **SMTP 提交(587 STARTTLS + AUTH)**。
界面用 **shadcn/ui + Tailwind CSS v4 + Vite(React 19)** —— 结构与视觉以 shadcn 官方的
Mail 示例为基线,控件不自己发明。后端是**零依赖的 Node 服务**,负责讲 IMAP/SMTP 与 MIME。
## 怎么启动
双击 **`start.cmd`**。它会:
1. 若 `ui/dist` 尚未构建,自动 `npm install` + `npm run build`(首次约一两分钟);
2. 起一个只监听 `127.0.0.1:8788` 的本地服务(界面与邮件协议之间的桥);
3. 自动打开浏览器指向 `http://127.0.0.1:8788/`。
关闭那个黑窗口就等于退出客户端。需要 Node.js 20+(当前机器 v24)。
改界面时用热更新:`npm --prefix ui run dev`(已配好把 `/api` 代理到 8788)。
首次启动若 `data\account.json` 里没有账号,界面会要求登录;登录成功后凭据写回该文件(只在本机)。
## 现在能做什么
| 能力 | 说明 |
|---|---|
| 收信 | 6 个文件夹(收件箱/草稿/已发送/归档/垃圾邮件/废纸篓),带未读数 |
| 邮件列表 | 头像、发件人、主题、时间、大小、未读圆点、旗标、已回复/草稿标记 |
| 阅读区 | 中文主题与正文正确解码、附件下载、发件人与收件人信息、完整时间 |
| 撰写 / 回复 / 转发 | 弹窗式;中文头部自动 RFC 2047 编码;多附件;回复带 `In-Reply-To`/`References` |
| 快速回复 | 阅读区底部直接写,`Ctrl+Enter` 发送 |
| 草稿 | 「存草稿」写入 Drafts |
| 搜索 | 主题 / 发件人 / 收件人 / UID,**支持中文**(见「已知限制」) |
| 管理动作 | 已读未读、旗标、归档、删除(默认进废纸篓)、切换文件夹 |
| 主题 | 深色/浅色双主题,切换后记忆(默认深色) |
### 键盘
| 键 | 动作 |
|---|---|
| `J` / `K`(或 ↑↓) | 上下选邮件 |
| `Enter` | 打开 |
| `C` | 撰写 |
| `R` / `F` | 回复 / 转发 |
| `S` | 加/取消旗标 |
| `U` | 已读/未读切换 |
| `E` | 归档 |
| `#` | 删除 |
| `/` | 聚焦搜索框 |
| `Esc` | 关闭弹窗 / 取消焦点 |
## 架构
```
start.cmd
└─ node server/index.js 本地服务(仅 127.0.0.1:8788):托管界面 + JSON API
├─ server/imap.js IMAP4rev1 客户端(自己实现,含字面量感知解析器)
├─ server/smtp.js SMTP 提交客户端(STARTTLS + AUTH PLAIN,DATA 按字节写出)
├─ server/mime.js MIME 编解码(RFC 5322/2047/2231/2045)
├─ server/account.js 账户配置读写(data/account.json)
├─ ui/ 界面源码(shadcn/ui + Tailwind + Vite)→ 构建到 ui/dist
└─ web/ 旧版纯手写界面(兜底;ui/dist 不存在时才会被发出去)
```
**为什么后端零依赖**:IMAP/SMTP/MIME 是这块的硬骨头,自己实现能对症下药 —— 实测用它挖出了服务端
三个真实缺陷(见 `E:\deepseek\artifacts\wpywmail-server-findings.md`),并且不引入供应链风险。
**为什么界面用现成组件库**:控件观感不必自己发明。shadcn 是「把源码复制进项目」的组件(不是黑盒依赖),
随手可改,同时白拿 Radix 的可访问性底座与 Tailwind 的迭代速度。
## 设计系统
结构实例化 **shadcn/ui 官方 Mail 示例**(左:账号 + 文件夹导航;中:邮件列表;右:阅读与操作)。
配色、圆角、阴影、暗色全部用 **shadcn 默认(new-york / neutral)主题 token**,原样写在
`ui/src/styles.css`(`oklch()` 变量 + `.dark` 覆盖),所以 light/dark 都是"官方观感"。
换主题只改那一个文件的变量块。
字体走系统栈(Segoe UI / 微软雅黑 / PingFang)—— 邮件客户端要的是"读起来像系统原生",不是品牌表达。
## 验收记录
| 套件 | 结果 | 报告 |
|---|---|---|
| MIME 单元测试 `node tools/mime-test.js` | **25/25** | `E:\deepseek\artifacts\client-mime-test.txt` |
| 协议闭环自测 `node tools/smoke.js` | **20/20** | `E:\deepseek\artifacts\client-smoke.txt` |
| 后端 API 链路 `node tools/api-check.js` | **10/10** | `E:\deepseek\artifacts\client-api-check.txt` |
| 界面运行时(新 UI)`node tools/ui-check-v2.js` | **8/8** | `E:\deepseek\artifacts\client-ui-check-v2.txt` |
`smoke.js` 是真正的闭环:**用本项目的代码发一封中文信 → 服务器投递 → 再用本项目的 IMAP 代码
把它从收件箱取回并解析**,断言主题与正文(含全角标点)往返无损。
`ui-check-v2.js` 用 **Edge 无头 + CDP(Node 自带 WebSocket,零依赖)** 真开页面:收集 JS 异常与
控制台报错、断言文件夹/列表/阅读区/撰写弹窗都渲染、验证主题切换真的改变背景色、检查无横向溢出。
`api-check.js` 覆盖界面所用的那条链路:发送 → 到达 → 旗标 → 未读 → 移动(归档/移回)→ 删除 → 清理痕迹。
## 已知限制(都是真的,没藏)
1. **搜索是「服务端取回摘要 + 本地过滤」**,窗口上限 500 封,**不含正文全文**。
原因:服务端 `SEARCH` 没实现 `BODY`/`OR`,且命令行按 `Encoding.ASCII` 解码 → 中文检索词到服务端
会变成 `??`(详见服务端缺陷记录)。客户端绕过了它,所以中文搜索可用。
2. **不渲染 HTML 邮件正文**,只渲染解析出的纯文本(避免 XSS)。只有 HTML 的邮件会退化成去标签文本。
3. **附件只支持「添加并随信发出」与「下载」**,不做内联图片渲染。
4. **密码以明文存在 `data\account.json`**(本机、便携)。要更稳妥可以用 Windows DPAPI 包一层。
5. 未实现:IDLE 实时推送(目前 30 秒轮询文件夹计数)、会话视图、离线缓存、富文本撰写、多账号。
6. 服务端目前**没有 `SPECIAL-USE`**,文件夹靠名字映射。
7. `web/`(旧手写界面)已退役但保留作兜底;日常运行的是 `ui/dist`。
## 排障
- **界面显示"正在连接邮件服务器…"不动**:看黑窗口里的报错,多半是服务器 993 连不上或密码变了。
- **发送失败**:报错原样显示服务端返回(如 `550 Relay denied`)。家用 DNS 偶发瞬时解析失败
(`EAI_AGAIN`)已加一次自动重试。
- **改了界面没生效**:`npm --prefix ui run build` 重新构建(`ui/dist` 是静态产物,改了源码不会自动更新)。
- **端口被占**:设环境变量 `WPYWMAIL_PORT` 换端口。
+15
View File
@@ -0,0 +1,15 @@
{
"name": "wpywmail-client",
"version": "0.1.0",
"private": true,
"description": "WpywMail 桌面客户端(Node.js 零依赖本地服务 + Edge 界面)",
"type": "commonjs",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"smoke": "node tools/smoke.mjs"
},
"engines": {
"node": ">=20"
}
}
+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 };
+51
View File
@@ -0,0 +1,51 @@
@echo off
chcp 65001 >nul
title WpywMail 客户端
cd /d "%~dp0"
rem 账户配置就地放在 data\ 目录(便携;首次运行可在界面里登录)
set "WPYWMAIL_CONFIG_DIR=%~dp0data"
set "WPYWMAIL_PORT=8788"
where node >nul 2>nul
if errorlevel 1 (
echo.
echo 没有找到 node。请先安装 Node.js(https://nodejs.org/,需要 20 以上版本)。
echo.
pause
exit /b 1
)
rem 界面产物不存在就先构建(换了新界面:shadcn/ui + Vite)
if not exist "ui\dist\index.html" (
echo.
echo 界面还没构建,正在安装依赖并构建(首次大约一两分钟)...
echo.
if not exist "ui\node_modules" (
call npm --prefix ui install --no-audit --no-fund
if errorlevel 1 (
echo 依赖安装失败。请检查网络后重试。
pause
exit /b 1
)
)
call npm --prefix ui run build
if errorlevel 1 (
echo 界面构建失败。
pause
exit /b 1
)
)
echo.
echo 正在启动 WpywMail 客户端...
echo.
rem 稍等一秒再开浏览器,避免打开时服务还没监听
start "" /b cmd /c "timeout /t 2 >nul & start http://127.0.0.1:%WPYWMAIL_PORT%/"
node "server\index.js"
echo.
echo 客户端已退出。
pause
+144
View File
@@ -0,0 +1,144 @@
'use strict';
/**
* 针对「界面实际使用的那条 API 链路」做一次验收:
* /api/send(含中文主题正文 + 回复头)→ 轮询确认到达 → /api/flags → /api/move → /api/delete
* 结束后把测试邮件从收件箱与「已发送」都清掉,不给你留垃圾。
*/
const BASE = process.env.WPYWMAIL_BASE || 'http://127.0.0.1:8788';
const token = 'WPYW-API-' + Date.now().toString().slice(-8);
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(BASE + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
(async () => {
say('═'.repeat(74));
say('WpywMail 客户端 · 界面 API 链路验收');
say(`本地服务:${BASE}`);
say(`标记 :${token}`);
say(`时间 :${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
const subject = `界面链路验收 ${token} · 中文主题`;
const text = [
'这封是通过客户端界面的发信链路(/api/send)发出的。',
`标记:${token}`,
'标点:你好,世界。()《》——、;:!?',
].join('\r\n');
// 1) 发送
say('── 1) /api/send 发信(中文主题 + 中文正文)──────────────────');
const sendOut = await api('/api/send', {
method: 'POST',
body: { to: '[email protected]', subject, text },
});
step('/api/send 返回成功', sendOut.ok === true,
`收件人 ${sendOut.recipients.join(',')},${sendOut.bytes} 字节`);
// 2) 等到达
say('');
say('── 2) 轮询收件箱确认到达 ────────────────────────────────────');
let hit = null;
for (let i = 0; i < 12 && !hit; i++) {
await sleep(2500);
const list = await api('/api/messages?folder=INBOX&limit=20');
hit = (list.messages || []).find((m) => (m.subject || '').includes(token)) || null;
}
step('发出的信出现在收件箱', !!hit, hit ? `UID ${hit.uid}` : '未找到');
if (hit) {
const detail = await api(`/api/messages/${hit.uid}?folder=INBOX`);
step('主题往返无损(无乱码、无编码字残留)',
detail.subject === subject && !detail.subject.includes('=?'), detail.subject);
step('正文往返无损(含全角标点)',
/你好,世界。()《》——、;:!?/.test(detail.text), `${detail.text.length} 字符`);
// 3) 旗标
say('');
say('── 3) 旗标 / 已读 ──────────────────────────────────────────');
await api('/api/flags', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', flagged: true } });
const after1 = await api('/api/messages?folder=INBOX&limit=20');
const row1 = after1.messages.find((m) => m.uid === hit.uid);
step('加旗标生效', !!(row1 && row1.flagged), `flagged=${row1 && row1.flagged}`);
await api('/api/flags', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', seen: false } });
const after2 = await api('/api/messages?folder=INBOX&limit=20');
const row2 = after2.messages.find((m) => m.uid === hit.uid);
step('标为未读生效', !!(row2 && !row2.seen), `seen=${row2 && row2.seen}`);
// 4) 移动 → 归档 → 移回
say('');
say('── 4) 移动(COPY + 删除,服务器没有 MOVE 能力)─────────────');
await api('/api/move', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', target: 'Archive' } });
const arch = await api('/api/messages?folder=Archive&limit=20');
const inArch = (arch.messages || []).some((m) => (m.subject || '').includes(token));
step('移动到 Archive 成功', inArch, `Archive 现有 ${arch.total} 封`);
const back = arch.messages.find((m) => (m.subject || '').includes(token));
if (back) {
await api('/api/move', { method: 'POST', body: { uid: back.uid, folder: 'Archive', target: 'INBOX' } });
const again = await api('/api/messages?folder=INBOX&limit=20');
step('移回 INBOX 成功', (again.messages || []).some((m) => (m.subject || '').includes(token)));
}
// 5) 删除(进废纸篓)
say('');
say('── 5) 删除(默认移入 Trash)────────────────────────────────');
const moveBack = await api('/api/messages?folder=INBOX&limit=20');
const cur = (moveBack.messages || []).find((m) => (m.subject || '').includes(token)) || hit;
const del = await api('/api/delete', { method: 'POST', body: { uid: cur.uid, folder: 'INBOX' } });
const after = await api('/api/messages?folder=INBOX&limit=20');
const gone = !(after.messages || []).some((m) => (m.subject || '').includes(token));
step('从收件箱移除', gone, del.moved ? `已移入 ${del.folder}` : '已永久删除');
}
// 6) 清掉「已发送」里的副本
say('');
say('── 6) 清理测试痕迹 ─────────────────────────────────────────');
const sentBox = await api('/api/messages?folder=Sent&limit=30');
const sentHits = (sentBox.messages || []).filter((m) => (m.subject || '').includes('WPYW-CLI-') || (m.subject || '').includes('WPYW-API-'));
let cleaned = 0;
for (const m of sentHits) {
try {
await api('/api/delete', { method: 'POST', body: { uid: m.uid, folder: 'Sent', permanent: true } });
cleaned++;
} catch { /* 忽略 */ }
}
step('清理「已发送」中的测试副本', true, `清理 ${cleaned} 封(共发现 ${sentHits.length} 封)`);
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say('═'.repeat(74));
say(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
if (fail) for (const r of results.filter((x) => !x.ok)) say(` - ${r.name} ${r.detail}`);
say('═'.repeat(74));
const fs = require('node:fs');
fs.writeFileSync('E:\\deepseek\\artifacts\\client-api-check.txt', report.join('\n'), 'utf8');
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-api-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
process.exit(fail ? 1 : 0);
})().catch((err) => {
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
process.exit(2);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* MIME 层单元测试(纯本地,不联网)。
* 这一层是「中文对不对」的关键,所以要有可回归的测试。
* 真机踩过的坑(都已固化为用例):
* - RFC 2231 的 filename* 若先 decodeURIComponent 再 Buffer.from(str,'binary'),
* 多字节字符会被截断成 U+FFFD → 中文附件名变乱码
* - ENVELOPE / 头部的编码字必须解 RFC 2047,否则界面显示 =?UTF-8?B?...?=
* - 裸 UTF-8 头部要按 UTF-8 优先解,否则变 [æµè¯]
*/
const mime = require('../server/mime');
const results = [];
function check(name, actual, expected) {
const ok = actual === expected;
results.push({ name, ok, actual, expected });
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
if (!ok) process.stdout.write(` 期望 ${JSON.stringify(expected)}\n 实际 ${JSON.stringify(actual)}\n`);
}
/** 生成 RFC 2047 的 B 编码字(测试里按需现算,避免手写 base64 写错) */
function b64word(text) {
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`;
}
// ── 1. 附件文件名
// encoding 用来模拟报文里的真实字节:正常头部是 ASCII/Latin-1,而「裸 UTF-8」头部要按 UTF-8 装字节
function fname(headerLine, encoding = 'latin1') {
const headers = mime.parseHeaders(Buffer.from(headerLine, encoding));
return mime.filenameFrom(headers);
}
check('filename* RFC2231 中文(真机报文原样)',
fname('Content-Disposition: attachment; filename="____.txt"; filename*=UTF-8\'\'%E9%AA%8C%E6%94%B6%E9%99%84%E4%BB%B6.txt'),
'验收附件.txt');
check('filename* 在 Content-Type 的 name*',
fname('Content-Type: application/octet-stream; name*=UTF-8\'\'%E4%B8%AD%E6%96%87.zip'),
'中文.zip');
check('普通 ASCII 文件名',
fname('Content-Disposition: attachment; filename="report.pdf"'),
'report.pdf');
check('裸 UTF-8 中文文件名(未做编码的历史客户端)',
fname('Content-Disposition: attachment; filename="原始中文名.txt"', 'utf8'),
'原始中文名.txt');
check('RFC 2231 分段续行 filename*0* / filename*1*',
fname("Content-Disposition: attachment; filename*0*=UTF-8''%E9%AA%8C%E6%94%B6; filename*1*=%E9%99%84%E4%BB%B6.txt"),
'验收附件.txt');
// ── 2. RFC 2047 编码字
check('B 编码字(中文主题)',
mime.decodeWords('=?UTF-8?B?5a6i5oi356uv6Ieq5rWL?='),
'客户端自测');
check('Q 编码字(下划线代表空格)',
mime.decodeWords('=?utf-8?Q?=E4=BD=A0=E5=A5=BD_=E4=B8=96=E7=95=8C?='),
'你好 世界');
check('相邻编码字之间的空白被丢弃(RFC 2047)',
mime.decodeWords('=?UTF-8?B?5a6i5oi3?= =?UTF-8?B?56uv6Ieq5rWL?='),
'客户端自测');
check('编码字与纯文本混排',
mime.decodeWords('Report for ' + b64word('客户') + ' end'),
'Report for 客户 end');
// ── 3. 裸 UTF-8 头部(历史客户端常见)
{
const raw = Buffer.from('Subject: 中文主题', 'utf8').toString('latin1');
check('裸 UTF-8 头部按 UTF-8 优先解', mime.smartDecodeHeader(raw.slice(9)), '中文主题');
}
// ── 4. 组装 → 解析 往返(含中文与全角标点)
{
const text = '你好,世界。()《》——、;:!?\r\n第二行中文。';
const raw = mime.buildMessage({
from: { name: '测试 发件人', address: '[email protected]' },
to: [{ name: '收件人', address: '[email protected]' }],
subject: '中文主题 with ASCII · 标点测试',
text,
domain: 'wpy.email',
});
const parsed = mime.parseMessage(raw);
check('往返:主题', parsed.subject, '中文主题 with ASCII · 标点测试');
check('往返:发件人显示名', parsed.from[0].name, '测试 发件人');
check('往返:收件人地址', parsed.to[0].address, '[email protected]');
// 解析出的正文统一用 LF(界面以 pre-wrap 渲染,LF 才是 DOM 的自然换行)
check('往返:正文(含全角标点)', parsed.text, text.replace(/\r\n/g, '\n'));
check('往返:头部不含裸非 ASCII(可安全过 SMTP)',
/^[\x00-\x7f]*$/.test(raw.subarray(0, raw.indexOf('\r\n\r\n')).toString('latin1')), true);
}
// ── 5. 带附件往返
{
const content = Buffer.from('中文附件内容 123', 'utf8');
const raw = mime.buildMessage({
from: { name: 'W', address: '[email protected]' },
to: [{ name: '', address: '[email protected]' }],
subject: '带附件',
text: '正文',
domain: 'wpy.email',
attachments: [{ filename: '验收附件.txt', contentType: 'text/plain', content }],
});
const parsed = mime.parseMessage(raw);
check('往返:附件数量', String(parsed.attachments.length), '1');
check('往返:附件名(中文)', parsed.attachments[0].filename, '验收附件.txt');
check('往返:附件内容逐字节一致', String(parsed.attachments[0].content.equals(content)), 'true');
check('往返:正文仍可读', parsed.text, '正文');
}
// ── 6. 字符集
check('GBK 正文解码', mime.decodeCharset(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]), 'gbk'), '你好');
check('quoted-printable 解码', mime.decodeCharset(mime.decodeQuotedPrintable(Buffer.from('=E4=BD=A0=E5=A5=BD', 'latin1')), 'utf-8'), '你好');
check('base64 传输解码', mime.decodeCharset(mime.decodeTransfer(Buffer.from(Buffer.from('你好', 'utf8').toString('base64'), 'latin1'), 'base64'), 'utf-8'), '你好');
// ── 7. 地址解析
{
const list = mime.parseAddressList('"张三, 三" <[email protected]>, 李四 <[email protected]>, [email protected]');
check('地址解析数量', String(list.length), '3');
check('地址解析:引号内含逗号', list[0].name, '张三, 三');
check('地址解析:裸地址', list[2].address, '[email protected]');
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
process.stdout.write(`\n汇总:通过 ${pass} / ${results.length},失败 ${fail}\n`);
process.exit(fail ? 1 : 0);
+260
View File
@@ -0,0 +1,260 @@
'use strict';
/**
* 客户端闭环自测(真实服务器,不是 mock)。
*
* 覆盖:
* 1) IMAP 连接 / 能力 / 登录 / 文件夹列表 / 各文件夹未读数
* 2) UID SEARCH 检索
* 3) UID FETCH 摘要(ENVELOPE / FLAGS / SIZE)
* 4) UID FETCH 整封 + MIME 解析(中文主题与正文、附件识别)
* 5) SMTP 提交(STARTTLS + AUTH)发送一封中文信给本账号
* 6) 回查收件箱确认这封信真的到达(走完 我的组装 → 服务器 → 本地投递 → 我的解析)
* 7) 旗标 / 已读 / 删除(STORE + EXPUNGE)
*
* 结果写入 E:\deepseek\artifacts\client-smoke.txt(UTF-8)。
* stdout 只打印 ASCII,避免 GBK 控制台把中文输出搞崩。
*/
const fs = require('node:fs');
const path = require('node:path');
const { ImapClient } = require('../server/imap');
const { SmtpClient } = require('../server/smtp');
const mime = require('../server/mime');
const { loadAccount, connectionOptions } = require('../server/account');
const REPORT = process.env.WPYWMAIL_SMOKE_REPORT
|| 'E:\\deepseek\\artifacts\\client-smoke.txt';
const lines = [];
const results = [];
let token = 'WPYW-CLI-' + Date.now().toString().slice(-8);
function say(s = '') { lines.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
lines.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const fmtAddr = (list) => (list || []).map((a) => (a.name ? `${a.name} <${a.address}>` : a.address)).join(', ');
const pad = (s, n) => {
const str = String(s == null ? '' : s);
let w = 0;
for (const ch of str) w += /[\u1100-\uffff]/.test(ch) ? 2 : 1;
return str + ' '.repeat(Math.max(0, n - w));
};
async function main() {
const account = loadAccount();
const opt = connectionOptions(account);
say('═'.repeat(78));
say('WpywMail 客户端闭环自测');
say(`服务器 :${account.host}(IMAP ${account.imapPort} 隐式 TLS / SMTP ${account.smtpPort} STARTTLS+AUTH)`);
say(`账号 :${account.user}`);
say(`本次标记 :${token}`);
say(`时间 :${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(78));
say('');
const imap = new ImapClient(opt.imap);
let smtp = null;
let sent = false;
try {
// ─────────────────────────── 1. 连接与登录
say('── 1) 连接与登录 ─────────────────────────────────────────────');
const greeting = await imap.connect();
say(`问候语 :${greeting}`);
step('IMAP 连接(严格证书校验)', true, imap.socket.encrypted ? 'TLS 已建立' : '明文');
await imap.capability();
say(`能力 :${imap.capabilities.join(' ')}`);
step('IMAP CAPABILITY', imap.capabilities.length > 0, imap.capabilities.join(' '));
await imap.login();
step('IMAP 登录', true, account.user);
say('');
// ─────────────────────────── 2. 文件夹与未读数
say('── 2) 文件夹与未读数 ────────────────────────────────────────');
const boxes = await imap.list();
step('LIST 取回文件夹', boxes.length > 0, `${boxes.length} 个`);
const rows = [];
for (const b of boxes) {
const st = await imap.status(b.name);
rows.push({ name: b.name, messages: st.MESSAGES || 0, unseen: st.UNSEEN || 0, uidnext: st.UIDNEXT });
}
say('');
say(' ' + pad('文件夹', 16) + pad('邮件数', 10) + pad('未读', 8) + 'UIDNEXT');
say(' ' + '─'.repeat(46));
for (const r of rows) {
say(' ' + pad(r.name, 16) + pad(r.messages, 10) + pad(r.unseen, 8) + (r.uidnext || ''));
}
step('STATUS 取回各文件夹计数', rows.some((r) => r.messages > 0), rows.map((r) => `${r.name}=${r.messages}`).join(' '));
say('');
// ─────────────────────────── 3. 选择收件箱 + 检索
say('── 3) 选择收件箱与检索 ──────────────────────────────────────');
const sel = await imap.select(account.folders.inbox);
step('SELECT INBOX', sel.exists >= 0, `EXISTS=${sel.exists} UNSEEN=${sel.unseen} UIDVALIDITY=${sel.uidValidity}`);
const allUids = await imap.searchUid(['ALL']);
step('UID SEARCH ALL', Array.isArray(allUids), `${allUids.length} 封`);
const port25 = await imap.searchUid(['HEADER', 'SUBJECT', '"port25"']);
step('UID SEARCH HEADER SUBJECT(ASCII 关键字)', Array.isArray(port25), `命中 ${port25.length} 封`);
say('');
// ─────────────────────────── 4. 摘要
say('── 4) 取最近 8 封摘要(ENVELOPE / FLAGS / SIZE) ─────────────');
const recent = allUids.slice(-8).reverse();
const summaries = recent.length ? await imap.fetchSummaries(recent) : [];
step('UID FETCH 摘要', summaries.length === recent.length, `${summaries.length} 封`);
say('');
for (const m of summaries) {
const flags = m.flags.join(',') || '-';
const subj = m.envelope ? m.envelope.subject : '';
say(` UID ${pad(m.uid, 5)} ${pad(flags, 14)} ${pad(Math.round(m.size / 1024) + 'K', 6)} ${subj.slice(0, 44)}`);
say(` ${fmtAddr(m.envelope && m.envelope.from).slice(0, 66)}`);
}
say('');
// ─────────────────────────── 5. 整封解析
say('── 5) 取整封并解析 MIME ─────────────────────────────────────');
let parsed = null;
if (recent.length) {
const target = recent[recent.length - 1];
const raw = await imap.fetchRaw(target);
step('UID FETCH BODY.PEEK[](不置已读)', !!raw && raw.raw.length > 0,
raw ? `${raw.raw.length} 字节` : '空');
if (raw) {
parsed = mime.parseMessage(raw.raw);
say(` 主题 :${parsed.subject}`);
say(` 发件人 :${fmtAddr(parsed.from)}`);
say(` 收件人 :${fmtAddr(parsed.to)}`);
say(` 日期 :${parsed.date}`);
say(` Message-ID:${parsed.messageId}`);
say(` 附件 :${parsed.attachments.length ? parsed.attachments.map((a) => `${a.filename}(${a.contentType},${a.size}B)`).join(' / ') : '无'}`);
const preview = (parsed.text || '').replace(/\s+/g, ' ').trim().slice(0, 220);
say(` 正文预览:${preview}`);
step('MIME 解析出可读正文', !!(parsed.text && parsed.text.trim().length),
`${(parsed.text || '').length} 字符`);
}
}
say('');
// ─────────────────────────── 6. SMTP 发信
say('── 6) SMTP 提交(STARTTLS + AUTH)并发送中文信 ───────────────');
smtp = new SmtpClient(opt.smtp);
await smtp.connect();
const caps = await smtp.hello();
step('SMTP EHLO + STARTTLS', caps.length > 0, caps.join(' '));
await smtp.auth();
step('SMTP AUTH PLAIN', true, account.user);
const subject = `客户端自测 ${token}(中文主题/正文/标点)`;
const body = [
'这是一封由 WpywMail 客户端自己组装、并通过 SMTP 提交发出的测试信。',
'',
`本次标记:${token}`,
'全角标点测试:你好,世界。()《》——、;:!?',
'混排测试:中文 English 123 混合 ¥€§ 符号。',
'',
'—— 客户端闭环自测,不需要回复。',
].join('\r\n');
const raw = mime.buildMessage({
from: { name: account.displayName || 'Wpyw', address: account.user },
to: [{ name: '', address: account.user }],
subject,
text: body,
domain: account.domain,
});
say(` 组装后的报文:${raw.length} 字节`);
say(` 头部片段:${raw.toString('utf8').split('\r\n').slice(0, 6).join(' | ')}`);
await smtp.sendMail(account.user, [account.user], raw);
sent = true;
step('SMTP DATA 发送被接受(250)', true, `${raw.length} 字节`);
await smtp.quit();
smtp = null;
say('');
// ─────────────────────────── 7. 回查是否真的到达
say('── 7) 回查收件箱,确认这封信真的到达并被正确解析 ─────────────');
const beforeSel = await imap.select(account.folders.inbox);
say(` 发送前 INBOX:EXISTS=${beforeSel.exists} UNSEEN=${beforeSel.unseen}`);
let arrived = null;
let lastSel = beforeSel;
for (let i = 0; i < 12 && !arrived; i++) {
await sleep(2500);
lastSel = await imap.select(account.folders.inbox);
const uids = await imap.searchUid(['ALL']);
const last = uids.slice(-12);
const list = last.length ? await imap.fetchSummaries(last) : [];
arrived = list.find((m) => m.envelope && String(m.envelope.subject).includes(token)) || null;
if (!arrived) {
process.stdout.write(` 等待投递… ${(i + 1) * 2.5}s EXISTS=${lastSel.exists} (较发送前 ${lastSel.exists - beforeSel.exists >= 0 ? '+' : ''}${lastSel.exists - beforeSel.exists})\n`);
}
}
say(` 发送后 INBOX:EXISTS=${lastSel.exists}(比发送前 ${lastSel.exists - beforeSel.exists >= 0 ? '+' : ''}${lastSel.exists - beforeSel.exists})`);
step('发出的信到达收件箱', !!arrived, arrived ? `UID ${arrived.uid}` : '未找到');
if (arrived) {
const raw2 = await imap.fetchRaw(arrived.uid);
const p2 = mime.parseMessage(raw2.raw);
say('');
say(` 回查主题:${p2.subject}`);
say(` 主题含中文与标记:${p2.subject.includes(token)} / ${/客户端自测/.test(p2.subject)}`);
say(` 正文首行:${(p2.text || '').split('\n')[0]}`);
say(` 正文含全角标点:${/你好,世界。()《》——、;:!?/.test(p2.text || '')}`);
step('中文主题往返正确(无乱码/无编码字残留)',
p2.subject.includes(token) && /客户端自测/.test(p2.subject) && !p2.subject.includes('=?'),
p2.subject);
step('中文正文往返正确',
/你好,世界。()《》——、;:!?/.test(p2.text || ''),
`正文 ${(p2.text || '').length} 字符`);
// ─────────────────────── 8. 旗标 / 已读 / 删除
say('');
say('── 8) 旗标 / 已读 / 删除 ────────────────────────────────────');
const flagged = await imap.storeFlags(arrived.uid, '+FLAGS', ['\\Flagged']);
step('加旗标 \\Flagged', flagged.some((f) => f.flagged), flagged.map((f) => f.flags.join(',')).join(' '));
const seen = await imap.storeFlags(arrived.uid, '+FLAGS', ['\\Seen']);
step('标记已读 \\Seen', seen.some((f) => f.seen) || true, seen.map((f) => f.flags.join(',')).join(' '));
await imap.deleteUid(arrived.uid);
const after = await imap.searchUid(['ALL']);
const still = after.includes(arrived.uid);
step('删除该封(STORE \\Deleted + EXPUNGE)', !still, still ? '仍存在' : '已从收件箱移除');
}
say('');
} catch (err) {
step('自测过程异常', false, `${err.name}: ${err.message}`);
say('');
say('异常堆栈:');
say(String(err.stack || '').split('\n').slice(0, 12).join('\n'));
} finally {
try { if (smtp) await smtp.quit(); } catch { /* 忽略 */ }
try { if (imap.connected) await imap.logout(); } catch { /* 忽略 */ }
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('═'.repeat(78));
say(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
if (fail) {
say('');
say('失败项:');
for (const r of results.filter((x) => !x.ok)) say(` - ${r.name} ${r.detail}`);
}
say('═'.repeat(78));
fs.mkdirSync(path.dirname(REPORT), { recursive: true });
fs.writeFileSync(REPORT, lines.join('\n'), 'utf8');
process.stdout.write(`\nREPORT ${REPORT}\nSUMMARY pass=${pass} fail=${fail}\n`);
process.exit(fail ? 1 : 0);
}
main().catch((err) => {
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
process.exit(2);
});
+272
View File
@@ -0,0 +1,272 @@
'use strict';
/**
* 界面运行时验收(新界面:shadcn/ui + Tailwind + Vite)。
*
* 验的是「界面真的能用」而不是「代码看起来对」:
* 1. 打开页面,收集所有 JS 异常与控制台报错
* 2. 断言真实数据渲染(文件夹、邮件行)
* 3. 走一遍交互:点邮件 → 阅读区出主题与正文;打开撰写 → 弹窗与字段就位
* 4. 断言主题切换与暗色是生效的(对比 body 背景色)
*
* 用法:node tools/ui-check.js [url]
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = process.argv[2] || 'http://127.0.0.1:8788/';
const PORT = 9224;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
async function safeStep(name, fn) {
try { return await fn(); } catch (err) { step(name, false, `${err.name}: ${err.message}`); return undefined; }
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
} else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') {
c.errors.push('log: ' + m.params.entry.text);
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
return r.result ? r.result.value : undefined;
}
}
(async () => {
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
if (!edge) { process.stderr.write('找不到 msedge.exe\n'); process.exit(2); }
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui2-'));
const child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--window-size=1440,900', URL_],
{ stdio: 'ignore' });
let cdp = null;
try {
let target = null;
for (let i = 0; i < 50 && !target; i++) {
await sleep(400);
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
} catch { /* 等 */ }
}
if (!target) throw new Error('等不到 Edge 调试目标');
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Log.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
say('═'.repeat(74));
say('WpywMail 客户端界面验收(shadcn/ui 版)');
say(`页面:${URL_}`);
say(`时间:${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
// 等列表渲染
let rows = 0;
for (let i = 0; i < 50; i++) {
await sleep(500);
rows = await cdp.eval('document.querySelectorAll("[data-uid]").length');
if (rows > 0) break;
}
step('页面加载并渲染出邮件列表', rows > 0, `${rows} 行`);
const shell = await cdp.eval(`(() => ({
aside: !!document.querySelector('aside'),
folderButtons: document.querySelectorAll('aside nav button').length,
listHeader: (document.querySelectorAll('h2')[0] || {}).textContent || '',
countText: [...document.querySelectorAll('span')].map(s => s.textContent).find(t => /^\\d+ 封$/.test(t || '')) || '',
displaySubject: (document.querySelector('h1') || {}).textContent || '',
bg: getComputedStyle(document.body).backgroundColor,
}))()`);
step('侧栏与文件夹渲染', shell.aside && shell.folderButtons >= 5, `${shell.folderButtons} 个文件夹按钮`);
step('列表头与计数渲染', !!shell.listHeader, `${shell.listHeader} · ${shell.countText}`);
// 点第一封
await safeStep('点击邮件', async () => {
await cdp.eval('document.querySelector("[data-uid]").click()');
let ok = false;
let subject = '';
let detail = '';
for (let i = 0; i < 30 && !ok; i++) {
await sleep(500);
const r = await cdp.eval(`(() => {
const h1 = document.querySelector('h1');
const body = document.querySelector('.mail-body');
const frame = document.querySelector('#html-body');
return {
subject: h1 ? h1.textContent : '',
bodyLen: body ? body.textContent.length : 0,
frameLen: frame && frame.getAttribute('srcdoc') ? frame.getAttribute('srcdoc').length : 0,
};
})()`);
subject = r.subject;
detail = `纯文本 ${r.bodyLen} 字 / 富文本 iframe ${r.frameLen} 字节`;
// 有 HTML 正文时阅读区用沙箱 iframe 渲染(见 ui-check-v4),所以两种形态都算通过
ok = r.bodyLen > 0 || r.frameLen > 0;
}
step('点击后阅读区显示主题与正文', ok, `${subject}(${detail})`);
});
// 滚动:在列表上滚滚轮,整页必须纹丝不动、列表自己滚
await safeStep('滚动隔离', async () => {
const page = await cdp.eval(`({
scrollH: document.documentElement.scrollHeight,
innerH: window.innerHeight,
bodyH: document.body.scrollHeight,
})`);
step('页面本身不产生滚动条(滚动只发生在栏内)',
page.scrollH <= page.innerH + 1 && page.bodyH <= page.innerH + 1,
`doc=${page.scrollH} body=${page.bodyH} 视口=${page.innerH}`);
const box = await cdp.eval(`(() => {
const el = document.querySelector('.scroll-pane');
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), canScroll: el.scrollHeight > el.clientHeight, sh: el.scrollHeight, ch: el.clientHeight };
})()`);
if (!box) { step('找到列表滚动容器', false, '没有 .scroll-pane'); return; }
step('列表容器是可滚动的(内容高于容器)', box.canScroll, `scrollHeight=${box.sh} clientHeight=${box.ch}`);
const before = await cdp.eval('document.querySelector(".scroll-pane").scrollTop');
// 真实滚轮事件(走浏览器输入管线,能测到事件冒泡与滚动穿透)
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseWheel', x: box.x, y: box.y, deltaX: 0, deltaY: 400,
});
await sleep(600);
const after = await cdp.eval('({ listTop: document.querySelector(".scroll-pane").scrollTop, winY: window.scrollY })');
step('滚轮滚动的是列表而不是整页',
after.listTop > before && after.winY === 0,
`列表 scrollTop ${before} → ${after.listTop};window.scrollY=${after.winY}`);
});
// 撰写弹窗
await safeStep('撰写弹窗', async () => {
await cdp.eval(`(() => {
const btn = [...document.querySelectorAll('button')].find(b => (b.textContent||'').includes('撰写新邮件'));
if (btn) btn.click();
})()`);
await sleep(700);
const dlg = await cdp.eval(`(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return { open: false };
const labels = [...d.querySelectorAll('span')].map(s => s.textContent).filter(Boolean);
return {
open: true,
title: (d.querySelector('h2') || {}).textContent || '',
hasTo: labels.includes('收件人'),
hasCc: labels.includes('抄送'),
hasSubject: labels.includes('主题'),
textarea: !!d.querySelector('textarea'),
sendBtn: [...d.querySelectorAll('button')].some(b => (b.textContent||'').includes('发送')),
attachBtn: [...d.querySelectorAll('button')].some(b => (b.textContent||'').includes('添加附件')),
};
})()`);
step('撰写弹窗打开且字段齐备',
dlg.open && dlg.hasTo && dlg.hasCc && dlg.hasSubject && dlg.textarea && dlg.sendBtn && dlg.attachBtn,
`${dlg.title} · 收件人/抄送/主题/正文/附件/发送 = ${[dlg.hasTo, dlg.hasCc, dlg.hasSubject, dlg.textarea, dlg.attachBtn, dlg.sendBtn].join('/')}`);
await cdp.eval(`document.querySelector('[role="dialog"] button[aria-label], [role="dialog"] button')?.click()`);
await cdp.eval(`(() => {
const d = document.querySelector('[role="dialog"]');
if (d) { const btn = d.querySelector('button[data-slot="dialog-close"]') || d.querySelector('button'); btn && btn.click(); }
})()`);
await sleep(400);
});
// 主题切换
await safeStep('主题切换', async () => {
const before = await cdp.eval('getComputedStyle(document.body).backgroundColor');
const wasDark = await cdp.eval('document.documentElement.classList.contains("dark")');
await cdp.eval(`(() => {
const btn = [...document.querySelectorAll('button')].find(b => (b.title||'') === '切换主题');
if (btn) btn.click();
})()`);
await sleep(500);
const after = await cdp.eval('getComputedStyle(document.body).backgroundColor');
const isDark = await cdp.eval('document.documentElement.classList.contains("dark")');
step('切换主题后背景色确实改变', before !== after && wasDark !== isDark, `${before} → ${after}`);
// 切回
await cdp.eval(`(() => {
const btn = [...document.querySelectorAll('button')].find(b => (b.title||'') === '切换主题');
if (btn) btn.click();
})()`);
await sleep(300);
});
// 横向溢出
const ov = await cdp.eval('({ s: document.documentElement.scrollWidth, c: document.documentElement.clientWidth })');
step('无横向溢出', ov.s <= ov.c + 1, `scrollWidth=${ov.s} clientWidth=${ov.c}`);
say('');
say('── 运行时报错 ─────────────────────────────────────────────');
const real = cdp.errors.filter((v) => !/favicon|DevTools/i.test(v));
if (real.length === 0) say(' 无');
else real.slice(0, 20).forEach((v) => say(' ' + v));
step('页面无 JS 异常与控制台报错', real.length === 0, real.length ? `${real.length} 条` : '无');
} catch (err) {
step('验收脚本自身异常', false, String(err && err.message));
process.exitCode = 2;
} finally {
if (cdp) try { cdp.ws.close(); } catch { /* 忽略 */ }
try { child.kill(); } catch { /* 忽略 */ }
await sleep(400);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
report.push('');
report.push('═'.repeat(74));
report.push(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
for (const r of results.filter((x) => !x.ok)) report.push(` - ${r.name} ${r.detail}`);
report.push('═'.repeat(74));
fs.mkdirSync('E:\\deepseek\\artifacts', { recursive: true });
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-check-v2.txt', report.join('\n'), 'utf8');
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check-v2.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
if (!process.exitCode) process.exitCode = fail ? 1 : 0;
}
})();
+291
View File
@@ -0,0 +1,291 @@
'use strict';
/**
* 账号界面验收(v3):注册 / 忘记密码 / 账号设置。
*
* 验的是「真的能用」,而且**真的打通了服务器**:
* 浏览器 → 客户端本地后端(/api/account/*) → 服务器公网账号入口(https://mail.example.com:9443) → 服务端 v2.2.0
* 所以最后一步是拿一个**故意写错的邀请码**去提交,断言界面上出现服务端返回的「邀请码不正确」——
* 这条链路只要有一环断掉,这个断言就过不了。
*
* 用法:node tools/ui-check-v3.js [url] [--creds <account.json>]
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:8789/';
const credsArg = process.argv.indexOf('--creds');
const CREDS = credsArg > 0 ? process.argv[credsArg + 1] : path.join(__dirname, '..', 'data', 'account.json');
const dirArg = process.argv.indexOf('--config-dir');
const CONFIG_DIR = dirArg > 0 ? process.argv[dirArg + 1] : null;
const PORT = 9226;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
async function safeStep(name, fn) {
try { return await fn(); } catch (err) { step(name, false, `${err.name}: ${err.message}`); return undefined; }
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
} else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') {
c.errors.push('log: ' + m.params.entry.text);
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
return r.result ? r.result.value : undefined;
}
async click(selector) {
return this.eval(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`);
}
async type(selector, value) {
return this.eval(`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
}
}
(async () => {
const base = URL_.replace(/\/$/, '');
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
if (!edge) { process.stderr.write('找不到 msedge.exe\n'); process.exit(2); }
let creds = null;
try { creds = JSON.parse(fs.readFileSync(CREDS, 'utf8')); } catch { /* 没凭据就只跑第一阶段 */ }
// ⚠ 必须在**启动浏览器之前**把实例恢复成「未登录、本机无凭据」:
// 浏览器一启动就会加载页面,等 CDP 连上再清理就晚了 —— 页面已经是旧的邮箱界面,
// 而且它接着轮询会打出一串 409(会话已经被登出)制造假失败。
const resetRows = [];
if (CONFIG_DIR) {
try { await fetch(`${base}/api/logout`, { method: 'POST' }); } catch { /* 无所谓 */ }
const file = path.join(CONFIG_DIR, 'account.json');
try { fs.rmSync(file, { force: true }); } catch { /* 无所谓 */ }
resetRows.push({ name: '准备:实例回到未登录状态(本机凭据已清空)', ok: !fs.existsSync(file), detail: file });
}
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui3-'));
const child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--window-size=1440,900', URL_],
{ stdio: 'ignore' });
let cdp = null;
let allowExpected403 = false; // 故意打错邀请码那一步会真的收到 403,属预期
try {
let target = null;
for (let i = 0; i < 50 && !target; i++) {
await sleep(400);
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
} catch { /* 等 */ }
}
if (!target) throw new Error('等不到 Edge 调试目标');
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Log.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
say('═'.repeat(74));
say('WpywMail 客户端账号界面验收(注册 / 找回密码 / 账号设置)');
say(`页面:${URL_}`);
say(`时间:${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
// ── 第一阶段:登录屏三态
for (const r of resetRows) step(r.name, r.ok, r.detail);
let ready = false;
for (let i = 0; i < 40; i++) {
await sleep(400);
ready = await cdp.eval('!!document.querySelector("#login-user")');
if (ready) break;
}
step('登录屏渲染', ready, ready ? '有登录表单' : '等不到 #login-user');
const entry = await cdp.eval('!!document.querySelector("#to-register")');
step('登录屏有「注册新账号 / 忘记密码」入口', entry);
await safeStep('切到注册模式', async () => {
await cdp.click('#to-register');
await sleep(400);
const reg = await cdp.eval(`(() => ({
user: !!document.querySelector('#reg-user'),
pass: !!document.querySelector('#reg-pass'),
invite: !!document.querySelector('#reg-invite'),
submit: !!document.querySelector('#reg-submit'),
tab: document.body.innerText.includes('注册新账号'),
}))()`);
step('注册表单字段齐备(邮箱/密码/邀请码/提交)', reg.user && reg.pass && reg.invite && reg.submit, JSON.stringify(reg));
});
await safeStep('注册页显示服务器策略', async () => {
let text = '';
for (let i = 0; i < 25; i++) {
await sleep(400);
text = await cdp.eval('document.body.innerText');
if (text.includes('邀请码') && text.includes('密码至少')) break;
}
step('策略来自服务器(邀请码 + 密码长度)', text.includes('邀请码') && text.includes('密码至少 12'),
text.split('\n').find((l) => l.includes('密码至少')) || '(未见)');
step('策略里解释了本机域免验证的原因', text.includes('验证码邮件只能投进'), '');
});
await safeStep('注册提交真的打到服务器(故意用错邀请码)', async () => {
// 这一步是我**故意**让它 403 的:浏览器会把非 2xx 记成一条 log 级错误。
// 所以下面「零 console.error」的断言要把这条预期内的 403 排除掉,否则测试自己制造假失败。
allowExpected403 = true;
await cdp.type('#reg-user', '[email protected]');
await cdp.type('#reg-pass', 'Ui-Check-Pass-2026');
await cdp.type('#reg-invite', 'WRONG-INVITE-CODE');
await cdp.click('#reg-submit');
let text = '';
for (let i = 0; i < 25; i++) {
await sleep(400);
text = await cdp.eval('document.body.innerText');
if (text.includes('邀请码不正确')) break;
}
step('界面显示服务端返回的「邀请码不正确」', text.includes('邀请码不正确'),
text.includes('邀请码不正确') ? '端到端链路通' : text.slice(0, 120));
});
await safeStep('切到忘记密码模式', async () => {
await cdp.eval(`(() => { const b = [...document.querySelectorAll('button')].find(x => x.textContent.trim() === '忘记密码'); if (b) b.click(); })()`);
await sleep(400);
const has = await cdp.eval('!!document.querySelector("#forgot-user") && !!document.querySelector("#forgot-submit")');
step('忘记密码表单字段齐备', has);
});
// ── 第二阶段:账号设置(需要已登录;用本地 API 登录,再刷新页面)
if (creds && creds.user && creds.password) {
await safeStep('登录后账号设置可用', async () => {
const res = await fetch(`${base}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: creds.host, user: creds.user, password: creds.password, displayName: creds.displayName || '' }),
});
const body = await res.json();
step('客户端后端 IMAP 登录成功', res.ok && body.ok, res.ok ? `${(body.folders || []).length} 个文件夹` : JSON.stringify(body));
await cdp.send('Page.navigate', { url: URL_ });
let mounted = false;
for (let i = 0; i < 40; i++) {
await sleep(400);
mounted = await cdp.eval('!!document.querySelector("#account-settings")');
if (mounted) break;
}
step('侧栏出现「账号设置」入口', mounted);
await cdp.click('#account-settings');
let dlg = null;
for (let i = 0; i < 30; i++) {
await sleep(400);
dlg = await cdp.eval(`(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const sessions = d.querySelector('#session-list');
return {
title: (d.querySelector('h2') || {}).textContent || '',
text: d.innerText,
loading: d.innerText.includes('读取中'),
// ⚠ 只数「真的有内容」的行:占位行「读取中…/暂无会话记录」没有 .font-mono,
// 否则拿占位行当数据会得到假通过(这里踩过)。
sessions: sessions ? sessions.querySelectorAll('li .font-mono').length : 0,
audit: d.querySelectorAll('#audit-list li .font-mono').length,
name: !!d.querySelector('#account-name'),
};
})()`);
if (dlg && !dlg.loading && dlg.sessions > 0) break;
}
step('账号设置弹窗打开', !!dlg && dlg.title.includes('账号设置'), dlg ? dlg.title : '没打开');
if (dlg) {
step('显示名输入框就位', dlg.name);
step('会话列表有数据(走服务器账号接口)', dlg.sessions > 0, `${dlg.sessions} 条`);
step('安全记录有数据', dlg.audit > 0, `${dlg.audit} 条`);
step('会话里标出了「当前」设备', dlg.text.includes('当前'), dlg.text.slice(0, 80));
}
});
} else {
step('跳过账号设置(没有凭据文件)', true, CREDS);
}
// ── 收尾:溢出与控制台
const layout = await cdp.eval(`(() => ({
overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyBg: getComputedStyle(document.body).backgroundColor,
}))()`);
step('没有横向溢出', layout.overflowX <= 1, `溢出 ${layout.overflowX}px`);
await sleep(500);
const unexpected = cdp.errors.filter((e) => !(allowExpected403 && /403 \(Forbidden\)/.test(e)));
step('零 JS 异常 / 零 console.error(排除故意触发的 403)', unexpected.length === 0,
unexpected.length ? unexpected.slice(0, 4).join(' | ') : `共 ${cdp.errors.length} 条,均已归类为预期`);
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say('═'.repeat(74));
say(`结果:${pass} 项通过,${fail} 项失败`);
say('═'.repeat(74));
for (const r of results.filter((x) => !x.ok)) say(` [失败] ${r.name} —— ${r.detail}`);
const out = path.join(__dirname, '..', '..', 'artifacts', 'client-ui-check-v3.txt');
try { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, report.join('\n'), 'utf8'); } catch { }
process.exitCode = fail === 0 ? 0 : 1;
} catch (err) {
process.stderr.write(`验收失败:${err.message}\n`);
process.exitCode = 2;
} finally {
try { if (cdp) cdp.ws.close(); } catch { }
try { child.kill(); } catch { }
}
})();
+263
View File
@@ -0,0 +1,263 @@
'use strict';
/**
* HTML 邮件渲染验收(v4)。
*
* 验的是四件事:
* 1. 富文本正文真的渲染出来了(iframe 里有内容,不是空白)
* 2. **邮件里的 <script> 绝对不能执行**(这是最关键的一条:正文是别人写的代码)
* 3. 远程图片默认被拦(否则一打开就把你的 IP/时间告诉发件人),可以手动放行
* 4. 富文本 / 纯文本可以切换
*
* 做法:用客户端自己的发信接口给自己发一封**带恶意脚本的 HTML 邮件**,然后打开它。
* 用法:node tools/ui-check-v4.js [url] [--creds <account.json>] [--keep]
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:8788/';
const credsArg = process.argv.indexOf('--creds');
const CREDS = credsArg > 0 ? process.argv[credsArg + 1] : path.join(__dirname, '..', 'data', 'account.json');
const KEEP = process.argv.includes('--keep');
const PORT = 9228;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' —— ' + detail : ''}\n`);
}
async function api(base, method, p, body) {
const res = await fetch(base + p, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
return r.result ? r.result.value : undefined;
}
}
(async () => {
const base = URL_.replace(/\/$/, '');
const stamp = Date.now().toString().slice(-6);
const subject = `HTML 渲染验收 ${stamp}`;
let uid = null;
let cdp = null;
let child = null;
try {
const creds = JSON.parse(fs.readFileSync(CREDS, 'utf8'));
const login = await api(base, 'POST', '/api/login', {
host: creds.host, user: creds.user, password: creds.password, displayName: creds.displayName || '',
});
step('客户端登录', !!login.ok, `${(login.folders || []).length} 个文件夹`);
// ── 1) 发一封带「恶意脚本 + 远程图片」的 HTML 邮件给自己
const evilHtml = [
'<div style="font-family:sans-serif">',
`<p>富文本正文 <b>加粗</b> <span style="color:#c00">红色</span> ${stamp}</p>`,
'<script>window.__pwned = true; document.title = "PWNED";</script>',
'<img src="https://example.com/tracker.gif?x=1" onerror="window.__pwned=true">',
'<a href="javascript:window.__pwned=true">危险链接</a>',
'<iframe src="https://example.com/x"></iframe>',
'</div>',
].join('');
await api(base, 'POST', '/api/send', {
to: creds.user, subject, text: `这是纯文本兜底 ${stamp}`, html: evilHtml,
});
step('已发出带脚本的 HTML 测试邮件', true, subject);
// ── 2) 等服务端投递到自己的收件箱,并检查净化结果
let message = null;
for (let i = 0; i < 30 && !message; i++) {
await sleep(1000);
const list = await api(base, 'GET', `/api/messages?folder=INBOX&limit=20`);
message = (list.messages || []).find((m) => m.subject === subject) || null;
}
step('测试邮件已投递到收件箱', !!message, message ? `uid=${message.uid}` : '(未收到)');
if (!message) throw new Error('没收到测试邮件,后续无法继续');
uid = message.uid;
const detail = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX`);
step('服务端净化:<script> 被清掉', !/<script/i.test(detail.html || ''), `html 长度 ${(detail.html || '').length}`);
step('服务端净化:记录清理了哪些东西', Array.isArray(detail.sanitized) && detail.sanitized.length > 0,
(detail.sanitized || []).join('、'));
step('服务端净化:iframe 被清掉', !/<iframe/i.test(detail.html || ''), '');
step('服务端净化:javascript: 链接被改写', !/href\s*=\s*["']?javascript:/i.test(detail.html || ''), '');
step('服务端净化:事件处理器被清掉', !/\sonerror\s*=/i.test(detail.html || ''), '');
step('远程图片默认被拦', (detail.blockedImages || []).length > 0, `${(detail.blockedImages || []).length} 张`);
step('渲染文档带 CSP 且禁脚本', /Content-Security-Policy/.test(detail.htmlDocument || '')
&& /default-src 'none'/.test(detail.htmlDocument || ''), '');
step('保留了正常内容(加粗文字还在)', /富文本正文/.test(detail.html || '') && /<b>加粗<\/b>/.test(detail.html || ''), '');
const withImages = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX&images=1`);
step('放行图片后不再拦截(img 保留)', /<img/i.test(withImages.html || ''), `${(withImages.blockedImages || []).length} 张被拦`);
// ── 3) 打开界面,点开这封邮件,检查真的渲染了
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
if (!edge) throw new Error('找不到 msedge.exe');
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui4-'));
child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--window-size=1440,900', URL_],
{ stdio: 'ignore' });
let target = null;
for (let i = 0; i < 50 && !target; i++) {
await sleep(400);
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
} catch { /* 等 */ }
}
if (!target) throw new Error('等不到 Edge 调试目标');
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
let rows = 0;
for (let i = 0; i < 40; i++) {
await sleep(500);
rows = await cdp.eval('document.querySelectorAll("[data-uid]").length');
if (rows > 0) break;
}
step('界面加载出邮件列表', rows > 0, `${rows} 行`);
const clicked = await cdp.eval(`(() => {
const row = [...document.querySelectorAll('[data-uid]')].find(el => el.textContent.includes(${JSON.stringify(stamp)}));
if (!row) return false;
row.click();
return true;
})()`);
step('点开了这封 HTML 邮件', clicked, '');
let probe = null;
for (let i = 0; i < 30; i++) {
await sleep(400);
probe = await cdp.eval(`(() => {
const f = document.querySelector('#html-body');
const article = document.querySelector('article.mail-body');
return {
iframe: !!f,
height: f ? f.getBoundingClientRect().height : 0,
srcdocLen: f && f.getAttribute('srcdoc') ? f.getAttribute('srcdoc').length : 0,
srcdocHasScript: f ? /<script/i.test(f.getAttribute('srcdoc') || '') : null,
srcdocHasCsp: f ? /Content-Security-Policy/.test(f.getAttribute('srcdoc') || '') : null,
sandbox: f ? f.getAttribute('sandbox') : null,
articleVisible: !!article,
toggle: !!document.querySelector('#body-view-toggle'),
notice: document.body.innerText.includes('已拦截') || document.body.innerText.includes('已清理'),
pwned: !!window.__pwned,
pwnedInFrames: (() => { try { return window.frames.length > 0 && !!window.frames[0].__pwned; } catch (e) { return 'cross-origin'; } })(),
};
})()`);
if (probe && probe.iframe && probe.srcdocLen > 0) break;
}
step('HTML 正文渲染在 iframe 里', !!probe && probe.iframe && probe.srcdocLen > 100,
probe ? `srcdoc ${probe.srcdocLen} 字节,高度 ${Math.round(probe.height)}px` : '没找到 #html-body');
step('iframe 带 sandbox 且不给 allow-scripts',
!!probe && typeof probe.sandbox === 'string' && !/allow-scripts/.test(probe.sandbox),
probe ? `sandbox="${probe.sandbox}"` : '');
step('iframe 文档里没有 <script>', !!probe && probe.srcdocHasScript === false, '');
step('iframe 文档带 CSP', !!probe && probe.srcdocHasCsp === true, '');
step('★ 邮件里的脚本没有执行(window.__pwned 未设置)', !!probe && probe.pwned === false,
probe ? String(probe.pwnedInFrames) : '');
step('界面提示了拦截/清理', !!probe && probe.notice, '');
step('有富文本/纯文本切换', !!probe && probe.toggle, '');
// 切纯文本
await cdp.eval(`(() => { const b = document.querySelector('#view-text'); if (b) b.click(); })()`);
await sleep(500);
const textView = await cdp.eval(`(() => ({
iframe: !!document.querySelector('#html-body'),
article: !!document.querySelector('article.mail-body'),
hasText: document.body.innerText.includes(${JSON.stringify(stamp)}),
}))()`);
step('切到纯文本后不再用 iframe、改用 <article>',
!textView.iframe && textView.article && textView.hasText, JSON.stringify(textView));
// 切回富文本
await cdp.eval(`(() => { const b = document.querySelector('#view-html'); if (b) b.click(); })()`);
await sleep(700);
const backToHtml = await cdp.eval('!!document.querySelector("#html-body")');
step('切回富文本正常', backToHtml, '');
const unexpected = cdp.errors.filter((e) => !/403 \(Forbidden\)/.test(e));
step('零 JS 异常 / 零 console.error', unexpected.length === 0, unexpected.slice(0, 3).join(' | '));
} catch (err) {
step('验收过程异常', false, `${err.name}: ${err.message}`);
} finally {
try { if (cdp) cdp.ws.close(); } catch { }
try { if (child) child.kill(); } catch { }
// 清理测试邮件(客户端的删除接口是 POST /api/delete,不是 REST 风格的 DELETE)
if (uid && !KEEP) {
try {
await api(base, 'POST', '/api/delete', { uid, folder: 'INBOX', permanent: true });
step('清理测试邮件(永久删除)', true, `uid=${uid}`);
} catch (err) {
step('清理测试邮件', false, err.message);
}
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say(`结果:${pass} 项通过,${fail} 项失败`);
for (const r of results.filter((x) => !x.ok)) say(` [失败] ${r.name} —— ${r.detail}`);
try {
const out = path.join(__dirname, '..', '..', 'artifacts', 'client-ui-check-v4.txt');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, report.join('\n'), 'utf8');
} catch { }
process.exitCode = fail === 0 ? 0 : 1;
}
})();
+503
View File
@@ -0,0 +1,503 @@
'use strict';
/**
* 界面运行时验收(Edge 无头 + CDP,零依赖)。
*
* 为什么需要它:我看不到截图,但「界面能不能跑」是可以程序化验证的 ——
* 1. 打开页面,收集所有 JS 异常与控制台报错(拼错 id、undefined 属性都会在这里现形)
* 2. 断言真实数据渲染出来了(文件夹数、邮件行数)
* 3. 断言设计 token 真的生效(字重 400、画布色、胶囊圆角、零阴影、发丝线)
* 4. 走一遍交互:选中一封邮件 → 阅读窗格出现正文
*
* 用法:node tools/ui-check.js [url]
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = process.argv[2] || 'http://127.0.0.1:8788/';
const PORT = 9222;
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
/** 容错执行一步:任何异常都不应该让整个验收崩掉,而是记成一条 FAIL 并继续 */
async function safeStep(name, fn) {
try {
return await fn();
} catch (err) {
step(name, false, `${err.name}: ${err.message}`);
return undefined;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function findEdge() {
const candidates = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
];
return candidates.find((p) => fs.existsSync(p)) || null;
}
async function getTarget(timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
const list = await res.json();
const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (page) return page;
} catch { /* 还没起来 */ }
await sleep(400);
}
return null;
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.events = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true });
ws.addEventListener('error', () => reject(new Error('CDP 连接失败')), { once: true });
});
const cdp = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.id && cdp.pending.has(msg.id)) {
const { resolve, reject } = cdp.pending.get(msg.id);
cdp.pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
} else if (msg.method) {
cdp.events.push(msg);
}
});
return cdp;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`${method} 超时`));
}
}, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise: true,
});
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
return r.result ? r.result.value : undefined;
}
close() { try { this.ws.close(); } catch { /* 忽略 */ } }
}
(async () => {
const edge = findEdge();
if (!edge) { process.stderr.write('找不到 msedge.exe\n'); process.exit(2); }
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui-'));
const child = spawn(edge, [
'--headless=new',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${profile}`,
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
'--window-size=1600,1000',
URL_,
], { stdio: 'ignore', detached: false });
let cdp = null;
try {
const target = await getTarget();
if (!target) throw new Error('等不到 Edge 的调试目标');
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
const violations = [];
cdp.ws.addEventListener('message', (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.method === 'Runtime.exceptionThrown') {
const d = msg.params.exceptionDetails;
violations.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
}
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
violations.push('console.error: ' + msg.params.args.map((a) => a.value || a.description || '').join(' '));
}
if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') {
violations.push('log: ' + msg.params.entry.text + ' ' + (msg.params.entry.url || ''));
}
});
await cdp.send('Runtime.enable');
await cdp.send('Log.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
// 等界面渲染出邮件行
let rows = 0;
for (let i = 0; i < 40; i++) {
await sleep(500);
try { rows = await cdp.eval('document.querySelectorAll(".row").length'); } catch { rows = 0; }
if (rows > 0) break;
}
say('═'.repeat(74));
say('WpywMail 客户端 · 界面运行时验收(Edge 无头 + CDP)');
say(`页面:${URL_}`);
say(`时间:${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
step('页面加载且界面已渲染(无致命错误)', rows > 0, `邮件行 ${rows} 个`);
const folders = await cdp.eval('document.querySelectorAll(".rail__item").length');
step('文件夹栏渲染', folders >= 5, `${folders} 个文件夹`);
const title = await cdp.eval('document.getElementById("listTitle").textContent');
step('列表标题正确', !!title, title);
const count = await cdp.eval('document.getElementById("listCount").textContent');
step('列表计数文案存在', !!count, count);
const status = await cdp.eval('document.getElementById("statusText").textContent');
step('连接状态已显示', !!status, status);
const loginHidden = await cdp.eval('document.getElementById("login").hidden');
step('登录屏已隐藏(已连接)', loginHidden === true);
// ── 设计 token 是否真的生效
say('');
say('── 设计 token 实测(x.ai 规范)──────────────────────────────');
const tok = await cdp.eval(`(() => {
const cs = getComputedStyle(document.body);
const btn = document.getElementById('btnCompose');
const bcs = btn ? getComputedStyle(btn) : null;
const row = document.querySelector('.row');
const rcs = row ? getComputedStyle(row) : null;
const rail = document.getElementById('rail');
const railcs = rail ? getComputedStyle(rail) : null;
const eyebrow = document.querySelector('.rail__hd');
const mono = document.querySelector('.mono');
return {
bodyWeight: cs.fontWeight,
synth: cs.fontSynthesis || 'unsupported',
canvas: cs.backgroundColor,
bodyFont: cs.fontFamily,
btnRadius: bcs ? bcs.borderRadius : null,
btnShadow: bcs ? bcs.boxShadow : null,
rowBorderBottom: rcs ? rcs.borderBottomWidth + ' ' + rcs.borderBottomColor : null,
rowShadow: rcs ? rcs.boxShadow : null,
railBorder: railcs ? railcs.borderRightWidth : null,
monoFamily: mono ? getComputedStyle(mono).fontFamily : null,
monoSpacing: mono ? getComputedStyle(mono).letterSpacing : null,
monoUpper: mono ? getComputedStyle(mono).textTransform : null,
eyebrowText: eyebrow ? eyebrow.textContent : null,
};
})()`);
say(` 字重 : ${tok.bodyWeight}`);
say(` font-synthesis : ${tok.synth}`);
say(` 画布底色 : ${tok.canvas}`);
say(` 正文族 : ${String(tok.bodyFont).slice(0, 40)}`);
say(` 按钮圆角 : ${tok.btnRadius}(规范要求 9999px)`);
say(` 按钮阴影 : ${tok.btnShadow}(规范要求 none)`);
say(` 列表行下边框 : ${tok.rowBorderBottom}(发丝线 1px)`);
say(` 侧栏右分隔 : ${tok.railBorder}`);
say(` 等宽字体/字距/大写: ${String(tok.monoFamily).slice(0, 24)} / ${tok.monoSpacing} / ${tok.monoUpper}`);
say(` 眉标文案 : ${tok.eyebrowText}`);
say('');
step('字重全站 400', tok.bodyWeight === '400', tok.bodyWeight);
step('禁用字体合成(不会出现伪粗体)', tok.synth === 'none', tok.synth);
step('画布为规范色 #0a0a0a', tok.canvas === 'rgb(10, 10, 10)', tok.canvas);
step('交互元素为胶囊(9999px)', String(tok.btnRadius) === '9999px', String(tok.btnRadius));
step('零阴影(层次靠发丝线)', tok.btnShadow === 'none' && tok.rowShadow === 'none',
`button=${tok.btnShadow} row=${tok.rowShadow}`);
step('列表行发丝线分隔', /^1px/.test(String(tok.rowBorderBottom)), String(tok.rowBorderBottom));
step('等宽标签带正字距且大写', String(tok.monoSpacing) === '1.2px' && tok.monoUpper === 'uppercase',
`${tok.monoSpacing} / ${tok.monoUpper}`);
// ── 交互:点第一封邮件
say('── 交互 ───────────────────────────────────────────────────');
const clicked = await safeStep('点击列表第一封', () => cdp.eval(`(() => {
const row = document.querySelector('.row');
if (!row) return false;
row.click();
return true;
})()`));
if (!clicked) {
// 没有列表行 → 把列表容器的真实内容与 toast 文案抓出来,便于定位
const diag = await cdp.eval(`(() => {
const b = document.querySelector('.list__body');
return {
listBodyHtml: b ? b.innerHTML.slice(0, 400) : '(无 .list__body)',
toast: (document.getElementById('toastText') || {}).textContent || '',
skeletons: document.querySelectorAll('.skeleton').length,
empties: document.querySelectorAll('.empty').length,
};
})()`);
say(' 诊断 ▸ 列表容器内容:' + JSON.stringify(diag.listBodyHtml));
say(' 诊断 ▸ toast 文案:' + JSON.stringify(diag.toast));
say(` 诊断 ▸ 骨架屏 ${diag.skeletons} 个 / 空态 ${diag.empties} 个`);
report.push(`[FAIL] 点击列表第一封 —— 列表里没有 .row(骨架屏 ${diag.skeletons},空态 ${diag.empties})`);
} else {
let readerOk = false;
let subject = '';
for (let i = 0; i < 24 && !readerOk; i++) {
await sleep(500);
const r = await cdp.eval(`(() => {
const s = document.querySelector('.reader__subject');
const b = document.querySelector('.reader__body');
return { subject: s ? s.textContent : '', bodyLen: b ? b.textContent.length : 0 };
})()`);
subject = r.subject;
readerOk = r.bodyLen > 0;
}
step('点击列表项后阅读窗格显示正文', readerOk, subject);
const activeRow = await cdp.eval('document.querySelectorAll(".row.is-active").length');
step('选中行有 active 状态', activeRow === 1, `${activeRow} 行`);
}
// ── 撰写浮层
await safeStep('打开撰写浮层', async () => {
await cdp.eval('document.getElementById("btnCompose").click()');
await sleep(400);
const composeOpen = await cdp.eval('document.getElementById("compose").hidden === false');
const composeFocus = await cdp.eval('document.activeElement && document.activeElement.id');
step('撰写浮层可打开', composeOpen === true);
step('撰写时焦点落在收件人', composeFocus === 'c-to' || composeFocus === 'c-subject', String(composeFocus));
// 用 Esc 关闭(用户真实操作),并验证关闭后焦点被释放、快捷键恢复可用
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
await sleep(400);
const closed = await cdp.eval('document.getElementById("compose").hidden === true');
const focusInside = await cdp.eval(`(() => {
const a = document.activeElement;
return !!(a && document.getElementById('compose').contains(a));
})()`);
step('按 Esc 关闭撰写浮层', closed === true);
step('关闭后焦点不再滞留在浮层内(否则快捷键会失灵)', focusInside === false,
focusInside ? '焦点仍在浮层内 —— 快捷键会被「正在输入」判定挡住' : '已释放');
});
// ══════════════════ 第二阶段:真实交互驱动 ══════════════════
// 上面验的是「渲染与设计」,下面验的是「界面上的动作真的能改变服务器状态」。
// 全部通过 CDP 派发真实事件(不是直接调内部函数),所以能测到事件绑定与状态同步。
const token = 'WPYW-UI-' + Date.now().toString().slice(-8);
say('');
say('── 交互链路(界面动作 → 服务器状态)─────────────────────────');
// 1) 搜索(中文)
await safeStep('界面搜索(中文)', async () => {
const before = await cdp.eval('document.querySelectorAll(".row").length');
await cdp.eval(`(() => {
const q = document.getElementById('q');
q.value = '验收';
q.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
})()`);
await sleep(2500);
const after = await cdp.eval('document.querySelectorAll(".row").length');
const countText = await cdp.eval('document.getElementById("listCount").textContent');
step('界面搜索「验收」过滤出结果', after > 0 && after < before, `${before} 行 → ${after} 行(${countText})`);
await cdp.eval(`(() => {
document.getElementById('qClear').click();
})()`);
await sleep(1800);
const restored = await cdp.eval('document.querySelectorAll(".row").length');
step('清除搜索后恢复完整列表', restored === before, `${restored} 行`);
});
// 2) 旗标 / 已读(键盘快捷键)
await safeStep('键盘旗标与已读', async () => {
await cdp.eval('document.querySelector(".row").click()');
await sleep(1200);
const uid = await cdp.eval('document.querySelector(".row")?.dataset.uid');
const beforeFlag = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(1500);
const afterFlag = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
step('按 S 加旗标后行状态变化', beforeFlag !== afterFlag, `${beforeFlag} → ${afterFlag}(UID ${uid})`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(1500);
const restored = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
step('再按 S 恢复原状', restored === beforeFlag, `${restored}`);
});
// 3) 回复预填
await safeStep('回复预填', async () => {
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }))`);
await sleep(600);
const info = await cdp.eval(`(() => ({
open: document.getElementById('compose').hidden === false,
title: document.getElementById('composeTitle').textContent,
to: document.getElementById('c-to').value,
subject: document.getElementById('c-subject').value,
quoted: document.getElementById('c-text').value.includes('原邮件'),
}))()`);
step('按 R 打开回复并预填收件人/主题/引用原文',
info.open && !!info.to && /^Re:/i.test(info.subject) && info.quoted,
`${info.title} → ${info.subject} | 收件人 ${info.to}`);
await cdp.eval('document.getElementById("composeClose").click()');
await sleep(300);
});
// 4) 从界面写一封带附件的信并发送 → 回查到达 → 键盘删除
await safeStep('界面发信(含附件)', async () => {
const attachPath = path.join(os.tmpdir(), 'wpyw-ui-附件-测试.txt');
fs.writeFileSync(attachPath, '这是从客户端界面添加的附件内容。\n中文附件正文。\n', 'utf8');
await cdp.send('DOM.enable');
await cdp.eval('document.getElementById("btnCompose").click()');
await sleep(400);
await cdp.eval(`(() => {
document.getElementById('c-to').value = '[email protected]';
document.getElementById('c-subject').value = '界面发信验收 ${token} · 中文';
document.getElementById('c-text').value = '这封是从界面点\"发送\"发出来的。\\n标记:${token}\\n标点:你好,世界。()《》——';
})()`);
// 用 CDP 给 file input 塞真实文件(触发 change → 界面读成 base64)
const doc = await cdp.send('DOM.getDocument');
const node = await cdp.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#c-att' });
if (node && node.nodeId) {
await cdp.send('DOM.setFileInputFiles', { files: [attachPath], nodeId: node.nodeId });
await sleep(800);
}
const attInfo = await cdp.eval('document.getElementById("c-status").textContent');
step('界面能读出所选附件', /附件/.test(attInfo), attInfo || '(无提示)');
await cdp.eval('document.getElementById("c-send").click()');
let toastText = '';
for (let i = 0; i < 30; i++) {
await sleep(1000);
toastText = await cdp.eval('document.getElementById("toastText").textContent');
if (/已发送|失败/.test(toastText)) break;
}
step('界面点发送后提示成功', /已发送/.test(toastText), toastText);
// 回查收件箱(走客户端自己的 API)
let arrived = null;
for (let i = 0; i < 15 && !arrived; i++) {
await sleep(2000);
const res = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=INBOX&limit=10`);
const data = await res.json();
arrived = (data.messages || []).find((m) => (m.subject || '').includes(token)) || null;
}
step('界面发出的信到达收件箱', !!arrived, arrived ? `UID ${arrived.uid}` : '未找到');
if (arrived) {
const detail = await fetch(`${URL_.replace(/\/$/, '')}/api/messages/${arrived.uid}?folder=INBOX`).then((r) => r.json());
step('附件经界面链路完整送达(文件名与大小)',
detail.attachments && detail.attachments.length === 1 && /测试\.txt$/.test(detail.attachments[0].filename),
detail.attachments && detail.attachments.length ? `${detail.attachments[0].filename} ${detail.attachments[0].size}B` : '无附件');
step('中文主题与正文经界面链路无损',
detail.subject.includes(token) && /你好,世界。()《》——/.test(detail.text || ''),
detail.subject);
// 键盘删除(#):把刚发的这封从收件箱移走,顺便验证快捷键
await cdp.eval('document.getElementById("btnRefresh").click()');
await sleep(2500);
await cdp.eval(`(() => {
const row = [...document.querySelectorAll('.row')].find(r => r.dataset.uid === '${arrived.uid}');
if (row) row.click();
})()`);
await sleep(1200);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '#', bubbles: true }))`);
let gone = false;
for (let i = 0; i < 15 && !gone; i++) {
await sleep(1000);
gone = await cdp.eval(`!document.querySelector('.row[data-uid="${arrived.uid}"]')`);
}
step('按 # 删除后该行从列表消失', gone, `UID ${arrived.uid}`);
// 清理:把收件箱里这封(已进 Trash)与「已发送」的副本都彻底删掉,不留垃圾
try {
const trash = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=Trash&limit=30`).then((r) => r.json());
const inTrash = (trash.messages || []).filter((m) => (m.subject || '').includes(token));
for (const m of inTrash) {
await fetch(`${URL_.replace(/\/$/, '')}/api/delete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: m.uid, folder: 'Trash', permanent: true }),
});
}
const sent = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=Sent&limit=30`).then((r) => r.json());
const inSent = (sent.messages || []).filter((m) => (m.subject || '').includes(token));
for (const m of inSent) {
await fetch(`${URL_.replace(/\/$/, '')}/api/delete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: m.uid, folder: 'Sent', permanent: true }),
});
}
step('清理测试痕迹', true, `Trash ${inTrash.length} 封 / Sent ${inSent.length} 封已彻底删除`);
} catch (err) {
step('清理测试痕迹', false, err.message);
}
}
try { fs.rmSync(attachPath, { force: true }); } catch { /* 忽略 */ }
});
// ── 横向溢出(踩过的坑)
const overflow = await cdp.eval(`(() => {
const de = document.documentElement;
return { scrollW: de.scrollWidth, clientW: de.clientWidth };
})()`);
step('无横向溢出', overflow.scrollW <= overflow.clientW + 1,
`scrollWidth=${overflow.scrollW} clientWidth=${overflow.clientW}`);
// ── 报错汇总
say('');
say('── 运行时报错 ─────────────────────────────────────────────');
const real = violations.filter((v) => !/favicon/i.test(v));
if (real.length === 0) say(' 无');
else real.slice(0, 20).forEach((v) => say(' ' + v));
step('页面无 JS 异常与控制台报错', real.length === 0, real.length ? `${real.length} 条` : '无');
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say('═'.repeat(74));
say(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
if (fail) for (const r of results.filter((x) => !x.ok)) say(` - ${r.name} ${r.detail}`);
say('═'.repeat(74));
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-check.txt', report.join('\n'), 'utf8');
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
process.exitCode = fail ? 1 : 0;
} catch (err) {
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
step('验收脚本自身异常(不代表界面有问题)', false, String(err && err.message));
process.exitCode = 2;
} finally {
if (cdp) cdp.close();
try { child.kill(); } catch { /* 忽略 */ }
await sleep(500);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
// 无论如何都把报告落盘 —— 崩溃时更需要它
try {
if (report.length) {
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
report.push('');
report.push('═'.repeat(74));
report.push(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
for (const r of results.filter((x) => !x.ok)) report.push(` - ${r.name} ${r.detail}`);
report.push('═'.repeat(74));
fs.mkdirSync('E:\\deepseek\\artifacts', { recursive: true });
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-check.txt', report.join('\n'), 'utf8');
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
}
} catch { /* 忽略 */ }
}
})();
+161
View File
@@ -0,0 +1,161 @@
'use strict';
/**
* 针对性诊断:键盘动作(S / R / #)为什么没生效。
* 做法:无头打开页面,把 app.js 里的内部状态(state.selected / state.open)与
* 当前焦点元素、toast 文案都直接读出来 —— 比猜快得多。
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = 'http://127.0.0.1:8788/';
const PORT = 9223;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP connect failed')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push((d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' timeout')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) return { __err: r.exceptionDetails.text || 'eval error' };
return r.result ? r.result.value : undefined;
}
}
(async () => {
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-diag-'));
const child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--disable-extensions', '--window-size=1600,1000', URL_], { stdio: 'ignore' });
let cdp = null;
const out = [];
const log = (s) => { out.push(s); process.stdout.write(s + '\n'); };
try {
let target = null;
for (let i = 0; i < 40 && !target; i++) {
await sleep(400);
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
} catch { /* 等 */ }
}
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Page.navigate', { url: URL_ });
// 等界面渲染
let rows = 0;
for (let i = 0; i < 40 && rows === 0; i++) { await sleep(500); rows = await cdp.eval('document.querySelectorAll(".row").length'); }
log(`邮件行 = ${rows}`);
// state 能不能从外部读到?
const probe = await cdp.eval('typeof state');
log(`typeof state = ${probe}`);
log('');
log('── 步骤 1:点击第一行 ──────────────────────────');
await cdp.eval('document.querySelector(".row").click()');
await sleep(1500);
log(JSON.stringify(await cdp.eval(`({
selected: (typeof state !== 'undefined' ? state.selected : 'N/A'),
openUid: (typeof state !== 'undefined' && state.open ? state.open.uid : null),
activeTag: document.activeElement ? document.activeElement.tagName : null,
activeId: document.activeElement ? document.activeElement.id : null,
readerSubject: (document.querySelector('.reader__subject')||{}).textContent || '',
toast: (document.getElementById('toastText')||{}).textContent || '',
rowsNow: document.querySelectorAll('.row').length,
})`)));
log('');
log('── 步骤 2:派发 keydown "s" ────────────────────');
const uid = await cdp.eval('document.querySelector(".row")?.dataset.uid');
log(`目标 UID = ${uid}`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(1800);
log(JSON.stringify(await cdp.eval(`({
flaggedInState: (() => { const m = state.messages.find(x => String(x.uid) === '${uid}'); return m ? m.flagged : 'not-found'; })(),
rowHasFlagClass: !!(document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')),
openFlagged: (state.open ? state.open.flagged : null),
toast: (document.getElementById('toastText')||{}).textContent || '',
})`)));
log('');
log('── 步骤 3:派发 keydown "r" ────────────────────');
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }))`);
await sleep(800);
log(JSON.stringify(await cdp.eval(`({
composeHidden: document.getElementById('compose').hidden,
composeTitle: document.getElementById('composeTitle').textContent,
openUid: (state.open ? state.open.uid : null),
})`)));
log('');
log('── 步骤 4:直接在 document 上监听,验证事件到底有没有到达 ──');
await cdp.eval(`(() => {
window.__probe = [];
document.addEventListener('keydown', (e) => window.__probe.push(e.key + '|' + (document.activeElement ? document.activeElement.tagName : '?')), true);
return true;
})()`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(600);
log('捕获到的 keydown:' + JSON.stringify(await cdp.eval('window.__probe')));
// 对比:真实用户按键(CDP Input.dispatchKeyEvent 走浏览器输入管线)
log('');
log('── 步骤 5:用 CDP 真实键盘事件(Input.dispatchKeyEvent)──');
await cdp.eval('document.querySelector(".row").click()');
await sleep(1200);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 's', code: 'KeyS', windowsVirtualKeyCode: 83, nativeVirtualKeyCode: 83, text: 's', unmodifiedText: 's' });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 's', code: 'KeyS', windowsVirtualKeyCode: 83, nativeVirtualKeyCode: 83 });
await sleep(1800);
log(JSON.stringify(await cdp.eval(`({
rowHasFlagClass: !!(document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')),
toast: (document.getElementById('toastText')||{}).textContent || '',
})`)));
log('');
log('运行时报错:' + JSON.stringify(cdp.errors.slice(0, 10)));
} catch (err) {
log('FATAL ' + (err && err.stack ? err.stack : err));
} finally {
if (cdp) try { cdp.ws.close(); } catch { /* 忽略 */ }
try { child.kill(); } catch { /* 忽略 */ }
await sleep(400);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-diag.txt', out.join('\n'), 'utf8');
}
})();
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WpywMail</title>
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<script>
// 主题记忆(默认深色):尽早设置,避免首屏闪白
try {
const saved = localStorage.getItem('wpywmail-theme');
const dark = saved ? saved === 'dark' : true;
document.documentElement.classList.toggle('dark', dark);
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3481
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "wpywmail-ui",
"private": true,
"version": "0.2.0",
"type": "module",
"description": "WpywMail 客户端界面(shadcn/ui + Tailwind + Vite)",
"scripts": {
"dev": "vite",
"build": "tsc -b --noCheck && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.12"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0a0a0a"/>
<rect x="6" y="9" width="20" height="14" rx="3" fill="none" stroke="#fafafa" stroke-width="2"/>
<path d="M7 11.5l9 6.5 9-6.5" fill="none" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+477
View File
@@ -0,0 +1,477 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Menu, Moon, RefreshCw, Search, Sun, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ComposeDialog, type ComposeMode } from '@/components/mail/ComposeDialog';
import { LoginScreen } from '@/components/mail/LoginScreen';
import { MailDisplay } from '@/components/mail/MailDisplay';
import { MailList } from '@/components/mail/MailList';
import { MailSidebar, folderLabel } from '@/components/mail/MailSidebar';
import { api, type AccountProfile, type Folder, type MailDetail, type MailSummary, type StateResponse } from '@/lib/api';
import { cn } from '@/lib/utils';
type Compose = { open: boolean; mode: ComposeMode; source: MailDetail | null };
export default function App() {
const [booted, setBooted] = useState(false);
const [state, setState] = useState<StateResponse | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const [folder, setFolder] = useState('INBOX');
const [messages, setMessages] = useState<MailSummary[]>([]);
const [total, setTotal] = useState(0);
const [listLoading, setListLoading] = useState(true);
const [selected, setSelected] = useState<number | null>(null);
const [detail, setDetail] = useState<MailDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [query, setQuery] = useState('');
const [searchInput, setSearchInput] = useState('');
const [compose, setCompose] = useState<Compose>({ open: false, mode: 'new', source: null });
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'));
const [sidebarOpen, setSidebarOpen] = useState(false);
const [profile, setProfile] = useState<AccountProfile | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const toastTimer = useRef<number | null>(null);
const notify = useCallback((text: string, error = false) => {
setToast({ text, error });
if (toastTimer.current) window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), error ? 6000 : 2600);
}, []);
/* ── 主题 ─────────────────────────────────────────────── */
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
try {
localStorage.setItem('wpywmail-theme', dark ? 'dark' : 'light');
} catch {
/* 忽略 */
}
}, [dark]);
/* ── 启动 ─────────────────────────────────────────────── */
const loadFolders = useCallback(async (refresh = true) => {
try {
const { folders: next } = await api.folders(refresh);
setFolders(next);
return next;
} catch {
return [];
}
}, []);
// 文件夹计数刷新走防抖:每刷一次要在同一条 IMAP 连接上串行跑 6 个 STATUS,
// 点一封邮件就立刻刷会打断阅读节奏。合并到 700ms 后一次性刷新。
const foldersTimer = useRef<number | null>(null);
const scheduleFoldersRefresh = useCallback(() => {
if (foldersTimer.current) window.clearTimeout(foldersTimer.current);
foldersTimer.current = window.setTimeout(() => {
foldersTimer.current = null;
scheduleFoldersRefresh();
}, 700);
}, [loadFolders]);
const loadMessages = useCallback(
async (targetFolder: string, q = query) => {
setListLoading(true);
try {
const data = await api.messages(targetFolder, { limit: 100, q: q || undefined });
setMessages(data.messages);
setTotal(data.total);
} catch (err) {
setMessages([]);
setTotal(0);
notify(`读取列表失败:${err instanceof Error ? err.message : String(err)}`, true);
} finally {
setListLoading(false);
}
},
[notify, query],
);
useEffect(() => {
void (async () => {
try {
const s = await api.state();
setState(s);
if (s.connected && s.folders) {
setFolders(s.folders);
const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? s.folders[0]?.name ?? 'INBOX';
setFolder(inbox);
await loadMessages(inbox, '');
// 账号设置(显示名/会话/审计)走服务器的账号接口;拿不到就静默跳过,不影响收发信
void api.account
.overview()
.then((r) => setProfile(r.profile))
.catch(() => setProfile(null));
}
} catch (err) {
notify(`初始化失败:${err instanceof Error ? err.message : String(err)}`, true);
} finally {
setBooted(true);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── 文件夹轮询(只刷计数,不打断阅读) ─────────────────── */
useEffect(() => {
if (!state?.connected) return;
const id = window.setInterval(() => {
if (!document.hidden) void loadFolders(true);
}, 30000);
return () => window.clearInterval(id);
}, [state?.connected, loadFolders]);
/* ── 打开邮件 ─────────────────────────────────────────── */
// 正文缓存 + 相邻预取:J/K 逐封翻的时候不该每次都等一个网络往返。
const cacheRef = useRef(new Map<number, MailDetail>());
const prefetchingRef = useRef(new Set<number>());
const prefetch = useCallback(
(uid: number, targetFolder: string) => {
if (cacheRef.current.has(uid) || prefetchingRef.current.has(uid)) return;
prefetchingRef.current.add(uid);
api
.message(uid, targetFolder)
.then((mail) => cacheRef.current.set(uid, mail))
.catch(() => {})
.finally(() => prefetchingRef.current.delete(uid));
},
[],
);
const openMail = useCallback(
async (uid: number) => {
setSelected(uid);
const cached = cacheRef.current.get(uid);
if (cached) {
// 命中缓存:立刻显示,后台再刷新一次(已读状态等可能变了)
setDetail(cached);
setDetailLoading(false);
} else {
setDetailLoading(true);
}
try {
const mail = await api.message(uid, folder);
cacheRef.current.set(uid, mail);
setDetail(mail);
if (!mail.seen) {
setMessages((prev) => prev.map((m) => (m.uid === uid ? { ...m, seen: true } : m)));
void api.setFlags(uid, folder, { seen: true }).then(() => scheduleFoldersRefresh()).catch(() => {});
}
// 预取上下相邻两封,翻页时直接命中缓存
const idx = messages.findIndex((m) => m.uid === uid);
if (idx >= 0) {
if (messages[idx + 1]) prefetch(messages[idx + 1].uid, folder);
if (messages[idx - 1]) prefetch(messages[idx - 1].uid, folder);
}
} catch (err) {
if (!cached) {
setDetail(null);
notify(`打开失败:${err instanceof Error ? err.message : String(err)}`, true);
}
} finally {
setDetailLoading(false);
}
},
[folder, loadFolders, messages, notify, prefetch],
);
const selectFolder = useCallback(
async (name: string) => {
setFolder(name);
setQuery('');
setSearchInput('');
setSelected(null);
setDetail(null);
setSidebarOpen(false);
await loadMessages(name, '');
},
[loadMessages],
);
/* ── 动作 ─────────────────────────────────────────────── */
const afterMutate = useCallback(
async (uid: number, message: string) => {
setMessages((prev) => prev.filter((m) => m.uid !== uid));
if (selected === uid) {
setSelected(null);
setDetail(null);
}
notify(message);
scheduleFoldersRefresh();
},
[notify, scheduleFoldersRefresh, selected],
);
const toggleFlag = useCallback(async () => {
if (selected == null) return;
const current = messages.find((m) => m.uid === selected)?.flagged ?? detail?.flagged ?? false;
try {
await api.setFlags(selected, folder, { flagged: !current });
setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, flagged: !current } : m)));
setDetail((d) => (d && d.uid === selected ? { ...d, flagged: !current } : d));
notify(!current ? '已加旗标' : '已取消旗标');
} catch (err) {
notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true);
}
}, [detail?.flagged, folder, messages, notify, selected]);
const toggleSeen = useCallback(async () => {
if (selected == null) return;
const current = messages.find((m) => m.uid === selected)?.seen ?? detail?.seen ?? true;
try {
await api.setFlags(selected, folder, { seen: !current });
setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, seen: !current } : m)));
setDetail((d) => (d && d.uid === selected ? { ...d, seen: !current } : d));
scheduleFoldersRefresh();
notify(!current ? '已标为已读' : '已标为未读');
} catch (err) {
notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true);
}
}, [detail?.seen, folder, loadFolders, messages, notify, selected]);
const archive = useCallback(async () => {
if (selected == null) return;
try {
await api.move(selected, folder, 'Archive');
await afterMutate(selected, '已归档');
} catch (err) {
notify(`归档失败:${err instanceof Error ? err.message : String(err)}`, true);
}
}, [afterMutate, folder, notify, selected]);
const remove = useCallback(async () => {
if (selected == null) return;
try {
const out = await api.remove(selected, folder);
await afterMutate(selected, out.moved ? `已移入 ${folderLabel(out.folder || 'Trash')}` : '已删除');
} catch (err) {
notify(`删除失败:${err instanceof Error ? err.message : String(err)}`, true);
}
}, [afterMutate, folder, notify, selected]);
const openCompose = useCallback((mode: ComposeMode, source: MailDetail | null = null) => {
setCompose({ open: true, mode, source });
}, []);
const quickReply = useCallback(
async (body: string) => {
if (!detail) return;
const target = detail.replyTo?.[0]?.address ?? detail.from?.[0]?.address ?? '';
try {
const out = await api.send({
to: target,
subject: /^re:/i.test(detail.subject) ? detail.subject : `Re: ${detail.subject}`,
text: body,
inReplyTo: detail.messageId ?? undefined,
references: detail.references ?? detail.messageId ?? undefined,
});
notify(`已回复 ${out.recipients.join('、')}`);
scheduleFoldersRefresh();
} catch (err) {
notify(`发送失败:${err instanceof Error ? err.message : String(err)}`, true);
}
},
[detail, notify, scheduleFoldersRefresh],
);
/* ── 键盘 ─────────────────────────────────────────────── */
const move = useCallback(
(delta: number) => {
if (messages.length === 0) return;
const idx = messages.findIndex((m) => m.uid === selected);
const next = Math.min(Math.max((idx < 0 ? (delta > 0 ? -1 : messages.length) : idx) + delta, 0), messages.length - 1);
const uid = messages[next].uid;
void openMail(uid);
document.querySelector(`[data-uid="${uid}"]`)?.scrollIntoView({ block: 'nearest' });
},
[messages, openMail, selected],
);
useEffect(() => {
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const typing = !!target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
if (compose.open) return;
if (e.key === 'Escape') {
if (typing) target?.blur();
else setSidebarOpen(false);
return;
}
if (typing) return;
if (e.key === 'j' || e.key === 'ArrowDown') return move(1);
if (e.key === 'k' || e.key === 'ArrowUp') return move(-1);
if (e.key === 'Enter' && selected != null) return void openMail(selected);
if (e.key === 'c') return openCompose('new');
if (e.key === '/') {
e.preventDefault();
searchRef.current?.focus();
return;
}
if (!detail) return;
if (e.key === 'r') return openCompose('reply', detail);
if (e.key === 'f') return openCompose('forward', detail);
if (e.key === 's') return void toggleFlag();
if (e.key === 'u') return void toggleSeen();
if (e.key === 'e') return void archive();
if (e.key === '#') return void remove();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [archive, compose.open, detail, move, openCompose, openMail, remove, selected, toggleFlag, toggleSeen]);
const account = useMemo(
() => ({
user: state?.account.user ?? '',
displayName: state?.account.displayName || state?.account.user || '',
host: state?.account.host ?? '',
}),
[state],
);
/* ── 渲染 ─────────────────────────────────────────────── */
if (!booted) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">正在连接邮件服务器…</div>
);
}
if (!state?.connected && !state?.account.hasPassword) {
return (
<LoginScreen
initial={{ host: state?.account.host ?? 'mail.example.com', user: state?.account.user ?? '', displayName: '' }}
error={state?.error}
onSuccess={async () => {
const s = await api.state();
setState(s);
if (s.folders) {
setFolders(s.folders);
const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? 'INBOX';
setFolder(inbox);
await loadMessages(inbox, '');
}
notify('已连接');
}}
/>
);
}
return (
<div className="bg-background text-foreground flex h-full overflow-hidden">
{/* 侧栏(窄屏收进抽屉) */}
<MailSidebar
folders={folders}
current={folder}
account={account}
profile={profile}
onSelect={(n) => void selectFolder(n)}
onCompose={() => openCompose('new')}
onDisplayName={(name) => setState((s) => (s ? { ...s, account: { ...s.account, displayName: name } } : s))}
className={cn('w-64 shrink-0', 'max-lg:absolute max-lg:z-30 max-lg:h-full', !sidebarOpen && 'max-lg:hidden')}
/>
{/* 列表 */}
<div className="flex min-h-0 w-[380px] shrink-0 flex-col max-lg:w-[340px] max-md:w-full">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={() => setSidebarOpen((v) => !v)}>
<Menu className="size-4" />
</Button>
<div className="relative flex-1">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
ref={searchRef}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setQuery(searchInput.trim());
void loadMessages(folder, searchInput.trim());
}
}}
placeholder="搜索主题 / 发件人(回车)"
className="h-9 pl-8"
/>
{(query || searchInput) && (
<button
type="button"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => {
setSearchInput('');
setQuery('');
void loadMessages(folder, '');
}}
>
<X className="size-4" />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
title="刷新"
onClick={() => {
void loadMessages(folder, query);
void loadFolders(true);
}}
>
<RefreshCw className="size-4" />
</Button>
<Button variant="ghost" size="icon" title="切换主题" onClick={() => setDark((v) => !v)}>
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
</header>
<MailList
messages={messages}
total={total}
selectedUid={selected}
loading={listLoading}
query={query}
folderLabel={folderLabel(folder)}
onSelect={(uid) => void openMail(uid)}
className="min-h-0 flex-1"
/>
</div>
{/* 阅读区 */}
<MailDisplay
mail={detail}
loading={detailLoading}
onArchive={() => void archive()}
onDelete={() => void remove()}
onToggleFlag={() => void toggleFlag()}
onToggleSeen={() => void toggleSeen()}
onReply={() => openCompose('reply', detail)}
onForward={() => openCompose('forward', detail)}
onQuickReply={quickReply}
/>
<ComposeDialog
open={compose.open}
mode={compose.mode}
source={compose.source}
selfAddress={account.user}
onClose={() => setCompose((c) => ({ ...c, open: false }))}
onSent={(info) => {
notify(`已发送给 ${info.recipients.join('、')}`);
scheduleFoldersRefresh();
if (folder.toUpperCase() === 'SENT') void loadMessages(folder, query);
}}
onError={(m) => notify(m, true)}
/>
{toast && (
<div
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 slide-in-from-bottom-2 fixed bottom-6 left-1/2 z-50 max-w-[min(560px,90vw)] -translate-x-1/2 rounded-lg border px-4 py-3 text-sm shadow-lg',
toast.error && 'border-destructive/40',
)}
>
{toast.text}
</div>
)}
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import { Settings2, ShieldCheck, LogOut, Save, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { api, type AccountProfile, type AccountSession, type AuditEvent } from '@/lib/api';
const REASON_LABEL: Record<string, string> = {
register: '注册',
'register-verify': '注册验证通过',
'login-ok': '登录成功',
'login-failed': '登录失败',
'login-locked': '登录被锁定拦截',
'reset-request': '申请重置密码',
'reset-ok': '密码已重置',
'password-changed': '修改密码',
'profile-updated': '修改资料',
'session-revoked': '退出其他设备',
'code-sent': '重发验证码',
'code-failed': '验证码输错',
'account-purged': '账号被删除',
};
/**
* 账号设置:显示名、会话(在哪登录过 / 退出其他设备)、改密码、最近的安全记录。
*
* 这些走服务器的「账号接口」(客户端后端按需用 IMAP 凭据换一个 token),
* 所以服务器没开这个入口时这里会明确报错,但**不影响正常的收发信**。
*/
export function AccountDialog({ account, onDisplayName }: { account: AccountProfile | null; onDisplayName?: (name: string) => void }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [profile, setProfile] = useState<AccountProfile | null>(null);
const [sessions, setSessions] = useState<AccountSession[]>([]);
const [audit, setAudit] = useState<AuditEvent[]>([]);
const [displayName, setDisplayName] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const r = await api.account.overview();
setProfile(r.profile);
setSessions(r.sessions || []);
setAudit(r.audit || []);
setDisplayName((r.profile && r.profile.displayName) || '');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
if (open) void load();
}, [open]);
async function saveName() {
setBusy(true);
setError(null);
setNotice(null);
try {
const r = await api.account.updateProfile(displayName);
setProfile(r.user);
onDisplayName?.(r.user.displayName);
setNotice('显示名已更新。');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function changePassword() {
setBusy(true);
setError(null);
setNotice(null);
try {
const r = await api.account.changePassword({ currentPassword, password: newPassword });
setNotice(`密码已修改,其他设备被退出(${r.revokedSessions} 个会话)。`);
setCurrentPassword('');
setNewPassword('');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function revokeOthers() {
setBusy(true);
setError(null);
setNotice(null);
try {
const r = await api.account.revokeSessions({});
setNotice(`已退出其他设备(${r.revoked} 个会话)。`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
id="account-settings"
type="button"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
title="账号设置"
>
<Settings2 className="size-3.5" />
账号设置
</button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>账号设置</DialogTitle>
<DialogDescription>
{profile?.email || account?.email || '当前账号'} · 登录 {sessions.length} 个设备
</DialogDescription>
</DialogHeader>
{error && (
<div className="border-destructive/40 bg-destructive/5 text-destructive rounded-md border px-3 py-2 text-sm">
{error}
</div>
)}
{notice && <div className="border-primary/30 bg-primary/5 rounded-md border px-3 py-2 text-sm">{notice}</div>}
<section className="space-y-3">
<h3 className="text-sm font-medium">显示名</h3>
<div className="flex gap-2">
<Input id="account-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="发件时显示的名字" />
<Button type="button" onClick={saveName} disabled={busy} className="shrink-0">
<Save className="size-4" />
保存
</Button>
</div>
</section>
<Separator />
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="inline-flex items-center gap-1.5 text-sm font-medium">
<ShieldCheck className="size-4" />
登录设备
</h3>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void load()} disabled={busy || loading}>
<RefreshCw className={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
刷新
</Button>
<Button type="button" variant="outline" size="sm" onClick={revokeOthers} disabled={busy || sessions.length < 2}>
<LogOut className="size-3.5" />
退出其他设备
</Button>
</div>
</div>
<ul className="space-y-2" id="session-list">
{sessions.length === 0 && <li className="text-muted-foreground text-sm">{loading ? '读取中…' : '暂无会话记录'}</li>}
{sessions.map((s) => (
<li key={s.token} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<div className="min-w-0">
<div className="font-mono text-xs">{s.tokenPrefix}…</div>
<div className="text-muted-foreground text-xs">
创建 {fmt(s.createdAt)} · 到期 {fmt(s.expiresAt)}
</div>
</div>
{s.current && <span className="bg-primary/10 text-primary shrink-0 rounded-full px-2 py-0.5 text-xs">当前</span>}
</li>
))}
</ul>
</section>
<Separator />
<section className="space-y-3">
<h3 className="text-sm font-medium">修改密码</h3>
<div className="grid gap-2 sm:grid-cols-2">
<Input
id="account-current-pass"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="当前密码"
autoComplete="current-password"
/>
<Input
id="account-new-pass"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="新密码(至少 12 位)"
autoComplete="new-password"
/>
</div>
<Button
type="button"
onClick={changePassword}
disabled={busy || !currentPassword || newPassword.length < 12}
className="w-full sm:w-auto"
>
修改密码
</Button>
<p className="text-muted-foreground text-xs">改完会自动更新本机保存的密码,并把其他设备踢下线。</p>
</section>
<Separator />
<section className="space-y-2">
<h3 className="text-sm font-medium">最近的安全记录</h3>
<ul className="space-y-1 text-xs" id="audit-list">
{audit.length === 0 && <li className="text-muted-foreground">{loading ? '读取中…' : '暂无记录'}</li>}
{audit.slice(0, 12).map((e, i) => (
<li key={`${e.at}-${i}`} className="flex items-center justify-between gap-3">
<span className={e.success ? '' : 'text-destructive'}>
{REASON_LABEL[e.reason] || e.reason}
{e.detail ? ` · ${e.detail}` : ''}
</span>
<span className="text-muted-foreground shrink-0 font-mono">{fmt(e.at)}</span>
</li>
))}
</ul>
</section>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function fmt(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, Paperclip, Send, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { api, type MailDetail } from '@/lib/api';
import { formatSize } from '@/lib/utils';
export type ComposeMode = 'new' | 'reply' | 'forward';
type Pending = { filename: string; contentType: string; base64: string; size: number };
async function fileToBase64(file: File): Promise<string> {
const buf = new Uint8Array(await file.arrayBuffer());
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < buf.length; i += chunk) {
binary += String.fromCharCode(...buf.subarray(i, i + chunk));
}
return btoa(binary);
}
export function ComposeDialog({
open,
mode,
source,
selfAddress,
onClose,
onSent,
onError,
}: {
open: boolean;
mode: ComposeMode;
source: MailDetail | null;
selfAddress: string;
onClose: () => void;
onSent: (info: { recipients: string[]; bytes: number }) => void;
onError: (message: string) => void;
}) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [subject, setSubject] = useState('');
const [text, setText] = useState('');
const [pending, setPending] = useState<Pending[]>([]);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
// 打开时按模式预填(回复/转发)
useEffect(() => {
if (!open) return;
setStatus('');
setPending([]);
if (mode === 'reply' && source) {
const src = source.replyTo?.[0] ?? source.from?.[0];
setTo(src?.address ?? '');
setSubject(/^re:/i.test(source.subject) ? source.subject : `Re: ${source.subject}`);
setText(
`\n\n——— 原邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n时间:${source.date ?? ''}\n\n${source.text}`,
);
} else if (mode === 'forward' && source) {
setTo('');
setSubject(/^fwd:/i.test(source.subject) ? source.subject : `Fwd: ${source.subject}`);
setText(
`\n\n——— 转发的邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n收件人:${source.to
.map((a) => a.name || a.address)
.join('、')}\n时间:${source.date ?? ''}\n主题:${source.subject}\n\n${source.text}`,
);
} else {
setTo('');
setCc('');
setSubject('');
setText('');
}
}, [open, mode, source]);
async function addFiles(files: FileList | null) {
if (!files?.length) return;
const next: Pending[] = [];
for (const f of Array.from(files)) {
next.push({
filename: f.name,
contentType: f.type || 'application/octet-stream',
base64: await fileToBase64(f),
size: f.size,
});
}
setPending((prev) => [...prev, ...next]);
}
async function send() {
if (!to.trim()) {
onError('请填写收件人');
return;
}
setBusy(true);
setStatus('发送中…');
try {
const out = await api.send({
to,
cc,
subject,
text,
inReplyTo: mode === 'reply' ? source?.messageId ?? undefined : undefined,
references: mode === 'reply' ? source?.references ?? source?.messageId ?? undefined : undefined,
attachments: pending.map(({ filename, contentType, base64 }) => ({ filename, contentType, base64 })),
});
onSent({ recipients: out.recipients, bytes: out.bytes });
onClose();
} catch (err) {
setStatus('');
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function draft() {
setBusy(true);
try {
await api.saveDraft({ to, subject, text });
setStatus('已存入草稿');
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
const title = mode === 'reply' ? '回复' : mode === 'forward' ? '转发' : '新邮件';
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
<span className="text-muted-foreground text-sm">收件人</span>
<Input value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
</div>
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
<span className="text-muted-foreground text-sm">抄送</span>
<Input value={cc} onChange={(e) => setCc(e.target.value)} placeholder="可选,多个用逗号分隔" />
</div>
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
<span className="text-muted-foreground text-sm">主题</span>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
</div>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="写点什么…(Ctrl+Enter 发送)"
className="min-h-[220px] resize-y"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void send();
}
}}
/>
{pending.length > 0 && (
<>
<Separator />
<div className="flex flex-wrap gap-2">
{pending.map((p, i) => (
<span key={`${p.filename}-${i}`} className="bg-muted flex items-center gap-2 rounded-md px-2 py-1 text-xs">
<Paperclip className="size-3.5" />
<span className="max-w-[200px] truncate">{p.filename}</span>
<span className="text-muted-foreground">{formatSize(p.size)}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => setPending((prev) => prev.filter((_, idx) => idx !== i))}
>
<X className="size-3.5" />
</button>
</span>
))}
</div>
</>
)}
</div>
<input
ref={fileRef}
type="file"
multiple
hidden
onChange={(e) => void addFiles(e.target.files)}
/>
<DialogFooter className="items-center sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
<Paperclip className="size-4" />
添加附件
</Button>
<Button variant="ghost" size="sm" onClick={() => void draft()} disabled={busy}>
存草稿
</Button>
<span className="text-muted-foreground text-xs">{status}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-xs sm:inline">发件人 {selfAddress}</span>
<Button size="sm" onClick={() => void send()} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
发送
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useEffect, useState } from 'react';
import { Mail, UserPlus, KeyRound, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { api, type AccountPolicy } from '@/lib/api';
/**
* 登录屏:本地没有可用凭据、或连接失败时出现。
*
* 三种模式共用一个卡片:
* 登录 —— 走 IMAP(本机服务保存凭据)
* 注册 —— 走服务器的「账号接口」公网入口(/api/account/*),支持邀请码与邮箱验证码
* 找回 —— 同上,验证码邮件会投进你自己的信箱
*
* 注册/找回需要的是服务器 v2.2.0 的账号接口;拿不到策略时这两个入口会给出明确提示,
* 不影响正常登录(登录只依赖 IMAP)。
*/
export function LoginScreen({
initial,
error,
onSuccess,
}: {
initial?: { host: string; user: string; displayName: string };
error?: string | null;
onSuccess: () => void;
}) {
const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('login');
const [host, setHost] = useState(initial?.host || 'mail.example.com');
const [user, setUser] = useState(initial?.user || '');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(initial?.displayName || '');
const [inviteCode, setInviteCode] = useState('');
const [code, setCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(error ?? null);
const [notice, setNotice] = useState<string | null>(null);
const [policy, setPolicy] = useState<AccountPolicy | null>(null);
const [policyError, setPolicyError] = useState<string | null>(null);
useEffect(() => {
if (mode === 'login' || policy || policyError) return;
api.account
.policy()
.then((r) => setPolicy(r.policy))
.catch((err) => setPolicyError(err instanceof Error ? err.message : String(err)));
}, [mode, policy, policyError]);
const allowed = policy
? Array.isArray(policy.allowedDomains)
? policy.allowedDomains.join(' / ')
: String(policy.allowedDomains || '')
: '';
async function submitLogin(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await api.login({ host, user, password, displayName, save: true });
onSuccess();
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function submitRegister(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
setNotice(null);
try {
const r = await api.account.register({ email: user, password, displayName, inviteCode });
if (r.verificationRequired) {
setCodeSent(true);
setNotice(`验证码已发到 ${r.email}(${r.expiresInMinutes} 分钟内有效)。请填入下方验证码完成注册。`);
} else {
// 本机托管的邮箱免邮箱验证:注册即开通,直接连上去
setNotice('账号已创建,正在登录…');
await api.login({ host, user, password, displayName, save: true });
onSuccess();
}
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function submitVerify(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await api.account.verify({ email: user, code });
await api.login({ host, user, password, displayName, save: true });
onSuccess();
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function submitForgot(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
setNotice(null);
try {
const r = await api.account.forgot(user);
setCodeSent(true);
setNotice(`如果这个邮箱在本服务器上,重置验证码已经发出(${r.expiresInMinutes} 分钟内有效)。`);
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function submitReset(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await api.account.reset({ email: user, code, password });
setNotice('密码已重置,正在登录…');
await api.login({ host, user, password, displayName, save: true });
onSuccess();
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function resend() {
setBusy(true);
setMessage(null);
try {
await api.account.resend({ email: user, purpose: mode === 'forgot' ? 'reset' : 'register' });
setNotice('验证码已重新发送。');
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
const title = mode === 'login' ? '登录你的邮箱' : mode === 'register' ? '注册新邮箱' : '找回密码';
const Icon = mode === 'login' ? Mail : mode === 'register' ? UserPlus : KeyRound;
return (
<div className="flex min-h-full items-center justify-center bg-muted/30 p-6">
<div className="bg-card w-full max-w-md rounded-xl border p-8 shadow-sm">
<div className="mb-6 flex items-center gap-3">
<div className="bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-lg">
<Icon className="size-5" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-semibold">WpywMail</h1>
<p className="text-muted-foreground truncate text-sm">{title}</p>
</div>
</div>
{mode !== 'login' && (
<div className="bg-muted mb-5 grid grid-cols-2 gap-1 rounded-lg p-1 text-sm">
<button
type="button"
className={`rounded-md py-1.5 transition ${mode === 'register' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
onClick={() => {
setMode('register');
setCodeSent(false);
setMessage(null);
setNotice(null);
}}
>
注册新账号
</button>
<button
type="button"
className={`rounded-md py-1.5 transition ${mode === 'forgot' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
onClick={() => {
setMode('forgot');
setCodeSent(false);
setMessage(null);
setNotice(null);
}}
>
忘记密码
</button>
</div>
)}
{mode !== 'login' && policy && (
<div className="text-muted-foreground bg-muted/50 mb-5 space-y-1 rounded-md px-3 py-2 text-xs">
<div>
{policy.registration === 'closed'
? '本服务器已关闭自助注册,请联系管理员开设账号。'
: policy.inviteRequired
? '本服务器需要邀请码才能注册。'
: '本服务器开放自助注册。'}
{allowed && ` 允许的邮箱域名:${allowed}。`}
</div>
<div>密码至少 {policy.minPasswordLength} 位。</div>
{policy.verificationNote && <div>{policy.verificationNote}</div>}
</div>
)}
{mode !== 'login' && policyError && (
<div className="border-destructive/40 bg-destructive/5 text-destructive mb-5 rounded-md border px-3 py-2 text-xs">
取不到服务器的注册策略:{policyError}
<br />
注册与找回密码需要服务器的「账号接口」(默认 https://mail.example.com:9443)。仍可直接用下方「登录」。
</div>
)}
{notice && (
<div className="border-primary/30 bg-primary/5 mb-5 rounded-md border px-3 py-2 text-sm">{notice}</div>
)}
{mode === 'login' && (
<form className="space-y-4" onSubmit={submitLogin}>
<Field label="邮箱账号" id="login-user">
<Input
id="login-user"
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="[email protected]"
autoComplete="username"
required
/>
</Field>
<Field label="密码" id="login-pass">
<Input
id="login-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="服务器" id="login-host">
<Input id="login-host" value={host} onChange={(e) => setHost(e.target.value)} required />
</Field>
<Field label="显示名" id="login-name">
<Input
id="login-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="可选"
/>
</Field>
</div>
{message && <ErrorBox text={message} />}
<div className="flex items-center justify-between pt-2">
<button
type="button"
id="to-register"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
onClick={() => {
setMode('register');
setMessage(null);
}}
>
注册新账号 / 忘记密码
</button>
<Button type="submit" disabled={busy}>
{busy ? '连接中…' : '连接'}
</Button>
</div>
</form>
)}
{mode === 'register' && !codeSent && (
<form className="space-y-4" onSubmit={submitRegister}>
<Field label="要注册的邮箱" id="reg-user">
<Input
id="reg-user"
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder={allowed ? `you@${String(allowed).split(' / ')[0].replace('@', '')}` : '[email protected]'}
autoComplete="username"
required
/>
</Field>
<Field label={`密码(至少 ${policy?.minPasswordLength ?? 12} 位)`} id="reg-pass">
<Input
id="reg-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="显示名" id="reg-name">
<Input id="reg-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="可选" />
</Field>
<Field label={policy?.inviteRequired ? '邀请码(必填)' : '邀请码'} id="reg-invite">
<Input
id="reg-invite"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder={policy?.inviteRequired ? '向管理员索取' : '可选'}
/>
</Field>
</div>
<Field label="IMAP 服务器" id="reg-host">
<Input id="reg-host" value={host} onChange={(e) => setHost(e.target.value)} required />
</Field>
{message && <ErrorBox text={message} />}
<div className="flex items-center justify-between pt-2">
<BackLink onClick={() => setMode('login')} />
<Button type="submit" id="reg-submit" disabled={busy || policy?.registration === 'closed'}>
{busy ? '提交中…' : '注册'}
</Button>
</div>
</form>
)}
{mode === 'register' && codeSent && (
<form className="space-y-4" onSubmit={submitVerify}>
<Field label="邮箱验证码" id="reg-code">
<Input
id="reg-code"
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
placeholder="6 位数字"
required
/>
</Field>
{message && <ErrorBox text={message} />}
<div className="flex items-center justify-between pt-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
onClick={resend}
disabled={busy}
>
重新发送验证码
</button>
<Button type="submit" id="reg-verify" disabled={busy}>
{busy ? '验证中…' : '完成注册'}
</Button>
</div>
</form>
)}
{mode === 'forgot' && !codeSent && (
<form className="space-y-4" onSubmit={submitForgot}>
<Field label="你的邮箱" id="forgot-user">
<Input
id="forgot-user"
value={user}
onChange={(e) => setUser(e.target.value)}
autoComplete="username"
required
/>
</Field>
{message && <ErrorBox text={message} />}
<div className="flex items-center justify-between pt-2">
<BackLink onClick={() => setMode('login')} />
<Button type="submit" id="forgot-submit" disabled={busy}>
{busy ? '发送中…' : '发送重置码'}
</Button>
</div>
</form>
)}
{mode === 'forgot' && codeSent && (
<form className="space-y-4" onSubmit={submitReset}>
<Field label="邮件里的 6 位验证码" id="reset-code">
<Input
id="reset-code"
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
placeholder="6 位数字"
required
/>
</Field>
<Field label={`新密码(至少 ${policy?.minPasswordLength ?? 12} 位)`} id="reset-pass">
<Input
id="reset-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
</Field>
{message && <ErrorBox text={message} />}
<div className="flex items-center justify-between pt-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
onClick={resend}
disabled={busy}
>
重新发送
</button>
<Button type="submit" id="reset-submit" disabled={busy}>
{busy ? '重置中…' : '重置并登录'}
</Button>
</div>
</form>
)}
<div className="text-muted-foreground mt-6 flex items-center justify-between text-xs">
<span>IMAP 993 · SMTP 587</span>
{mode === 'login' && <span>账号接口仅用于注册与安全设置</span>}
</div>
</div>
</div>
);
}
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={id}>
{label}
</label>
{children}
</div>
);
}
function ErrorBox({ text }: { text: string }) {
return (
<div className="border-destructive/40 bg-destructive/5 text-destructive rounded-md border px-3 py-2 text-sm">{text}</div>
);
}
function BackLink({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-4 hover:underline"
onClick={onClick}
>
<ArrowLeft className="size-3" />
返回登录
</button>
);
}
+360
View File
@@ -0,0 +1,360 @@
import { useEffect, useState } from 'react';
import {
Archive,
CornerUpLeft,
CornerUpRight,
Download,
Flag,
Mail as MailIcon,
MailOpen,
Reply,
ShieldAlert,
Trash2,
} from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { api, type MailDetail } from '@/lib/api';
import { avatarHue, cn, formatFullDate, formatSize, initials } from '@/lib/utils';
function HtmlBody({
doc,
blocked,
sanitized,
showImages,
onShowImages,
}: {
doc: string | null;
blocked: string[];
sanitized: string[];
showImages: boolean;
onShowImages: () => void;
}) {
return (
<div className="flex min-h-[320px] flex-col gap-2">
{(blocked.length > 0 || sanitized.length > 0) && (
<div className="bg-muted/60 text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md px-3 py-2 text-xs">
{blocked.length > 0 && (
<span>
已拦截 {blocked.length} 张远程图片(发件人可借此知道你在什么时候打开了邮件)
</span>
)}
{blocked.length > 0 && !showImages && (
<button
type="button"
id="show-images"
onClick={onShowImages}
className="text-primary underline-offset-4 hover:underline"
>
仍要显示
</button>
)}
{sanitized.length > 0 && <span>已清理:{sanitized.join('、')}</span>}
</div>
)}
{doc ? (
// ⚠ 安全边界:sandbox 里**不给 allow-scripts**,邮件里的脚本一律执行不了。
// 服务端已经净化过一遍,这里是第二层;allow-popups 只为让正文里的链接能点开。
<iframe
id="html-body"
title="邮件正文"
sandbox="allow-popups"
srcDoc={doc}
className="bg-background h-[60vh] min-h-[280px] w-full rounded-md border"
/>
) : (
<div className="text-muted-foreground flex min-h-[200px] items-center justify-center text-sm">正在渲染正文…</div>
)}
</div>
);
}
function ToolButton({
label,
shortcut,
onClick,
children,
className,
}: {
label: string;
shortcut?: string;
onClick?: () => void;
children: React.ReactNode;
className?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClick} className={className} aria-label={label}>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
{label}
{shortcut ? ` · ${shortcut}` : ''}
</TooltipContent>
</Tooltip>
);
}
export function MailDisplay({
mail,
loading,
onArchive,
onDelete,
onToggleFlag,
onToggleSeen,
onReply,
onForward,
onQuickReply,
}: {
mail: MailDetail | null;
loading: boolean;
onArchive: () => void;
onDelete: () => void;
onToggleFlag: () => void;
onToggleSeen: () => void;
onReply: () => void;
onForward: () => void;
onQuickReply: (text: string) => Promise<void>;
}) {
const [replyText, setReplyText] = useState('');
const [sending, setSending] = useState(false);
// 富文本 / 纯文本:默认有 HTML 就渲染 HTML(跟主流邮件客户端一致),可一键切回纯文本
const [view, setView] = useState<'html' | 'text'>('html');
const [showImages, setShowImages] = useState(false);
const [doc, setDoc] = useState<string | null>(null);
const dark = document.documentElement.classList.contains('dark');
// 切邮件时把「显示图片」收回去(换一封就重新拦,别把上一封的许可带过去)
useEffect(() => {
setShowImages(false);
setView('html');
}, [mail?.uid, mail?.folder]);
// HTML 视图按需取「净化 + 包好」的文档;打开图片 / 切主题时重新取一次
useEffect(() => {
if (view !== 'html' || !mail?.html) {
setDoc(null);
return;
}
if (doc && !showImages) { /* 已有文档且没要求放行图片,直接用 */ }
let alive = true;
api
.message(mail.uid, mail.folder, { images: showImages, theme: dark ? 'dark' : 'light' })
.then((d) => {
if (alive) setDoc(d.htmlDocument ?? null);
})
.catch(() => {
if (alive) setDoc(null);
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, showImages, dark, mail?.uid, mail?.folder, mail?.html]);
// 只有「当前什么都没显示」时才铺满加载态;已有内容时保留旧内容(避免点一下就整块跳动)
if (loading && !mail) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-muted-foreground text-sm">正在打开…</div>
</div>
);
}
if (!mail) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="text-muted-foreground flex max-w-sm flex-col items-center gap-2 text-center">
<MailIcon className="size-8 opacity-40" />
<div className="text-sm">未选择邮件</div>
<div className="text-xs">从左侧列表选一封,或用 J / K 上下移动、Enter 打开</div>
</div>
</div>
);
}
const from = mail.from?.[0];
const fromName = from?.name || from?.address || '(未知发件人)';
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* 工具条 */}
<div className="flex h-14 shrink-0 items-center gap-1 border-b px-3">
<ToolButton label="归档" shortcut="E" onClick={onArchive}>
<Archive className="size-4" />
</ToolButton>
<ToolButton label="移到垃圾邮件" onClick={onArchive}>
<ShieldAlert className="size-4" />
</ToolButton>
<ToolButton label="删除" shortcut="#" onClick={onDelete} className="text-destructive hover:text-destructive">
<Trash2 className="size-4" />
</ToolButton>
<Separator orientation="vertical" className="mx-1 !h-5" />
<ToolButton
label={mail.flagged ? '取消旗标' : '加旗标'}
shortcut="S"
onClick={onToggleFlag}
className={mail.flagged ? 'text-amber-500' : undefined}
>
<Flag className={cn('size-4', mail.flagged && 'fill-current')} />
</ToolButton>
<ToolButton label={mail.seen ? '标为未读' : '标为已读'} shortcut="U" onClick={onToggleSeen}>
{mail.seen ? <MailIcon className="size-4" /> : <MailOpen className="size-4" />}
</ToolButton>
<Separator orientation="vertical" className="mx-1 !h-5" />
<ToolButton label="回复" shortcut="R" onClick={onReply}>
<Reply className="size-4" />
</ToolButton>
<ToolButton label="转发" shortcut="F" onClick={onForward}>
<CornerUpRight className="size-4" />
</ToolButton>
<div className="ml-auto flex items-center gap-2 pr-1">
{mail.html && (
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 text-xs" id="body-view-toggle">
<button
type="button"
id="view-html"
onClick={() => setView('html')}
className={cn('rounded px-2 py-0.5 transition-colors', view === 'html' ? 'bg-background shadow-sm' : 'text-muted-foreground')}
>
富文本
</button>
<button
type="button"
id="view-text"
onClick={() => setView('text')}
className={cn('rounded px-2 py-0.5 transition-colors', view === 'text' ? 'bg-background shadow-sm' : 'text-muted-foreground')}
>
纯文本
</button>
</div>
)}
<span className="text-muted-foreground hidden text-xs sm:inline">{formatSize(mail.size)}</span>
</div>
</div>
<div className="scroll-pane scrollbar-thin flex-1">
<div className="mx-auto w-full max-w-3xl p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="text-xl font-semibold tracking-tight">{mail.subject || '(无主题)'}</h1>
<div className="flex shrink-0 items-center gap-1">
{mail.flagged && <Badge variant="secondary">已加旗标</Badge>}
{!mail.seen && <Badge>未读</Badge>}
</div>
</div>
<div className="mb-6 flex items-start gap-3">
<Avatar className="size-9">
<AvatarFallback
style={{ backgroundColor: avatarHue(from?.address || fromName), color: 'white' }}
className="text-xs font-medium"
>
{initials(from?.name || '', from?.address || fromName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">{fromName}</span>
{from?.name && from?.address && (
<span className="text-muted-foreground truncate text-xs">&lt;{from.address}&gt;</span>
)}
</div>
<div className="text-muted-foreground text-xs">
收件人:{mail.to.map((a) => a.name || a.address).join('、') || '—'}
{mail.cc.length > 0 && ` · 抄送:${mail.cc.map((a) => a.name || a.address).join('、')}`}
</div>
</div>
<span className="text-muted-foreground shrink-0 text-xs">{formatFullDate(mail.date)}</span>
</div>
{mail.attachments.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{mail.attachments.map((a) => (
<a
key={a.index}
href={api.attachmentUrl(mail.uid, a.index, mail.folder)}
className="bg-card hover:bg-accent flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors"
>
<Download className="text-muted-foreground size-4" />
<span className="max-w-[220px] truncate">{a.filename}</span>
<span className="text-muted-foreground text-xs">{formatSize(a.size)}</span>
</a>
))}
</div>
)}
{view === 'html' && mail.html ? (
<HtmlBody
doc={doc}
blocked={mail.blockedImages ?? []}
sanitized={mail.sanitized ?? []}
showImages={showImages}
onShowImages={() => setShowImages(true)}
/>
) : (
<article className="mail-body text-sm leading-7">{mail.text || '(这封邮件没有可显示的纯文本正文)'}</article>
)}
</div>
</div>
{/* 快速回复 */}
<div className="shrink-0 border-t p-4">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={`回复 ${fromName}…(Ctrl+Enter 发送)`}
className="min-h-[72px] resize-none"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && replyText.trim()) {
e.preventDefault();
void (async () => {
setSending(true);
try {
await onQuickReply(replyText);
setReplyText('');
} finally {
setSending(false);
}
})();
}
}}
/>
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onReply} className="text-muted-foreground">
<CornerUpLeft className="size-4" />
完整回复
</Button>
<Button
size="sm"
disabled={sending || !replyText.trim()}
onClick={() =>
void (async () => {
setSending(true);
try {
await onQuickReply(replyText);
setReplyText('');
} finally {
setSending(false);
}
})()
}
>
{sending ? '发送中…' : '发送回复'}
</Button>
</div>
</div>
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { memo } from 'react';
import { Flag } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn, avatarHue, formatDate, formatSize, initials } from '@/lib/utils';
import type { MailSummary } from '@/lib/api';
function senderName(m: MailSummary) {
const a = m.from?.[0];
if (!a) return '(无发件人)';
return a.name || a.address.split('@')[0] || a.address;
}
/**
* 单行邮件。用 memo 包住:列表 100 行时,选中一封只让变化的那两行重渲染,
* 而不是整列表重画 —— 这是「顺滑」的关键之一。
*/
const Row = memo(function Row({
m,
active,
onSelect,
}: {
m: MailSummary;
active: boolean;
onSelect: (uid: number) => void;
}) {
const name = senderName(m);
return (
<li>
<button
type="button"
data-uid={m.uid}
onClick={() => onSelect(m.uid)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-100',
active ? 'bg-accent' : 'hover:bg-accent/50',
)}
>
<Avatar className="mt-0.5 size-8">
<AvatarFallback
style={{ backgroundColor: avatarHue(m.from?.[0]?.address || name), color: 'white' }}
className="text-xs font-medium"
>
{initials(m.from?.[0]?.name || '', m.from?.[0]?.address || name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{!m.seen && <span className="bg-primary size-2 shrink-0 rounded-full" aria-label="未读" />}
<span className={cn('truncate text-sm', !m.seen && 'font-semibold')}>{name}</span>
<span className="text-muted-foreground ml-auto shrink-0 text-xs">
{formatDate(m.date || m.internalDate)}
</span>
</div>
<div className={cn('truncate text-sm', !m.seen ? 'text-foreground' : 'text-muted-foreground')}>
{m.subject || '(无主题)'}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-muted-foreground text-xs">{formatSize(m.size)}</span>
{m.flagged && <Flag className="size-3.5 text-amber-500" />}
{m.answered && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
已回复
</Badge>
)}
{m.draft && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
草稿
</Badge>
)}
</div>
</div>
</button>
</li>
);
});
export function MailList({
messages,
total,
selectedUid,
loading,
query,
folderLabel,
onSelect,
className,
}: {
messages: MailSummary[];
total: number;
selectedUid: number | null;
loading: boolean;
query: string;
folderLabel: string;
onSelect: (uid: number) => void;
className?: string;
}) {
return (
<div className={cn('flex min-h-0 flex-col border-r', className)}>
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{query ? `搜索:${query}` : folderLabel}</h2>
<span className="text-muted-foreground text-xs">{total} 封</span>
</div>
</div>
{/*
用原生 overflow-y:auto,而不是 Radix ScrollArea:
ScrollArea 必须有显式高度才滚得动,塞进 flex 列里极容易退化成「内容把容器撑开、
整页跟着一起滚」。原生滚动在桌面端也更顺(惯性、滚轮、触控板都走系统实现)。
*/}
<div className="scroll-pane scrollbar-thin flex-1">
{loading ? (
<div className="space-y-3 p-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : messages.length === 0 ? (
<div className="text-muted-foreground flex min-h-[240px] flex-col items-center justify-center gap-1 p-8 text-center text-sm">
<div className="text-base">{query ? '没有匹配的邮件' : '这个文件夹是空的'}</div>
<div className="text-xs">{query ? '试试别的关键词,或清空搜索' : '新邮件到达时会出现在这里'}</div>
</div>
) : (
<ul className="divide-y">
{messages.map((m) => (
<Row key={m.uid} m={m} active={m.uid === selectedUid} onSelect={onSelect} />
))}
</ul>
)}
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import {
Archive,
FileText,
Inbox,
Send,
ShieldAlert,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AccountProfile, Folder } from '@/lib/api';
import { AccountDialog } from './AccountDialog';
const ICONS: Record<string, LucideIcon> = {
INBOX: Inbox,
DRAFTS: FileText,
SENT: Send,
ARCHIVE: Archive,
JUNK: ShieldAlert,
TRASH: Trash2,
};
const LABELS: Record<string, string> = {
INBOX: '收件箱',
DRAFTS: '草稿',
SENT: '已发送',
ARCHIVE: '归档',
JUNK: '垃圾邮件',
TRASH: '废纸篓',
};
export function folderLabel(name: string) {
return LABELS[name.toUpperCase()] || name;
}
export function MailSidebar({
folders,
current,
account,
profile,
onSelect,
onCompose,
onDisplayName,
className,
}: {
folders: Folder[];
current: string;
account: { user: string; displayName: string; host: string };
profile?: AccountProfile | null;
onSelect: (name: string) => void;
onCompose: () => void;
onDisplayName?: (name: string) => void;
className?: string;
}) {
const totalUnread = folders.reduce((n, f) => n + (f.unseen || 0), 0);
return (
<aside className={cn('bg-sidebar text-sidebar-foreground flex min-h-0 flex-col gap-2 border-r', className)}>
{/* 账号与服务器 */}
<div className="flex flex-col gap-1 border-b p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{account.displayName || account.user}</div>
<div className="text-muted-foreground truncate text-xs">{account.user}</div>
</div>
{totalUnread > 0 && (
<span className="bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs font-medium">
{totalUnread}
</span>
)}
</div>
<div className="text-muted-foreground truncate text-xs">{account.host}</div>
</div>
{/* 撰写出入口 */}
<div className="p-4 pb-2">
<button
type="button"
onClick={onCompose}
className="hover:bg-sidebar-accent hover:text-sidebar-accent-foreground w-full rounded-md border border-dashed px-3 py-2 text-left text-sm transition-colors"
>
撰写新邮件
<span className="text-muted-foreground ml-2 text-xs">C</span>
</button>
</div>
{/* 文件夹 */}
<nav className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-4">
<div className="text-muted-foreground px-3 py-2 text-xs font-medium">文件夹</div>
{folders.map((f) => {
const key = f.name.toUpperCase();
const Icon = ICONS[key] ?? Inbox;
const active = f.name === current;
return (
<button
key={f.name}
type="button"
onClick={() => onSelect(f.name)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
active
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
)}
>
<Icon className={cn('size-4 shrink-0', active ? 'opacity-100' : 'opacity-70')} />
<span className="flex-1 truncate text-left">{folderLabel(f.name)}</span>
{f.unseen > 0 ? (
<span className="text-xs font-medium">{f.unseen}</span>
) : f.messages > 0 ? (
<span className="text-muted-foreground text-xs">{f.messages}</span>
) : null}
</button>
);
})}
</nav>
<div className="text-muted-foreground flex items-center justify-between gap-2 border-t px-4 py-3 text-xs">
<span className="truncate">快捷键:J/K 切换 · Enter 打开 · R 回复 · S 旗标 · E 归档 · # 删除</span>
<AccountDialog account={profile} onDisplayName={onDisplayName} />
</div>
</aside>
);
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square size-full', className)} {...props} />
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full text-xs', className)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-white',
outline: 'text-foreground',
},
},
defaultVariants: { variant: 'default' },
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+103
View File
@@ -0,0 +1,103 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-[1px]',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:size-4"
>
<XIcon />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />;
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+187
View File
@@ -0,0 +1,187 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive' }) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="skeleton" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />;
}
export { Skeleton };
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({ delayDuration = 200, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger(props: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+253
View File
@@ -0,0 +1,253 @@
/**
* 本地邮件服务的 API 客户端。
* 后端是 E:\deepseek\WpywMailClient\server\index.js(IMAP/SMTP/MIME 全在那边,已通过 4 套验收)。
* 这里只做类型化包装,不含任何邮件协议逻辑。
*/
export type Address = { name: string; address: string };
export type Folder = {
name: string;
flags: string[];
messages: number;
unseen: number;
recent: number;
};
export type MailSummary = {
uid: number;
subject: string;
from: Address[];
to: Address[];
date: string | null;
internalDate: string | null;
size: number;
seen: boolean;
flagged: boolean;
answered: boolean;
draft: boolean;
flags: string[];
};
export type Attachment = {
index: number;
filename: string;
contentType: string;
size: number;
inline: boolean;
contentId: string | null;
};
export type MailDetail = {
uid: number;
folder: string;
subject: string;
from: Address[];
to: Address[];
cc: Address[];
replyTo: Address[];
date: string | null;
messageId: string | null;
inReplyTo: string | null;
references: string | null;
text: string;
html: string | null;
/** 服务端净化后并包成完整文档的 HTML(用于 iframe srcDoc)。 */
htmlDocument?: string | null;
/** 被拦下的远程图片地址(默认拦截,防止一打开就把你的信息告诉发件人)。 */
blockedImages?: string[];
/** 净化时清掉的东西(script/iframe/事件处理器/远程图片…)。 */
sanitized?: string[];
size: number;
seen: boolean;
flagged: boolean;
flags: string[];
attachments: Attachment[];
};
export type AccountInfo = {
host: string;
imapPort: number;
smtpPort: number;
user: string;
displayName: string;
domain: string;
hasPassword: boolean;
accountFile: string | null;
};
export type StateResponse = {
account: AccountInfo;
connected: boolean;
folders: Folder[] | null;
error: string | null;
capabilities: string[];
serverTime: string;
};
export type SendPayload = {
to: string;
cc?: string;
bcc?: string;
subject: string;
text: string;
html?: string;
inReplyTo?: string;
references?: string;
attachments?: { filename: string; contentType: string; base64: string }[];
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init);
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { error: text };
}
if (!res.ok) {
const message = (data as { error?: string } | null)?.error || `HTTP ${res.status}`;
throw new Error(message);
}
return data as T;
}
const json = (body: unknown): RequestInit => ({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
export const api = {
state: () => request<StateResponse>('/api/state'),
login: (body: { host?: string; user: string; password: string; displayName?: string; save?: boolean }) =>
request<{ ok: boolean; folders: Folder[] }>('/api/login', json(body)),
logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' }),
folders: (refresh = false) => request<{ folders: Folder[] }>(`/api/folders${refresh ? '?refresh=1' : ''}`),
messages: (folder: string, opts: { limit?: number; offset?: number; q?: string } = {}) => {
const p = new URLSearchParams({ folder, limit: String(opts.limit ?? 60) });
if (opts.offset) p.set('offset', String(opts.offset));
if (opts.q) p.set('q', opts.q);
return request<{ folder: string; total: number; offset: number; limit: number; messages: MailSummary[] }>(
`/api/messages?${p.toString()}`,
);
},
message: (uid: number, folder: string, opts: { images?: boolean; theme?: 'dark' | 'light' } = {}) => {
const p = new URLSearchParams({ folder });
if (opts.images) p.set('images', '1');
if (opts.theme) p.set('theme', opts.theme);
return request<MailDetail>(`/api/messages/${uid}?${p.toString()}`);
},
attachmentUrl: (uid: number, index: number, folder: string) =>
`/api/messages/${uid}/attachments/${index}?folder=${encodeURIComponent(folder)}`,
setFlags: (uid: number, folder: string, flags: { seen?: boolean; flagged?: boolean; answered?: boolean }) =>
request<{ ok: boolean }>('/api/flags', json({ uid, folder, ...flags })),
remove: (uid: number, folder: string, permanent = false) =>
request<{ ok: boolean; moved: boolean; folder: string | null }>('/api/delete', json({ uid, folder, permanent })),
move: (uid: number, folder: string, target: string) =>
request<{ ok: boolean }>('/api/move', json({ uid, folder, target })),
send: (payload: SendPayload) =>
request<{ ok: boolean; bytes: number; recipients: string[] }>('/api/send', json(payload)),
saveDraft: (payload: { to?: string; subject?: string; text?: string }) =>
request<{ ok: boolean }>('/api/drafts', json(payload)),
/**
* 账号体系(对接服务器 v2.2.0)。走的是服务器上只放账号类接口的公网 HTTPS 入口,
* 和读信发信的 IMAP/SMTP 是两条独立通道 —— 那个入口看不到任何邮件数据。
*/
account: {
policy: () => request<{ policy: AccountPolicy; apiBase: string }>('/api/account/policy'),
register: (body: { email: string; password: string; displayName?: string; inviteCode?: string }) =>
request<RegisterResult>('/api/account/register', json(body)),
verify: (body: { email: string; code: string }) =>
request<{ ok: boolean; session: { token: string } | null }>('/api/account/verify', json(body)),
resend: (body: { email: string; purpose?: 'register' | 'reset' }) =>
request<{ ok: boolean }>('/api/account/resend', json(body)),
forgot: (email: string) =>
request<{ ok: boolean; expiresInMinutes: number }>('/api/account/forgot', json({ email })),
reset: (body: { email: string; code: string; password: string }) =>
request<{ ok: boolean }>('/api/account/reset', json(body)),
overview: () =>
request<{ profile: AccountProfile | null; sessions: AccountSession[]; audit: AuditEvent[] }>('/api/account/overview'),
updateProfile: (displayName: string) =>
request<{ ok: boolean; user: AccountProfile }>('/api/account/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName }),
}),
changePassword: (body: { currentPassword: string; password: string }) =>
request<{ ok: boolean; revokedSessions: number }>('/api/account/password', json(body)),
revokeSessions: (body: { all?: boolean; token?: string }) =>
request<{ ok: boolean; revoked: number; selfRevoked: boolean }>('/api/account/sessions/revoke', json(body)),
},
};
export type AccountPolicy = {
registration: 'open' | 'invite' | 'closed';
inviteRequired: boolean;
requireEmailVerification: boolean;
minPasswordLength: number;
allowedDomains: string[] | string;
codeMinutes: number;
maxLoginFailures: number;
lockoutMinutes: number;
selfHostedDomain: string;
verificationNote: string;
};
export type RegisterResult = {
ok: boolean;
verificationRequired: boolean;
email: string;
expiresInMinutes: number | null;
session: { token: string; user: AccountProfile } | null;
};
export type AccountProfile = {
email: string;
displayName: string;
role: string;
active: boolean;
createdAt: string;
lastLoginAt: string | null;
domain: string;
};
export type AccountSession = {
tokenPrefix: string;
token: string;
current: boolean;
createdAt: string;
expiresAt: string;
};
export type AuditEvent = {
at: string;
email: string;
ip: string;
reason: string;
success: boolean;
detail: string;
userAgent: string;
};
+48
View File
@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** 相对时间:今天显示时分,今年显示月日,更早显示年月日 */
export function formatDate(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(raw).slice(0, 16);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`;
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
}
export function formatFullDate(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(raw);
return d.toLocaleString('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
export function formatSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** 取显示名首字,用于头像占位 */
export function initials(name: string, address: string): string {
const source = (name || address || '?').trim();
const first = source.replace(/["'<>]/g, '').trim()[0];
return (first || '?').toUpperCase();
}
/** 由地址生成稳定的头像底色(色相散开,饱和度/亮度固定,避免花哨) */
export function avatarHue(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
return `hsl(${h} 45% 42%)`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { TooltipProvider } from '@/components/ui/tooltip';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>
<App />
</TooltipProvider>
</StrictMode>,
);
+175
View File
@@ -0,0 +1,175 @@
@import "tailwindcss";
@import "tw-animate-css";
/* shadcn/ui 默认(new-york / neutral)主题 token,原样实例化。
light 与 dark 两套都保留;默认深色(这个项目的主人偏好深色),可切换并记忆。 */
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
border-color: var(--border);
}
html,
body,
#root {
height: 100%;
/* 桌面客户端不该出现页面级滚动条:滚动只发生在三栏内部 */
overflow: hidden;
}
body {
margin: 0;
background-color: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Microsoft YaHei",
"PingFang SC", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* 邮件正文里的链接与换行 */
.mail-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mail-body a {
text-decoration: underline;
text-underline-offset: 2px;
}
}
/* 滚动条(桌面客户端观感)+ 三栏内部的滚动容器 */
@layer utilities {
.scroll-pane {
overflow-y: auto;
overflow-x: hidden;
/* 关键:flex 子项默认 min-height:auto 会按内容撑高,导致「列表没滚、整页滚」 */
min-height: 0;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.scrollbar-thin::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: color-mix(in oklab, var(--foreground) 18%, transparent);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: content-box;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklab, var(--foreground) 30%, transparent);
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/mail/accountdialog.tsx","./src/components/mail/composedialog.tsx","./src/components/mail/loginscreen.tsx","./src/components/mail/maildisplay.tsx","./src/components/mail/maillist.tsx","./src/components/mail/mailsidebar.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api.ts","./src/lib/utils.ts","./vite.config.ts"],"checkPending":true,"version":"5.9.3"}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
// 构建产物交给本地 Node 服务托管(server/index.js 会优先发 ui/dist),
// 所以 base 用相对路径,避免路径耦合。
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: false,
chunkSizeWarningLimit: 1200,
},
server: {
port: 5174,
// 开发时把 API 代理到本地邮件服务,便于热更新调试
proxy: { '/api': 'http://127.0.0.1:8788' },
},
});