From addde97600ed1623634cc26d7991e9116ddb4847 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 06:16:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(electron):=20=E5=A4=9A=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=BA=B5=E5=88=87=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=AD=98=E5=82=A8/=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8/=E8=81=9A=E5=90=88=E6=94=B6=E4=BB=B6=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。 --- client/electron/electron/main.cjs | 69 ++++ client/electron/electron/preload.cjs | 12 + client/electron/src/App.tsx | 10 +- client/electron/src/api/client.ts | 56 +++- client/electron/src/api/config.ts | 54 ++- .../electron/src/components/AccountList.tsx | 189 +++++++++++ .../electron/src/components/AccountPage.tsx | 6 + .../src/components/AccountSwitcher.tsx | 138 ++++++++ client/electron/src/components/MailList.tsx | 35 +- client/electron/src/lib/accounts.ts | 178 ++++++++++ client/electron/src/stores/accountStore.ts | 209 ++++++++++++ client/electron/src/stores/mailStore.ts | 72 +++- client/electron/src/types/index.ts | 10 + .../test/components/AccountSwitcher.test.tsx | 132 ++++++++ .../test/components/LoginPage.test.tsx | 1 - client/electron/test/lib/accounts.test.mjs | 186 +++++++++++ .../test/manual/multi-account-verify.mjs | 309 ++++++++++++++++++ docs/MULTI-ACCOUNT-PLAN.md | 27 +- 18 files changed, 1671 insertions(+), 22 deletions(-) create mode 100644 client/electron/src/components/AccountList.tsx create mode 100644 client/electron/src/components/AccountSwitcher.tsx create mode 100644 client/electron/src/lib/accounts.ts create mode 100644 client/electron/src/stores/accountStore.ts create mode 100644 client/electron/test/components/AccountSwitcher.test.tsx create mode 100644 client/electron/test/lib/accounts.test.mjs create mode 100644 client/electron/test/manual/multi-account-verify.mjs diff --git a/client/electron/electron/main.cjs b/client/electron/electron/main.cjs index d687f2a..ec04ca2 100644 --- a/client/electron/electron/main.cjs +++ b/client/electron/electron/main.cjs @@ -14,6 +14,7 @@ const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron'); const path = require('node:path'); +const fs = require('node:fs'); // 开发模式:ELECTRON_START_URL 环境变量指向 vite dev server const DEV_URL = process.env.ELECTRON_START_URL || 'http://localhost:5173'; @@ -133,4 +134,72 @@ app.on('before-quit', () => { // IPC:渲染进程问主进程要 Gateway 地址(Phase 2 用) ipcMain.handle('get-gateway-url', () => { return process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180'; +}); + +// ─── 多账号持久化(docs/MULTI-ACCOUNT-PLAN.md 第二、三节)─────────────── +// +// # 为什么放主进程而不是渲染进程的 localStorage +// +// 账号里有 **user_key**(永久凭据)。localStorage 是渲染进程的存储, +// 一份 XSS 或一次误注入就能读走全部令牌;而且它在 file:// 与 http:// 下 +// 是两套存储,桌面端和网页端会各存一份、互相看不见。 +// 放主进程的文件里,渲染层只能通过这两个 IPC 拿到"当前账号列表", +// 并且可以选择**不回传令牌**(见 load 的 revealToken 参数)。 +// +// # 落盘方式 +// +// 原子写(临时文件 + rename):写到一半断电/被杀,留下的要么是旧文件、 +// 要么是新文件,不会是半截 JSON —— 半截 JSON 会让下次启动把账号全丢。 +// 权限 0600:同机器上的其它用户读不到。 +// 目录用 app.getPath('userData'),与 Electron 自己的配置同处。 +function accountsFile() { + return path.join(app.getPath('userData'), 'accounts.json'); +} + +function readAccounts() { + try { + const raw = fs.readFileSync(accountsFile(), 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed?.accounts) ? parsed.accounts : []; + } catch (e) { + // 文件不存在是正常首次启动;解析失败则**不能静默当成空列表** —— + // 那样下一次保存会用空列表覆盖掉用户的账号。 + if (e && e.code !== 'ENOENT') { + console.error('[accounts] 读取失败(保留原文件,不覆盖):', e.message); + throw e; + } + return []; + } +} + +function writeAccounts(accounts) { + const file = accountsFile(); + const tmp = `${file}.tmp-${process.pid}`; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify({ version: 1, accounts }, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); + try { + fs.chmodSync(file, 0o600); + } catch { + /* 某些文件系统不支持,权限不是失败条件 */ + } + return true; +} + +ipcMain.handle('accounts:load', () => { + try { + return { ok: true, accounts: readAccounts(), file: accountsFile() }; + } catch (e) { + return { ok: false, accounts: [], error: String(e?.message || e), file: accountsFile() }; + } +}); + +ipcMain.handle('accounts:save', (_evt, accounts) => { + if (!Array.isArray(accounts)) return { ok: false, error: 'accounts 必须是数组' }; + try { + writeAccounts(accounts); + return { ok: true, file: accountsFile() }; + } catch (e) { + return { ok: false, error: String(e?.message || e), file: accountsFile() }; + } }); \ No newline at end of file diff --git a/client/electron/electron/preload.cjs b/client/electron/electron/preload.cjs index 10d2aec..0f21304 100644 --- a/client/electron/electron/preload.cjs +++ b/client/electron/electron/preload.cjs @@ -33,6 +33,18 @@ contextBridge.exposeInMainWorld('__AGENTMAIL_TOKEN__', getInjected('token') || u contextBridge.exposeInMainWorld('__AGENTMAIL_SHELL__', 'desktop'); contextBridge.exposeInMainWorld('agentmail', { + /** + * 多账号持久化(主进程落盘,见 main.cjs 的 accounts:* IPC)。 + * + * 令牌经这条窄通道回到渲染层是必要的(要拿它发请求),但**不落 + * localStorage**:那份存储对渲染层的任何脚本都可读,而且在 file:// 与 + * http:// 下是两套、桌面端与网页端会各存一份互相看不见。 + */ + accounts: { + load: () => ipcRenderer.invoke('accounts:load'), + save: accounts => ipcRenderer.invoke('accounts:save', accounts) + }, + /** Gateway 基础地址(主进程 env 或默认 127.0.0.1:8180) */ gatewayUrl: () => ipcRenderer.invoke('get-gateway-url'), diff --git a/client/electron/src/App.tsx b/client/electron/src/App.tsx index 2a225f0..3d54adc 100644 --- a/client/electron/src/App.tsx +++ b/client/electron/src/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useEffect } from 'react'; import { connectSSE } from './api/sse'; import { useAuthStore } from './stores/authStore'; +import { useAccountStore } from './stores/accountStore'; import { useMailStore } from './stores/mailStore'; import { useSessionStore } from './stores/sessionStore'; import { useContactStore } from './stores/contactStore'; @@ -50,8 +51,15 @@ export default function App() { const removeSessionLocally = useContactStore(s => s.removeSessionLocally); // 启动时检测初始化状态 / 登录态 + // + // 顺序要紧:**先加载账号**(它会把选中账号的认证写进 api 单例),再 bootstrap。 + // 反过来的话,bootstrap 里那几次请求会用旧令牌发出去,表现为"启动瞬间 + // 显示未登录、过一会儿自己好了"。 useEffect(() => { - bootstrap(); + (async () => { + await useAccountStore.getState().load(); + bootstrap(); + })(); }, []); // 登出后清理客户端状态,避免脏数据残留 diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts index 8572c78..0fff4a5 100644 --- a/client/electron/src/api/client.ts +++ b/client/electron/src/api/client.ts @@ -1,10 +1,18 @@ import type { User } from '../types'; import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types'; -import { API_BASE, authHeaders, withToken } from './config'; +import { API_BASE, authHeaders, withToken, fetchWithAuth } from './config'; -export { API_BASE, setToken, getToken, authHeaders, withToken } from './config'; +export { API_BASE, setToken, getToken, authHeaders, withToken, fetchWithAuth } from './config'; -const BASE = API_BASE; +/** + * 基地址**每次读**(不是模块加载时缓存)。 + * + * 多账号下 `API_BASE` 会随当前账号变化 —— 缓存成 `const` 的话, + * 切换账号后只有第一次请求指向新网关,之后又回到旧网关。 + */ +function base(): string { + return API_BASE; +} export class ApiError extends Error { status: number; @@ -30,7 +38,7 @@ async function request(method: string, path: string, body?: unknown): Promise }; if (body !== undefined) init.body = JSON.stringify(body); - const res = await fetch(`${BASE}${path}`, init); + const res = await fetch(`${base()}${path}`, init); if (!res.ok) { const payload = await res.json().catch(() => ({ error: res.statusText })); @@ -247,6 +255,40 @@ export async function getInbox(status = 'all', limit = 50) { ); } +/** + * 用**指定的**账号认证取收件箱(聚合视图用)。 + * + * 与 `getInbox` 的区别:那条走认证单例(= 当前账号),而聚合要同时问多个账号。 + * 借用单例来回切换会让并发请求串号(A 的请求带上 B 的令牌)—— + * 所以这里显式带认证,且**不碰单例**。 + * + * 返回体与 `getInbox` 一致,调用方才能真正把两者混着用。 + */ +export async function getInboxWithAuth( + auth: { base: string; token: string }, + status = 'all', + limit = 50 +): Promise<{ mails: Mail[]; total: number }> { + const res = await fetchWithAuth(auth, `/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`); + const text = await res.text(); + let body: any = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = {}; + } + if (!res.ok) { + // 错误要带上账号信息是调用方的事(它知道是哪个账号),这里只把状态与 + // 服务端原话抛出去 —— 聚合里"某个账号挂了"必须能说清是哪一种挂法。 + throw new ApiError( + res.status, + `HTTP ${res.status}${body?.error ? `:${body.error}` : ''}`, + body + ); + } + return { mails: body?.mails ?? [], total: body?.total ?? (body?.mails?.length ?? 0) }; +} + export async function getSent() { return request<{ mails: Mail[] }>('GET', '/me/mail/sent'); } @@ -292,7 +334,7 @@ export async function uploadAttachment(file: File, onProgress?: (pct: number) => if (onProgress) { return new Promise<{ attachment: Attachment }>((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.open('POST', `${BASE}/me/attachments`); + xhr.open('POST', `${base()}/me/attachments`); xhr.withCredentials = true; for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v); xhr.upload.onprogress = e => { @@ -317,7 +359,7 @@ export async function uploadAttachment(file: File, onProgress?: (pct: number) => }); } - const res = await fetch(`${BASE}/me/attachments`, { + const res = await fetch(`${base()}/me/attachments`, { method: 'POST', credentials: 'include', // 不设 Content-Type:multipart 的 boundary 要交给浏览器生成 @@ -341,7 +383,7 @@ export async function deleteAttachment(id: string) { * Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。 */ export function attachmentURL(id: string) { - return withToken(`${BASE}/me/attachments/${id}`); + return withToken(`${base()}/me/attachments/${id}`); } /** 人类可读的字节数 */ diff --git a/client/electron/src/api/config.ts b/client/electron/src/api/config.ts index f2bf524..59c30af 100644 --- a/client/electron/src/api/config.ts +++ b/client/electron/src/api/config.ts @@ -32,7 +32,17 @@ function resolveBase(): string { return base.replace(/\/+$/, ''); } -export const API_BASE = resolveBase(); +/** + * 当前生效的 API 基地址。 + * + * **是 `let` 而不是 `const`**:多账号下每个账号自带 gateway,"当前账号"换了 + * 基地址就得跟着换。ESM 的实时绑定让所有 `import { API_BASE }` 的模块看到 + * 新值 —— 但**取快照的模块看不到**(`const B = API_BASE`),所以 api 层里 + * 一律在读的时候取,不缓存。 + * + * 网页端(同源 /api/v1)不会被改动:那里没有账号切换,值始终是解析出来的那个。 + */ +export let API_BASE = resolveBase(); /** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */ let bearerToken: string | null = @@ -55,6 +65,48 @@ export function authHeaders(): Record { return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}; } +/** + * 切换"当前账号"的认证(多账号用)。 + * + * 基地址与令牌**一起切**:账号自带 gateway,只切令牌会把这封信发到上一个 + * 账号的服务器上去(或 401)。切换后所有走单例的调用点都指向新账号。 + * + * 代价是 api 层不能在模块作用域缓存 `API_BASE`(缓存了就只有第一次是对的)—— + * 见 `client.ts` 里的 `base()`。 + */ +export function setActiveAuth(auth: { base: string; token: string }): void { + const base = String(auth?.base ?? '').replace(/\/+$/, ''); + if (base) API_BASE = base; + bearerToken = auth?.token && String(auth.token).trim() !== '' ? String(auth.token).trim() : null; +} + +/** 当前认证(基地址 + 令牌)的快照,供需要判断"这两个请求是不是同一个账号"的地方用。 */ +export function activeAuth(): { base: string; token: string | null } { + return { base: API_BASE, token: bearerToken }; +} + +/** + * 显式认证的一次请求(聚合收件箱、每账号 SSE 用)。 + * + * 为什么不复用 `request()`:那个函数把 base/令牌写死在单例上, + * 而聚合要**同时**问多个账号 —— 借用单例就得来回切换它, + * 期间的并发请求会串号(A 的请求带上 B 的令牌)。 + * + * @param auth `accountAuth(account)` 的结果 + */ +export async function fetchWithAuth( + auth: { base: string; token: string }, + path: string, + init: RequestInit = {} +): Promise { + const base = String(auth?.base ?? '').replace(/\/+$/, ''); + const headers: Record = { + ...(init.headers as Record | undefined) + }; + if (auth?.token) headers.Authorization = `Bearer ${auth.token}`; + return fetch(`${base}${path}`, { ...init, headers, credentials: 'include' }); +} + /** * 给 URL 附加认证信息,供无法设置请求头的场景使用: * - EventSource(SSE)不支持自定义头 diff --git a/client/electron/src/components/AccountList.tsx b/client/electron/src/components/AccountList.tsx new file mode 100644 index 0000000..7b02365 --- /dev/null +++ b/client/electron/src/components/AccountList.tsx @@ -0,0 +1,189 @@ +/** + * 多账号管理(账号页里的一段):列出已添加账号、添加新账号、删除。 + * + * # 添加时**真的去验证**连通性 + * + * 粘贴一个错的密钥不会报错——它只会表现为"这个账号的收件箱一直是空的"。 + * 所以添加流程必须调一次 `GET /auth/me`: + * 成功 → 显示用户名(用户能确认"这是我要的那个账号") + * 401 → 明确说"密钥无效",不写进账号列表 + * 网络错 → 说清是哪一种(连不上 / 超时),因为这两种的修法不同 + * + * 用「显式认证」发这个请求(`fetchWithAuth`),不碰当前账号的认证单例 —— + * 否则验证一个坏账号的过程会把当前账号的令牌换掉。 + */ + +import { useState } from 'react'; +import { fetchWithAuth } from '../api/config'; +import { deriveDisplayName, normalizeGateway } from '../lib/accounts'; +import { activeAccount, useAccountStore } from '../stores/accountStore'; + +export default function AccountList() { + const accounts = useAccountStore(s => s.accounts); + const activeId = useAccountStore(s => s.activeId); + const ephemeral = useAccountStore(s => s.ephemeralStorage); + const storageFile = useAccountStore(s => s.storageFile); + const addAccount = useAccountStore(s => s.add); + const removeAccount = useAccountStore(s => s.remove); + const setActive = useAccountStore(s => s.setActive); + const current = useAccountStore(activeAccount); + + const [open, setOpen] = useState(false); + const [gateway, setGateway] = useState(''); + const [token, setToken] = useState(''); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + const submit = async () => { + setMsg(null); + const g = normalizeGateway(gateway); + const t = token.trim(); + if (!g) return setMsg({ kind: 'err', text: '请填写 Gateway 地址' }); + if (!t) return setMsg({ kind: 'err', text: '请填写用户密钥' }); + + setBusy(true); + try { + // 先验证,再入库 —— 顺序反过来就会出现"列表里有个永远空的账号" + const res = await fetchWithAuth({ base: `${g}/api/v1`, token: t }, '/auth/me'); + const body = await res.json().catch(() => ({})); + if (res.status === 401 || res.status === 403) { + setBusy(false); + return setMsg({ kind: 'err', text: '密钥无效(服务端返回 401)——请确认用的是用户密钥' }); + } + if (!res.ok) { + setBusy(false); + return setMsg({ kind: 'err', text: `服务端返回 ${res.status}${body?.error ? `:${body.error}` : ''}` }); + } + const username = body?.user?.username || body?.username || ''; + const r = await addAccount({ + gateway: g, + token: t, + username, + displayName: name.trim() || deriveDisplayName(g, username) + }); + setBusy(false); + if (!r.ok) return setMsg({ kind: 'err', text: r.error || '添加失败' }); + setMsg({ kind: 'ok', text: `已添加${username ? `(${username})` : ''}` }); + setGateway(''); + setToken(''); + setName(''); + setOpen(false); + } catch (e) { + setBusy(false); + // 连不上 / 超时 / CORS 都要能分辨:这里至少把原始错误原样说出来 + setMsg({ kind: 'err', text: `连不上:${(e as Error)?.message || e}` }); + } + }; + + return ( +
+
+

