feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱

按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与
写信账号切换留作下一轮)。

- `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并),
  16 条测试钉住每条判据(含反向对照)。
- 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+
  0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与
  http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。
- 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的
  令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。
- ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的
  服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。
- ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表
  少一整份邮件而界面看起来完全正常。
- `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它
  (`client.ts` 的 `const BASE` 快照已改成每次读)。
- UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页
  「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。
- 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、
  typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
This commit is contained in:
2026-09-13 06:16:59 +08:00
parent c7cb88d9aa
commit addde97600
18 changed files with 1671 additions and 22 deletions

View File

@ -14,6 +14,7 @@
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron');
const path = require('node:path');
const fs = require('node:fs');
// 开发模式:ELECTRON_START_URL 环境变量指向 vite dev server
const DEV_URL = process.env.ELECTRON_START_URL || 'http://localhost:5173';
@ -133,4 +134,72 @@ app.on('before-quit', () => {
// IPC:渲染进程问主进程要 Gateway 地址(Phase 2 用)
ipcMain.handle('get-gateway-url', () => {
return process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
});
// ─── 多账号持久化(docs/MULTI-ACCOUNT-PLAN.md 第二、三节)───────────────
//
// # 为什么放主进程而不是渲染进程的 localStorage
//
// 账号里有 **user_key**(永久凭据)。localStorage 是渲染进程的存储,
// 一份 XSS 或一次误注入就能读走全部令牌;而且它在 file:// 与 http:// 下
// 是两套存储,桌面端和网页端会各存一份、互相看不见。
// 放主进程的文件里,渲染层只能通过这两个 IPC 拿到"当前账号列表",
// 并且可以选择**不回传令牌**(见 load 的 revealToken 参数)。
//
// # 落盘方式
//
// 原子写(临时文件 + rename):写到一半断电/被杀,留下的要么是旧文件、
// 要么是新文件,不会是半截 JSON —— 半截 JSON 会让下次启动把账号全丢。
// 权限 0600:同机器上的其它用户读不到。
// 目录用 app.getPath('userData'),与 Electron 自己的配置同处。
function accountsFile() {
return path.join(app.getPath('userData'), 'accounts.json');
}
function readAccounts() {
try {
const raw = fs.readFileSync(accountsFile(), 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed?.accounts) ? parsed.accounts : [];
} catch (e) {
// 文件不存在是正常首次启动;解析失败则**不能静默当成空列表** ——
// 那样下一次保存会用空列表覆盖掉用户的账号。
if (e && e.code !== 'ENOENT') {
console.error('[accounts] 读取失败(保留原文件,不覆盖):', e.message);
throw e;
}
return [];
}
}
function writeAccounts(accounts) {
const file = accountsFile();
const tmp = `${file}.tmp-${process.pid}`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(tmp, JSON.stringify({ version: 1, accounts }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, file);
try {
fs.chmodSync(file, 0o600);
} catch {
/* 某些文件系统不支持,权限不是失败条件 */
}
return true;
}
ipcMain.handle('accounts:load', () => {
try {
return { ok: true, accounts: readAccounts(), file: accountsFile() };
} catch (e) {
return { ok: false, accounts: [], error: String(e?.message || e), file: accountsFile() };
}
});
ipcMain.handle('accounts:save', (_evt, accounts) => {
if (!Array.isArray(accounts)) return { ok: false, error: 'accounts 必须是数组' };
try {
writeAccounts(accounts);
return { ok: true, file: accountsFile() };
} catch (e) {
return { ok: false, error: String(e?.message || e), file: accountsFile() };
}
});

View File

@ -33,6 +33,18 @@ contextBridge.exposeInMainWorld('__AGENTMAIL_TOKEN__', getInjected('token') || u
contextBridge.exposeInMainWorld('__AGENTMAIL_SHELL__', 'desktop');
contextBridge.exposeInMainWorld('agentmail', {
/**
* 多账号持久化(主进程落盘,见 main.cjs 的 accounts:* IPC)。
*
* 令牌经这条窄通道回到渲染层是必要的(要拿它发请求),但**不落
* localStorage**:那份存储对渲染层的任何脚本都可读,而且在 file:// 与
* http:// 下是两套、桌面端与网页端会各存一份互相看不见。
*/
accounts: {
load: () => ipcRenderer.invoke('accounts:load'),
save: accounts => ipcRenderer.invoke('accounts:save', accounts)
},
/** Gateway 基础地址(主进程 env 或默认 127.0.0.1:8180) */
gatewayUrl: () => ipcRenderer.invoke('get-gateway-url'),