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

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