'use strict'; /** * WpywMail 客户端本地服务。 * * 职责:① 保持一条到 mail.example.com 的 IMAP 会话;② 把 IMAP 能力包成 JSON API; * ③ 托管界面静态文件。界面在浏览器里跑,因此不需要任何原生壳。 * * 设计要点: * - **一条连接、串行化**:IMAP 是单连接状态机,多个请求并发会互相踩。 * 所有 IMAP 操作都过 _queue(),保证同一时刻只有一条命令在飞。 * - **按需重连**:连接掉了自动重建一次,失败则把错误抛给界面(界面显示离线态)。 * - 只监听 127.0.0.1,绝不对外暴露。 */ const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const { URL } = require('node:url'); const { ImapClient, ImapError } = require('./imap'); const { SmtpClient } = require('./smtp'); const mime = require('./mime'); const { loadAccount, saveAccount, connectionOptions } = require('./account'); const { createAccountApi, DEFAULT_API_BASE } = require('./account-api'); const { sanitizeHtml, buildDocument } = require('./sanitize'); const HOST = process.env.WPYWMAIL_HOST || '127.0.0.1'; const PORT = Number(process.env.WPYWMAIL_PORT || 8788); const WEB_DIR = path.join(__dirname, '..', 'web'); // 新界面(shadcn/ui + Vite)的构建产物;存在就优先发它,否则回退到旧的纯手写界面 const UI_DIST = path.join(__dirname, '..', 'ui', 'dist'); const SITE_DIR = fs.existsSync(path.join(UI_DIST, 'index.html')) ? UI_DIST : WEB_DIR; // ─────────────────────────────────────────────────────────── IMAP 会话 class Session { constructor(account) { this.account = account; this.options = connectionOptions(account); this.imap = null; this.connecting = null; this.tail = Promise.resolve(); // 串行化队列 this.cache = { folders: null, foldersAt: 0, summaries: new Map() }; this.lastError = null; } /** 把任意异步操作串到队列尾部,保证 IMAP 上一时刻只有一条命令 */ _queue(fn) { const run = this.tail.then(fn, fn); // 队列本身不因单次失败而中断 this.tail = run.then(() => undefined, () => undefined); return run; } async connect({ force = false } = {}) { if (this.imap && this.imap.connected && !force) return this.imap; if (this.connecting) return this.connecting; this.connecting = (async () => { const c = new ImapClient(this.options.imap); try { await c.connect(); await c.capability(); await c.login(); c.on('error', (err) => { this.lastError = err.message; }); this.imap = c; this.lastError = null; this.cache.folders = null; return c; } catch (err) { try { c._destroy(); } catch { /* 忽略 */ } this.imap = null; this.lastError = err.message; throw err; } finally { this.connecting = null; } })(); return this.connecting; } /** 带一次自动重连的操作包装 */ async withImap(fn) { return this._queue(async () => { let client = await this.connect(); try { return await fn(client); } catch (err) { // 连接类错误 → 重连一次再试;业务类错误(NO/BAD)直接抛 const retriable = err instanceof ImapError ? !/被拒绝/.test(err.message) : true; if (!retriable) throw err; client = await this.connect({ force: true }); return fn(client); } }); } async folders({ refresh = false, maxAgeMs = 15000 } = {}) { const now = Date.now(); if (!refresh && this.cache.folders && now - this.cache.foldersAt < maxAgeMs) { return this.cache.folders; } return this.withImap(async (client) => { const boxes = await client.list(); const out = []; for (const b of boxes) { const st = await client.status(b.name).catch(() => ({})); out.push({ name: b.name, flags: b.flags, messages: st.MESSAGES || 0, unseen: st.UNSEEN || 0, recent: st.RECENT || 0, }); } // 常见顺序:收件箱最前,其次草稿/已发送,最后是归档/垃圾/废纸篓 const order = ['INBOX', 'Drafts', 'Sent', 'Archive', 'Junk', 'Trash']; out.sort((a, b) => { const ia = order.indexOf(a.name.toUpperCase()); const ib = order.indexOf(b.name.toUpperCase()); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); }); this.cache.folders = out; this.cache.foldersAt = Date.now(); return out; }); } async messageList({ folder, limit = 50, offset = 0, query = '' }) { return this.withImap(async (client) => { await client.select(folder, { readonly: false }); const allUids = await client.searchUid(['ALL']); // ── 搜索 // 为什么不用服务器端 SEARCH:WpywMail 的 SEARCH 只实现了 SEEN/UNSEEN/FLAGGED/ // UNFLAGGED/DELETED/FROM/SUBJECT/TEXT/UID,**没有 BODY、没有 OR**(未知条件被静默忽略), // 而且它的命令行是按 Encoding.ASCII 解码的 —— 中文检索词到了服务端会变成 "??", // 永远匹配不到。所以这里改为:取回摘要(已解码 RFC 2047)后在本地过滤。 // 代价是搜索窗口有上限(下面 500 封);个人邮箱足够,且永远正确。 if (query && query.trim()) { const needle = query.trim().toLowerCase(); const window = allUids.slice().sort((a, b) => b - a).slice(0, 500); const summaries = window.length ? await client.fetchSummaries(window) : []; const hit = summaries.filter((s) => { const env = s.envelope || {}; const hay = [ env.subject || '', ...(env.from || []).map((a) => `${a.name} ${a.address}`), ...(env.to || []).map((a) => `${a.name} ${a.address}`), ...(env.cc || []).map((a) => `${a.name} ${a.address}`), ].join('\n').toLowerCase(); if (hay.includes(needle)) return true; // 支持直接按 UID 找 return /^\d+$/.test(needle) && String(s.uid) === needle; }); const total = hit.length; return { folder, total, offset, limit, query: query.trim(), messages: hit.slice(offset, offset + limit).map(mapSummary), }; } const total = allUids.length; const newestFirst = allUids.slice().sort((a, b) => b - a); const page = newestFirst.slice(offset, offset + limit); const summaries = page.length ? await client.fetchSummaries(page) : []; const byUid = new Map(summaries.map((s) => [s.uid, s])); const ordered = page.map((u) => byUid.get(u)).filter(Boolean); return { folder, total, offset, limit, messages: ordered.map(mapSummary) }; }); } async message(uid, folder) { return this.withImap(async (client) => { await client.select(folder, { readonly: false }); const raw = await client.fetchRaw(uid, { peek: true }); if (!raw) throw new Error(`找不到 UID ${uid}`); const parsed = mime.parseMessage(raw.raw); return { uid, folder, subject: parsed.subject, from: parsed.from, to: parsed.to, cc: parsed.cc, replyTo: parsed.replyTo, date: parsed.date, messageId: parsed.messageId, inReplyTo: parsed.inReplyTo, references: parsed.references, text: parsed.text, html: parsed.html, size: parsed.size, seen: raw.flags.some((f) => /\\Seen/i.test(f)), flagged: raw.flags.some((f) => /\\Flagged/i.test(f)), flags: raw.flags, attachments: parsed.attachments.map((a, i) => ({ index: i, filename: a.filename, contentType: a.contentType, size: a.size, inline: a.inline, contentId: a.contentId, })), _attachments: parsed.attachments, // 仅服务端内部使用,不外发 }; }); } async attachment(uid, index, folder) { const msg = await this.message(uid, folder); const att = msg._attachments[index]; if (!att) throw new Error('附件不存在'); return att; } async setFlags(uid, folder, { seen, flagged, answered }) { return this.withImap(async (client) => { await client.select(folder, { readonly: false }); const ops = []; if (typeof seen === 'boolean') ops.push([seen ? '+FLAGS' : '-FLAGS', ['\\Seen']]); if (typeof flagged === 'boolean') ops.push([flagged ? '+FLAGS' : '-FLAGS', ['\\Flagged']]); if (typeof answered === 'boolean') ops.push([answered ? '+FLAGS' : '-FLAGS', ['\\Answered']]); let last = null; for (const [mode, flags] of ops) last = await client.storeFlags(uid, mode, flags); this.cache.folders = null; return last; }); } async deleteMessage(uid, folder, { permanent = false } = {}) { const trash = this.account.folders.trash || 'Trash'; return this.withImap(async (client) => { await client.select(folder, { readonly: false }); const isTrash = folder.toUpperCase() === String(trash).toUpperCase(); if (!permanent && !isTrash) { try { await client.copy(uid, trash); } catch (err) { // 垃圾箱不存在就直接原地删除 if (!/不存在|NONEXISTENT|NO/i.test(err.message)) throw err; } } await client.deleteUid(uid, { expunge: true }); this.cache.folders = null; return { moved: !permanent && !isTrash, folder: isTrash || permanent ? null : trash }; }); } async moveMessage(uid, from, to) { return this.withImap(async (client) => { await client.select(from, { readonly: false }); await client.copy(uid, to); await client.deleteUid(uid, { expunge: true }); this.cache.folders = null; return true; }); } async saveDraft({ to, subject, text, html }) { const drafts = this.account.folders.drafts || 'Drafts'; const raw = mime.buildMessage({ from: { name: this.account.displayName || '', address: this.account.user }, to: to ? [{ name: '', address: to }] : [], subject, text, html, domain: this.account.domain, }); return this.withImap(async (client) => { await client.append(drafts, raw, { flags: ['\\Draft'] }); this.cache.folders = null; return true; }); } async send({ to, cc, bcc, subject, text, html, inReplyTo, references, attachments }) { const list = (v) => String(v || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean); const toList = list(to); if (!toList.length) throw new Error('至少需要一个收件人'); const ccList = list(cc); const bccList = list(bcc); const raw = mime.buildMessage({ from: { name: this.account.displayName || '', address: this.account.user }, to: toList.map((a) => ({ name: '', address: a })), cc: ccList.map((a) => ({ name: '', address: a })), subject, text, html, inReplyTo: inReplyTo || undefined, references: references || undefined, domain: this.account.domain, attachments: (attachments || []).map((a) => ({ filename: a.filename, contentType: a.contentType || 'application/octet-stream', content: Buffer.from(a.base64 || '', 'base64'), })), }); const smtp = new SmtpClient(this.options.smtp); // 家用/运营商 DNS 偶尔会瞬时解析失败(EAI_AGAIN);发信是用户主动动作, // 这种瞬时错误重试一次比直接报错体验好得多。 const transient = (err) => /EAI_AGAIN|EAI_NODATA|EAI_FAIL|ETIMEDOUT|ECONNRESET|socket hang up/i.test(err && err.message || ''); const attempt = async () => { await smtp.connect(); await smtp.hello(); await smtp.auth(); await smtp.sendMail(this.account.user, [...toList, ...ccList, ...bccList], raw); }; try { try { await attempt(); } catch (err) { if (!transient(err)) throw err; await new Promise((r) => setTimeout(r, 1200)); await attempt(); } } finally { try { await smtp.quit(); } catch { /* 忽略 */ } } // 自发自收时服务器会投递到收件箱;同时把副本放到「已发送」 const sent = this.account.folders.sent || 'Sent'; try { await this.withImap(async (client) => { await client.append(sent, raw); }); } catch { /* 存副本失败不影响发送结果 */ } this.cache.folders = null; return { ok: true, bytes: raw.length, recipients: [...toList, ...ccList, ...bccList] }; } async close() { if (this.imap) { const c = this.imap; this.imap = null; try { await c.logout(); } catch { /* 忽略 */ } } } } function quoteStr(s) { return '"' + String(s).replace(/[\\"]/g, (m) => '\\' + m) + '"'; } /** IMAP 摘要 → 列表项(统一字段名,界面直接用) */ function mapSummary(s) { return { uid: s.uid, subject: s.envelope ? s.envelope.subject : '', from: s.envelope ? s.envelope.from : [], to: s.envelope ? s.envelope.to : [], date: s.envelope ? s.envelope.date : null, internalDate: s.internalDate, size: s.size, seen: s.seen, flagged: s.flagged, answered: s.answered, draft: s.draft, flags: s.flags, }; } // ─────────────────────────────────────────────────────────── HTTP let session = null; function json(res, code, body) { const data = Buffer.from(JSON.stringify(body), 'utf8'); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': data.length, 'Cache-Control': 'no-store', }); res.end(data); } function readBody(req, limit = 60 * 1024 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => { const buf = Buffer.concat(chunks); if (!buf.length) return resolve({}); try { resolve(JSON.parse(buf.toString('utf8'))); } catch (e) { reject(new Error('请求体不是合法 JSON')); } }); req.on('error', reject); }); } const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.png': 'image/png', '.ico': 'image/x-icon', }; function serveStatic(req, res, urlPath) { // 浏览器会主动请求 /favicon.ico:直接回 204,避免日志和验收里出现无意义的 404 if (urlPath === '/favicon.ico') { res.writeHead(204); res.end(); return; } const rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, ''); const target = path.join(SITE_DIR, rel); // 目录穿越保护 if (!target.startsWith(SITE_DIR)) { res.writeHead(403); res.end('forbidden'); return; } fs.stat(target, (err, st) => { if (err || !st.isFile()) { // SPA 回退:没有扩展名的路径一律回 index.html(前端路由) if (!path.extname(rel)) { const indexFile = path.join(SITE_DIR, 'index.html'); fs.stat(indexFile, (e2, st2) => { if (e2 || !st2.isFile()) { res.writeHead(404); res.end('404'); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': st2.size, 'Cache-Control': 'no-store', }); fs.createReadStream(indexFile).pipe(res); }); return; } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('404'); return; } const type = MIME_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream'; const isHtml = path.extname(target).toLowerCase() === '.html'; res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store' : 'public, max-age=3600', }); fs.createReadStream(target).pipe(res); }); } async function handleApi(req, res, url) { const p = url.pathname; const q = url.searchParams; // ── 状态 if (p === '/api/state' && req.method === 'GET') { const acc = session ? session.account : loadAccount(); let folders = null; let error = null; let connected = false; if (session) { try { folders = await session.folders(); connected = true; } catch (err) { error = err.message; connected = false; } } return json(res, 200, { account: { host: acc.host, imapPort: acc.imapPort, smtpPort: acc.smtpPort, user: acc.user, displayName: acc.displayName, domain: acc.domain, hasPassword: !!acc.password, accountFile: acc._file || null, }, connected, folders, error, capabilities: session && session.imap ? session.imap.capabilities : [], serverTime: new Date().toISOString(), }); } // ── 登录 / 登出(客户端本地会话) if (p === '/api/login' && req.method === 'POST') { const body = await readBody(req); const acc = loadAccount(); if (body.host) acc.host = body.host; if (body.imapPort) acc.imapPort = Number(body.imapPort); if (body.smtpPort) acc.smtpPort = Number(body.smtpPort); if (body.user) acc.user = body.user; if (body.password) acc.password = body.password; if (body.displayName != null) acc.displayName = body.displayName; if (body.save !== false) saveAccount(acc); if (session) await session.close(); session = new Session(acc); await session.connect(); const folders = await session.folders({ refresh: true }); return json(res, 200, { ok: true, folders }); } if (p === '/api/logout' && req.method === 'POST') { if (session) await session.close(); session = null; return json(res, 200, { ok: true }); } // ── 账号体系(无需登录:注册 / 邮箱验证码 / 找回密码) // 走服务器的公网账号入口,和 IMAP/SMTP 是两条独立通道。 const accApi = (acc) => createAccountApi((acc && acc.apiBase) || DEFAULT_API_BASE); const accountRoute = async (res, fn) => { try { return json(res, 200, await fn()); } catch (err) { const status = err && err.status ? err.status : 502; return json(res, status, { error: (err && err.message) || String(err), pendingVerification: !!(err && err.data && err.data.pendingVerification), retryAfterSeconds: (err && err.data && err.data.retryAfterSeconds) || null, }); } }; const currentAccount = () => (session ? session.account : loadAccount()); if (p === '/api/account/policy' && req.method === 'GET') { return accountRoute(res, async () => { const api = accApi(currentAccount()); const policy = await api.policy(); return { policy, apiBase: api.apiBase }; }); } if (p === '/api/account/register' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(currentAccount()).register({ email: body.email, password: body.password, displayName: body.displayName || '', inviteCode: body.inviteCode || '', })); } if (p === '/api/account/verify' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(currentAccount()).verifyRegistration({ email: body.email, code: body.code })); } if (p === '/api/account/resend' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(currentAccount()).resendCode({ email: body.email, purpose: body.purpose || 'register' })); } if (p === '/api/account/forgot' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(currentAccount()).forgot(body.email)); } if (p === '/api/account/reset' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(currentAccount()).reset({ email: body.email, code: body.code, password: body.password })); } if (!session) return json(res, 409, { error: '尚未登录' }); // ── 账号体系(需登录:资料 / 会话 / 改密码 / 审计) // 客户端自己只持 IMAP 凭据,这里按需换一个账号接口 token(换不到就只报错,不影响收发信)。 async function accountToken() { if (session.apiToken) return session.apiToken; const acc = session.account; if (!acc || !acc.user || !acc.password) throw new Error('本机没有保存密码,无法管理账号设置'); const r = await accApi(acc).login(acc.user, acc.password); session.apiToken = (r && r.token) || null; if (!session.apiToken) throw new Error('账号接口没有返回登录令牌'); return session.apiToken; } if (p === '/api/account/overview' && req.method === 'GET') { return accountRoute(res, async () => { const api = accApi(session.account); const token = await accountToken(); const [me, sessions, audit] = await Promise.all([api.profile(token), api.sessions(token), api.audit(token, 20)]); return { profile: me && me.user ? me.user : null, sessions: (sessions && sessions.sessions) || [], audit: (audit && audit.events) || [] }; }); } if (p === '/api/account/profile' && req.method === 'PATCH') { const body = await readBody(req); return accountRoute(res, async () => { const api = accApi(session.account); const r = await api.updateProfile(await accountToken(), body.displayName || ''); // 本地也更新一份,界面/发件显示名立刻跟上 session.account.displayName = r && r.user ? r.user.displayName : session.account.displayName; saveAccount(session.account); return r; }); } if (p === '/api/account/password' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => { const api = accApi(session.account); const r = await api.changePassword(await accountToken(), { currentPassword: body.currentPassword, password: body.password }); // 改完密码要把本机保存的密码一起换掉,否则下次登录还用旧密码 if (body.password) { session.account.password = body.password; saveAccount(session.account); } session.apiToken = null; // 旧 token 已被服务器保留(当前会话),但重新换一个更保险 return r; }); } if (p === '/api/account/sessions/revoke' && req.method === 'POST') { const body = await readBody(req); return accountRoute(res, async () => accApi(session.account).revokeSessions(await accountToken(), body || {})); } // ── 文件夹 if (p === '/api/folders' && req.method === 'GET') { const folders = await session.folders({ refresh: q.get('refresh') === '1' }); return json(res, 200, { folders }); } // ── 邮件列表 if (p === '/api/messages' && req.method === 'GET') { const folder = q.get('folder') || session.account.folders.inbox; const limit = Math.min(Number(q.get('limit') || session.account.pageSize || 50), 300); const offset = Math.max(Number(q.get('offset') || 0), 0); const query = q.get('q') || ''; const out = await session.messageList({ folder, limit, offset, query }); return json(res, 200, out); } // ── 单封详情 / 附件 let m; if ((m = /^\/api\/messages\/(\d+)\/attachments\/(\d+)$/.exec(p)) && req.method === 'GET') { const att = await session.attachment(Number(m[1]), Number(m[2]), q.get('folder') || session.account.folders.inbox); res.writeHead(200, { 'Content-Type': att.contentType || 'application/octet-stream', 'Content-Length': att.content.length, 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(att.filename)}`, 'Cache-Control': 'no-store', }); return res.end(att.content); } if ((m = /^\/api\/messages\/(\d+)$/.exec(p)) && req.method === 'GET') { const msg = await session.message(Number(m[1]), q.get('folder') || session.account.folders.inbox); delete msg._attachments; // HTML 正文是**别人写的代码**:这里做第一层净化(界面还会把它放进不带 allow-scripts 的沙箱 iframe) if (msg.html) { const clean = sanitizeHtml(msg.html, { allowRemoteImages: q.get('images') === '1' }); msg.html = clean.html; msg.htmlDocument = buildDocument(clean.html, { theme: q.get('theme') === 'dark' ? 'dark' : 'light' }); msg.blockedImages = clean.blockedImages; msg.sanitized = clean.removed; } return json(res, 200, msg); } // ── 旗标 if (p === '/api/flags' && req.method === 'POST') { const body = await readBody(req); await session.setFlags(Number(body.uid), body.folder || session.account.folders.inbox, body); return json(res, 200, { ok: true }); } // ── 删除 / 移动 if (p === '/api/delete' && req.method === 'POST') { const body = await readBody(req); const out = await session.deleteMessage(Number(body.uid), body.folder || session.account.folders.inbox, { permanent: !!body.permanent }); return json(res, 200, Object.assign({ ok: true }, out)); } if (p === '/api/move' && req.method === 'POST') { const body = await readBody(req); await session.moveMessage(Number(body.uid), body.folder || session.account.folders.inbox, body.target); return json(res, 200, { ok: true }); } // ── 发送 / 存草稿 if (p === '/api/send' && req.method === 'POST') { const body = await readBody(req); const out = await session.send(body); return json(res, 200, out); } if (p === '/api/drafts' && req.method === 'POST') { const body = await readBody(req); await session.saveDraft(body); return json(res, 200, { ok: true }); } return json(res, 404, { error: '未知的 API 路径:' + p }); } const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host || HOST}`); if (!url.pathname.startsWith('/api/')) return serveStatic(req, res, url.pathname); handleApi(req, res, url).catch((err) => { const code = err && /超时/.test(err.message) ? 504 : 500; json(res, code, { error: err.message, name: err.name || 'Error' }); }); }); async function main() { // 启动即有账户配置就自动连一次(失败不阻止启动,界面会显示离线态与错误) try { const acc = loadAccount(); if (acc.user && acc.password) { session = new Session(acc); await session.connect(); const folders = await session.folders({ refresh: true }); process.stdout.write(`[ok] 已连接 ${acc.host} 账号 ${acc.user},文件夹 ${folders.length} 个\n`); } else { process.stdout.write('[warn] 账户未配置(缺账号或密码),界面会要求登录\n'); } } catch (err) { session = session || new Session(loadAccount()); process.stdout.write(`[warn] 启动自动连接失败:${err.message}\n`); } server.listen(PORT, HOST, () => { process.stdout.write(`WpywMail 客户端已启动:http://${HOST}:${PORT}/\n`); process.stdout.write('(关闭此窗口即退出客户端)\n'); }); } if (require.main === module) { main().catch((err) => { process.stderr.write('启动失败:' + (err.stack || err) + '\n'); process.exit(1); }); } module.exports = { server, Session };