Files

261 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});