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

@ -32,7 +32,17 @@ function resolveBase(): string {
return base.replace(/\/+$/, '');
}
export const API_BASE = resolveBase();
/**
* 当前生效的 API 基地址。
*
* **是 `let` 而不是 `const`**:多账号下每个账号自带 gateway,"当前账号"换了
* 基地址就得跟着换。ESM 的实时绑定让所有 `import { API_BASE }` 的模块看到
* 新值 —— 但**取快照的模块看不到**(`const B = API_BASE`),所以 api 层里
* 一律在读的时候取,不缓存。
*
* 网页端(同源 /api/v1)不会被改动:那里没有账号切换,值始终是解析出来的那个。
*/
export let API_BASE = resolveBase();
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
let bearerToken: string | null =
@ -55,6 +65,48 @@ export function authHeaders(): Record<string, string> {
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
}
/**
* 切换"当前账号"的认证(多账号用)。
*
* 基地址与令牌**一起切**:账号自带 gateway,只切令牌会把这封信发到上一个
* 账号的服务器上去(或 401)。切换后所有走单例的调用点都指向新账号。
*
* 代价是 api 层不能在模块作用域缓存 `API_BASE`(缓存了就只有第一次是对的)——
* 见 `client.ts` 里的 `base()`。
*/
export function setActiveAuth(auth: { base: string; token: string }): void {
const base = String(auth?.base ?? '').replace(/\/+$/, '');
if (base) API_BASE = base;
bearerToken = auth?.token && String(auth.token).trim() !== '' ? String(auth.token).trim() : null;
}
/** 当前认证(基地址 + 令牌)的快照,供需要判断"这两个请求是不是同一个账号"的地方用。 */
export function activeAuth(): { base: string; token: string | null } {
return { base: API_BASE, token: bearerToken };
}
/**
* 显式认证的一次请求(聚合收件箱、每账号 SSE 用)。
*
* 为什么不复用 `request()`:那个函数把 base/令牌写死在单例上,
* 而聚合要**同时**问多个账号 —— 借用单例就得来回切换它,
* 期间的并发请求会串号(A 的请求带上 B 的令牌)。
*
* @param auth `accountAuth(account)` 的结果
*/
export async function fetchWithAuth(
auth: { base: string; token: string },
path: string,
init: RequestInit = {}
): Promise<Response> {
const base = String(auth?.base ?? '').replace(/\/+$/, '');
const headers: Record<string, string> = {
...(init.headers as Record<string, string> | undefined)
};
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
return fetch(`${base}${path}`, { ...init, headers, credentials: 'include' });
}
/**
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
* - EventSource(SSE)不支持自定义头