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:
209
client/electron/src/stores/accountStore.ts
Normal file
209
client/electron/src/stores/accountStore.ts
Normal 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;
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user