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