133 lines
5.8 KiB
JavaScript
133 lines
5.8 KiB
JavaScript
'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);
|