Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
+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);
|
||||
});
|
||||
Reference in New Issue
Block a user