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,209 @@
/**
* 账号 store:账号列表、当前选中(含聚合)、以及"选中变化 → 认证单例跟着变"。
*
* # 关键约定:`config.ts` 的单例 = **当前账号的认证**
*
* `src/api/config.ts` 里的 `API_BASE` / `bearerToken` 是模块级单例,全部既有
* 调用点(收件箱、会话、日历、附件…)都用它们。多账号没有把它们改成
* "每个调用点传账号"(那要动整个 api 层与所有组件),而是:
*
* **单例始终等于"当前选中账号"的认证,切换时由这里同步。**
*
* 只有两处必须显式持有各自的认证,它们本来就需要跨账号:
* · 聚合收件箱(同时问多个账号,见 mailStore.fetchInbox)
* · 多账号 SSE(每账号一条连接,下一轮)
*
* 于是单账号视图的代码路径逐字节不变。
*
* # 持久化
*
* 优先走主进程 IPC(`window.agentmail.accounts`,落 `userData/accounts.json`,
* 0600 原子写)。没有 IPC 时(浏览器里跑同一份前端)退到 localStorage ——
* 那不是安全存储,网页端首次使用时界面会明说这一点(不假装它是安全的)。
*/
import { create } from 'zustand';
import {
AGGREGATE_ID,
type Account,
accountAuth,
deriveDisplayName,
isUsableAccount,
normalizeGateway,
pickDefaultAccountId,
removeAccount as removeFrom,
sameIdentity,
touchAccount,
upsertAccount
} from '../lib/accounts';
import { setActiveAuth } from '../api/config';
interface AccountState {
accounts: Account[];
/** 当前选中:某个账号 id,或 AGGREGATE_ID(聚合) */
activeId: string;
/** 持久化位置(供界面显示"账号存在哪") */
storageFile: string;
/** 网页端(无 IPC)时为 true —— 界面据此提示"令牌存在 localStorage" */
ephemeralStorage: boolean;
loaded: boolean;
error: string | null;
load: () => Promise<void>;
add: (input: Omit<Account, 'id' | 'displayName' | 'lastUsed'> & { displayName?: string }) => Promise<{ ok: boolean; error?: string }>;
remove: (id: string) => Promise<void>;
setActive: (id: string) => Promise<void>;
}
/** 主进程 IPC(仅 Electron 里有)。 */
function bridge(): { load: () => Promise<any>; save: (a: Account[]) => Promise<any> } | null {
const w = globalThis as any;
return w?.agentmail?.accounts ?? null;
}
const LS_KEY = 'agentmail.accounts.v1';
async function persist(accounts: Account[]): Promise<{ file: string; ephemeral: boolean }> {
const b = bridge();
if (b) {
const r = await b.save(accounts);
if (!r?.ok) throw new Error(r?.error || '主进程保存失败');
return { file: String(r.file || ''), ephemeral: false };
}
try {
localStorage.setItem(LS_KEY, JSON.stringify(accounts));
} catch (e) {
throw new Error(`浏览器存储不可用:${(e as Error)?.message || e}`);
}
return { file: '', ephemeral: true };
}
function readLocal(): Account[] {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** 把选中账号的认证同步到 api 单例。聚合模式用第一个可用账号(发信要有身份)。 */
function syncAuth(accounts: Account[], activeId: string): void {
const target =
activeId === AGGREGATE_ID
? accounts.find(a => isUsableAccount(a))
: accounts.find(a => a.id === activeId);
if (target && isUsableAccount(target)) {
const { base, token } = accountAuth(target);
setActiveAuth({ base, token });
}
}
function newId(): string {
const c = globalThis.crypto as Crypto | undefined;
if (c?.randomUUID) return c.randomUUID();
return `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
export const useAccountStore = create<AccountState>((set, get) => ({
accounts: [],
activeId: AGGREGATE_ID,
storageFile: '',
ephemeralStorage: false,
loaded: false,
error: null,
load: async () => {
const b = bridge();
let accounts: Account[] = [];
let file = '';
let ephemeral = false;
if (b) {
const r = await b.load();
accounts = Array.isArray(r?.accounts) ? r.accounts : [];
file = String(r?.file || '');
if (r && r.ok === false) set({ error: `读取账号失败:${r.error}` });
} else {
accounts = readLocal();
ephemeral = true;
}
// 选中项:记住上次选中的(落盘在 activeId 里不合适——它是视图状态,
// 这里用"最近使用"推导,避免多一个持久化字段和它的迁移问题)
const activeId = pickDefaultAccountId(accounts) || AGGREGATE_ID;
syncAuth(accounts, activeId);
set({ accounts, activeId, storageFile: file, ephemeralStorage: ephemeral, loaded: true });
},
add: async input => {
const gateway = normalizeGateway(input.gateway);
const token = String(input.token ?? '').trim();
if (!gateway) return { ok: false, error: '请填写 Gateway 地址' };
if (!token) return { ok: false, error: '请填写用户密钥(user key)' };
const { accounts } = get();
// 同一身份重复添加会让收件箱出现两份同样的邮件、SSE 也多一条 —— 直接挡住
if (accounts.some(a => sameIdentity(a, { gateway, token }))) {
return { ok: false, error: '这个账号已经添加过了(同一 Gateway + 同一密钥)' };
}
const acct: Account = {
id: newId(),
displayName: (input.displayName || '').trim() || deriveDisplayName(gateway, input.username),
gateway,
token,
username: input.username,
lastUsed: new Date().toISOString()
};
const next = upsertAccount(accounts, acct);
set({ accounts: next, activeId: acct.id, error: null });
syncAuth(next, acct.id);
try {
const r = await persist(next);
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
return { ok: true };
} catch (e) {
set({ error: `保存失败:${(e as Error)?.message || e}` });
return { ok: false, error: `保存失败:${(e as Error)?.message || e}` };
}
},
remove: async id => {
const { accounts, activeId } = get();
const next = removeFrom(accounts, id);
const nextActive = activeId === id ? (next[0]?.id ?? AGGREGATE_ID) : activeId;
set({ accounts: next, activeId: nextActive, error: null });
syncAuth(next, nextActive);
try {
const r = await persist(next);
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
} catch (e) {
set({ error: `保存失败:${(e as Error)?.message || e}` });
}
},
setActive: async id => {
const { accounts } = get();
const next = id === AGGREGATE_ID ? accounts : touchAccount(accounts, id);
set({ activeId: id, accounts: next });
syncAuth(next, id);
// lastUsed 变了要落盘,否则下次启动的默认账号会退回旧值
try {
await persist(next);
} catch {
/* 落盘失败不该挡住切换:内存里已经切好了,下次启动最多是默认账号不对 */
}
}
}));
/** 当前选中的账号(聚合模式下为 null —— 它不是一个账号)。 */
export function activeAccount(s: { accounts: Account[]; activeId: string }): Account | null {
if (s.activeId === AGGREGATE_ID) return null;
return s.accounts.find(a => a.id === s.activeId) ?? null;
}
/** 是否处于聚合视图。 */
export function isAggregate(s: { activeId: string }): boolean {
return s.activeId === AGGREGATE_ID;
}

View File

@ -1,6 +1,8 @@
import { create } from 'zustand';
import type { Mail } from '../types';
import * as api from '../api/client';
import { accountAuth, mergeInboxes, normalizeGateway } from '../lib/accounts';
import { isAggregate, useAccountStore } from './accountStore';
interface MailState {
inbox: Mail[];
@ -8,6 +10,14 @@ interface MailState {
currentMail: Mail | null;
loading: boolean;
error: string | null;
/**
* 聚合模式下**某个账号**取失败的原因(账号名 + 原因)。
*
* 单独一个字段而不是塞进 `error`:`error` 会让整个列表变成错误态,
* 而这里更常见的是"两个账号里有一个挂了" —— 那时其余邮件仍应显示,
* 只在列表上方标明少了一份。静默丢一整个账号才是真的会骗人。
*/
accountErrors: { account: string; error: string }[];
fetchInbox: (status?: string) => Promise<void>;
fetchSent: () => Promise<void>;
@ -28,18 +38,68 @@ interface MailState {
export const useMailStore = create<MailState>((set, get) => ({
inbox: [],
sent: [],
accountErrors: [],
currentMail: null,
loading: false,
error: null,
fetchInbox: async (status = 'all') => {
set({ loading: true, error: null });
try {
const { mails } = await api.getInbox(status);
set({ inbox: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
const acc = useAccountStore.getState();
const usable = acc.accounts.filter(a => a.token && a.gateway);
// 单账号(或只有一个账号):走原来的路径,逐字节不变。
// 聚合只在**真的有两个以上可用账号**时才发生 —— 否则"全部邮箱"与
// 单账号看到的是同一份数据,多绕一圈只会多出失败面。
if (!isAggregate(acc) || usable.length < 2) {
set({ loading: true, error: null, accountErrors: [] });
try {
const { mails } = await api.getInbox(status);
set({ inbox: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
return;
}
// ★ 只合并**同一网关**的账号。
//
// 单例基地址 = 当前账号的网关,打开邮件/标已读这些动作都走它。若把另一个
// 网关的邮件混进列表,点开时会去问第一个账号的服务器 —— 要么 404,
// 要么更糟:mail_id 恰好撞上就打开了别人的信。所以宁可如实排除,
// 并在列表上方说明(这是可见的缺失,不是静默少一份)。
const hostGateway = normalizeGateway(usable[0].gateway);
const sameGateway = usable.filter(a => normalizeGateway(a.gateway) === hostGateway);
const otherGateway = usable.filter(a => normalizeGateway(a.gateway) !== hostGateway);
set({ loading: true, error: null, accountErrors: [] });
const results = await Promise.allSettled(
sameGateway.map(async a => ({ account: a, ...(await api.getInboxWithAuth(accountAuth(a), status)) }))
);
const ok: { account: typeof usable[number]; mails: Mail[] }[] = [];
const failures: { account: string; error: string }[] = otherGateway.map(a => ({
account: a.displayName,
error: `在另一个网关(${a.gateway}),未参与聚合 —— 切到该账号可单独查看`
}));
results.forEach((r, i) => {
const a = sameGateway[i];
if (r.status === 'fulfilled') ok.push({ account: r.value.account, mails: r.value.mails || [] });
else {
// ★ 某个账号取不到**必须说出来**:静默丢掉它,聚合列表会少一整份邮件,
// 而界面看起来完全正常(这正是"聚合"最容易骗人的失败方式)。
failures.push({
account: a.displayName,
error: r.reason instanceof Error ? r.reason.message : String(r.reason)
});
}
});
set({
inbox: mergeInboxes(ok),
loading: false,
accountErrors: failures,
error: failures.length && ok.length === 0 ? `全部账号都取不到邮件:${failures.map(f => f.account).join('、')}` : null
});
},
fetchSent: async () => {