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
+730
View File
@@ -0,0 +1,730 @@
'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 };