按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
/**
|
||
* 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 附加认证信息,供无法设置请求头的场景使用:
|
||
* - EventSource(SSE)不支持自定义头
|
||
* - <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)}`;
|
||
}
|