Files

731 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };