Files
MailUI4Agents/client/electron/src/api/config.ts
JianFeeeee addde97600 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`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00

123 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* API 接入配置。
*
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1独立部署的客户端需要指向具体主机
* 2. 凭证:浏览器用登录 Cookie第三方客户端用用户密钥Authorization: Bearer
*
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
* 不需要改任何调用点。
*/
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
declare global {
interface Window {
__AGENTMAIL_API_BASE__?: string;
__AGENTMAIL_TOKEN__?: string;
/** 宿主壳标识Electron preload 会设成 'desktop'。见 components/LoginPage 的说明 */
__AGENTMAIL_SHELL__?: string;
}
}
/**
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
*
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
*/
function resolveBase(): string {
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
const build = import.meta.env?.VITE_API_BASE as string | undefined;
const base = (runtime || build || '/api/v1').trim();
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
return base.replace(/\/+$/, '');
}
/**
* 当前生效的 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 =
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
/**
* 设置用户密钥。第三方客户端在启动时调用一次即可,
* 之后所有请求(含 SSE 与附件下载)自动带上。
*/
export function setToken(token: string | null) {
bearerToken = token && token.trim() !== '' ? token.trim() : null;
}
export function getToken(): string | null {
return bearerToken;
}
/** 认证请求头。用 Cookie 时返回空对象。 */
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不支持自定义头
* - <a download> / <img src> 由浏览器直接发起
*
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
*/
export function withToken(url: string): string {
if (!bearerToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
}