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
+260
View File
@@ -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);
});