261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
'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);
|
||
});
|