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

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* MIME 层单元测试(纯本地,不联网)。
* 这一层是「中文对不对」的关键,所以要有可回归的测试。
* 真机踩过的坑(都已固化为用例):
* - RFC 2231 的 filename* 若先 decodeURIComponent 再 Buffer.from(str,'binary'),
* 多字节字符会被截断成 U+FFFD → 中文附件名变乱码
* - ENVELOPE / 头部的编码字必须解 RFC 2047,否则界面显示 =?UTF-8?B?...?=
* - 裸 UTF-8 头部要按 UTF-8 优先解,否则变 [æµè¯]
*/
const mime = require('../server/mime');
const results = [];
function check(name, actual, expected) {
const ok = actual === expected;
results.push({ name, ok, actual, expected });
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
if (!ok) process.stdout.write(` 期望 ${JSON.stringify(expected)}\n 实际 ${JSON.stringify(actual)}\n`);
}
/** 生成 RFC 2047 的 B 编码字(测试里按需现算,避免手写 base64 写错) */
function b64word(text) {
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`;
}
// ── 1. 附件文件名
// encoding 用来模拟报文里的真实字节:正常头部是 ASCII/Latin-1,而「裸 UTF-8」头部要按 UTF-8 装字节
function fname(headerLine, encoding = 'latin1') {
const headers = mime.parseHeaders(Buffer.from(headerLine, encoding));
return mime.filenameFrom(headers);
}
check('filename* RFC2231 中文(真机报文原样)',
fname('Content-Disposition: attachment; filename="____.txt"; filename*=UTF-8\'\'%E9%AA%8C%E6%94%B6%E9%99%84%E4%BB%B6.txt'),
'验收附件.txt');
check('filename* 在 Content-Type 的 name*',
fname('Content-Type: application/octet-stream; name*=UTF-8\'\'%E4%B8%AD%E6%96%87.zip'),
'中文.zip');
check('普通 ASCII 文件名',
fname('Content-Disposition: attachment; filename="report.pdf"'),
'report.pdf');
check('裸 UTF-8 中文文件名(未做编码的历史客户端)',
fname('Content-Disposition: attachment; filename="原始中文名.txt"', 'utf8'),
'原始中文名.txt');
check('RFC 2231 分段续行 filename*0* / filename*1*',
fname("Content-Disposition: attachment; filename*0*=UTF-8''%E9%AA%8C%E6%94%B6; filename*1*=%E9%99%84%E4%BB%B6.txt"),
'验收附件.txt');
// ── 2. RFC 2047 编码字
check('B 编码字(中文主题)',
mime.decodeWords('=?UTF-8?B?5a6i5oi356uv6Ieq5rWL?='),
'客户端自测');
check('Q 编码字(下划线代表空格)',
mime.decodeWords('=?utf-8?Q?=E4=BD=A0=E5=A5=BD_=E4=B8=96=E7=95=8C?='),
'你好 世界');
check('相邻编码字之间的空白被丢弃(RFC 2047)',
mime.decodeWords('=?UTF-8?B?5a6i5oi3?= =?UTF-8?B?56uv6Ieq5rWL?='),
'客户端自测');
check('编码字与纯文本混排',
mime.decodeWords('Report for ' + b64word('客户') + ' end'),
'Report for 客户 end');
// ── 3. 裸 UTF-8 头部(历史客户端常见)
{
const raw = Buffer.from('Subject: 中文主题', 'utf8').toString('latin1');
check('裸 UTF-8 头部按 UTF-8 优先解', mime.smartDecodeHeader(raw.slice(9)), '中文主题');
}
// ── 4. 组装 → 解析 往返(含中文与全角标点)
{
const text = '你好,世界。()《》——、;:!?\r\n第二行中文。';
const raw = mime.buildMessage({
from: { name: '测试 发件人', address: '[email protected]' },
to: [{ name: '收件人', address: '[email protected]' }],
subject: '中文主题 with ASCII · 标点测试',
text,
domain: 'wpy.email',
});
const parsed = mime.parseMessage(raw);
check('往返:主题', parsed.subject, '中文主题 with ASCII · 标点测试');
check('往返:发件人显示名', parsed.from[0].name, '测试 发件人');
check('往返:收件人地址', parsed.to[0].address, '[email protected]');
// 解析出的正文统一用 LF(界面以 pre-wrap 渲染,LF 才是 DOM 的自然换行)
check('往返:正文(含全角标点)', parsed.text, text.replace(/\r\n/g, '\n'));
check('往返:头部不含裸非 ASCII(可安全过 SMTP)',
/^[\x00-\x7f]*$/.test(raw.subarray(0, raw.indexOf('\r\n\r\n')).toString('latin1')), true);
}
// ── 5. 带附件往返
{
const content = Buffer.from('中文附件内容 123', 'utf8');
const raw = mime.buildMessage({
from: { name: 'W', address: '[email protected]' },
to: [{ name: '', address: '[email protected]' }],
subject: '带附件',
text: '正文',
domain: 'wpy.email',
attachments: [{ filename: '验收附件.txt', contentType: 'text/plain', content }],
});
const parsed = mime.parseMessage(raw);
check('往返:附件数量', String(parsed.attachments.length), '1');
check('往返:附件名(中文)', parsed.attachments[0].filename, '验收附件.txt');
check('往返:附件内容逐字节一致', String(parsed.attachments[0].content.equals(content)), 'true');
check('往返:正文仍可读', parsed.text, '正文');
}
// ── 6. 字符集
check('GBK 正文解码', mime.decodeCharset(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]), 'gbk'), '你好');
check('quoted-printable 解码', mime.decodeCharset(mime.decodeQuotedPrintable(Buffer.from('=E4=BD=A0=E5=A5=BD', 'latin1')), 'utf-8'), '你好');
check('base64 传输解码', mime.decodeCharset(mime.decodeTransfer(Buffer.from(Buffer.from('你好', 'utf8').toString('base64'), 'latin1'), 'base64'), 'utf-8'), '你好');
// ── 7. 地址解析
{
const list = mime.parseAddressList('"张三, 三" <[email protected]>, 李四 <[email protected]>, [email protected]');
check('地址解析数量', String(list.length), '3');
check('地址解析:引号内含逗号', list[0].name, '张三, 三');
check('地址解析:裸地址', list[2].address, '[email protected]');
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
process.stdout.write(`\n汇总:通过 ${pass} / ${results.length},失败 ${fail}\n`);
process.exit(fail ? 1 : 0);