多账号

+ +
+ + {/* 存储位置如实说:网页端退到 localStorage,那不是安全存储 */} +

+ {ephemeral + ? '账号保存在浏览器 localStorage(当前不在桌面端,这不是加密存储)' + : `账号保存在本机文件:${storageFile || '(未知路径)'}(权限 600)`} +

+ +
    + {accounts.map(a => { + const usable = Boolean(a.token && a.gateway); + return ( +
  • + + {a.displayName} + + {a.gateway} + {a.username ? ` · ${a.username}` : ''} + + + {!usable && 缺密钥} + {a.id === current?.id ? ( + 当前 + ) : a.id !== activeId ? ( + + ) : null} + +
  • + ); + })} + {accounts.length === 0 &&
  • 还没有添加任何账号
  • } +
+ + {open && ( +
+ setGateway(e.target.value)} + placeholder="Gateway 地址,如 http://192.168.2.60:8180" + className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300" + /> + setToken(e.target.value)} + placeholder="用户密钥(user key,64 位十六进制)" + className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300 font-mono" + /> + setName(e.target.value)} + placeholder="显示名(可留空,默认用 用户名@主机)" + className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300" + /> + +

添加前会调用一次 /auth/me 验证密钥,通过才写入。

+
+ )} + + {msg && ( +

+ {msg.text} +

+ )} +
+ ); +} diff --git a/client/electron/src/components/AccountPage.tsx b/client/electron/src/components/AccountPage.tsx index 7e64442..75bbea3 100644 --- a/client/electron/src/components/AccountPage.tsx +++ b/client/electron/src/components/AccountPage.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore'; import * as api from '../api/client'; import { LockIcon, LogoutIcon } from './icons'; import KeyPanel from './KeyPanel'; +import AccountList from './AccountList'; import ThemePicker from './ThemePicker'; import BackgroundPicker from './BackgroundPicker'; @@ -199,6 +200,11 @@ export default function AccountPage() { + {/* 多账号(docs/MULTI-ACCOUNT-PLAN.md) */} +
+ +
+ {/* 客户端连接密钥 */}
s.accounts); + const activeId = useAccountStore(s => s.activeId); + const setActive = useAccountStore(s => s.setActive); + const fetchInbox = useMailStore(s => s.fetchInbox); + const setViewMode = useUIStore(s => s.setViewMode); + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + const usable = accounts.filter(isUsableAccount); + const active = usable.find(a => a.id === activeId) ?? null; + const aggregateAvailable = usable.length >= 2; + + // 点外面 / 按 Esc 关下拉:不关的话它会盖住列表且没有明显出口 + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + // 没有账号(首次运行 / 全被删掉)时不渲染:那时界面该显示登录, + // 由 LoginPage 负责,这里画一个"未命名账号"的下拉只会添乱 + if (usable.length === 0) return null; + + const label = activeId === AGGREGATE_ID && aggregateAvailable ? '全部邮箱' : (active?.displayName ?? '全部邮箱'); + + const pick = async (id: string) => { + setOpen(false); + await setActive(id); + // 切账号后**必须重取**:收件箱是按账号返回的,不重取就会看到上一个账号的信。 + // 这条在聚合⇄单账号之间同样成立(聚合要并发问多个账号)。 + await fetchInbox('all'); + }; + + const title = aggregateAvailable ? label : active?.displayName; + + return ( +
+ + + {open && ( +
+ {aggregateAvailable && ( + + )} +
+ {usable.map(a => ( + + ))} +
+ +
+ )} +
+ ); +} diff --git a/client/electron/src/components/MailList.tsx b/client/electron/src/components/MailList.tsx index 9fa23f6..ac877c7 100644 --- a/client/electron/src/components/MailList.tsx +++ b/client/electron/src/components/MailList.tsx @@ -5,6 +5,8 @@ import { useUIStore } from '../stores/uiStore'; import type { Mail } from '../types'; import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups'; import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons'; +import AccountSwitcher from './AccountSwitcher'; +import { isAggregate, useAccountStore } from '../stores/accountStore'; import { participantAddress } from '../lib/replyTarget'; export default function MailList() { @@ -20,6 +22,11 @@ export default function MailList() { const fetchSent = useMailStore(s => s.fetchSent); const clearSession = useSessionStore(s => s.clearSession); + // 聚合视图("全部邮箱"):列表要画账号徽标、并在某个账号取失败时提示 + const activeId = useAccountStore(s => s.activeId); + const accountErrors = useMailStore(s => s.accountErrors); + const aggregate = isAggregate({ activeId }); + // 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信 // 淹掉其他任务」,默认展开等于没分组 const [expanded, setExpanded] = useState>(new Set()); @@ -66,6 +73,7 @@ export default function MailList() {

{isSent ? '发件箱' : '收件箱'}

+ {/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是 「有几件事」,后者只是流量 */} @@ -75,6 +83,18 @@ export default function MailList() {
+ {/* 聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件, + 而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式 */} + {aggregate && accountErrors.length > 0 && ( +
+ 有 {accountErrors.length} 个账号没取到: + {accountErrors.map(e => `${e.account}(${e.error})`).join(';')} +
+ )} +
{groups.map(g => isFlatGroup(g) ? ( @@ -84,6 +104,7 @@ export default function MailList() { active={currentMail?.mail_id === g.latest.mail_id} showTo={isSent} onClick={() => pick(g.latest)} + accountName={aggregate ? g.latest.account_name : undefined} /> ) : ( void; + /** 聚合视图下的账号归属徽标(单账号视图传 undefined,不画) */ + accountName?: string; /** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */ compact?: boolean; }) { @@ -276,6 +300,15 @@ function MailItem({ )} + {accountName && ( + + {accountName} + + )} {time}
diff --git a/client/electron/src/lib/accounts.ts b/client/electron/src/lib/accounts.ts new file mode 100644 index 0000000..32e526a --- /dev/null +++ b/client/electron/src/lib/accounts.ts @@ -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): 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; +} diff --git a/client/electron/src/stores/accountStore.ts b/client/electron/src/stores/accountStore.ts new file mode 100644 index 0000000..7c522da --- /dev/null +++ b/client/electron/src/stores/accountStore.ts @@ -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; + add: (input: Omit & { displayName?: string }) => Promise<{ ok: boolean; error?: string }>; + remove: (id: string) => Promise; + setActive: (id: string) => Promise; +} + +/** 主进程 IPC(仅 Electron 里有)。 */ +function bridge(): { load: () => Promise; save: (a: Account[]) => Promise } | 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((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; +} diff --git a/client/electron/src/stores/mailStore.ts b/client/electron/src/stores/mailStore.ts index 89e8a37..e7ea18a 100644 --- a/client/electron/src/stores/mailStore.ts +++ b/client/electron/src/stores/mailStore.ts @@ -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; fetchSent: () => Promise; @@ -28,18 +38,68 @@ interface MailState { export const useMailStore = create((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 () => { diff --git a/client/electron/src/types/index.ts b/client/electron/src/types/index.ts index c2af814..81e7b97 100644 --- a/client/electron/src/types/index.ts +++ b/client/electron/src/types/index.ts @@ -87,6 +87,16 @@ export interface Attachment { } export interface Mail { + /** + * 这封信属于哪个账号 —— **只在聚合视图下存在**。 + * + * 由前端 `mergeInboxes()` 在合并时附加(网关按账号返回收件箱,它自己 + * 不知道"聚合"这回事,所以后端不会给这个字段)。单账号视图下为 undefined, + * 界面据此决定要不要画账号徽标。 + */ + account_id?: string; + account_name?: string; + mail_id: string; session_id: string; parent_mail_id: string | null; diff --git a/client/electron/test/components/AccountSwitcher.test.tsx b/client/electron/test/components/AccountSwitcher.test.tsx new file mode 100644 index 0000000..27c8351 --- /dev/null +++ b/client/electron/test/components/AccountSwitcher.test.tsx @@ -0,0 +1,132 @@ +/** + * 账号选择器的行为测试。 + * + * 判据集中在三件会**静默出错**的事上: + * 1. 只有一个账号时不该出现「全部邮箱」(它是噪声,会让人以为漏了谁) + * 2. 从"全部邮箱"切到某个账号后**必须重取收件箱** —— 不重取就会继续显示 + * 聚合结果,看起来像切换没生效 + * 3. 没有账号时不该渲染这个控件(那时界面该由登录页负责) + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AccountSwitcher from '../../src/components/AccountSwitcher'; +import { useAccountStore } from '../../src/stores/accountStore'; +import { useMailStore } from '../../src/stores/mailStore'; +import { useUIStore } from '../../src/stores/uiStore'; +import * as api from '../../src/api/client'; + +const acct = (id: string, name: string) => ({ + id, + displayName: name, + gateway: 'http://192.168.2.60:8180', + token: `tok-${id}`, + username: name +}); + +function seed(accounts: ReturnType[], activeId: string) { + useAccountStore.setState({ accounts, activeId, loaded: true } as never); +} + +describe('AccountSwitcher', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(api, 'getInbox').mockResolvedValue({ mails: [], total: 0 } as never); + useAccountStore.setState({ accounts: [], activeId: 'all', loaded: true } as never); + useMailStore.setState({ inbox: [], accountErrors: [] }); + useUIStore.setState({ viewMode: 'inbox' }); + }); + + it('没有账号时不渲染(那时该由登录页负责)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('★ 只有一个账号时退化成静态标题:不出现「全部邮箱」', async () => { + seed([acct('a1', '工作邮箱')], 'a1'); + render(); + const btn = screen.getByTestId('account-switcher'); + expect(btn).toHaveTextContent('工作邮箱'); + + await userEvent.click(btn); + // 点不开:聚合没有意义(一个账号的"全部"就是它自己) + expect(screen.queryByTestId('account-menu')).toBeNull(); + expect(screen.queryByTestId('account-option-all')).toBeNull(); + }); + + it('两个账号时可以展开,菜单里有「全部邮箱」与各账号', async () => { + seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1'); + render(); + + await userEvent.click(screen.getByTestId('account-switcher')); + expect(screen.getByTestId('account-menu')).toBeInTheDocument(); + expect(screen.getByTestId('account-option-all')).toHaveTextContent('全部邮箱'); + expect(screen.getByTestId('account-option-a1')).toHaveTextContent('工作邮箱'); + expect(screen.getByTestId('account-option-a2')).toHaveTextContent('私人邮箱'); + }); + + it('★ 切到某个账号后必须重取收件箱(否则会继续显示聚合结果)', async () => { + seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'all'); + render(); + + await userEvent.click(screen.getByTestId('account-switcher')); + await userEvent.click(screen.getByTestId('account-option-a2')); + + await waitFor(() => expect(useAccountStore.getState().activeId).toBe('a2')); + expect(api.getInbox).toHaveBeenCalled(); + }); + + it('★ 切到「全部邮箱」也要重取(聚合要并发问多个账号)', async () => { + seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1'); + render(); + + await userEvent.click(screen.getByTestId('account-switcher')); + await userEvent.click(screen.getByTestId('account-option-all')); + + await waitFor(() => expect(useAccountStore.getState().activeId).toBe('all')); + // 两个可用账号 → 走聚合路径(getInboxWithAuth 每个账号一次) + const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never); + await useMailStore.getState().fetchInbox('all'); + expect(withAuth).toHaveBeenCalledTimes(2); + }); + + it('聚合模式下**只有一个可用账号**时不并发两个请求(第二条是多余失败面)', async () => { + useAccountStore.setState({ + accounts: [{ ...acct('a1', '工作邮箱') }, { ...acct('a2', '坏账号'), token: '' }], + activeId: 'all', + loaded: true + } as never); + const withAuth = vi.spyOn(api, 'getInboxWithAuth').mockResolvedValue({ mails: [], total: 0 } as never); + await useMailStore.getState().fetchInbox('all'); + expect(withAuth).not.toHaveBeenCalled(); + expect(api.getInbox).toHaveBeenCalled(); + }); + + it('★ 某个账号取失败必须在 store 里说出来,而不是静默少一份', async () => { + useAccountStore.setState({ + accounts: [acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], + activeId: 'all', + loaded: true + } as never); + vi.spyOn(api, 'getInboxWithAuth') + .mockResolvedValueOnce({ mails: [{ mail_id: 'm1' }], total: 1 } as never) + .mockRejectedValueOnce(new Error('HTTP 401:密钥无效')); + + await useMailStore.getState().fetchInbox('all'); + + expect(useMailStore.getState().inbox.map((m: any) => m.mail_id)).toEqual(['m1']); + expect(useMailStore.getState().accountErrors).toEqual([ + { account: '私人邮箱', error: 'HTTP 401:密钥无效' } + ]); + }); + + it('「管理账号…」跳到账号页', async () => { + seed([acct('a1', '工作邮箱'), acct('a2', '私人邮箱')], 'a1'); + render(); + await userEvent.click(screen.getByTestId('account-switcher')); + await userEvent.click(screen.getByTestId('account-manage')); + expect(useUIStore.getState().viewMode).toBe('account'); + }); +}); diff --git a/client/electron/test/components/LoginPage.test.tsx b/client/electron/test/components/LoginPage.test.tsx index 6729f0a..42998f6 100644 --- a/client/electron/test/components/LoginPage.test.tsx +++ b/client/electron/test/components/LoginPage.test.tsx @@ -1,7 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; import LoginPage from '../../src/components/LoginPage'; import * as api from '../../src/api/client'; diff --git a/client/electron/test/lib/accounts.test.mjs b/client/electron/test/lib/accounts.test.mjs new file mode 100644 index 0000000..053628d --- /dev/null +++ b/client/electron/test/lib/accounts.test.mjs @@ -0,0 +1,186 @@ +/** + * `src/lib/accounts.ts` 的测试。 + * + * 这一层的判据是「多账号怎么合并、谁是默认、什么算同一个身份」—— + * 它们错了都不会报错,只会表现为「同一封信出现两次」「切了账号还是老数据」 + * 「重复添加了三个同样的账号」。所以每条都配了反向对照。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + AGGREGATE_ID, + normalizeGateway, + deriveDisplayName, + accountAuth, + isUsableAccount, + upsertAccount, + removeAccount, + sameIdentity, + pickDefaultAccountId, + touchAccount, + mergeInboxes +} from '../../src/lib/accounts.ts'; + +const acct = (over = {}) => ({ + id: 'a1', + displayName: '工作邮箱', + gateway: 'http://192.168.2.60:8180', + token: 'k1', + ...over +}); + +// ─── 地址规范化 ───────────────────────────────────────────────────────── + +test('★ 网关地址的各种写法都收敛成同一个(否则会重复添加同一个账号)', () => { + const want = 'http://192.168.2.60:8180'; + for (const raw of [ + 'http://192.168.2.60:8180', + 'http://192.168.2.60:8180/', + 'http://192.168.2.60:8180/api/v1', + 'http://192.168.2.60:8180/api/v1/', + ' http://192.168.2.60:8180 ', + '192.168.2.60:8180', // 用户常只写 ip:port + '192.168.2.60:8180/' + ]) { + assert.equal(normalizeGateway(raw), want, `输入 ${JSON.stringify(raw)}`); + } +}); + +test('空串 / 缺协议以外的东西不炸', () => { + assert.equal(normalizeGateway(''), ''); + assert.equal(normalizeGateway(' '), ''); + assert.equal(normalizeGateway(undefined), ''); + // https 不该被改写成 http + assert.equal(normalizeGateway('https://mail.example.com'), 'https://mail.example.com'); +}); + +test('★ accountAuth 拼出的 base 恰好是 /api/v1(不能出现双份)', () => { + assert.deepEqual(accountAuth(acct()), { base: 'http://192.168.2.60:8180/api/v1', token: 'k1' }); + // 粘贴了带 /api/v1 的地址也不该拼成 /api/v1/api/v1 + assert.equal( + accountAuth(acct({ gateway: 'http://h:8180/api/v1' })).base, + 'http://h:8180/api/v1' + ); +}); + +test('显示名可派生:有用户名带用户名,没有就用主机名', () => { + assert.equal(deriveDisplayName('http://192.168.2.60:8180', 'gui-lab'), 'gui-lab@192.168.2.60:8180'); + assert.equal(deriveDisplayName('http://192.168.2.60:8180'), '192.168.2.60:8180'); + assert.equal(deriveDisplayName(''), '未命名账号'); +}); + +test('可用性判据:有地址且有令牌才算可用', () => { + assert.equal(isUsableAccount(acct()), true); + assert.equal(isUsableAccount(acct({ token: ' ' })), false); + assert.equal(isUsableAccount(acct({ gateway: '' })), false); + assert.equal(isUsableAccount(null), false); + // 反向对照:宽松是刻意的 —— 自建网关的相对地址也不该在这里被拒 + assert.equal(isUsableAccount(acct({ gateway: 'localhost:8180' })), true); +}); + +// ─── 增删与身份判重 ───────────────────────────────────────────────────── + +test('★ 同一个身份不能重复添加(id 不同但网关+令牌相同)', () => { + const a = acct({ id: 'a1' }); + const b = acct({ id: 'a2' }); // 新建时 uuid 必然不同,所以不能靠 id 判重 + assert.equal(sameIdentity(a, b), true); + // 网关写法不同但其实是同一台机器 → 也算同一个身份 + assert.equal(sameIdentity(a, acct({ gateway: '192.168.2.60:8180/' })), true); + // 反向对照:换了令牌或换了网关就不是同一个身份 + assert.equal(sameIdentity(a, acct({ token: 'k2' })), false); + assert.equal(sameIdentity(a, acct({ gateway: 'http://other:8180' })), false); +}); + +test('upsert 同 id 覆盖、不产生两条', () => { + const list = [acct({ id: 'a1', displayName: '旧名' })]; + const out = upsertAccount(list, acct({ id: 'a1', displayName: '新名' })); + assert.equal(out.length, 1); + assert.equal(out[0].displayName, '新名'); + const out2 = upsertAccount(out, acct({ id: 'b1', displayName: '另一个' })); + assert.equal(out2.length, 2); +}); + +test('删除幂等:删不存在的 id 返回原样', () => { + const list = [acct({ id: 'a1' }), acct({ id: 'a2' })]; + assert.equal(removeAccount(list, 'a1').length, 1); + assert.equal(removeAccount(list, 'nope').length, 2); +}); + +// ─── 默认账号 ─────────────────────────────────────────────────────────── + +test('★ 默认账号 = 最近用过的那个;都没用过则取第一个', () => { + const list = [ + acct({ id: 'a1' }), + acct({ id: 'a2', lastUsed: '2026-09-07T12:00:00Z' }), + acct({ id: 'a3', lastUsed: '2026-09-08T12:00:00Z' }) + ]; + assert.equal(pickDefaultAccountId(list), 'a3'); + // 反向对照:一个都没有 lastUsed 时取列表第一个(顺序稳定由调用方保证) + assert.equal(pickDefaultAccountId([acct({ id: 'a1' }), acct({ id: 'a2' })]), 'a1'); + // 空列表返回空串,不是抛错 + assert.equal(pickDefaultAccountId([]), ''); +}); + +test('touch 只改那一个账号的时间,不动顺序与其它字段', () => { + const list = [acct({ id: 'a1' }), acct({ id: 'a2' })]; + const out = touchAccount(list, 'a2', '2026-09-12T00:00:00Z'); + assert.equal(out[1].lastUsed, '2026-09-09T00:00:00Z'.replace('2026-09-09', '2026-09-12')); + assert.equal(out[0].lastUsed, undefined); + assert.deepEqual(out.map(a => a.id), ['a1', 'a2']); +}); + +// ─── 聚合合并 ─────────────────────────────────────────────────────────── + +test('★ 合并按时间倒序(不是把几个收件箱拼起来)', () => { + const merged = mergeInboxes([ + { account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1', created_at: '2026-09-01T00:00:00Z' }] }, + { account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2', created_at: '2026-09-05T00:00:00Z' }] } + ]); + assert.deepEqual(merged.map(m => m.mail_id), ['m2', 'm1']); +}); + +test('★ 同一 mail_id 只留一条,且归属是第一条胜出', () => { + const merged = mergeInboxes([ + { account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'same', created_at: '2026-09-01T00:00:00Z' }] }, + { account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'same', created_at: '2026-09-02T00:00:00Z' }] } + ]); + assert.equal(merged.length, 1, '同一封信出现两次 ⇒ 删除/已读只在一侧生效,看起来"点过了还在"'); + assert.equal(merged[0].account_id, 'a1'); + assert.equal(merged[0].account_name, '甲'); +}); + +test('★ 缺 created_at 的排在最后(不能因为缺字段就排到最前)', () => { + const merged = mergeInboxes([ + { account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'no-time' }] }, + { account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'has-time', created_at: '2020-01-01T00:00:00Z' }] } + ]); + assert.deepEqual(merged.map(m => m.mail_id), ['has-time', 'no-time']); +}); + +test('每条都带归属(界面才有徽标可画)', () => { + const merged = mergeInboxes([ + { account: { id: 'a1', displayName: '甲' }, mails: [{ mail_id: 'm1' }] }, + { account: { id: 'a2', displayName: '乙' }, mails: [{ mail_id: 'm2' }] } + ]); + assert.deepEqual( + merged.map(m => m.account_name).sort(), + ['乙', '甲'] + ); +}); + +test('空账号 / 空列表都不炸', () => { + assert.deepEqual(mergeInboxes([]), []); + assert.deepEqual(mergeInboxes([{ account: { id: 'a1', displayName: '甲' }, mails: [] }]), []); + // 没有 mail_id 的条目不能把其它条目挤掉(它们各自都留下来) + const merged = mergeInboxes([ + { account: { id: 'a1', displayName: '甲' }, mails: [{ created_at: '2026-01-01T00:00:00Z' }, {}] } + ]); + assert.equal(merged.length, 2); +}); + +test('聚合 id 与真实账号 id 不会冲突(真实 id 是 uuid)', () => { + assert.equal(AGGREGATE_ID, 'all'); + assert.equal(/^[0-9a-f-]{36}$/.test(AGGREGATE_ID), false, '聚合 id 不能长得像 uuid'); +}); diff --git a/client/electron/test/manual/multi-account-verify.mjs b/client/electron/test/manual/multi-account-verify.mjs new file mode 100644 index 0000000..11646aa --- /dev/null +++ b/client/electron/test/manual/multi-account-verify.mjs @@ -0,0 +1,309 @@ +/** + * 多账号验收(真起打包产物 + 两个真实账号)。 + * + * 用法: + * DESKTOP_BIN=release/linux-unpacked/agentmail-desktop \ + * ACCT2_KEY_FILE=/root/gotmp/verify-l2-token.txt \ + * node test/manual/multi-account-verify.mjs + * + * 环境: + * DESKTOP_BIN 打包产物可执行文件(给了就由本脚本起:xvfb + CDP) + * DESKTOP_CDP CDP 端点,默认 http://127.0.0.1:9224(避开 Phase 3 的 9223) + * AGENTMAIL_URL Gateway,默认 http://127.0.0.1:8180 + * ACCT1_USER/ACCT1_PW 第一个账号(默认 gui-lab / 从 ADMIN_PW 或 /root/gotmp/gui-lab-pw.txt 读) + * ACCT2_KEY_FILE 第二个账号的用户密钥文件(默认 /root/gotmp/verify-l2-token.txt) + * + * # 判据为什么落在**网络层**而不是"列表里有多少行" + * + * 收件箱是按会话分组的、默认折叠,DOM 里的行数 ≠ 邮件数。所以"聚合了没有" + * 用一个不会被分组干扰的判据:**聚合模式下每个可用账号各发一次收件箱请求, + * 且各带自己的令牌**(抓请求头即可确认)。徽标的有无则是单/聚合视图的 + * 确定性差异 —— 单账号视图不该有徽标。 + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +const PW = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs'; +const { chromium } = await import(PW); +const { spawn } = await import('node:child_process'); + +const CDP = process.env.DESKTOP_CDP || 'http://127.0.0.1:9224'; +const GW = (process.env.AGENTMAIL_URL || 'http://127.0.0.1:8180').replace(/\/$/, ''); +const BIN = process.env.DESKTOP_BIN || ''; +const TMP = process.env.TMPDIR_REAL || '/root/gotmp/multi-account'; +const USERDATA = join(TMP, 'userdata'); + +const ACCT1_USER = process.env.ACCT1_USER || 'gui-lab'; +const ACCT1_PW = + process.env.ACCT1_PW || + (existsSync('/root/gotmp/gui-lab-pw.txt') ? readFileSync('/root/gotmp/gui-lab-pw.txt', 'utf8').trim() : 'gui123456'); +const ACCT2_KEY_FILE = process.env.ACCT2_KEY_FILE || '/root/gotmp/verify-l2-token.txt'; + +let pass = 0; +let fail = 0; +function check(name, ok, detail = '') { + if (ok) { + pass++; + console.log(` ✓ ${name}`); + } else { + fail++; + console.log(` ✗ ${name}${detail ? ` —— ${detail}` : ''}`); + } +} + +// ─── 准备两个账号的密钥 ─────────────────────────────────────────────── + +async function json(path, init = {}) { + const res = await fetch(`${GW}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(init.headers || {}) } }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text.slice(0, 200) }; + } + return { status: res.status, body }; +} + +async function loginCookie() { + const r = await json('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ username: ACCT1_USER, password: ACCT1_PW }) + }); + if (r.status !== 200) throw new Error(`登录失败 ${r.status}:${JSON.stringify(r.body).slice(0, 120)}`); + const raw = r.body?.session_token || r.body?.token || r.body?.data?.session_token; + return raw ? `agentmail_session=${raw}` : (r.headers || '').toString(); +} + +/** 用会话 cookie 造一把新的用户密钥(每次跑都新造,避免和线上既有密钥混在一起)。 */ +async function makeKeyForAcct1() { + // 会话 cookie 直接取 Set-Cookie + const res = await fetch(`${GW}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: ACCT1_USER, password: ACCT1_PW }) + }); + if (!res.ok) throw new Error(`登录失败 ${res.status}`); + const sc = res.headers.getSetCookie?.() ?? []; + const cookie = sc.map(c => c.split(';')[0]).join('; '); + const mk = await fetch(`${GW}/api/v1/me/keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ label: `multi-account-e2e-${Date.now()}` }) + }); + const body = await mk.json(); + const token = body?.key_token || body?.key?.key_token; + if (!token) throw new Error(`造密钥失败:${JSON.stringify(body).slice(0, 150)}`); + return token; +} + +const key1 = await makeKeyForAcct1(); +const key2 = existsSync(ACCT2_KEY_FILE) ? readFileSync(ACCT2_KEY_FILE, 'utf8').trim() : ''; +if (!key2) { + console.error(`缺少第二个账号的密钥文件:${ACCT2_KEY_FILE}`); + process.exit(2); +} + +// 服务端的期望值(与前端同一批端点、同样的参数) +async function serverInbox(key) { + const r = await json('/api/v1/me/mail/inbox?status=all&limit=50', { headers: { Authorization: `Bearer ${key}` } }); + return (r.body?.mails || []).map(m => m.mail_id); +} +const ids1 = await serverInbox(key1); +const ids2 = await serverInbox(key2); +const expectedMerged = new Set([...ids1, ...ids2]).size; +console.log(`\n服务端:账号1 ${ids1.length} 封、账号2 ${ids2.length} 封,去重后 ${expectedMerged} 封`); + +// ─── 预置 accounts.json 并起应用 ────────────────────────────────────── + +rmSync(TMP, { recursive: true, force: true }); +mkdirSync(USERDATA, { recursive: true }); +const accountsFile = join(USERDATA, 'accounts.json'); +const seed = { + version: 1, + accounts: [ + { + id: '11111111-1111-4111-8111-111111111111', + displayName: '甲账号', + gateway: GW, + token: key1, + username: ACCT1_USER, + lastUsed: '2026-09-12T01:00:00Z' + }, + { + id: '22222222-2222-4222-8222-222222222222', + displayName: '乙账号', + gateway: GW, + token: key2, + username: 'jianf', + lastUsed: '2026-09-12T00:00:00Z' + } + ] +}; +writeFileSync(accountsFile, JSON.stringify(seed, null, 2), { mode: 0o600 }); + +// 第二个账号的真实收件箱也取一次(用于"切换后只显示这个账号"的判据) +const gate = spawn( + 'xvfb-run', + ['-a', '-s', '-screen 0 1400x900x24', BIN, '--no-sandbox', '--disable-gpu', `--user-data-dir=${USERDATA}`, `--remote-debugging-port=${new URL(CDP).port}`], + { env: { ...process.env, AGENTMAIL_GATEWAY_URL: GW, DBUS_SESSION_BUS_ADDRESS: 'disabled:' }, stdio: ['ignore', 'pipe', 'pipe'] } +); +gate.stdout.on('data', () => {}); +gate.stderr.on('data', () => {}); + +async function waitCdp() { + for (let i = 0; i < 60; i++) { + try { + const r = await fetch(`${CDP}/json/version`); + if (r.ok) return true; + } catch { + /* 还没起来 */ + } + await new Promise(r => setTimeout(r, 500)); + } + return false; +} + +let browser = null; +try { + if (!(await waitCdp())) throw new Error(`CDP 不可用:${CDP}`); + browser = await chromium.connectOverCDP(CDP); + const ctx = browser.contexts()[0] ?? (await browser.newContext()); + const page = + ctx.pages().find(p => p.url().startsWith('file:') || p.url().includes('index.html')) ?? ctx.pages()[0]; + if (!page) throw new Error('没找到应用窗口'); + await page.setViewportSize({ width: 1280, height: 820 }); + + const consoleErrors = []; + page.on('pageerror', e => consoleErrors.push(String(e).slice(0, 200))); + page.on('console', m => { + if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); + }); + + // 抓收件箱请求(含 Authorization 头) + const inboxReqs = []; + page.on('request', r => { + const u = r.url(); + if (!u.includes('/me/mail/inbox')) return; + inboxReqs.push({ url: u, auth: r.headers()['authorization'] || '' }); + }); + + console.log('\n=== 多账号验收 ===\n'); + + // ─── A. 启动就绪 ────────────────────────────────────────────────── + await page.waitForTimeout(2500); + mkdirSync(TMP, { recursive: true }); + await page.screenshot({ path: join(TMP, '10-boot.png') }).catch(() => {}); + + const boot = await page.evaluate(() => ({ + root: document.getElementById('root')?.children.length ?? -1, + hasSwitcher: !!document.querySelector('[data-testid="account-switcher"]') + })); + check('应用渲染出内容(非白屏)', boot.root > 0, `root children=${boot.root}`); + check('★ 顶栏出现账号选择器', boot.hasSwitcher); + + // 预置里甲账号 lastUsed 更新 → 默认选中它,标签应是它的显示名 + const label = await page.textContent('[data-testid="account-switcher"]').catch(() => ''); + check('默认选中最近使用的账号(甲账号)', (label || '').includes('甲账号'), `实际「${label}」`); + + // ─── B. 聚合:每账号各一次请求、各带自己的令牌 ──────────────────── + inboxReqs.length = 0; + await page.click('[data-testid="account-switcher"]'); + await page.click('[data-testid="account-option-all"]'); + await page.waitForTimeout(2000); + await page.screenshot({ path: join(TMP, '20-aggregate.png') }).catch(() => {}); + + const aggReqs = inboxReqs.filter(r => r.url.includes('status=all')); + const aggTokens = new Set(aggReqs.map(r => r.auth.replace(/^Bearer\s+/, ''))); + check( + '★ 聚合:每个可用账号各发一次收件箱请求', + aggReqs.length >= 2, + `实际 ${aggReqs.length} 次` + ); + check('★ 聚合:两次请求各带**自己的**令牌(没有串号)', aggTokens.size >= 2, `去重后 ${aggTokens.size} 个令牌`); + + const badges = await page.$$eval('[data-testid="account-badge"]', els => els.map(e => e.textContent.trim())); + check('★ 聚合:列表出现账号归属徽标', badges.length > 0, `徽标 ${badges.length} 个`); + check( + '★ 徽标覆盖两个账号(两边都有邮件进列表)', + new Set(badges).size >= 2, + `去重后 ${[...new Set(badges)].join('/') || '(无)'}` + ); + check('聚合视图没有"账号取不到"告警', (await page.$('[data-testid="account-errors"]')) === null); + + // ─── C. 切到单账号:只问这一个账号,且不再画徽标 ────────────────── + inboxReqs.length = 0; + await page.click('[data-testid="account-switcher"]'); + await page.click('[data-testid="account-option-22222222-2222-4222-8222-222222222222"]'); + await page.waitForTimeout(2000); + await page.screenshot({ path: join(TMP, '30-single.png') }).catch(() => {}); + + const singleReqs = inboxReqs.filter(r => r.url.includes('status=all')); + const singleTokens = new Set(singleReqs.map(r => r.auth.replace(/^Bearer\s+/, ''))); + check('★ 切到单账号后只问这一个账号', singleReqs.length === 1 && singleTokens.size === 1, `实际 ${singleReqs.length} 次`); + check('★ 用的是乙账号的令牌', [...singleTokens][0] === key2, `实际 ${String([...singleTokens][0]).slice(0, 8)}…`); + + const badges2 = await page.$$eval('[data-testid="account-badge"]', els => els.length); + check('★ 单账号视图不画归属徽标(徽标是聚合专有)', badges2 === 0, `仍有 ${badges2} 个`); + + // ─── D. 账号页:添加流程真的验证密钥 ────────────────────────────── + await page.click('[data-testid="account-switcher"]'); + await page.click('[data-testid="account-manage"]'); + await page.waitForTimeout(800); + await page.click('[data-testid="account-add-toggle"]'); + await page.fill('[data-testid="account-gateway"]', GW); + await page.fill('[data-testid="account-token"]', 'deadbeef'.repeat(8)); // 64 位但无效 + await page.fill('[data-testid="account-name"]', '坏账号'); + await page.click('[data-testid="account-add-submit"]'); + await page.waitForTimeout(1500); + const badMsg = (await page.textContent('[data-testid="account-msg"]').catch(() => '')) || ''; + check('★ 无效密钥被当场拒绝(不写进列表)', badMsg.includes('密钥无效'), `提示「${badMsg}」`); + const rowsAfterBad = await page.$$eval('[data-testid^="account-row-"]', els => els.length); + check('无效密钥没有产生账号行', rowsAfterBad === 2, `实际 ${rowsAfterBad} 行`); + await page.screenshot({ path: join(TMP, '40-bad-key.png') }).catch(() => {}); + + // 有效密钥(新造一把,避免"重复添加"挡路) + const key3 = await makeKeyForAcct1(); + await page.fill('[data-testid="account-token"]', key3); + await page.fill('[data-testid="account-name"]', '丙账号'); + await page.click('[data-testid="account-add-submit"]'); + await page.waitForTimeout(1500); + const okMsg = (await page.textContent('[data-testid="account-msg"]').catch(() => '')) || ''; + const rowsAfterGood = await page.$$eval('[data-testid^="account-row-"]', els => els.length); + check('★ 有效密钥被接受并写成账号行', rowsAfterGood === 3, `提示「${okMsg}」行数 ${rowsAfterGood}`); + await page.screenshot({ path: join(TMP, '50-added.png') }).catch(() => {}); + + // ─── E. 持久化:落盘内容与界面一致、权限 600 ────────────────────── + const mode = (statSync(accountsFile).mode & 0o777).toString(8); + const onDisk = JSON.parse(readFileSync(accountsFile, 'utf8')); + check('★ 账号落盘到 accounts.json', onDisk.accounts.length === 3, `盘上 ${onDisk.accounts.length} 条`); + check('★ 落盘文件权限 600', mode === '600', `实际 ${mode}`); + const uiNames = await page.$$eval('[data-testid^="account-row-"]', els => els.map(e => e.textContent)); + check( + '盘上的账号名与界面一致', + onDisk.accounts.every(a => uiNames.some(t => t.includes(a.displayName))), + `${onDisk.accounts.map(a => a.displayName).join('/')}` + ); + check('落盘内容不含明文以外的意外字段(只 6 个字段)', onDisk.accounts.every(a => Object.keys(a).length <= 6)); + + check('全程没有页面级错误', consoleErrors.length === 0, consoleErrors.slice(0, 2).join(' | ')); +} catch (e) { + fail++; + console.error(`\n ✗ 异常:${e?.stack || e}`); +} finally { + if (browser) await browser.close().catch(() => {}); + gate.kill('SIGKILL'); + // 等它真的走完,避免读完就删目录(Electron 退出时会写回配置) + await new Promise(r => { + const t = setTimeout(r, 3000); + gate.on('exit', () => { + clearTimeout(t); + r(); + }); + }); + writeFileSync(join(TMP, 'accounts-after.json'), readFileSync(accountsFile, 'utf8')); +} + +console.log(`\n === 结果: ${pass} 通过, ${fail} 失败 ===`); +process.exitCode = fail === 0 ? 0 : 1; diff --git a/docs/MULTI-ACCOUNT-PLAN.md b/docs/MULTI-ACCOUNT-PLAN.md index ac31489..6d76904 100644 --- a/docs/MULTI-ACCOUNT-PLAN.md +++ b/docs/MULTI-ACCOUNT-PLAN.md @@ -149,10 +149,27 @@ AccountManager | 维度 | 鸿蒙(dsh) | ele(pi) | |---|---|---| -| 账号存储 | preferences JSON | electron-store(encrypted) | -| SSE 多连接 | 改造中(当前单连接) | 需新增 | -| 聚合收件箱 | 已实现(M7) | 需新增 | -| 账号选择器 UI | 需新增 | 需新增 | -| 写信账号切换 | 需新增 | 需新增 | +| 账号存储 | preferences JSON | ✅ 主进程 `userData/accounts.json`(原子写 + 0600;无 IPC 时退 localStorage) | +| 账号增删/验证 | 需新增 | ✅ 账号页"多账号"一段:`/auth/me` 验证通过才写入 | +| 聚合收件箱 | 已实现(M7) | ✅ 收件箱/发件箱列表头 + 账号徽标 + 部分失败的可见告警 | +| 账号选择器 UI | 需新增 | ✅ 列表头下拉(≥2 个可用账号才出现「全部邮箱」) | +| SSE 多连接 | 改造中(当前单连接) | ❌ 未做(下一轮) | +| 写信账号切换 | 需新增 | ❌ 未做(下一轮) | 两边都以此文档为准,不以 cc 里的讨论为依据。 + +### 实现备注(ele 侧,2026-09-12) + +- **"当前账号"复用 api 单例**:`config.ts` 的 `API_BASE`/`bearerToken` 仍是 + 模块级状态,含义变成"当前选中账号的认证",切换时由 `accountStore` 同步 + (`API_BASE` 因此改成 `let`,且 api 层不得在模块作用域缓存它)。 + 单账号视图的代码路径逐字节不变。 +- **只有两处走显式认证**(`fetchWithAuth`):聚合收件箱、以及将来的多账号 SSE。 + 借用单例来回切会让并发请求串号(A 的请求带上 B 的令牌)。 +- **只合并同一网关的账号**。跨网关的邮件混进列表后,点开会去问当前账号的 + 服务器(要么 404,要么 mail_id 撞上就打开了别人的信)—— 所以如实排除并在 + 列表上方说明,而不是静默少一份。 +- **部分失败必须可见**:某个账号取不到时列表上方给出账号名与原因, + 这正是"聚合"最容易骗人的失败方式(看起来一切正常,只是少了一整份邮件)。 +- 加密存储:本机 `safeStorage` 依赖系统 keyring(本环境无),故未启用; + 现状是有据可查的 0600 明文文件,界面上如实写明存放位置。