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

@ -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() };
}
});

View File

@ -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'),

View File

@ -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();
})();
}, []);
// 登出后清理客户端状态,避免脏数据残留

View File

@ -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<T>(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-Typemultipart 的 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}`);
}
/** 人类可读的字节数 */

View File

@ -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<string, string> {
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<Response> {
const base = String(auth?.base ?? '').replace(/\/+$/, '');
const headers: Record<string, string> = {
...(init.headers as Record<string, string> | undefined)
};
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
return fetch(`${base}${path}`, { ...init, headers, credentials: 'include' });
}
/**
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
* - EventSourceSSE不支持自定义头

View File

@ -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 (
<div data-testid="account-list">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-xs font-medium text-gray-500"></h3>
<button
type="button"
data-testid="account-add-toggle"
onClick={() => setOpen(o => !o)}
className="ml-auto text-xs text-blue-600 hover:underline"
>
{open ? '取消' : '+ 添加账号'}
</button>
</div>
{/* 存储位置如实说:网页端退到 localStorage那不是安全存储 */}
<p className="text-[11px] text-gray-400 mb-3">
{ephemeral
? '账号保存在浏览器 localStorage当前不在桌面端这不是加密存储'
: `账号保存在本机文件:${storageFile || '(未知路径)'}(权限 600`}
</p>
<ul className="space-y-1.5">
{accounts.map(a => {
const usable = Boolean(a.token && a.gateway);
return (
<li
key={a.id}
data-testid={`account-row-${a.id}`}
className="flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200"
>
<span className="min-w-0 flex-1">
<span className="block text-sm text-gray-800 truncate">{a.displayName}</span>
<span className="block text-[11px] text-gray-400 truncate">
{a.gateway}
{a.username ? ` · ${a.username}` : ''}
</span>
</span>
{!usable && <span className="text-[11px] text-amber-600"></span>}
{a.id === current?.id ? (
<span className="text-[11px] text-blue-600"></span>
) : a.id !== activeId ? (
<button
type="button"
data-testid={`account-use-${a.id}`}
onClick={() => setActive(a.id)}
className="text-[11px] text-gray-600 hover:underline"
>
</button>
) : null}
<button
type="button"
data-testid={`account-del-${a.id}`}
onClick={() => removeAccount(a.id)}
className="text-[11px] text-red-600 hover:underline"
>
</button>
</li>
);
})}
{accounts.length === 0 && <li className="text-xs text-gray-400"></li>}
</ul>
{open && (
<div className="mt-3 space-y-2 p-3 rounded-md bg-gray-50 border border-gray-200">
<input
data-testid="account-gateway"
value={gateway}
onChange={e => 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"
/>
<input
data-testid="account-token"
value={token}
onChange={e => setToken(e.target.value)}
placeholder="用户密钥user key64 位十六进制)"
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300 font-mono"
/>
<input
data-testid="account-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="显示名(可留空,默认用 用户名@主机)"
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-gray-300"
/>
<button
type="button"
data-testid="account-add-submit"
disabled={busy}
onClick={submit}
className="tap px-3 py-1.5 text-sm rounded-md bg-blue-600 text-white disabled:opacity-50"
>
{busy ? '验证中…' : '验证并添加'}
</button>
<p className="text-[11px] text-gray-500"> /auth/me </p>
</div>
)}
{msg && (
<p
data-testid="account-msg"
className={`mt-2 text-xs ${msg.kind === 'ok' ? 'text-green-600' : 'text-red-600'}`}
>
{msg.text}
</p>
)}
</div>
);
}

View File

@ -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() {
</form>
</section>
{/* 多账号docs/MULTI-ACCOUNT-PLAN.md */}
<section className="border-t border-gray-200 pt-6">
<AccountList />
</section>
{/* 客户端连接密钥 */}
<section className="border-t border-gray-200 pt-6">
<KeyPanel

View File

@ -0,0 +1,138 @@
/**
* 账号选择器(顶栏下拉):像邮箱 app 的账号切换。
*
* 列表:`全部邮箱`≥2 个可用账号时才出现——只有一个账号时它是噪声)、
* 每个账号(带未选中点/选中勾)、`管理账号…`。
*
* # 为什么"全部邮箱"在只有一个可用账号时不显示
*
* 聚合的语义是"合并多个账号的收件箱"。只有一个账号时它与该账号等价,
* 留着只会让人以为漏了谁。少于两个可用账号时选择器直接退化成静态标题。
*/
import { useEffect, useRef, useState } from 'react';
import { AGGREGATE_ID, isUsableAccount } from '../lib/accounts';
import { useAccountStore } from '../stores/accountStore';
import { useMailStore } from '../stores/mailStore';
import { useUIStore } from '../stores/uiStore';
import { ChevronRightIcon } from './icons';
export default function AccountSwitcher() {
const accounts = useAccountStore(s => 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<HTMLDivElement>(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 (
<div className="relative ml-auto" ref={boxRef}>
<button
type="button"
data-testid="account-switcher"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => aggregateAvailable && setOpen(o => !o)}
className={`flex items-center gap-1 max-w-[150px] text-xs rounded-md px-2 py-1 border ${
aggregateAvailable
? 'border-gray-200 text-gray-600 hover:bg-gray-50 cursor-pointer'
: 'border-transparent text-gray-400 cursor-default'
}`}
>
<span className="truncate" title={title ?? ''}>
{title}
</span>
{aggregateAvailable && (
<ChevronRightIcon className={`w-3 h-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
)}
</button>
{open && (
<div
role="listbox"
data-testid="account-menu"
className="absolute right-0 top-full mt-1 z-20 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-1"
>
{aggregateAvailable && (
<button
type="button"
role="option"
aria-selected={activeId === AGGREGATE_ID}
data-testid="account-option-all"
onClick={() => pick(AGGREGATE_ID)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
>
<span className="flex-1"></span>
<span className="text-[11px] text-gray-400">{usable.length} </span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
{usable.map(a => (
<button
key={a.id}
type="button"
role="option"
aria-selected={a.id === activeId}
data-testid={`account-option-${a.id}`}
onClick={() => pick(a.id)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50"
>
<span className="block truncate">{a.displayName}</span>
<span className="block text-[11px] text-gray-400 truncate">{a.gateway}</span>
</button>
))}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
data-testid="account-manage"
onClick={() => {
setOpen(false);
setViewMode('account');
}}
className="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
>
</button>
</div>
)}
</div>
);
}

View File

@ -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<Set<string>>(new Set());
@ -66,6 +73,7 @@ export default function MailList() {
<div className="w-full lg:w-[320px] shrink-0 lg:shadow-panel bg-white flex flex-col min-w-0">
<div className="px-4 py-3.5 border-b border-gray-200 flex items-center gap-1">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<AccountSwitcher />
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
「有几件事」,后者只是流量 */}
<span className="ml-2 text-xs text-gray-500">
@ -75,6 +83,18 @@ export default function MailList() {
</span>
</div>
{/* 聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
而界面看起来完全正常 —— 这正是"聚合"最容易骗人的失败方式 */}
{aggregate && accountErrors.length > 0 && (
<div
data-testid="account-errors"
className="mx-2.5 mt-2 px-2.5 py-1.5 rounded-md bg-amber-50 border border-amber-200 text-[11px] text-amber-800"
>
{accountErrors.length}
{accountErrors.map(e => `${e.account}${e.error}`).join('')}
</div>
)}
<div className="flex-1 overflow-y-auto p-2.5 space-y-1">
{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}
/>
) : (
<SessionGroup
@ -222,12 +243,15 @@ function MailItem({
active,
showTo,
onClick,
compact = false
compact = false,
accountName
}: {
mail: Mail;
active: boolean;
showTo: boolean;
onClick: () => void;
/** 聚合视图下的账号归属徽标(单账号视图传 undefined不画 */
accountName?: string;
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
compact?: boolean;
}) {
@ -276,6 +300,15 @@ function MailItem({
</>
)}
</span>
{accountName && (
<span
data-testid="account-badge"
className="text-[10px] leading-4 px-1.5 rounded-full bg-gray-100 text-gray-600 shrink-0 max-w-[80px] truncate"
title={`来自账号:${accountName}`}
>
{accountName}
</span>
)}
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div>

View File

@ -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<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;
}

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 () => {

View File

@ -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;

View File

@ -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<typeof acct>[], 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(<AccountSwitcher />);
expect(container.firstChild).toBeNull();
});
it('★ 只有一个账号时退化成静态标题:不出现「全部邮箱」', async () => {
seed([acct('a1', '工作邮箱')], 'a1');
render(<AccountSwitcher />);
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(<AccountSwitcher />);
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(<AccountSwitcher />);
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(<AccountSwitcher />);
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(<AccountSwitcher />);
await userEvent.click(screen.getByTestId('account-switcher'));
await userEvent.click(screen.getByTestId('account-manage'));
expect(useUIStore.getState().viewMode).toBe('account');
});
});

View File

@ -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';

View File

@ -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');
});

View File

@ -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;

View File

@ -149,10 +149,27 @@ AccountManager
| 维度 | 鸿蒙dsh | elepi |
|---|---|---|
| 账号存储 | preferences JSON | electron-storeencrypted |
| 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 明文文件,界面上如实写明存放位置。