Files
MailUI4Agents/client/electron/src/lib/accounts.ts
JianFeeeee addde97600 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`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00

179 lines
7.3 KiB
TypeScript
Raw 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.

/**
* 多账号的**纯逻辑**:账号模型、增删、默认账号选择、聚合收件箱合并。
*
* 单独一个文件、不含任何 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;
}