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

@ -0,0 +1,178 @@
/**
* 多账号的**纯逻辑**:账号模型、增删、默认账号选择、聚合收件箱合并。
*
* 单独一个文件、不含任何 I/O是为了让它能被直接测
* `test/lib/accounts.test.mjs`)。持久化在 `stores/accountStore.ts`
* 主进程落盘在 `electron/main.cjs` 的 IPC 里 —— 那两层都只调用这里的函数,
* 于是「账号怎么合并、谁是默认」这类判据只有一份。
*
* # 设计取舍:为什么"当前账号"仍然走全局单例
*
* `api/config.ts` 里的 `API_BASE` / `bearerToken` 是**模块级单例**
* 全部既有调用点(收件箱、会话、日历、附件……)都直接用它们。
*
* 多账号有两条路:
* 1. 把每个调用点改成"传账号"—— 改动面覆盖整个 api 层与所有组件;
* 2. **保留单例 = "当前账号的认证"**,只在两处需要跨账号的地方走显式认证:
* · 聚合收件箱(要同时问多个账号)
* · 多账号 SSE每账号一条连接
*
* 选 2。理由是风险与收益单账号视图占绝大多数用量保持逐字节不变
* 而聚合与 SSE 这两处本来就必须显式持有各自的 base/token。
*/
/** 聚合视图的伪账号 id与真实账号 id 不会冲突:真实 id 是 uuid。 */
export const AGGREGATE_ID = 'all';
/** 一个账号。字段与 `docs/MULTI-ACCOUNT-PLAN.md` 第三节一致。 */
export interface Account {
id: string;
/** 显示用名(「工作邮箱」),不是登录用户名 */
displayName: string;
/** Gateway 根地址,如 http://192.168.2.60:8180不带 /api/v1 */
gateway: string;
/** permanent user_key用作 Bearer */
token: string;
/** 登录用户名,仅用于下次验证,可空 */
username?: string;
/** 最近一次选中时间ISO用于挑默认账号 */
lastUsed?: string;
}
/** 账号的认证信息base 已含 /api/v1。 */
export interface AccountAuth {
base: string;
token: string;
}
/**
* 规范化 Gateway 地址。
*
* 用户会粘贴各种形态:带尾斜杠、带 `/api/v1`、带空格、写 `localhost`。
* 统一收敛成"根地址不带尾斜杠",拼 `/api/v1` 只在一处做(`accountAuth`)。
*/
export function normalizeGateway(raw: string): string {
let s = String(raw ?? '').trim();
if (s === '') return '';
// 补协议:用户常只写 ip:port
if (!/^https?:\/\//i.test(s)) s = `http://${s}`;
s = s.replace(/\/+$/, '');
// 粘贴了完整 API 地址时把尾巴去掉,避免拼出 /api/v1/api/v1
s = s.replace(/\/api\/v1$/i, '');
return s.replace(/\/+$/, '');
}
/** 由 gateway / username 派生一个默认显示名(用户可改)。 */
export function deriveDisplayName(gateway: string, username?: string): string {
const host = String(gateway ?? '')
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/, '');
if (username && username.trim()) return `${username.trim()}@${host || '?'}`;
return host || '未命名账号';
}
/** 账号的认证信息。`base` 是真正要拼在路径前的那一段。 */
export function accountAuth(a: Pick<Account, 'gateway' | 'token'>): AccountAuth {
return { base: `${normalizeGateway(a.gateway)}/api/v1`, token: String(a.token ?? '') };
}
/**
* 账号是否够格入库/使用。
*
* 判据刻意宽松(只查"有地址且有令牌"):把格式校验做严了,
* 用户粘贴一个自建网关的相对地址就会被拒,而那种情况下唯一该说话的是
* 「验证连通性」那一步(`GET /auth/me`)。
*/
export function isUsableAccount(a: Partial<Account> | null | undefined): boolean {
if (!a) return false;
return normalizeGateway(a.gateway ?? '') !== '' && String(a.token ?? '').trim() !== '';
}
/**
* 按 id 插入或更新(同 id 覆盖)。
*
* 幂等:同一个 id 更新两次不会产生两条 —— 添加账号时如果用户重复粘贴同一个
* 网关+令牌,调用方应当先按 `sameIdentity` 去重,而不是靠这里。
*/
export function upsertAccount(list: Account[], acct: Account): Account[] {
const out = list.slice();
const i = out.findIndex(a => a.id === acct.id);
if (i >= 0) out[i] = { ...out[i], ...acct };
else out.push(acct);
return out;
}
/** 删除账号;不存在的 id 返回原列表(幂等)。 */
export function removeAccount(list: Account[], id: string): Account[] {
return list.filter(a => a.id !== id);
}
/**
* 两个账号是不是"同一个身份"(同网关 + 同令牌)。
*
* 用来挡住重复添加uuid 每次新建都不同,所以不能靠 id 判重。
* 比较前先规范化网关 —— 否则 `http://host:8180` 与 `host:8180/` 会被当成两个。
*/
export function sameIdentity(a: Pick<Account, 'gateway' | 'token'>, b: Pick<Account, 'gateway' | 'token'>): boolean {
return normalizeGateway(a.gateway) === normalizeGateway(b.gateway) && String(a.token ?? '') === String(b.token ?? '');
}
/**
* 挑默认账号:最近使用过的那个(`lastUsed` 最大)。
*
* 都没有 lastUsed 时取列表第一个 —— "第一个添加的账号"是方案里写死的兜底,
* 而它的顺序由列表本身决定,所以调用方要保持列表顺序稳定(追加而非重排)。
*/
export function pickDefaultAccountId(list: Account[]): string {
if (list.length === 0) return '';
let best = list[0];
for (const a of list) {
if (!a.lastUsed) continue;
if (!best.lastUsed || a.lastUsed > best.lastUsed) best = a;
}
return best.id;
}
/** 把某个账号标记为"刚用过"(不改其它字段、不改顺序)。 */
export function touchAccount(list: Account[], id: string, now = new Date().toISOString()): Account[] {
return list.map(a => (a.id === id ? { ...a, lastUsed: now } : a));
}
/** 带账号归属的邮件(聚合列表用)。 */
export type AccountMail<T> = T & { account_id: string; account_name: string };
/**
* 合并多个账号的收件箱。
*
* 三条判据(都有测试钉着):
* 1. **按时间倒序** —— 聚合列表看起来要像一份收件箱,不是几个收件箱拼接。
* 时间缺失的排在最后(不能因为缺字段就把它排到最前)。
* 2. **同一 mail_id 只留一条** —— 两个账号可能都在同一条线索里(同一封信
* 被抄送给了两边)。重复会让同一封信出现两次,而删除/标记已读只在其中一个
* 账号上生效,于是"点过了还在"。
* 3. **归属标注保留** —— 每条带上 `account_id` / `account_name`
* 界面才有徽标可画;同时第一条胜出,所以"这封信属于谁"是确定的。
*/
export function mergeInboxes<T extends { mail_id?: string; created_at?: string }>(
perAccount: { account: Pick<Account, 'id' | 'displayName'>; mails: T[] }[]
): AccountMail<T>[] {
const seen = new Set<string>();
const out: AccountMail<T>[] = [];
for (const { account, mails } of perAccount) {
for (const m of mails ?? []) {
const key = String(m?.mail_id ?? '');
if (key && seen.has(key)) continue;
if (key) seen.add(key);
out.push({ ...m, account_id: account.id, account_name: account.displayName });
}
}
out.sort((a, b) => {
const ta = String(a.created_at ?? '');
const tb = String(b.created_at ?? '');
if (ta === tb) return 0;
if (!ta) return 1; // 缺时间的排最后
if (!tb) return -1;
return ta < tb ? 1 : -1; // 倒序
});
return out;
}