'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); });