Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
+730
@@ -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 };
|
||||
Reference in New Issue
Block a user