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:
@ -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() };
|
||||
}
|
||||
});
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 登出后清理客户端状态,避免脏数据残留
|
||||
|
||||
@ -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-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}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
|
||||
@ -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 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
|
||||
189
client/electron/src/components/AccountList.tsx
Normal file
189
client/electron/src/components/AccountList.tsx
Normal 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 key,64 位十六进制)"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
138
client/electron/src/components/AccountSwitcher.tsx
Normal file
138
client/electron/src/components/AccountSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
178
client/electron/src/lib/accounts.ts
Normal file
178
client/electron/src/lib/accounts.ts
Normal 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;
|
||||
}
|
||||
209
client/electron/src/stores/accountStore.ts
Normal file
209
client/electron/src/stores/accountStore.ts
Normal file
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 账号 store:账号列表、当前选中(含聚合)、以及"选中变化 → 认证单例跟着变"。
|
||||
*
|
||||
* # 关键约定:`config.ts` 的单例 = **当前账号的认证**
|
||||
*
|
||||
* `src/api/config.ts` 里的 `API_BASE` / `bearerToken` 是模块级单例,全部既有
|
||||
* 调用点(收件箱、会话、日历、附件…)都用它们。多账号没有把它们改成
|
||||
* "每个调用点传账号"(那要动整个 api 层与所有组件),而是:
|
||||
*
|
||||
* **单例始终等于"当前选中账号"的认证,切换时由这里同步。**
|
||||
*
|
||||
* 只有两处必须显式持有各自的认证,它们本来就需要跨账号:
|
||||
* · 聚合收件箱(同时问多个账号,见 mailStore.fetchInbox)
|
||||
* · 多账号 SSE(每账号一条连接,下一轮)
|
||||
*
|
||||
* 于是单账号视图的代码路径逐字节不变。
|
||||
*
|
||||
* # 持久化
|
||||
*
|
||||
* 优先走主进程 IPC(`window.agentmail.accounts`,落 `userData/accounts.json`,
|
||||
* 0600 原子写)。没有 IPC 时(浏览器里跑同一份前端)退到 localStorage ——
|
||||
* 那不是安全存储,网页端首次使用时界面会明说这一点(不假装它是安全的)。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
AGGREGATE_ID,
|
||||
type Account,
|
||||
accountAuth,
|
||||
deriveDisplayName,
|
||||
isUsableAccount,
|
||||
normalizeGateway,
|
||||
pickDefaultAccountId,
|
||||
removeAccount as removeFrom,
|
||||
sameIdentity,
|
||||
touchAccount,
|
||||
upsertAccount
|
||||
} from '../lib/accounts';
|
||||
import { setActiveAuth } from '../api/config';
|
||||
|
||||
interface AccountState {
|
||||
accounts: Account[];
|
||||
/** 当前选中:某个账号 id,或 AGGREGATE_ID(聚合) */
|
||||
activeId: string;
|
||||
/** 持久化位置(供界面显示"账号存在哪") */
|
||||
storageFile: string;
|
||||
/** 网页端(无 IPC)时为 true —— 界面据此提示"令牌存在 localStorage" */
|
||||
ephemeralStorage: boolean;
|
||||
loaded: boolean;
|
||||
error: string | null;
|
||||
|
||||
load: () => Promise<void>;
|
||||
add: (input: Omit<Account, 'id' | 'displayName' | 'lastUsed'> & { displayName?: string }) => Promise<{ ok: boolean; error?: string }>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
setActive: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 主进程 IPC(仅 Electron 里有)。 */
|
||||
function bridge(): { load: () => Promise<any>; save: (a: Account[]) => Promise<any> } | null {
|
||||
const w = globalThis as any;
|
||||
return w?.agentmail?.accounts ?? null;
|
||||
}
|
||||
|
||||
const LS_KEY = 'agentmail.accounts.v1';
|
||||
|
||||
async function persist(accounts: Account[]): Promise<{ file: string; ephemeral: boolean }> {
|
||||
const b = bridge();
|
||||
if (b) {
|
||||
const r = await b.save(accounts);
|
||||
if (!r?.ok) throw new Error(r?.error || '主进程保存失败');
|
||||
return { file: String(r.file || ''), ephemeral: false };
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(accounts));
|
||||
} catch (e) {
|
||||
throw new Error(`浏览器存储不可用:${(e as Error)?.message || e}`);
|
||||
}
|
||||
return { file: '', ephemeral: true };
|
||||
}
|
||||
|
||||
function readLocal(): Account[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选中账号的认证同步到 api 单例。聚合模式用第一个可用账号(发信要有身份)。 */
|
||||
function syncAuth(accounts: Account[], activeId: string): void {
|
||||
const target =
|
||||
activeId === AGGREGATE_ID
|
||||
? accounts.find(a => isUsableAccount(a))
|
||||
: accounts.find(a => a.id === activeId);
|
||||
if (target && isUsableAccount(target)) {
|
||||
const { base, token } = accountAuth(target);
|
||||
setActiveAuth({ base, token });
|
||||
}
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
const c = globalThis.crypto as Crypto | undefined;
|
||||
if (c?.randomUUID) return c.randomUUID();
|
||||
return `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountState>((set, get) => ({
|
||||
accounts: [],
|
||||
activeId: AGGREGATE_ID,
|
||||
storageFile: '',
|
||||
ephemeralStorage: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
const b = bridge();
|
||||
let accounts: Account[] = [];
|
||||
let file = '';
|
||||
let ephemeral = false;
|
||||
if (b) {
|
||||
const r = await b.load();
|
||||
accounts = Array.isArray(r?.accounts) ? r.accounts : [];
|
||||
file = String(r?.file || '');
|
||||
if (r && r.ok === false) set({ error: `读取账号失败:${r.error}` });
|
||||
} else {
|
||||
accounts = readLocal();
|
||||
ephemeral = true;
|
||||
}
|
||||
// 选中项:记住上次选中的(落盘在 activeId 里不合适——它是视图状态,
|
||||
// 这里用"最近使用"推导,避免多一个持久化字段和它的迁移问题)
|
||||
const activeId = pickDefaultAccountId(accounts) || AGGREGATE_ID;
|
||||
syncAuth(accounts, activeId);
|
||||
set({ accounts, activeId, storageFile: file, ephemeralStorage: ephemeral, loaded: true });
|
||||
},
|
||||
|
||||
add: async input => {
|
||||
const gateway = normalizeGateway(input.gateway);
|
||||
const token = String(input.token ?? '').trim();
|
||||
if (!gateway) return { ok: false, error: '请填写 Gateway 地址' };
|
||||
if (!token) return { ok: false, error: '请填写用户密钥(user key)' };
|
||||
|
||||
const { accounts } = get();
|
||||
// 同一身份重复添加会让收件箱出现两份同样的邮件、SSE 也多一条 —— 直接挡住
|
||||
if (accounts.some(a => sameIdentity(a, { gateway, token }))) {
|
||||
return { ok: false, error: '这个账号已经添加过了(同一 Gateway + 同一密钥)' };
|
||||
}
|
||||
|
||||
const acct: Account = {
|
||||
id: newId(),
|
||||
displayName: (input.displayName || '').trim() || deriveDisplayName(gateway, input.username),
|
||||
gateway,
|
||||
token,
|
||||
username: input.username,
|
||||
lastUsed: new Date().toISOString()
|
||||
};
|
||||
const next = upsertAccount(accounts, acct);
|
||||
set({ accounts: next, activeId: acct.id, error: null });
|
||||
syncAuth(next, acct.id);
|
||||
try {
|
||||
const r = await persist(next);
|
||||
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
set({ error: `保存失败:${(e as Error)?.message || e}` });
|
||||
return { ok: false, error: `保存失败:${(e as Error)?.message || e}` };
|
||||
}
|
||||
},
|
||||
|
||||
remove: async id => {
|
||||
const { accounts, activeId } = get();
|
||||
const next = removeFrom(accounts, id);
|
||||
const nextActive = activeId === id ? (next[0]?.id ?? AGGREGATE_ID) : activeId;
|
||||
set({ accounts: next, activeId: nextActive, error: null });
|
||||
syncAuth(next, nextActive);
|
||||
try {
|
||||
const r = await persist(next);
|
||||
set({ storageFile: r.file, ephemeralStorage: r.ephemeral });
|
||||
} catch (e) {
|
||||
set({ error: `保存失败:${(e as Error)?.message || e}` });
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async id => {
|
||||
const { accounts } = get();
|
||||
const next = id === AGGREGATE_ID ? accounts : touchAccount(accounts, id);
|
||||
set({ activeId: id, accounts: next });
|
||||
syncAuth(next, id);
|
||||
// lastUsed 变了要落盘,否则下次启动的默认账号会退回旧值
|
||||
try {
|
||||
await persist(next);
|
||||
} catch {
|
||||
/* 落盘失败不该挡住切换:内存里已经切好了,下次启动最多是默认账号不对 */
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
/** 当前选中的账号(聚合模式下为 null —— 它不是一个账号)。 */
|
||||
export function activeAccount(s: { accounts: Account[]; activeId: string }): Account | null {
|
||||
if (s.activeId === AGGREGATE_ID) return null;
|
||||
return s.accounts.find(a => a.id === s.activeId) ?? null;
|
||||
}
|
||||
|
||||
/** 是否处于聚合视图。 */
|
||||
export function isAggregate(s: { activeId: string }): boolean {
|
||||
return s.activeId === AGGREGATE_ID;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Mail } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { accountAuth, mergeInboxes, normalizeGateway } from '../lib/accounts';
|
||||
import { isAggregate, useAccountStore } from './accountStore';
|
||||
|
||||
interface MailState {
|
||||
inbox: Mail[];
|
||||
@ -8,6 +10,14 @@ interface MailState {
|
||||
currentMail: Mail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* 聚合模式下**某个账号**取失败的原因(账号名 + 原因)。
|
||||
*
|
||||
* 单独一个字段而不是塞进 `error`:`error` 会让整个列表变成错误态,
|
||||
* 而这里更常见的是"两个账号里有一个挂了" —— 那时其余邮件仍应显示,
|
||||
* 只在列表上方标明少了一份。静默丢一整个账号才是真的会骗人。
|
||||
*/
|
||||
accountErrors: { account: string; error: string }[];
|
||||
|
||||
fetchInbox: (status?: string) => Promise<void>;
|
||||
fetchSent: () => Promise<void>;
|
||||
@ -28,18 +38,68 @@ interface MailState {
|
||||
export const useMailStore = create<MailState>((set, get) => ({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
accountErrors: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchInbox: async (status = 'all') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
const acc = useAccountStore.getState();
|
||||
const usable = acc.accounts.filter(a => a.token && a.gateway);
|
||||
|
||||
// 单账号(或只有一个账号):走原来的路径,逐字节不变。
|
||||
// 聚合只在**真的有两个以上可用账号**时才发生 —— 否则"全部邮箱"与
|
||||
// 单账号看到的是同一份数据,多绕一圈只会多出失败面。
|
||||
if (!isAggregate(acc) || usable.length < 2) {
|
||||
set({ loading: true, error: null, accountErrors: [] });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 只合并**同一网关**的账号。
|
||||
//
|
||||
// 单例基地址 = 当前账号的网关,打开邮件/标已读这些动作都走它。若把另一个
|
||||
// 网关的邮件混进列表,点开时会去问第一个账号的服务器 —— 要么 404,
|
||||
// 要么更糟:mail_id 恰好撞上就打开了别人的信。所以宁可如实排除,
|
||||
// 并在列表上方说明(这是可见的缺失,不是静默少一份)。
|
||||
const hostGateway = normalizeGateway(usable[0].gateway);
|
||||
const sameGateway = usable.filter(a => normalizeGateway(a.gateway) === hostGateway);
|
||||
const otherGateway = usable.filter(a => normalizeGateway(a.gateway) !== hostGateway);
|
||||
|
||||
set({ loading: true, error: null, accountErrors: [] });
|
||||
const results = await Promise.allSettled(
|
||||
sameGateway.map(async a => ({ account: a, ...(await api.getInboxWithAuth(accountAuth(a), status)) }))
|
||||
);
|
||||
|
||||
const ok: { account: typeof usable[number]; mails: Mail[] }[] = [];
|
||||
const failures: { account: string; error: string }[] = otherGateway.map(a => ({
|
||||
account: a.displayName,
|
||||
error: `在另一个网关(${a.gateway}),未参与聚合 —— 切到该账号可单独查看`
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
const a = sameGateway[i];
|
||||
if (r.status === 'fulfilled') ok.push({ account: r.value.account, mails: r.value.mails || [] });
|
||||
else {
|
||||
// ★ 某个账号取不到**必须说出来**:静默丢掉它,聚合列表会少一整份邮件,
|
||||
// 而界面看起来完全正常(这正是"聚合"最容易骗人的失败方式)。
|
||||
failures.push({
|
||||
account: a.displayName,
|
||||
error: r.reason instanceof Error ? r.reason.message : String(r.reason)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
set({
|
||||
inbox: mergeInboxes(ok),
|
||||
loading: false,
|
||||
accountErrors: failures,
|
||||
error: failures.length && ok.length === 0 ? `全部账号都取不到邮件:${failures.map(f => f.account).join('、')}` : null
|
||||
});
|
||||
},
|
||||
|
||||
fetchSent: async () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
132
client/electron/test/components/AccountSwitcher.test.tsx
Normal file
132
client/electron/test/components/AccountSwitcher.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
186
client/electron/test/lib/accounts.test.mjs
Normal file
186
client/electron/test/lib/accounts.test.mjs
Normal 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');
|
||||
});
|
||||
309
client/electron/test/manual/multi-account-verify.mjs
Normal file
309
client/electron/test/manual/multi-account-verify.mjs
Normal 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;
|
||||
@ -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 明文文件,界面上如实写明存放位置。
|
||||
|
||||
Reference in New Issue
Block a user