/** * 多账号的**纯逻辑**:账号模型、增删、默认账号选择、聚合收件箱合并。 * * 单独一个文件、不含任何 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): AccountAuth { return { base: `${normalizeGateway(a.gateway)}/api/v1`, token: String(a.token ?? '') }; } /** * 账号是否够格入库/使用。 * * 判据刻意宽松(只查"有地址且有令牌"):把格式校验做严了, * 用户粘贴一个自建网关的相对地址就会被拒,而那种情况下唯一该说话的是 * 「验证连通性」那一步(`GET /auth/me`)。 */ export function isUsableAccount(a: Partial | 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, b: Pick): 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 & { account_id: string; account_name: string }; /** * 合并多个账号的收件箱。 * * 三条判据(都有测试钉着): * 1. **按时间倒序** —— 聚合列表看起来要像一份收件箱,不是几个收件箱拼接。 * 时间缺失的排在最后(不能因为缺字段就把它排到最前)。 * 2. **同一 mail_id 只留一条** —— 两个账号可能都在同一条线索里(同一封信 * 被抄送给了两边)。重复会让同一封信出现两次,而删除/标记已读只在其中一个 * 账号上生效,于是"点过了还在"。 * 3. **归属标注保留** —— 每条带上 `account_id` / `account_name`, * 界面才有徽标可画;同时第一条胜出,所以"这封信属于谁"是确定的。 */ export function mergeInboxes( perAccount: { account: Pick; mails: T[] }[] ): AccountMail[] { const seen = new Set(); const out: AccountMail[] = []; 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; }