commit 0e754617a46c5da296e99dea3d96d795822ad4e3 Author: JianFeeeee Date: Wed Sep 2 10:29:26 2026 +0800 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台 Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9723b21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# ---- 依赖与构建产物 ---- +# +# 前端只在**构建期**用到 npm:`npm run build` 出的 dist/ 被 cp 进 +# gateway/internal/static/static/ 再由 go:embed 编进二进制。 +# 部署机上没有 node,产物就是「一个二进制 + 一个 .db 文件」。 +# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。 +web/node_modules/ +web/dist/ +plugins/*/node_modules/ + +# go:embed 的输入目录 = web/dist 的副本,同属构建产物。 +# 注意 embed 要求目录存在才能编译,克隆后需先跑一次 +# `deploy/install.sh`(或 npm run build && cp -r web/dist ...)。 +gateway/internal/static/static/ + +# Go 构建产物 +gateway/agentmail-gateway +gateway/gw +*.test + +# ---- 运行态数据 ---- +# +# SQLite 库与附件目录:生产在 /opt/agentmail/data, +# 本地调试可能落在仓库里,绝不能提交(含真实邮件与凭证哈希)。 +*.db +*.db-shm +*.db-wal +data/ +attachments/ + +# ---- 凭证 ---- +# +# 管理员密码、Agent 密钥、opencode server password 都在 env 文件里。 +.env +*.env + +# ---- Agent 工具的运行态目录 ---- +# +# 这些是 pi / omo 在仓库里落的会话与后台任务状态,属于本机运行痕迹, +# 不是项目的一部分。 +.pi/ +.omo/ +.pi-glla/ +.codegraph + +# ---- 编辑器与系统 ---- +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e51eead --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# 邮件驱动·多智能体协作平台 (AgentMail) + +> 以「邮件交互」为统一范式的多 Agent 调度系统 + +人给 Agent 发邮件派活,Agent 之间互相发邮件协作,需要人拍板时发一封「权限请求」邮件等回复。 +所有交互都是邮件,所以协作过程天然可读、可追溯、可归档。 + +## 核心概念:三维寻址 + +收件人地址形如 `name@path.session`: + +| 地址 | 含义 | +|------|------| +| `pi@root` | 投递到 `pi` 在 `root` 工作区的**默认会话**(从未通信过则建立) | +| `pi@root.new` | **强制新建**一个会话 | +| `pi@root.fix-leak` | 投递到别名为 `fix-leak` 的**已有会话**;不存在则报「无法送达」,不会静默新建 | +| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) | + +`path` 内可以含 `/` 和 `.`,解析时按**最后一个 `.`** 切分 session 位。 + +会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台 +本就会由模型为会话生成摘要标题和 slug,AgentMail 直接复用,不另造一套。 + +## 快速开始 + +### 开发 + +```bash +# 后端:默认用内置 SQLite,无需任何外部依赖 +cd gateway +ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server +# → http://localhost:8180 + +# 前端(另开一个终端) +cd web +npm install +npm run dev # → http://localhost:5173,自动代理到 8180 +``` + +### 部署 + +```bash +sudo ./deploy/install.sh +``` + +该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。 +产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。 +首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。 + +### 构建期依赖 + +npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。 +`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制, +**部署机上不需要 node**。 + +`web/node_modules`、`web/dist`、`gateway/internal/static/static/` 与编译出的二进制都是 +构建产物,不进版本库(见 `.gitignore`)。因此**克隆后必须先跑一次 `deploy/install.sh`** +——`go:embed` 要求那个目录存在才能编译,否则 `go build` 直接失败。只想编 Go 的话: + +```bash +cd web && npm ci && npm run build +rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static +cd ../gateway && go build ./cmd/server +``` + +### 数据库 + +默认 SQLite,落在 `$AGENTMAIL_DATA_DIR/agentmail.db`(默认 `./data/`)。想接外部库就设 +`DATABASE_URL`: + +```bash +DATABASE_URL=postgres://user:pass@host:5432/agentmail # 外部 PostgreSQL +DATABASE_URL=sqlite:///var/lib/agentmail/mail.db # 指定 SQLite 路径 +DATABASE_URL= # 留空 = 内置 SQLite +``` + +两种方言共用一份 repo 层 SQL,差异集中在 `internal/db`(占位符、`NOW()`、JSON 包含判断、 +唯一冲突识别)。SQLite 开了 WAL,读写不互斥。 + +### 接入 Agent + +以 opencode 为例: + +```bash +# 在 opencode 配置的 plugin 列表里加上本地路径 +"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"] +``` + +插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` / +`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件: +收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。 + +两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额: + +- **平台原生的权限询问**:opencode 拦下一个危险操作时(`permission.ask`), + 插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。 + 这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话, + 它可能忘了调,而真正被拦下的那次询问反而没人看见。 +- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。 + 模型已经把话说完了,插件只是搬运。 + +配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。 + +**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志, +管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往 +服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`。 + +密钥分两类,权限边界不同: + +| | 签发方 | 用途 | 不能做什么 | +|---|---|---|---| +| Agent 密钥 | 管理员 | 注册、收发邮件、订阅 SSE | 读不了人类邮箱 | +| 用户密钥 | 用户自助 | 第三方客户端访问自己的邮箱 | 注册不了 Agent | + +三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。 + +环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`。 + +## 项目结构 + +``` +agentmail/ +├── docs/ +│ ├── PLAN.md # 分阶段实施计划 +│ └── MVP-SPEC.md # MVP 技术规格书 +├── gateway/ # 后端(Go,单二进制) +│ ├── cmd/server/ # 入口与路由表 +│ └── internal/ +│ ├── db/ # 连接 + 方言适配 + 内嵌迁移 +│ ├── models/ # 三维地址解析、领域模型 +│ ├── repo/ # 数据访问 +│ ├── handler/ # HTTP 处理 +│ ├── middleware/ # Agent / 用户双认证 +│ ├── sse/ # 事件推送(按收件人分流) +│ └── static/ # go:embed 的前端产物 +├── plugins/ +│ └── opencode-mail-bridge/ # opencode 桥接插件 +├── web/ # 前端(React + Vite + Tailwind) +└── deploy/ # systemd 单元 + 安装脚本 +``` + +## 技术栈 + +| 组件 | 技术 | +|------|------| +| 后端 | Go + chi | +| 数据库 | SQLite(默认,零依赖)/ PostgreSQL(可选) | +| 前端 | React 18 + TypeScript + Vite + TailwindCSS | +| 通信 | HTTP REST + SSE | +| 认证 | 人类 bcrypt + Cookie;密钥认证(Agent / 用户两类,Bearer) | +| 附件 | 内容寻址磁盘存储(sha256),元数据入库 | +| 对话树 | parent_mail_id 递归 CTE,按方向分块加载 | +| 部署 | 单二进制 + SQLite 文件,systemd 托管 | + +## 验证 + +```bash +cd gateway && go build ./... && go test ./... # 后端 +cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试) +``` + +## WebAPI + +WebUI 调用的就是这套公开 API,没有「仅前端可用」的私有通道 —— 第三方客户端拿一把用户密钥 +即可获得与网页完全相同的能力: + +```bash +# 在网页「账号 → 客户端连接密钥」创建密钥,然后 +curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN" +``` + +`src/api/` 本身就是可复用的客户端 SDK,基地址与凭证集中在 `src/api/config.ts`, +同一份构建产物可通过 `window.__AGENTMAIL_API_BASE__` 指向不同后端。 + +完整接口见 [WebAPI 文档](docs/API.md)。 + +## 往返预算 + +配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。 +所以预算落在会话上,在写信时给、在对话页头部随时调: + +- 新建邮件时填「往返预算」(留空 = 不限) +- 对话页头部点预算徽标即可改上限,或把已用次数归零 +- Agent 全局配额(管理员页)仍然生效,两层都要过 —— + 否则 Agent 自己用 `.new` 开一串会话,每条都是全新预算,全局上限就形同虚设 + +插件自动转发的权限询问与最终总结**不占用**任何一层:配额约束的是模型的自主发信, +不是 harness 的搬运。 + +## 会话别名 + +会话别名是寻址的第三维(`name@path.别名`)。默认**复用 Agent 平台自己的命名机制** —— +opencode 创建会话时就有 slug,首轮对话后模型会生成摘要标题,平台叫什么本侧就叫什么, +不另造一套。 + +Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头: +别名是人的寻址入口,Agent 中途改掉会让人刚记住的地址立刻失效。 + +## 对话树 + +邮件的 `parent_mail_id` 天然编码了树结构(回复指向来信,转发指向原件), +所以对话树直接用递归查询在 `mails` 上展开,不额外维护一张树表 —— 那会变成第二份真相。 + +树可以跨会话:转发把线索引到新会话,却仍属同一条线索。邮件详情页点「对话树」查看, +首屏只加载当前屏幕附近的节点,上滑逐步补齐更早的往来。 + +## 附件 + +支持给邮件附加文件。上传与发信是两步:先 `POST /me/attachments` 拿 `attachment_id`, +再在发信时放进 `attachment_ids`。 + +内容按 sha256 内容寻址存磁盘(数据库只存元数据),同内容重复上传不占额外空间; +下载一律强制 `octet-stream` + `attachment`,绝不按声明的 MIME 内联渲染。 +单个默认上限 25MB,未随邮件发出的附件 24 小时后自动清理。 + +## 文档 + +- [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定 +- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准 +- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节 diff --git a/deploy/agentmail-gateway.service b/deploy/agentmail-gateway.service new file mode 100644 index 0000000..52bf4da --- /dev/null +++ b/deploy/agentmail-gateway.service @@ -0,0 +1,36 @@ +[Unit] +Description=AgentMail Gateway - email-driven multi-agent collaboration platform +Documentation=https://github.com/agentmail +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/agentmail +ExecStart=/opt/agentmail/agentmail-gateway + +# 数据库:留空即用内置 SQLite(/opt/agentmail/data/agentmail.db)。 +# 要接外部 PostgreSQL 就把 DATABASE_URL 填成 postgres://user:pass@host:5432/db。 +Environment=DATABASE_URL= +Environment=AGENTMAIL_DATA_DIR=/opt/agentmail/data +Environment=PORT=8180 + +# 附件:内容存磁盘(默认在 data/attachments),单个上限 25MB +Environment=AGENTMAIL_MAX_ATTACHMENT_BYTES=26214400 + +# 首启创建的管理员。密码放在 EnvironmentFile 里,避免出现在 systemctl show 输出。 +EnvironmentFile=/etc/agentmail/gateway.env + +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# 只需要读自己的程序目录与写 data/,其余文件系统一律只读 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/agentmail/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..ccc2aff --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# 把 AgentMail Gateway 与 opencode serve 安装为 systemd 服务。 +# +# sudo ./deploy/install.sh +# +# 幂等:重复执行等价于「重新构建 + 重启」。已存在的 env 文件不会被覆盖, +# 因为里面有管理员密码与 Agent secret,重装不该把它们冲掉。 +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PREFIX=/opt/agentmail +ETC=/etc/agentmail + +[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0" >&2; exit 1; } + +echo "==> 构建前端" +( cd "$REPO/web" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund ) +( cd "$REPO/web" && npm run typecheck && npm test && npm run build ) + +echo "==> 前端产物嵌入 Gateway" +rm -rf "$REPO/gateway/internal/static/static" +cp -r "$REPO/web/dist" "$REPO/gateway/internal/static/static" + +echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)" +# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次) +rm -f "$REPO/gateway/agentmail-gateway" +( cd "$REPO/gateway" && go vet ./... && go test ./... && go build -o "$REPO/gateway/agentmail-gateway" ./cmd/server ) + +echo "==> 安装到 $PREFIX" +install -d "$PREFIX" "$PREFIX/data" "$ETC" +install -m 0755 "$REPO/gateway/agentmail-gateway" "$PREFIX/agentmail-gateway" + +# ---- env 文件:仅在缺失时生成,密码随机 ---- +if [[ ! -f "$ETC/gateway.env" ]]; then + ADMIN_PASS="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)" + cat > "$ETC/gateway.env" < "$ETC/opencode.env" </dev/null && exit 0; \ + sleep 1; \ + done; true' + +[Install] +WantedBy=multi-user.target diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..8d8b4cb --- /dev/null +++ b/docs/API.md @@ -0,0 +1,377 @@ +# AgentMail WebAPI + +WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。 +这份文档描述如何以纯 API 方式接入。 + +基地址:`{host}/api/v1` + +## 一、认证 + +三类调用者,各有凭证,互不越界: + +| 调用者 | 凭证 | 可访问 | +|--------|------|--------| +| 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 | +| 第三方客户端 | 用户密钥 `Authorization: Bearer ` | 人类接口(与 Cookie 完全等价) | +| Agent | Agent 密钥 `Authorization: Bearer `,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 | + +两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent, +Agent 密钥读不了人类邮箱。 + +### 取得用户密钥 + +在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调: + +```bash +curl -X POST {host}/api/v1/me/keys \ + -H 'Content-Type: application/json' \ + -b cookies.txt \ + -d '{"label":"我的客户端","key_type":"permanent"}' +``` + +密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。 +类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。 + +### 之后每个请求 + +```bash +curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN" +``` + +### 两处例外:`?access_token=` + +`EventSource`(SSE)与 `` 由浏览器直接发起,无法设置请求头。 +只有这两个端点额外接受 query 令牌: + +- `GET /events/stream?access_token=` +- `GET /me/attachments/{id}?access_token=` + +其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。 + +## 二、三维寻址 + +收件人地址形如 `name@path.session`,`session` 位三种语义: + +| 地址 | 含义 | +|------|------| +| `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) | +| `pi@root.new` | **强制新建**会话 | +| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 | +| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) | + +`path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。 +会话别名负责寻址,因此全局唯一;`new` 是保留字。 + +## 三、人类接口 + +### 邮件 + +``` +POST /me/mail/send 发信 +GET /me/mail/inbox 收件箱(?status=unread|all&limit=N) +GET /me/mail/sent 发件箱 +GET /mail/{id} 单封详情(含附件列表) +GET /mail/{id}/thread 对话树(分块加载,见下) +POST /mail/{id}/read 标记已读 +POST /me/mail/{id}/forward 转发(引用原文 + 附件随行) +``` + +发信请求体: + +```json +{ + "to": "pi@root.new", + "cc": "alice@.new, bob@.new", + "subject": "标题", + "body": "Markdown 正文", + "reply_to": "", + "session_alias": "fix-leak", + "attachment_ids": [""], + "max_rounds": 3 +} +``` + +`reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾 +(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段, +每封新信都会悄悄改掉对方正在遵守的约定。 + +### 对话树 + +``` +GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙 +GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载) +GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下 +``` + +树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。 +因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。 + +```json +{ + "anchor_mail_id": "...", + "dir": "around", + "nodes": [ + { + "mail_id": "...", "parent_mail_id": "...", + "depth": -3, + "from_name": "admin", "to_name": "pi", + "subject": "...", "body_preview": "正文前 240 字节…", + "attachment_count": 2, + "detached": true, "parent_hidden": true + } + ], + "total": 21, "hidden": 4, + "has_more_up": true, "has_more_down": false, + "next_up": 20, "next_down": 20 +} +``` + +- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。 + 分块加载时根可能还没取到,所以不用「距根深度」 +- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down` + 原样回传即可,不必自己算已加载数量 +- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取 +- `hidden` = 本页因权限被过滤掉的节点数 +- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分 + 「确实无权查看」(永久)与「尚未加载」(随上滑补齐) +- `limit` 夹到 [1, 200],非法值回落默认 40 + +**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。 +拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。 + +### 会话 + +``` +GET /me/sessions 我参与的会话 +GET /sessions/{id} 会话详情 +GET /sessions/{id}/mails 会话内邮件(含附件) +PUT /sessions/{id}/alias 改会话别名(冲突 409) + +GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null) +POST /sessions/{id}/rename-proposal/dismiss 驳回建议 + +GET /sessions/{id}/budget 本任务的往返预算 +PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?} +``` + +### 往返预算 + +配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。 +只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。 +所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。 + +```bash +# 派活时给 3 个来回 +curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \ + -d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}' + +# 看着往来内容决定加到 5 +curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \ + -d '{"max_rounds":5}' + +# 加到 20 并从头算(两者可同时给) +curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \ + -d '{"max_rounds":20,"reset":true}' +``` + +- `max_rounds = 0`(或省略)= 本会话不限,仅受 Agent 全局配额约束 +- **两层都要过**:会话预算 + `agents.max_rounds` 全局配额。少了后者, + Agent 自己用 `.new` 开一串会话每条都是全新预算,全局上限形同虚设 +- 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 —— + 那次往返实际上没有发生 +- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦 +- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining` +- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见 + +### Agent 提议改会话别名 + +Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口, +Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。 + +Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的 +HTML 注释 ``),服务端解析后 +**从入库正文里剥掉标记**并记在该封邮件上。 + +```bash +curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN" +# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}} +``` + +- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409) +- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议 +- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里 +- Agent 之后提**别的**名字会重新出现;同一个名字不会 + +会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名, +`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** —— +否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。 +标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。 + +### 联系人 + +``` +GET /contacts 联系人 = 一条 name@path.session 地址 +GET /contacts/suggest 三段式补全(?name=&path=) +POST /contacts/archive 归档 +``` + +`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path; +给 `name`+`path` 返回已有会话别名与 `new`。 + +### 权限决策 + +``` +GET /permission/pending 待我决策的请求 +POST /permission/decide 决策 {mail_id, decision, note} +``` + +### 附件 + +``` +POST /me/attachments 上传(multipart,字段名 file)→ attachment_id +GET /me/attachments/{id} 下载 +DELETE /me/attachments/{id} 删除(仅未随邮件发出的) +``` + +上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。 +未随邮件发出的附件 24 小时后由 GC 清理。 + +内容按 sha256 内容寻址:同内容重复上传不占额外空间。 +下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`, +绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。 + +单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。 + +### 账号与密钥 + +``` +GET /auth/me 当前用户 +POST /auth/password 改密码 +POST /me/keys 创建客户端密钥 +GET /me/keys 我的密钥(只给 token_hint) +DELETE /me/keys/{id} 吊销 +``` + +### 管理员(role=admin) + +``` +GET|POST /admin/users 用户管理 +PUT|DELETE /admin/users/{id} +POST /admin/users/{id}/reset 重置密码 +GET /admin/scopes 可选的 Agent/路径范围 + +POST|GET /admin/agent-keys 签发/登记 Agent 密钥 +DELETE /admin/agent-keys/{id} +POST /admin/agent-keys/{id}/bind + +GET /admin/quotas Agent 发信配额 +PUT /admin/quotas/{name} 设上限 {max_rounds} 或归零 {reset:true} +``` + +## 四、Agent 接口 + +``` +POST /agent/register 注册(Bearer 或 body.secret) +POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota +POST /mail/send 发信(扣配额) +GET /mail/inbox 收件箱(含附件清单) +POST /mail/{id}/forward 转发(扣配额) +POST /permission/request 请求人类决策 +POST /attachments 上传附件 +GET /attachments/{id} 下载附件 +POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug +``` + +发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。 +只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。 + +### 免配额通道:harness 代劳的转发 + +**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度: + +| relay | 上游 | 为什么免费 | +|---|---|---| +| `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 | +| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 | + +```bash +# 转发本轮总结(relay_key = opencode 的 assistant message id) +curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \ + -d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透", + "relay":"summary","relay_key":"msg_abc123"}' +``` + +- `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串) +- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来; + 唯一约束保证同一条上游消息只能免费转一次 +- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 —— + 插件重试与 SSE 重放是正常现象,不是故障 +- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了 +- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等, + 它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」) +- 人类决策后,`permission_decision` 事件会回传 `relay_key`, + 插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上 + +`max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。 +注意 Agent 全局配额与会话往返预算是两层,都要过。 + +## 五、实时推送(SSE) + +``` +GET /events/stream +``` + +事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。 + +按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。 +不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。 + +```js +// 浏览器:Cookie 模式 +new EventSource('/api/v1/events/stream', { withCredentials: true }); + +// 浏览器:密钥模式(EventSource 不能带头) +new EventSource(`/api/v1/events/stream?access_token=${token}`); +``` + +```bash +# 非浏览器客户端:用请求头 +curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN" +``` + +## 六、错误约定 + +失败响应统一为 `{"error": "中文可操作描述"}`,状态码: + +| 码 | 含义 | +|----|------| +| 400 | 请求体或地址格式非法 | +| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 | +| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) | +| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) | +| 409 | 冲突(别名被占用、附件已随其他邮件发出) | +| 413 | 附件超过大小上限 | +| 429 | 登录失败次数过多(响应含 `retry_after` 秒) | + +## 七、跨域 + +`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。 +已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length` +(前者是附件下载取文件名所必需)。 + +WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。 + +## 八、前端如何指向不同后端 + +WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`: + +```js +// 构建期 +VITE_API_BASE=https://mail.example.com/api/v1 npm run build + +// 运行时(同一份产物部署到不同后端) +window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1'; +window.__AGENTMAIL_TOKEN__ = ''; // 省略则走 Cookie +``` + +也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。 diff --git a/docs/MVP-SPEC.md b/docs/MVP-SPEC.md new file mode 100644 index 0000000..db55e4a --- /dev/null +++ b/docs/MVP-SPEC.md @@ -0,0 +1,796 @@ +# 邮件驱动·多智能体协作平台 — MVP 技术规格书 + +> 基于 v2.0 完整设计文档,聚焦最小可用闭环。 +> 版本:v0.1 | 日期:2026-09-01 + +--- + +## 一、MVP 范围定义 + +### 1.1 核心目标 + +**跑通一个完整的人-Agent协作闭环:** + +``` +人类发任务 → Agent 收到 → Agent 执行 → Agent 遇到需要人决策的事 → 请求权限 → 人类批准 → Agent 继续执行 → Agent 回复结果 +``` + +### 1.2 MVP 包含(✅)与不包含(❌) + +| 功能 | MVP | 说明 | +|------|-----|------| +| 邮件收发(人↔Agent) | ✅ | 核心闭环 | +| 三维寻址(name@path.session) | ✅ | 核心寻址范式;session 位三态:省略=默认会话 / `new`=新建 / 别名=必须已存在(否则无法送达) | +| 会话别名(命名与改名) | ✅ | 别名负责寻址,全局唯一;`new` 为保留字 | +| 别名/标题复用 Agent 平台命名 | ✅ | 平台(如 opencode)由模型生成会话摘要标题 + slug,经 `POST /sessions/:id/sync` 回写;撞名自动加 `-2` 后缀 | +| 密钥认证(Agent / 用户分离) | ✅ | agent_keys 用于注册/心跳/SSE;user_keys 仅用于 /me/*;三种生命周期 permanent/one_time/timed | +| 内置 SQLite(外部库可选) | ✅ | 默认零依赖;`DATABASE_URL` 非空时切 PostgreSQL | +| systemd 一键部署 | ✅ | `deploy/install.sh`,产物为一个二进制 + 一个 .db | +| 附件 | ✅ | 内容寻址存盘(sha256 去重),上传与发信两步;下载强制 octet-stream | +| 转发 | ✅ | 引用原文 + 附件随行;只能转发自己参与过的邮件 | +| Agent 发信配额 | ✅ | 只限发信不限收信;剩余次数随响应与心跳回传 | +| WebAPI 等价接入 | ✅ | WebUI 与第三方客户端同一套 API,见 docs/API.md | +| 对话树 | ✅ | 沿 parent_mail_id 递归展开,跨会话,按方向分块加载,不建 tree_nodes 表 | +| Agent 提议改会话名 | ✅ | 正文里的 HTML 注释标记,入库时剥除;改名需用户确认 | +| 会话往返预算 | ✅ | 写信时给 / 对话页随时改;与 Agent 全局配额两层都要过 | +| 插件自动转发 | ✅ | 平台原生权限询问 + 本轮最终总结;**不消耗配额** | +| 会话管理(创建/列表/状态) | ✅ | 会话是协作的边界 | +| 权限请求与决策 | ✅ | Agent 需要人批准才能继续 | +| Agent 注册与发现 | ✅ | 最小 Registry | +| 前端收件箱/发件箱列表 | ✅ | 基础 UI | +| 前端新建邮件/回复 | ✅ | 基础 UI | +| 邮件正文 Markdown 渲染 | ✅ | Agent 输出多为 MD | +| Agent 桥接插件 | ✅ | opencode 为第一个接入平台(`plugins/opencode-mail-bridge`) | +| 对话树 | ❌ | 后续迭代 | +| 抄送(CC) | ✅ | 已实现:`cc_list` + 收件箱抄送可见 | +| 转发 | ❌ | 后续迭代 | +| 配额机制 | ❌ | 后续迭代 | +| 会话别名命名/改名 | ✅ | 发信时命名、事后改名、平台命名自动同步 | +| Agent 正文里主动提议改名 | ❌ | 后续迭代 | +| 工作列表/卡片视图 | ❌ | 后续迭代 | +| DeepSeek Harness 插件 | ❌ | 后续迭代 | +| 跨主机 Agent 发现 | ❌ | 后续迭代 | + +--- + +## 二、系统简化架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 前端(Web UI) │ +│ 收件箱 / 发件箱 / 新建邮件 / 回复 │ +│ Markdown 编辑器 + 渲染器 │ +└──────────────────────┬──────────────────────────────┘ + │ HTTP REST + SSE + ▼ +┌─────────────────────────────────────────────────────┐ +│ Mail Gateway(单体服务) │ +│ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │ +│ │ Mail API │ │ Registry │ │ Session Mgr │ │ +│ │ 收发路由 │ │ 注册/发现 │ │ 会话/状态 │ │ +│ └───────────┘ └───────────┘ └───────────────┘ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Agent 通信层(WebSocket) │ │ +│ └───────────────────────────────────────────────┘ │ +└──────────────────────┬──────────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────┐ +│ Agent 侧(Pi Agent + 插件) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ pi-mail-bridge 插件 │ │ +│ │ - 注册 send_mail / read_inbox / req_perm │ │ +│ │ - WebSocket 连接 Gateway │ │ +│ │ - 新邮件注入 Agent 上下文 │ │ +│ └─────────────────────────────────────────────┘ │ +│ Pi Agent 本体(文件读写、终端、Git、推理) │ +└─────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ SQLite(默认) │ 邮件 + 会话持久化 + │ 或外部 PostgreSQL│ DATABASE_URL 指定 + └─────────────────┘ +``` + +**简化要点:** +- Gateway 和 Registry 合并为单体服务 +- 前端用 SSE(Server-Sent Events)替代 WebSocket(更简单,单向推送够用) +- 数据库默认内置 SQLite(零外部依赖,与 go:embed 的前端一起构成「一个二进制 + 一个 .db」); + `DATABASE_URL` 非空时切换到外部 PostgreSQL。方言差异收在 `internal/db`,repo 层只写一份 SQL +- SSE 推送不依赖数据库通知机制(无需 Redis 或 PG LISTEN/NOTIFY):单体进程内 `sse.Manager` + 按收件人名分流,Agent 通道与人类用户通道共用一套投递 + +--- + +## 三、数据模型 + +以下 DDL 以 PostgreSQL 方言书写。SQLite 侧结构与语义完全一致,仅方言不同 +(`UUID`→`TEXT`、`TIMESTAMPTZ`→`DATETIME`、`JSONB`→`TEXT`、`VARCHAR(n)`→`TEXT`), +见 `gateway/internal/db/migrations/init_sqlite.sql`。 + +### 3.1 agents 表 + +```sql +CREATE TABLE agents ( + agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_name VARCHAR(64) NOT NULL UNIQUE, -- 全局唯一 + secret VARCHAR(128) NOT NULL, -- 注册密钥 + host_url VARCHAR(256) NOT NULL, -- 插件通信地址(ws://...) + workspaces JSONB NOT NULL DEFAULT '[]', -- [{name, path}] + platform VARCHAR(32) NOT NULL DEFAULT 'pi', -- pi / dsh / cline + status VARCHAR(16) NOT NULL DEFAULT 'offline', -- online / offline + max_rounds INT NOT NULL DEFAULT 10, -- 通信配额(Phase 2 启用) + used_rounds INT NOT NULL DEFAULT 0, + last_seen TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 3.2 sessions 表 + +```sql +CREATE TABLE sessions ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_alias VARCHAR(128), -- 会话别名(如 add-health-check),全局唯一,负责寻址 + from_agent VARCHAR(64) NOT NULL, -- 发起者 agent_name(或 'human') + subject VARCHAR(512) NOT NULL, -- 会话主题 + status VARCHAR(32) NOT NULL DEFAULT 'active', -- active / waiting / completed + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_sessions_alias ON sessions(session_alias); +CREATE INDEX idx_sessions_status ON sessions(status); + +-- 别名负责三维寻址 name@path.,必须唯一;未命名会话(NULL)不受约束 +CREATE UNIQUE INDEX idx_sessions_alias_uniq + ON sessions(session_alias) WHERE session_alias IS NOT NULL; +``` + +### 3.3 mails 表 + +```sql +CREATE TABLE mails ( + mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(session_id), + parent_mail_id UUID REFERENCES mails(mail_id), -- 回复链(线性,非树) + + from_name VARCHAR(64) NOT NULL, -- 发件人 agent_name(或 'human') + from_workspace VARCHAR(128), -- 发件人工作区 + to_name VARCHAR(64) NOT NULL, -- 收件人 agent_name(或 'human') + to_workspace VARCHAR(128), -- 收件人工作区 + + subject VARCHAR(512) NOT NULL, + body TEXT NOT NULL, -- Markdown + + mail_type VARCHAR(32) NOT NULL DEFAULT 'normal', -- normal / permission_request + permission_options JSONB, -- 权限请求的选项 ["同意","拒绝"] + permission_result VARCHAR(32), -- approved / rejected / null + + status VARCHAR(16) NOT NULL DEFAULT 'unread', -- unread / read / archived + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- 防循环(Phase 2 启用) + hop_limit INT DEFAULT 5 +); + +CREATE INDEX idx_mails_session ON mails(session_id); +CREATE INDEX idx_mails_to ON mails(to_name, status); +CREATE INDEX idx_mails_parent ON mails(parent_mail_id); +``` + +### 3.4 设计决策说明 + +| 决策 | 理由 | +|------|------| +| `parent_mail_id` 而非 `tree_node_id` | MVP 只有线性回复链,不需要树结构。Phase 2 引入 CC/转发后再加 tree 节点表 | +| `from_name` 用字符串而非 UUID | MVP 阶段 agent_name 全局唯一,简化查询。Phase 2 可加 agent_id 外键 | +| `permission_options` 存 JSONB | 允许每个权限请求自定义选项,前端动态渲染按钮 | +| 不单独建 `conversations` 表 | session 已经承载会话概念,不重复建设 | + +--- + +## 四、API 设计 + +### 4.1 基础信息 + +- **Base URL**: `http://gateway:8080/api/v1` +- **认证**: Agent 侧用 `X-Agent-Name` + `X-Agent-Secret` header;前端用 session cookie +- **内容类型**: `application/json` + +### 4.2 Agent 注册与心跳 + +``` +POST /agent/register +Body: { name, secret, workspaces: [{name, path}], platform } +Response: { agent_id, status: "registered" } + +POST /agent/heartbeat +Header: X-Agent-Name, X-Agent-Secret +Response: { status: "ok", pending_mails: 3 } +``` + +心跳响应中 `pending_mails` 告诉插件有多少未读邮件需要拉取。 + +### 4.3 邮件收发 + +``` +POST /mail/send +Header: X-Agent-Name, X-Agent-Secret +Body: { + to: "builder@ModelRouter", -- name@workspace 格式 + subject: "请为 ModelRouter 增加健康检查接口", + body: "## 需求\n\n...", + session_alias: "add-health-check", -- 可选,新建会话时用 + reply_to: "uuid" -- 可选,回复某封邮件 +} +Response: { mail_id, session_id } + +GET /mail/inbox +Header: X-Agent-Name, X-Agent-Secret +Query: ?status=unread&limit=10 +Response: { + mails: [ + { + mail_id, session_id, session_alias, + from_name, from_workspace, subject, + body_preview, mail_type, status, created_at + } + ], + total: 5 +} + +GET /mail/:mail_id +Header: X-Agent-Name, X-Agent-Secret +Response: { ... 完整邮件字段 ... } + +POST /mail/:mail_id/read +Header: X-Agent-Name, X-Agent-Secret +Response: { status: "read" } +``` + +### 4.4 权限请求 + +``` +POST /permission/request +Header: X-Agent-Name, X-Agent-Secret +Body: { + to: "human", -- 固定为 human + question: "是否允许合并 PR #42?", + options: ["同意合并", "拒绝,需要修改"], + context: "PR 改动了 3 个文件,CI 全部通过...", + session_id: "uuid" -- 可选,关联现有会话 +} +Response: { mail_id, session_id, permission_mail_id } + +POST /permission/decide +Body: { + mail_id: "uuid", + decision: "同意合并", -- 必须是 request 中 options 之一 + note: "可以合并,但请补充测试" -- 可选备注 +} +Response: { status: "decided" } +``` + +### 4.5 会话与 Agent 列表 + +``` +GET /sessions +Query: ?status=active&limit=20 +Response: { + sessions: [ + { + session_id, session_alias, subject, + from_agent, status, created_at, updated_at, + mail_count: 5 + } + ] +} + +GET /sessions/:session_id +Response: { + session: { ... }, + mails: [ ... 按时间排序的邮件列表 ... ] +} + +GET /agents +Query: ?status=online +Response: { + agents: [ + { agent_name, workspaces: [...], platform, status } + ] +} +``` + +### 4.6 SSE 推送(替代 WebSocket) + +``` +GET /events/stream +Header: X-Agent-Name (Agent 侧) 或 Cookie (前端) +Query: ?agent_name=builder (Agent 侧) + 或无参数 (前端,推送所有相关事件) + +Event: new_mail +Data: { mail_id, session_id, from_name, subject, mail_type } + +Event: permission_decision +Data: { mail_id, decision, note } + +Event: session_update +Data: { session_id, status, updated_at } + +Event: agent_online +Data: { agent_name, status } +``` + +**为什么选 SSE 而不是 WebSocket:** +- 前端只需要接收推送,不需要双向通信 +- SSE 基于 HTTP,穿透代理/防火墙更容易 +- 浏览器原生支持 EventSource,无需第三方库 +- Agent 侧用 WebSocket(需要双向),前端用 SSE(只需单向) + +--- + +## 五、Pi Agent 桥接插件设计 + +### 5.1 插件能力(基于 Pi 真实 API) + +Pi Agent 的插件机制通过 `pi install` 安装,插件可以: +- 注册自定义 tool(工具) +- 在 session 生命周期钩子中执行逻辑 +- 通过 HTTP 与外部服务通信 + +### 5.2 插件架构 + +``` +pi-mail-bridge/ +├── index.js # 插件入口 + 钩子拦截器注册 +├── tools/ +│ ├── send_mail.js # 发送邮件工具(Agent 主动调用) +│ └── read_inbox.js # 读取收件箱工具(Agent 收到通知后调用) +├── hooks/ +│ ├── request_permission.js # 拦截 request_permission,格式化权限邮件 +│ ├── ask_user.js # 拦截 ask_user,格式化提问邮件 +│ └── final_message.js # 拦截最终输出,格式化总结邮件 +├── transport/ +│ ├── sse.js # SSE 客户端(接收推送) +│ └── http.js # HTTP 客户端(发送请求) +├── config.js # 配置管理 +└── package.json +``` + +### 5.3 核心区分:工具 vs 钩子拦截器 + +**重要设计原则:** + +| 类型 | 内容 | 谁来触发 | +|------|------|----------| +| Agent 可调用的工具 | `send_mail`、`read_inbox` | Agent 主动调用 | +| 自动触发器(钩子) | 权限请求、提问、最终总结 | 平台钩子自动拦截 | + +**邮箱不直接注入 Agent**:新邮件到达时,插件只向 Agent 注入一条**通知**(来自谁、主题是什么),Agent 看到通知后自行调用 `read_inbox` 工具拉取邮件正文。邮件全文从不直接塞进 Agent 上下文。 + +**三种自动转邮件触发器**(钩子拦截,非 Agent 调用工具): + +| 触发场景 | Agent 行为 | 插件钩子动作 | 邮件特征 | +|----------|-----------|--------------|----------| +| Agent 请求权限 | 调用 `request_permission()` | 冻结 Agent,格式化 `[权限请求]` 邮件发至人类 | `mail_type=permission_request`,正文自动渲染 ✅❌ 按钮 | +| Agent 提问人类 | 调用 `ask_user(question)` | 冻结 Agent,格式化 `[需回复]` 邮件发至人类 | subject 含 `[需回复]`,正文=问题 | +| Agent 最后一条消息 | 完成所有任务/配额用尽 | 拦截输出,格式化 `[最终总结]` 邮件发至人类 | subject 含 `[最终总结]`,Agent 进入休眠 | + +### 5.4 工具注册(Agent 可调用) + +```javascript +// tools/send_mail.js +module.exports = { + name: "send_mail", + description: "发送邮件给指定 Agent 或人类", + parameters: { + type: "object", + properties: { + to: { + type: "string", + description: "收件人,格式 name@workspace(如 builder@ModelRouter)" + }, + subject: { + type: "string", + description: "邮件主题" + }, + body: { + type: "string", + description: "邮件正文,支持 Markdown 格式" + }, + session_alias: { + type: "string", + description: "会话别名(新建会话时使用)" + } + }, + required: ["to", "subject", "body"] + }, + async execute(params, ctx) { + const { to, subject, body, session_alias } = params; + const [toName, toWorkspace] = to.split("@"); + + const result = await ctx.http.post("/api/v1/mail/send", { + to_name: toName, + to_workspace: toWorkspace || null, + subject, + body, + session_alias: session_alias || null, + reply_to: ctx.currentMailId || null // 如果是在回复某封邮件 + }); + + return `✅ 邮件已发送。Mail ID: ${result.mail_id},会话: ${result.session_id}`; + } +}; +``` + +```javascript +// tools/read_inbox.js +module.exports = { + name: "read_inbox", + description: "查阅收件箱中的邮件。收到新邮件通知后调用此工具查看完整内容。", + parameters: { + type: "object", + properties: { + filter: { + type: "string", + enum: ["unread", "all"], + description: "过滤条件,默认 unread" + }, + limit: { + type: "number", + description: "返回数量,默认 5" + } + } + }, + async execute(params, ctx) { + const { filter = "unread", limit = 5 } = params; + const result = await ctx.http.get("/api/v1/mail/inbox", { + params: { status: filter, limit } + }); + + if (result.mails.length === 0) { + return "📭 收件箱为空。"; + } + + return result.mails.map(m => + `📧 [${m.status}] ${m.from_name}: ${m.subject}\n` + + ` 会话: #${m.session_alias || '未命名'} | ${m.created_at}\n` + + ` 预览: ${m.body_preview?.substring(0, 100)}...` + ).join("\n\n"); + } +}; +``` + +### 5.5 钩子拦截器(自动触发器,非 Agent 工具) + +三种场景下,插件通过平台钩子**自动拦截** Agent 行为,格式化为邮件发出。Agent 无需(也不应)手动调用 send_mail 来做这些事。 + +```javascript +// hooks/request_permission.js +module.exports = { + name: "request_permission", + description: "拦截 Agent 的 request_permission 调用,自动格式化为权限请求邮件", + // 由平台钩子触发,非 Agent 调用 + async intercept(ctx, question, options = ["同意", "拒绝"], context = "") { + // 1. 调 Gateway 创建权限请求邮件 + const result = await ctx.http.post("/api/v1/permission/request", { + question, + options, + context, + session_id: ctx.sessionId + }); + + // 2. 冻结 Agent,标记等待状态 + ctx.setWaitingForPermission(result.permission_mail_id); + + // 3. 返回挂起状态给 Agent(Agent 不再继续执行) + return { suspended: true, wait_type: "permission" }; + } +}; + +// hooks/ask_user.js +module.exports = { + name: "ask_user", + description: "拦截 Agent 的 ask_user 调用,自动格式化为提问邮件", + async intercept(ctx, question) { + // 1. 调 Gateway 发送普通邮件(主题自动加 [需回复]) + const result = await ctx.http.post("/api/v1/mail/send", { + to: "human", + subject: `[需回复] ${question.substring(0, 100)}`, + body: question, + session_id: ctx.sessionId + }); + + // 2. 冻结 Agent,标记等待状态 + ctx.setWaitingForReply(result.mail_id); + + return { suspended: true, wait_type: "reply" }; + } +}; + +// hooks/final_message.js +module.exports = { + name: "final_message", + description: "拦截 Agent 最后一条消息,格式化为最终总结邮件", + async intercept(ctx, message) { + // 1. 调 Gateway 发送最终总结邮件(主题自动加 [最终总结]) + const result = await ctx.http.post("/api/v1/mail/send", { + to: "human", + subject: `[最终总结] ${ctx.sessionAlias || '任务完成'}`, + body: message, + session_id: ctx.sessionId + }); + + // 2. Agent 进入休眠 + ctx.enterSleepMode(); + + return { suspended: true, wait_type: "sleep" }; + } +}; +``` + +### 5.6 插件生命周期 + +```javascript +// index.js +const { registerTools, startSSE, registerAgent } = require('./transport'); +const config = require('./config'); + +module.exports = { + async onSessionStart(ctx) { + // 1. 向 Gateway 注册 + await registerAgent({ + name: config.agentName, + secret: config.agentSecret, + workspaces: config.workspaces, + platform: 'pi' + }); + + // 2. 启动 SSE 监听新邮件 + startSSE(config.gatewayUrl, config.agentName, (event) => { + if (event.type === 'new_mail') { + // 注入系统消息到 Agent 上下文 + ctx.addSystemMessage( + `📬 新邮件(${event.data.mail_type})\n` + + `来自: ${event.data.from_name}\n` + + `主题: ${event.data.subject}\n` + + `会话: #${event.data.session_alias || '未命名'}\n` + + `使用 read_inbox 工具查看详情。` + ); + } + if (event.type === 'permission_decision') { + // 恢复 Agent 执行 + ctx.resumeFromPermission(event.data.mail_id, event.data.decision); + } + }); + + // 3. 定期心跳(每30秒) + setInterval(async () => { + await ctx.http.post('/api/v1/agent/heartbeat'); + }, 30000); + } +}; +``` + +### 5.7 权限等待的恢复机制 + +Agent 被钩子拦截并冻结后的恢复流程: + +``` +1. Agent 调用 request_permission → 发邮件给 human → 标记等待 +2. 插件监听 SSE,收到 permission_decision 事件 +3. 插件调用 ctx.resumeFromPermission(mailId, decision) +4. Pi Agent 恢复执行,decision 作为 tool_result 返回给 Agent +5. Agent 根据 decision 继续后续操作 +``` + +**关键点:** Agent 进程不阻塞,只是标记为"等待权限"状态。SSE 收到决策后,通过 Pi Agent 的上下文注入机制恢复执行。 + +--- + +## 六、前端设计(MVP 简化版) + +### 6.1 布局 + +MVP 阶段用**两栏布局**(去掉中间的工作列表/对话树): + +``` +┌────────────────────┬────────────────────────────────────┐ +│ 左侧(300px) │ 右侧(剩余宽度) │ +├────────────────────┼────────────────────────────────────┤ +│ 📬 收件箱 (3) │ 当前查看的邮件正文 │ +│ 📤 发件箱 │ (Markdown 渲染) │ +│ 📝 草稿箱 │ │ +│ ✏️ 新建 │ ──── 分隔线 ──── │ +│ │ │ +│ ─── 邮件列表 ─── │ 回复编辑器 │ +│ │ 📧 邮件A [未读] │ [ Markdown 编辑器 ] │ +│ │ 📧 邮件B [权限] │ [发送] [取消] │ +│ │ 📧 邮件C │ │ +│ │ ... │ │ +└────────────────────┴────────────────────────────────────┘ +``` + +### 6.2 权限请求邮件的特殊渲染 + +当邮件类型为 `permission_request` 时,正文下方自动渲染按钮组: + +``` +┌──────────────────────────────────────┐ +│ 📧 权限请求 │ +│ 来自: @builder.ModelRouter │ +│ 主题: 是否允许合并 PR #42? │ +│ │ +│ ## 背景 │ +│ PR 改动了 3 个文件,CI 全部通过... │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ ✅ 同意合并 │ │ ❌ 拒绝 │ │ +│ └──────────┘ └──────────┘ │ +│ [可选备注输入框] │ +└──────────────────────────────────────┘ +``` + +### 6.3 技术栈 + +- **框架**: React 18 + TypeScript +- **样式**: TailwindCSS +- **Markdown**: react-markdown + remark-gfm(支持表格、代码高亮) +- **编辑器**: @uiw/react-md-editor(轻量 Markdown 编辑器) +- **状态管理**: Zustand(简单够用) +- **实时推送**: EventSource(SSE 原生 API) +- **构建**: Vite + +### 6.4 核心页面/组件 + +``` +src/ +├── App.tsx +├── api/ +│ ├── client.ts # HTTP 客户端 +│ └── sse.ts # SSE 连接管理 +├── stores/ +│ ├── mailStore.ts # 邮件状态 +│ └── sessionStore.ts # 会话状态 +├── components/ +│ ├── Sidebar/ +│ │ ├── Sidebar.tsx # 左侧图标栏 +│ │ ├── MailList.tsx # 邮件列表 +│ │ └── MailItem.tsx # 单封邮件条目 +│ ├── MailView/ +│ │ ├── MailView.tsx # 右侧邮件正文 +│ │ ├── MailBody.tsx # Markdown 渲染 +│ │ ├── PermissionCard.tsx # 权限请求卡片 +│ │ └── ReplyEditor.tsx # 回复编辑器 +│ └── Compose/ +│ └── ComposeModal.tsx # 新建邮件弹窗 +└── types/ + └── index.ts # TypeScript 类型定义 +``` + +--- + +## 七、完整 MVP 工作流(端到端) + +### 场景:人类要求 Builder Agent 增加健康检查接口 + +``` +步骤 1: 人类在前端新建邮件 + - 收件人: @builder.ModelRouter + - 主题: 为 ModelRouter 增加健康检查接口 + - 正文: ## 需求描述\n\n请为 ModelRouter 的 HTTP 服务增加 /health 端点... + - 会话别名: add-health-check + → 前端 POST /api/v1/mail/send + → Gateway 创建 session + mail,通过 SSE 推送 new_mail 给 Builder + +步骤 2: Builder Agent 收到通知 + - Pi Agent 收到系统消息: "📬 新邮件...使用 read_inbox 查看" + - Agent 调用 read_inbox 工具 + - Gateway 返回邮件详情 + → Agent 读取正文,理解需求 + +步骤 3: Builder Agent 执行任务 + - Agent 调用终端: git checkout -b feature/add-health-check + - Agent 调用文件读写: 编写 health check 代码 + - Agent 调用终端: 运行测试 + - Agent 调用终端: git commit, git push + +步骤 4: Builder Agent 请求权限(如需要) + - Agent 调用 request_permission: "是否允许合并 PR #42?" + - Gateway 发送权限请求邮件给人类 + → 前端收到 SSE 推送,显示权限请求卡片 + +步骤 5: 人类决策 + - 人类点击 "✅ 同意合并" + - 前端 POST /api/v1/permission/decide + → Gateway 发送 permission_decision SSE 给 Builder + +步骤 6: Builder Agent 恢复执行 + - 插件收到 permission_decision + - Agent 恢复执行,得到 "同意合并" 的结果 + - Agent 执行 gh pr merge + - Agent 调用 send_mail: "PR 已合并,健康检查接口已就绪" + → 人类收到完成通知 +``` + +--- + +## 八、MVP 实施计划 + +### Week 1:后端核心 + +| 任务 | 产出 | +|------|------| +| schema 建表(两方言各一份) | `internal/db/migrations/` | +| Gateway HTTP API(mail CRUD + session) | 可用的 REST API | +| Agent 注册/心跳 API | Agent 可注册并保持在线 | +| SSE 推送机制 | /events/stream 端点 | +| 权限请求/决策 API | 完整的权限闭环 | + +### Week 2:Pi 插件 + 前端骨架 + +| 任务 | 产出 | +|------|------| +| pi-mail-bridge 插件框架 | pi install 可用 | +| send_mail / read_inbox 工具 | Agent 可收发邮件 | +| request_permission 工具 | Agent 可请求权限 | +| 前端两栏布局 + 路由 | 可访问的 Web UI | +| 邮件列表 + 正文渲染 | 可查看邮件 | +| 新建邮件 + 回复编辑器 | 可发送邮件 | + +### Week 3:闭环联调 + +| 任务 | 产出 | +|------|------| +| 人→Agent→人 完整闭环测试 | 端到端可演示 | +| 权限请求闭环测试 | Agent 等待→人批准→Agent 恢复 | +| SSE 实时推送验证 | 新邮件到达时 UI 自动更新 | +| 错误处理与边界情况 | 网络断开、Agent 离线等 | +| 基础部署脚本 | `deploy/install.sh` + systemd 单元(数据库为内置 SQLite,无需容器编排) | + +--- + +## 九、关键设计决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| Gateway + Registry 合并 | ✅ 合并为单体 | MVP 阶段单机部署,减少运维复杂度 | +| 前端推送用 SSE | ✅ SSE | 单向推送够用,比 WebSocket 简单 | +| 会话内邮件链用线性回复 | ✅ parent_mail_id | 不需要树结构,线性链够用 | +| Agent 等待权限不阻塞进程 | ✅ 非阻塞 | Agent 进程不能挂起,用状态标记 + SSE 恢复 | +| 第一个插件选 Pi Agent | ✅ Pi | 团队更熟悉,API 更直接 | +| 不用 Redis | ✅ PG LISTEN/NOTIFY | MVP 减少依赖,PG 原生支持发布订阅 | +| 前端不用对话树 | ✅ 两栏布局 | Phase 1 聚焦核心闭环 | + +--- + +## 十、后续迭代路线 + +### 下一批(MVP 已完成的部分不再列出) +- 对话树数据模型 + 前端渲染 +- 转发功能(抄送已完成) +- 工作列表卡片视图(中间栏) +- Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成) +- DeepSeek Harness 插件 + +### Phase 3(Phase 2 后 2 周) +- 配额机制 +- 跨主机 Agent 发现(Gateway + Registry 拆分) +- Agent 间通信签名验证 +- Hop-limit 防循环 + +### Phase 4(持续) +- 监控面板 +- 审计日志查询 +- 智能路由推荐 +- Agent 自动会话别名建议 + +--- + +文档版本:v0.1 (MVP) +基于:完整设计文档 v2.0 +日期:2026-09-01 diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..b7e4a6d --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,1207 @@ +# AgentMail 实施计划 + +> 邮件驱动·多智能体协作平台 — Go 后端 + React 前端 + Pi 插件 +> 版本:v0.1 | 日期:2026-09-01 + +--- + +## 总览 + +``` +Phase 1 (Week 1-2) 后端核心 — Go Gateway + SQLite(默认)/PostgreSQL(可选)+ SSE +Phase 2 (Week 2-3) Pi Agent 插件 — 两个工具 + 三个钩子拦截器 + SSE 监听 +Phase 3 (Week 3-4) 前端 — React 三栏 UI + 三段式补全 + 权限卡片 +Phase 4 (Week 4) 多用户与鉴权 — 人类多账号 + 会话隔离 + 登录页 +Phase 5 (Week 5) 联调 + 部署 — 端到端闭环 + Docker + 公网反代 +Phase 6 (Week 6+) 进阶功能 — 对话树 / 转发 / 配额 / DSH 插件 +``` + +--- + +## Phase 1:后端核心(Go Gateway) + +### 1.1 项目初始化 ✅ + +- [x] 创建项目目录 `gateway/` +- [x] `go mod init github.com/agentmail/gateway` +- [x] 目录结构规划: + +``` +gateway/ +├── cmd/ +│ └── server/ +│ └── main.go # 入口,启动 HTTP 服务 +├── internal/ +│ ├── config/ +│ │ └── config.go # 环境变量配置 +│ ├── db/ +│ │ ├── db.go # 连接池 +│ │ ├── migrate.go # 迁移执行 +│ │ └── migrations/ +│ │ └── init.sql # 建表 SQL +│ ├── models/ +│ │ └── models.go # 数据结构 +│ ├── repo/ +│ │ └── repo.go # 数据库操作层 +│ ├── sse/ +│ │ └── manager.go # SSE 连接管理 +│ └── middleware/ +│ └── auth.go # Agent 认证中间件 +├── go.mod +├── go.sum +├── Dockerfile +└── Makefile +``` + +### 1.2 配置管理 + +- [x] `internal/config/config.go` + - 数据库连接串、端口、CORS 配置等 + - 从环境变量读取,提供默认值 + - 结构体单例 + `Load()` 函数 + +### 1.3 数据库层 + +- [x] `internal/db/db.go` — database/sql 连接 + 方言适配(SQLite 默认 / PostgreSQL 可选) +- [x] `internal/db/migrate.go` — embed SQL + 按方言执行迁移 +- [x] `internal/db/migrations/init.sql` + `init_sqlite.sql` — 6 张表 + (users/user_sessions/agents/sessions/mails/permission_requests) +- [x] `internal/repo/repo.go` — 全部数据库操作函数(方言差异走 db.CCHas / db.JSONCast) + +**repo 函数清单:** + +| 函数 | 用途 | +|------|------| +| `CreateOrUpdateAgent` | 注册/更新 Agent | +| `VerifyAgent` | 验证 Agent 名+密钥 | +| `HeartbeatAgent` | 更新心跳 + 返回未读数 | +| `ListAgents` | 列出 Agent(@补全用) | +| `CreateSession` | 创建新会话 | +| `GetSessionByID` | 查会话详情 | +| `ListSessions` | 列出会话 | +| `TouchSession` | 更新会话时间戳 | +| `UpdateSessionAlias` | 更新会话别名(校验唯一 + 保留字 new + 禁含 . / @ 空白) | +| `FindSessionByAlias` | 按别名查会话(唯一命名空间) | +| `FindNamedSessionFor` | 按 name@path.<别名> 严格寻址;不存在 → 无法送达 | +| `FindOrCreateDefaultSession` | session 位省略时的默认会话(复用最近活跃,无则建) | +| `SyncSessionAlias` | 回写平台侧 slug 为本侧别名,撞名自动追加 -2/-3 | +| `SyncSessionTitle` | 回写平台侧模型生成的摘要标题 | +| `AgentCanAccessSession` | Agent 只能同步自己参与过的会话 | +| `CreateMail` | 创建普通邮件 | +| `CreatePermissionMail` | 创建权限请求邮件 | +| `CreateDecisionMail` | 创建人类决策邮件 | +| `GetMailByID` | 查邮件详情 | +| `ListInbox` | 查收件箱 | +| `MarkMailRead` | 标记已读 | +| `CountUnread` | 统计未读数 | +| `GetSessionMails` | 查会话全部邮件 | +| `CreatePermissionRequest` | 创建权限请求记录 | +| `DecidePermission` | 执行权限决策 | +| `ListPendingPermissions` | 列出待决权限 | +| `GetPermissionByMailID` | 按邮件查权限请求 | + +### 1.4 SSE 连接管理 + +- [x] `internal/sse/manager.go` + +**功能:** +- 管理所有 SSE 客户端连接(Agent 侧 + 前端) +- 按 `agentName` 路由推送(Agent 只收自己的邮件通知,前端收所有) +- 心跳保活(每 30s 发 `: heartbeat`) +- 客户端断开自动清理 + +**数据结构:** +```go +type Client struct { + ID string + AgentName string // 空 = 前端客户端 + Res http.ResponseWriter + Flusher http.Flusher +} + +type Manager struct { + mu sync.RWMutex + clients map[string]*Client +} +``` + +**方法:** +- `AddClient(id, agentName, res, flusher)` +- `RemoveClient(id)` +- `SendToAgent(agentName, eventType, data)` +- `SendToFrontend(eventType, data)` +- `Broadcast(eventType, data)` +- `ClientCount() int` + +### 1.5 中间件 + +- [x] `internal/middleware/auth.go` + +**逻辑:** +1. 从 header 读 `X-Agent-Name` + `X-Agent-Secret` +2. 调 `repo.VerifyAgent` 验证 +3. 验证通过 → 更新 `last_seen`,把 `agentName` 注入 context +4. 失败 → 返回 401 + +**注意:** 这个中间件只用于 Agent 侧的 API。前端 API 用另一个认证(MVP 阶段可先不实现前端认证)。 + +### 1.6 HTTP 路由 + +- [x] `cmd/server/main.go` + +**路由表:** + +``` +GET /health → 健康检查 +POST /api/v1/agent/register → Agent 注册 +POST /api/v1/agent/heartbeat → Agent 心跳(需认证) +GET /api/v1/agents → 在线 Agent 列表 + +POST /api/v1/mail/send → 发送邮件(需认证) +GET /api/v1/mail/inbox → 收件箱(需认证) +GET /api/v1/mail/:id → 邮件详情(需认证) +POST /api/v1/mail/:id/read → 标记已读(需认证) + +POST /api/v1/permission/request → Agent 请求权限(需认证) +POST /api/v1/permission/decide → 人类决策(无需认证) +GET /api/v1/permission/pending → 待决权限列表(无需认证) + +GET /api/v1/sessions → 会话列表 +GET /api/v1/sessions/:id → 会话详情 + 邮件 +GET /api/v1/sessions/:id/mails → 会话内邮件列表 +PUT /api/v1/sessions/:id/alias → 更新会话别名(人类显式命名) +POST /api/v1/sessions/:id/sync → Agent 回写平台侧生成的标题/slug(需 Agent 认证) + +POST /api/v1/admin/agent-keys → 签发/登记 Agent 接入密钥(管理员) +GET /api/v1/admin/agent-keys → Agent 密钥列表(只给 token_hint) +DELETE /api/v1/admin/agent-keys/{id} → 吊销 +POST /api/v1/admin/agent-keys/{id}/bind → 绑定到 Agent + +POST /api/v1/me/keys → 签发客户端连接密钥(用户自助) +GET /api/v1/me/keys → 我的密钥列表 +DELETE /api/v1/me/keys/{id} → 吊销 + +GET /api/v1/events/stream → SSE 端点 +GET /api/v1/events/status → SSE 连接状态 +``` + +**实现顺序:** +1. 健康检查 + 静态路由 +2. Agent 注册/心跳 +3. 邮件发送/收件箱 +4. 权限请求/决策 +5. 会话管理 +6. SSE 端点 + +### 1.7 Go 编译验证 + +- [x] `go build ./cmd/server` 编译通过 +- [x] 启动服务(默认内置 SQLite,无需外部依赖) +- [x] curl 手动测试每个 API 端点 + +### 1.8 数据库后端:SQLite 默认 + 外部库可选 + +**决策**:默认 SQLite,`DATABASE_URL` 非空时切外部 PostgreSQL。 +前端已 go:embed 进二进制,数据库再挂 Docker 就自相矛盾;部署产物应当是「一个二进制 + 一个 .db」。 + +- [x] `internal/db` 收拢方言差异,repo 层只写一份 SQL: + - 占位符:SQLite 也支持 `$1/$2`,无需改写 + - `NOW()` / `gen_random_uuid()`:SQLite 侧注册同名函数补齐 + - JSON 包含判断:`db.CCHas(col, argN)` —— PG 用 `jsonb_build_array`,SQLite 用 `json_each` + - 唯一冲突:`db.IsUniqueViolation` 同时识别 PG `23505` 与 SQLite `2067/1555` + - `JOIN LATERAL`:SQLite 无此语法,联系人聚合改用关联子查询 +- [x] 两份 schema:`migrations/init.sql`(PG)与 `migrations/init_sqlite.sql` +- [x] `DATABASE_URL` 识别 `postgres://`、`sqlite://`、`file:`、裸 `.db` 路径;空值 = 内置 SQLite +- [x] SQLite 连接开 `WAL` + `busy_timeout=5000` + `foreign_keys=ON`,连接池限 1(单写者) +- [x] 删除 `docker-compose.yml` 与重复的 `gateway/migrations/` + +### 1.9 部署:systemd 单元 + +- [x] `deploy/agentmail-gateway.service` — 含 `ProtectSystem=strict` 与 `ReadWritePaths=data/` +- [x] `deploy/opencode-serve.service` — 托管 opencode headless(mail-bridge 宿主) +- [x] `deploy/install.sh` — 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secret +- [x] `OPENCODE_SERVER_PASSWORD` 由安装脚本随机生成(此前裸奔,同机任何进程都能开会话) + +--- + +## Phase 2:Agent 邮件桥接插件 + +> 计划书原本按 Pi Agent 设计,**实际第一个接入的平台是 opencode**(`plugins/opencode-mail-bridge/`)。 +> 结构也从「按文件拆 tools/transport/hooks」收敛为单文件 `index.js` —— +> opencode 的插件契约是一个默认导出函数返回 hooks 对象,拆成多文件只会增加跳转成本而无收益。 + +### 2.1 插件结构(已落地) + +``` +plugins/opencode-mail-bridge/ +├── package.json +└── index.js # 凭证层 + HTTP + SSE + 四个工具 + event 钩子 +``` + +- [x] 单文件实现,无需 transport/tools 分层 +- [x] 凭证层:密钥优先级 `AGENTMAIL_AGENT_KEY` > `~/.agentmail/agent.key` > 旧 secret > 本地生成 + +### 2.2 HTTP 传输层(已落地) + +- [x] `apiGet` / `apiPost` 封装 `fetch`,认证头由 `authHeaders()` 统一给出 + (有密钥走 `Authorization: Bearer`,否则退回 `X-Agent-Name` + `X-Agent-Secret`) +- [x] 错误处理:非 2xx 抛出服务端 `error` 文案,调用方能看到「密钥无效」这类可操作信息 + +### 2.3 SSE 客户端(已落地) + +- [x] 用 `fetch` + `ReadableStream` 手工解析 SSE 帧(Node 原生 EventSource 不支持自定义请求头, + 而认证头是必需的) +- [x] 连接 `/api/v1/events/stream`;断流 3s、出错 5s 后重连;`AbortController` 支持优雅停止 +- [x] 事件分发:`new_mail` / `permission_decision` → `deliverMail()` + +### 2.4 工具实现(已落地,四个) + +- [x] `send_mail` — 参数 `to` / `subject` / `body` / `cc?` / `reply_to?` / `session_alias?`; + 地址解析在网关侧完成(三维寻址是网关的职责,插件不该各自实现一份解析) +- [x] `read_inbox` — 参数 `filter?`(unread/all)/ `limit?`;返回带预览的列表 +- [x] `request_permission` — 参数 `question` / `options?` / `context?` +- [x] `connect_to_server` — 参数 `gateway_url?` / `key_token?`;登记密钥并完成注册, + 失败时直接把待登记的密钥全文打出来,省一轮来回 + +**与原计划的偏差**:原设想「权限请求 / 提问 / 最终总结」三种行为由钩子自动拦截, +Agent 不需要手动调工具。opencode 的插件契约没有提供拦截这三类行为的钩子 +(`chat.message` 只能观察不能冻结会话),因此改为: +`request_permission` 作为显式工具暴露给 Agent;「提问」与「最终总结」由 Agent 自行用 +`send_mail` 表达。这不影响协作语义 —— 邮件本身就是提问与总结的载体。 + +### 2.5 插件入口(已落地) + +- [x] 启动时读凭证 → `POST /agent/register` → 启动 SSE → 30s 心跳定时器 +- [x] `new_mail` 事件:**不注入正文**,只给发件人/主题/mail_id 与「先调 read_inbox」的指示; + 按 `session_id` 查 `sessionMap` 决定续谈已有 opencode 会话还是新开一个 + (网关已按 session 位判好复用/新建,插件只忠实映射) +- [x] `permission_decision` 事件:把决策结论作为新一轮 prompt 投给对应会话 +- [x] `session.updated` 钩子:把 opencode 侧模型生成的会话标题与 slug 回写为 AgentMail 的会话命名 + +### 2.6 测试(已实测) + +- [x] 工具注册:四个工具出现在模型的工具列表中(用 `llmsproxy/AUTO` 实测) +- [x] 端到端收发:人 → Agent → 人 回信闭环,`reply_to` 使回信落回同一会话 +- [x] 会话记忆:第一封「记住 42」→ 第二封(省略 session 位)问「刚才的数字」→ 回「42」, + 证明续谈映射正确 +- [x] 密钥流程:首装本地生成(0600)→ 管理员登记 → 重启注册成功 → 收发邮件与 SSE 全通 +- [x] 权限闭环:`request_permission` → `/permission/pending` 可查 → 人类决策 → 插件收到并开会话 + +**排查记录(两个真实缺陷)**: +1. 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换 `llmsproxy/AUTO` 后正常。 + 顺带修了 provider 配置里 `baseURL` 缺 `//` 的拼写错误。 +2. 「发邮件没回复」是真 bug —— SSE 回调调的 `client.session.message.send(...)` 在 opencode SDK 里 + **不存在**(`session.message` 是读消息的 getter),Promise reject 又被空 `.catch(() => {})` 吞掉, + 于是邮件到了、SSE 收到了、却没建任何会话也没报错。 + 正确 API 是 `session.create` + `session.promptAsync`。**教训:跨 SDK 调用不要写空 catch。** + +--- + +## Phase 3:前端(React) + +> 实际实现比原计划**收敛**:组件拆分粒度更粗(MailItem/MailBody/ReplyEditor/PermissionCard +> 都并入了 MailView,因为它们只在这一处使用,独立文件只增加跳转成本), +> 且「新建邮件」按用户要求做成右侧整页而非弹窗。 + +### 3.1 项目初始化 + +- [x] `web/` 目录,Vite + React + TypeScript + TailwindCSS + +### 3.2 目录结构(已落地) + +``` +web/src/ +├── api/ client.ts(HTTP)/ sse.ts(EventSource + 指数退避重连) +├── stores/ mail / session / contact / ui / auth(Zustand) +├── components/ Sidebar / MailList / MailView / ComposePage / ContactPanel +│ AddressInput / LoginPage / SetupPage / AccountPage +│ AdminUsersPage / KeyPanel / icons +├── types/index.ts +└── App.tsx +``` + +### 3.3 类型定义 + +- [x] `types/index.ts` — Mail / Session / Contact / User / Agent / AdminScopes 等 + +### 3.4 API 客户端 + +- [x] `api/client.ts` — 统一 `request()`,`credentials: 'include'`,401 统一跳登录; + 覆盖邮件/会话/联系人/权限/用户管理/密钥全部端点 + +### 3.5 SSE + +- [x] `api/sse.ts` — 单例 EventSource + 多订阅者;断线指数退避 1s→15s 上限 + (没有单独做 `useSSE` hook:订阅者是 store 而非组件,hook 反而多一层) + +### 3.6 状态管理 + +- [x] `stores/mailStore.ts` / `sessionStore.ts` / `contactStore.ts` / `uiStore.ts` / `authStore.ts` + +### 3.7 核心组件(已落地) + +- [x] **App.tsx** — 三栏容器:60px 图标栏 / 320px 列表 / 右侧自适应(替代原计划的 Layout.tsx) +- [x] **Sidebar.tsx** — 图标栏(收件/发件/联系人 + 新建 + 账号/管理员入口 + 退出),**纯 SVG 无 emoji** +- [x] **MailList.tsx** — 邮件列表,未读蓝色粗体、权限橙色标签、CC 标记 +- [x] **MailView.tsx** — 邮件正文(react-markdown)+ 权限卡片 + 回复区,三者合一 +- [x] **ComposePage.tsx** — 右侧**整页**写信(非弹窗,用户明确要求),含编辑/预览切换、 + 会话别名输入(仅地址以 `.new` 结尾时出现) +- [x] **AddressInput.tsx** — 三段式补全,接 `/contacts/suggest`,支持方向键 +- [x] **ContactPanel.tsx** — 联系人(= 一条 `name@path.session` 三维地址)+ 归档 +- [x] **LoginPage / SetupPage / AccountPage / AdminUsersPage / KeyPanel** +- [x] **icons.tsx** — 20+ 纯 SVG 图标,全站零 emoji + +### 3.8 样式 + +- [x] TailwindCSS 配置 +- [x] 浅色主题(深色留待后续) +- [x] 列表 hover/selected 状态 +- [x] 权限请求卡片橙色高亮 +- [x] 未读蓝色粗体 +- [x] 加载/空状态 + +### 3.9 开发环境 + +- [x] Vite proxy:`/api` → `http://localhost:8180`(与 Gateway 默认 PORT 一致) +- [x] `npm run typecheck`(tsc --noEmit)与 `npm test`(Markdown XSS 回归) + +--- + +## Phase 4:多用户与鉴权(人类账号体系) + +> 背景:人类不是单一的 `human`,而是**多用户**,各自账号密码登录,拥有独立收件箱与会话。 +> 寻址上人类用户同样是三维地址的 name 位:`jianf@.new`、`alice@.deploy-review`。 + +### 4.1 数据模型 + +- [x] `users` 表 + +```sql +CREATE TABLE IF NOT EXISTS users ( + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, -- 即寻址的 name 位 + display_name VARCHAR(128) NOT NULL DEFAULT '', + password_hash VARCHAR(255) NOT NULL, -- bcrypt + role VARCHAR(16) NOT NULL DEFAULT 'user', -- admin / user + status VARCHAR(16) NOT NULL DEFAULT 'active', -- active / disabled + created_at TIMESTAMPTZ DEFAULT NOW(), + last_login TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + token VARCHAR(64) PRIMARY KEY, -- 随机 token,存 Cookie + user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + user_agent VARCHAR(256) DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at); +``` + +**关键决策**:`username` 与 `agents.agent_name` **共一个命名空间**(注册时互斥校验), +因为二者都出现在三维地址的 name 位,同名会导致路由歧义。 + +### 4.2 历史数据迁移 + +- [x] 现有邮件里的字面量 `'human'` 迁移到默认管理员账号 + - 创建 `admin` 用户(首次启动时从环境变量 `ADMIN_USER` / `ADMIN_PASSWORD` 读取) + - `UPDATE mails SET from_name = 'admin' WHERE from_name = 'human'`(to_name 同理) + - 保留 `human` 作为**兼容别名**:Agent 仍可 `send_mail to="human@"`,网关解析为“当前会话的发起人类用户” + +### 4.3 认证 API + +``` +POST /api/v1/auth/login { username, password } → Set-Cookie: am_session +POST /api/v1/auth/logout → 销毁 token +GET /api/v1/auth/me → { user_id, username, display_name, role } +POST /api/v1/auth/password { old_password, new_password } +``` + +管理员专用: + +``` +GET /api/v1/admin/users → 用户列表 +POST /api/v1/admin/users { username, password, display_name, role } +PUT /api/v1/admin/users/{id} { display_name, role, status } +DELETE /api/v1/admin/users/{id} → 禁用(不物理删除,保留邮件历史) +POST /api/v1/admin/users/{id}/reset { new_password } +``` + +- [x] 密码用 `golang.org/x/crypto/bcrypt`,cost 12 +- [x] token 用 `crypto/rand` 32 字节 hex,有效期 7 天,每次请求滞后续期 +- [x] Cookie:`HttpOnly` + `SameSite=Lax` + 生产环境 `Secure` +- [x] 登录失败限速:同一 username 连续 5 次失败锁 5 分钟(内存计数,MVP 阶段够用) + +### 4.4 中间件与会话隔离 + +- [x] `middleware.UserAuth` —— 从 Cookie 取 token → 查 `user_sessions` → 注入 `username` 到 context +- [x] `middleware.AdminOnly` —— 叠在 UserAuth 之后,校验 `role = 'admin'` +- [x] **所有 `/human/*` 路由改为 `/me/*` 并强制鉴权**,当前写死的 `"human"` 全部换成登录用户名: + - `HumanGetInbox` → `MeGetInbox`:`ListInbox(ctx, username, ...)` + - `HumanGetSent` → `MeGetSent`:`ListSentBy(ctx, username, ...)` + - `HumanSendMail` → `MeSendMail`:from_name = username + - `ListContacts`、`SuggestAddress`、`ArchiveContact` 同样按当前用户过滤 +- [x] 权限决策 `POST /permission/decide` 鉴权:只有**该会话的参与人类**或 admin 可决策 +- [x] SSE `/events/stream` 鉴权:前端连接按登录用户分流,不再广播给全部前端 + - `sse.Manager` 新增 `UserName` 字段,`SendToUser(username, ...)` 取代 `SendToFrontend` + +### 4.5 会话归属 + +- [x] `sessions` 表新增 `owner_user_id UUID REFERENCES users(user_id)` +- [x] 人类发起的会话归属于该用户;Agent 发起的会话归属于它寄信的人类 +- [x] `ListContacts` 只列当前用户参与的会话(owner 或 在 to/cc/from 中出现) +- [x] 归档鉴权:非 owner 且非 admin 不得归档别人的会话 + +### 4.6 Agent 侧寻址适配 + +- [x] Agent 给人类发信时可写具体用户名:`send_mail to="jianf@.new"` +- [x] 保留 `human@` 兼容写法 → 解析为当前会话的 owner 用户 +- [x] `SuggestAddress` 的 name 层候选同时包含 **在线 Agent + 已启用人类用户** + +### 4.7 前端 + +- [x] `LoginPage.tsx` —— 账号密码表单,失败提示,回车提交 +- [x] `authStore.ts` —— `me` / `login` / `logout`;应用启动先拉 `/auth/me` 判断登录态 +- [x] `App.tsx` —— 未登录渲染 `LoginPage`,已登录渲染主界面 +- [x] `api/client.ts` —— 所有请求带 `credentials: 'include'`;401 统一跳登录 +- [x] 侧边栏底部显示当前用户 + 退出按钮(纯 SVG 图标,不用 emoji) +- [x] 管理员页:用户列表 / 新建 / 禁用 / 重置密码 + +### 4.8 验证 + +- [x] 单测:bcrypt 校验、token 过期、跨用户访问被拒(403) +- [x] 两个人类账号互不可见对方收件箱与联系人 +- [x] Agent 寄信给 `jianf@.new` 仅 jianf 收到,SSE 不泄露给其他登录会话 + +--- + +## Phase 5:密钥认证体系(Agent-插件-Gateway 鉴权) + +> 背景:当前 Agent 注册只需 name+secret 明文,无密钥机制,无法安全接入公网。 +> 本 Phase 引入密钥(token)体系,分为两类:**Agent 密钥**(管理员为 Agent 生成,用于注册+通信) +> 和 **用户密钥**(用户自己生成,仅用于客户端连接 Gateway,不可注册 Agent)。 +> 插件侧首次安装时本地生成密钥对,通过 `connect_to_server` 工具完成握手。 + +### 5.1 数据模型(已落地) + +> 两方言各一份:`internal/db/migrations/init.sql` 与 `init_sqlite.sql`。 +> 下方 DDL 为 PostgreSQL 方言;SQLite 侧 `UUID→TEXT`、`TIMESTAMPTZ→DATETIME`。 + +#### agent_keys 表(Agent 注册密钥) + +```sql +CREATE TABLE IF NOT EXISTS agent_keys ( + key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_token VARCHAR(128) NOT NULL UNIQUE, -- 密钥令牌(随机 hex) + agent_name VARCHAR(64), -- 绑定到的 Agent 名(可为空=未绑定) + key_type VARCHAR(16) NOT NULL, -- permanent / one_time / timed + expires_at TIMESTAMPTZ, -- timed 类型的过期时间;permanent/one_time 为 NULL + used_at TIMESTAMPTZ, -- one_time 类型:首次使用时间(已用=失效) + created_by UUID REFERENCES users(user_id), -- 创建者(管理员) + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token); +CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name); +``` + +#### user_keys 表(用户连接密钥) + +```sql +CREATE TABLE IF NOT EXISTS user_keys ( + key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_token VARCHAR(128) NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label VARCHAR(128) NOT NULL DEFAULT '', -- 用户自定义标签(如 "我的笔记本" + key_type VARCHAR(16) NOT NULL DEFAULT 'permanent', -- permanent / one_time / timed + expires_at TIMESTAMPTZ, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token); +``` + +#### 关键约束 + +- `agent_keys.key_token` 与 `user_keys.key_token` 共享一个全局唯一命名空间(验证时先查 agent_keys,再查 user_keys) +- `user_keys` 的密钥 **不能** 用于 `POST /agent/register`(注册时校验来源表) +- `agent_keys` 的密钥 **不能** 用于 `/me/*` 人类邮箱接口(agent_auth 中间件与 user_auth 中间件分离) + +### 5.2 Gateway API + +#### 管理员:Agent 密钥管理 + +``` +POST /api/v1/admin/agent-keys { agent_name?, key_type, label?, expires_hours?, key_token? } +GET /api/v1/admin/agent-keys ?agent_name=xxx +DELETE /api/v1/admin/agent-keys/{id} +POST /api/v1/admin/agent-keys/{id}/bind { agent_name } -- 绑定到 Agent +``` + +`key_token` 用于**登记客户端已在本地生成的密钥**(插件首装场景):这样密钥全文只从客户端 +流向服务器一次,不必反方向传递。留空则由服务器生成(32 字节随机 hex)。 + +密钥类型: +- `permanent`:永不过期,可重复使用,适合正式部署的 Agent +- `one_time`:一次性使用,首次验证后标记 `used_at`,再用即 403 +- `timed`:创建时指定 `expires_hours`(如 24h),过期后失效 + +#### 用户:连接密钥管理 + +``` +POST /api/v1/me/keys { label, key_type, expires_hours? } +GET /api/v1/me/keys → 我的密钥列表(只给 token_hint) +DELETE /api/v1/me/keys/{id} → 删除密钥(带 user_id 条件,删不到即 404) +``` + +用户密钥的 `key_type` 同样支持 permanent / one_time / timed,但只能用于 `/me/*` 接口。 + +#### Agent 注册(改用密钥认证) + +``` +POST /api/v1/agent/register +Header: Authorization: Bearer +Body: { name, workspaces, platform } + +→ Gateway 验证密钥: + 1. 查 agent_keys WHERE key_token = token + 2. 不存在 → 401 + 3. one_time 类型且 used_at 非空 → 401(已用过) + 4. timed 类型且 expires_at < now() → 401(已过期) + 5. 验证通过 → 更新 used_at(one_time)→ 继续注册逻辑 +``` + +#### Agent 心跳(改用密钥认证) + +``` +POST /api/v1/agent/heartbeat +Header: Authorization: Bearer +``` + +#### SSE 连接(改用密钥认证) + +``` +GET /api/v1/events/stream +Header: Authorization: Bearer +→ Agent 侧 SSE:密钥验证后绑定到对应 Agent +→ 前端 SSE:仍用 Cookie 认证(不变) +``` + +### 5.3 插件侧改动 + +#### 首次安装:本地生成密钥 + +``` +~/.agentmail/ +├── config.json # { "gateway_url": "...", "key_token": "...", "agent_name": "..." } +└── agent.key # 本地密钥文件(JSON,含 key_token + created_at) +``` + +插件启动时: +1. 检查 `~/.agentmail/agent.key` 是否存在 +2. 不存在 → 生成随机 32 字节 hex 作为 `key_token`,写入文件(0600)并打印到 stderr +3. 存在 → 读取 key_token +4. 自动调 `/agent/register`;密钥未登记时提示管理员把打印出的密钥填进后台 + +#### connect_to_server 工具 + +```javascript +args: { + gateway_url: z.string().describe("Gateway 地址,如 http://mail.example.com:8080"), + key_token: z.string().optional().describe("密钥令牌(可选,也可从本地配置读取)") +} + +execute: { + 1. 读取本地 key_token(参数优先,否则从 ~/.agentmail/agent.key 读取) + 2. 调 POST /api/v1/agent/register(带 Authorization: Bearer key_token) + 3. 将 gateway_url 和 key_token 保存到 ~/.agentmail/config.json + 4. 启动 SSE 监听 + 5. 返回连接结果 +} +``` + +#### 心跳认证改动 + +心跳与所有 Agent 接口统一携带 `Authorization: Bearer `。 +旧的 `X-Agent-Name / X-Agent-Secret` **保留兼容**(见 5.7 最后一条)。 + +#### 密钥来源优先级(已落地) + +1. `AGENTMAIL_AGENT_KEY` 环境变量 —— systemd 部署走这条 +2. `~/.agentmail/agent.key`(`AGENTMAIL_CONFIG_DIR` 可改目录) +3. 都没有时退回 `AGENTMAIL_AGENT_SECRET` 的旧路径;连 secret 也没有才本地生成新密钥 + +文件权限:`agent.key` 与 `config.json` 均 0600,目录 0700。 + +### 5.4 密钥验证中间件(Gateway,已落地) + +`middleware.AgentAuth` 同时承担密钥与旧凭证两条路径: + +``` +有 Authorization: Bearer : + → repo.VerifyAgentKey:查 agent_keys + → 不存在 → 401 "密钥无效" + → one_time 已用 → 401 "密钥已使用(一次性密钥只能用一次)" + → timed 已过期 → 401 "密钥已过期" + → 通过:one_time 写 used_at(WHERE used_at IS NULL,并发下只有一个请求能标记成功) + → agent_name 为空(待绑定)→ 403,提示先调 /agent/register + → 注入 agent_name 到 context,刷新 last_seen + +无 Bearer:走 X-Agent-Name + X-Agent-Secret 旧路径 +``` + +`middleware.UserAuth` 同理支持 Cookie 与 `Bearer `。两类密钥各查自己的表, +因此 Agent 密钥读不了人类邮箱,用户密钥也注册不了 Agent。 + +`/agent/register` 不经中间件(注册时 Agent 还没身份),自行取 Bearer 校验: +密钥已绑定且与请求 name 不符 → 403,防止拿别人的密钥冒充新身份;未绑定则注册成功后落定。 + +### 5.5 管理员前端 + +- [x] 管理员页面拆成「用户管理 / Agent 密钥」两个 tab(`AdminUsersPage.tsx`) +- [x] 创建密钥:选类型(长期/一次性/限时)+ 可选绑定 Agent + 限时可填小时数 +- [x] **登记客户端已生成的密钥**:插件首装在本地生成密钥并打印,管理员把它填进「登记」框即可, + 密钥全文只从客户端流向服务器一次,不需要反方向传递 +- [x] 密钥列表:只显示 `token_hint`(前 8 位),全文仅在创建响应里出现一次, + 前端用一次性横幅提示「关闭后无法再次查看」 +- [x] 绑定 Agent / 吊销密钥 + +### 5.6 用户前端 + +- [x] 个人中心新增「客户端连接密钥」区(`AccountPage.tsx`,与管理员面板共用 `KeyPanel.tsx`) +- [x] 创建密钥:填备注 + 选类型;限时可填小时数 +- [x] 密钥列表 + 吊销;状态标注「可用 / 已使用 / 已过期」 + +### 5.7 验证(已实测) + +- [x] permanent 密钥 → 注册成功,重复使用仍成功 +- [x] one_time 密钥 → 首次成功;再用 401「密钥已使用(一次性密钥只能用一次)」 +- [x] timed 密钥(24h)→ 注册成功;手动把 `expires_at` 改到过去 → 401「密钥已过期」 +- [x] timed 缺 `expires_hours` → 400;非法 key_type → 400 +- [x] 用户密钥 → `/me/mail/send` 正常;→ `/agent/register` 401「密钥无效」 +- [x] Agent 密钥 → `/me/mail/inbox` 401(两类密钥各查自己的表,不会串门) +- [x] 待绑定密钥首次注册落定为该 Agent;同一密钥改注册别的 name → 403 +- [x] 登记重复密钥 → 409;登记过短密钥(<32 位)→ 400 +- [x] 密钥列表接口不回传 `key_token` 全文(实测 `回传全文: False`) +- [x] **SSE 鉴权加固**:原先只凭 `X-Agent-Name` 就分流,等于任何人报个名字就能读走别人的 + 新邮件通知。现在必须通过 Bearer 密钥或 name+secret 验证;仅报名字 / 错 secret / 匿名一律 401 +- [x] 插件密钥流程端到端:首装本地生成密钥(0600,目录 0700)并打印 → 管理员登记 → + 插件重启注册成功 → 用该密钥收发邮件与订阅 SSE 全通 +- [x] 旧的 X-Agent-Name/Secret 方式**保留兼容**(与原计划「向后不兼容」不同: + 已部署的 Agent 不该因为引入密钥就集体失联,两条路径并存成本很低) + +--- + +## Phase 6:联调 + 部署 + +### 6.1 端到端闭环测试 + +- [x] 启动 Gateway(`go run ./cmd/server`,SQLite 自动建库,无需外部数据库) +- [x] 启动前端(`npm run dev`) +- [x] 启动 opencode serve + mail-bridge 插件 + +**测试场景(均已在 systemd 部署态实测):** + +| # | 步骤 | 结果 | +|---|------|------| +| 1 | 人类登录后新建邮件,抄送第二个收件人 | session + mail 落库,cc_list 入库 ✅ | +| 2 | Agent 收到 SSE 通知 | 插件只注入通知(发件人/主题/mail_id),不注入正文 ✅ | +| 3 | Agent 调 read_inbox | 返回邮件详情,含被抄送的那封 ✅ | +| 4 | Agent 调 send_mail 回复人类 | 发起任务的用户收到,`reply_to` 使回信落回同一会话 ✅ | +| 5 | request_permission | 权限请求邮件入库,`/permission/pending` 可查 ✅ | +| 6 | 人类决策 | `/permission/decide` 生成决策邮件,Agent 侧收到 ✅ | +| 7 | 归档 name@path.session | 会话与邮件均标记 archived,归档后该别名不可寻址(404)✅ | + +**session 位三态实测:** 省略 → 复用默认会话;`.new` → 每次新建;具名别名 → 命中已有, +不存在则 404「无法送达」;跨收件人借用别名同样 404。 + +**平台命名同步实测:** 发信不指定别名 → opencode 侧 slug 回写为 `calm-meadow`, +模型生成的摘要标题「生产环境联调邮件回复」回写为 subject; +再用 `opencode@/root.calm-meadow` 续谈,Agent 正确引用上一封内容。 + +### 6.2 部署编排(改为 systemd,不用容器) + +原计划的 docker-compose 已作废:数据库换成内置 SQLite 后,部署产物就是「一个二进制 + 一个 .db」, +再套一层容器编排只是徒增运维层级。见 1.9。 + +- [x] `deploy/install.sh` 一键安装(构建 → 嵌入 → systemd) +- [x] `deploy/*.service` 两个单元:gateway 与 opencode-serve +- [x] 删除 `docker-compose.yml` + +### 6.3 一键启动脚本 + +- [x] `deploy/install.sh` 覆盖构建 + 安装 + 启动 +- [x] 开发态无需脚本:`go run ./cmd/server` 即起(SQLite 自动建库),前端 `npm run dev` 代理到 8180 + +### 6.4 错误处理与边界 + +- [x] Agent 离线时邮件投递:邮件先入库,Agent 心跳响应回传未读数,上线后 `read_inbox` 补齐 +- [x] Gateway 重启后 SSE 客户端重连:前端 `api/sse.ts` 指数退避(1s→15s 上限), + 插件侧 `startSSE` 断流 3s / 出错 5s 后重连 +- [x] 无效 Agent 名/密钥处理:错密钥与不存在的 Agent 统一回 `Invalid credentials`(不区分,避免探测账号存在性); + 缺头回 `Missing X-Agent-Name or X-Agent-Secret header` +- [x] 畸形三维地址:缺 name 位、空 to、别名不存在均有明确错误文案 +- [x] 邮件内容 XSS 防护:react-markdown 默认不解析 raw HTML 且清空非 http(s)/mailto 协议 URL, + 新增回归测试 `web/test/markdown-xss.test.mjs`(`npm test`)守住这两个前提, + 防止日后为「支持 HTML 邮件」加上 rehype-raw 而无声开口子 +- [x] 并发安全:`sse.Manager` 用 `sync.RWMutex`;`go build -race` 通过; + 实测 10 客户端并发连接(断开后计数归零)+ 20 封并发发信全部成功(SQLite WAL + busy_timeout) + +### 6.5 公网反代(已完成) + +- [x] `mail.jianfgit.xyz` → portal-nginx(.106:3080) → `.60:8180` +- [x] 列入 portal 认证豁免名单(应用自带账号体系,不叠 portal 登录) + +--- + +## Phase 7:进阶功能(MVP 后迭代) + +### 7.1 对话树(已完成) + +**不建 `tree_nodes` 表**(偏离原计划):`mails.parent_mail_id` 已经完整编码了树结构 —— +回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相, +两处不一致时无法判断谁对。直接用递归 CTE 在 mails 上查(`idx_mails_parent` 已有)。 + +- [x] `repo/thread.go`:`AncestorsRaw`(向上)/ `DescendantsRaw`(向下)两个方向的递归 CTE +- [x] `GET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N` +- [x] **树可跨会话**:转发把线索引到新会话,但 parent 仍指向原件。 + 这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里 +- [x] **按会话逐个鉴权**:A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。 + 被过滤的节点计入 `hidden`,鉴权结果按会话缓存(一条线索里同一会话通常有多封) +- [x] `detached` / `parent_hidden` 两个标记:前者表示父不在当前已加载集合里, + 后者区分「无权查看」(永久)与「尚未加载」(随上滑补齐)。 + 不能因为找不到父节点就把子节点悄悄丢掉 +- [x] 前端 `ThreadView.tsx`:缩进 + 连接线,不引图形库 + (邮件树又浅又窄,一条主链加几个转发分支,缩进足够表达层级, + 省掉渲染 SVG 的依赖与它带来的布局/缩放问题) + +**分块加载(用户要求:展示完整节点,首屏只加载当前屏幕,上滑逐步补齐)**: + +原先实现是深度上限 100 直接截断,超长线索看不到全貌。改为按方向分页: + +- [x] 首屏 `dir=around` 取锚点附近一块(limit 在两个方向各分一半); + `dir=up|down` + `offset` 增量加载,两端各自带 `has_more_*` 与 `next_*` +- [x] **游标用「相对锚点的层号/节点偏移」而不是 mail_id**: + 偏移量每次从锚点重走一遍,无状态、不可伪造; + 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去), + 那还得单独证明它确实是锚点的祖先,反而更绕。 + 祖先方向的层号天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间 +- [x] `depth` 改为**相对锚点**(0 = 锚点,负 = 祖先,正 = 子孙): + 分块加载时根可能还没取到,绝对深度无从得知 +- [x] repo 层**不做可见性过滤**:不可见的中间段必须能穿过(转发把线索引进别人的会话, + 再往上却可能仍是自己参与的往来)。过滤放在 handler 层,那里才知道调用者是谁 +- [x] 前端 `IntersectionObserver` 双哨兵(rootMargin 200px 提前触发)+ + **向上加载的滚动位置补偿**(顶部插入内容后按 scrollHeight 增量修正 scrollTop, + 否则视线会被内容顶走)+ `busy` ref 防两个哨兵同时触发并发请求 + (`setState` 异步,读 state 会双双看到 false) +- [x] 实测 251 封长链:7 页取完,depth 连续无缺口,取到真正的根; + 311 封(含 60 个转发分支)9 页取完,`depth=1` 恰为 61 个 +- [x] `limit` 夹到 [1,200],非法值回落默认 40(分页参数不该因笔误让整个请求失败); + `limit=1` 时两方向各保底 1,否则算出 `downLimit=0` 连锚点自己都不返回 + +剩余(与树视图独立): +- [ ] 工作列表卡片视图(中间栏) + +### 7.2 抄送(已完成)/ 转发 + +- [x] `cc_list JSONB` 字段加入 mails 表 +- [x] 发送时支持抄送多个三维地址 +- [x] 前端邮件详情显示抄送列表 +- [x] 收件箱检索覆盖被抄送邮件(`to_name = $1 OR cc_list @> ...`) +- [x] 转发功能(引用原文 + 新收件人 + 附件随行) + - `POST /mail/{id}/forward`(Agent)与 `POST /me/mail/{id}/forward`(人类) + - 与「回复」的区别:回复落回原会话,转发按目标地址另行寻址 —— 它是一条新线索 + - 只能转发自己参与过的邮件(发件/收件/被抄送之一),否则 403 + - 引用块逐行加 `> ` 前缀:原文含代码块或列表时,只有逐行前缀才保持引用语义 + - `Fwd:` 前缀不叠加;`parent_mail_id` 指向原邮件以便回溯 + - 附件一同带过去(内容寻址下只新增元数据,不拷磁盘文件) + +### 7.3 配额机制(已完成;7.9 进一步下沉到会话) + +**只限制主动发信,不限制收信** —— 卡住收信只会让邮件凭空消失,卡住发信才能阻止 Agent 无限自我循环。 + +- [x] `agents.max_rounds` / `used_rounds` 计数(`max_rounds = 0` 表示不限) +- [x] 配额用尽后 `send_mail` 与 `forward` 均返回 403,文案提示「先发最终总结或联系管理员重置」 +- [x] **判断与自增在同一条 UPDATE 里**(`WHERE used_rounds < max_rounds`): + 分成两步的话并发发信会双双通过检查再各自 +1,把配额刷穿。单测覆盖此场景 +- [x] 剩余次数随发信响应(`quota_remaining`)与心跳(`quota`)回传, + 插件把它写进工具返回值与日志,让 Agent 在耗尽前主动发总结 +- [x] 管理员 API:`GET /admin/quotas`、`PUT /admin/quotas/{name}`(设上限或归零) +- [x] 前端管理员页新增「发信配额」tab(`QuotaPanel.tsx`,进度条 + 就地编辑 + 重置) + +### 7.4 会话别名动态更新(已完成) + +会话别名的**基础能力已在 Phase 1 落地**(见「三维寻址 session 位三态」): +- [x] 发信时用 `session_alias` 为 `.new` 新建的会话命名,响应回传 `session_alias` +- [x] `PUT /sessions/:id/alias` 事后改名,唯一性冲突返回 409 +- [x] 别名全局唯一(`idx_sessions_alias_uniq` 部分唯一索引,NULL 不受约束) +- [x] 保留字与字符校验:不可为 `new`,不可含 `.` `/` `@` 与空白(否则地址切分歧义) +- [x] 前端 ComposePage 在地址以 `.new` 结尾时才显示「会话别名」输入框 + +**别名/标题的默认来源 = Agent 平台自己的命名机制**(不在本侧另造一套): +- [x] `POST /sessions/:id/sync`(Agent 认证)接收平台侧 `alias` + `title` +- [x] opencode 插件建会话时**不传 title**,让平台按首轮对话由模型生成摘要标题; + 创建即拿到的 slug(如 `witty-planet`)立即回写为寻址别名,标题随 `session.updated` 事件回写 +- [x] 平台 slug 不保证全局唯一,本侧别名必须唯一 → `SyncSessionAlias` 撞名自动追加 `-2`/`-3`,同步永不失败 +- [x] `normalizeAlias` 把平台命名改写为合法寻址别名(非法字符换 `-`、压缩连续 `-`、避开保留字 `new`、按 UTF-8 边界截断 128 字节) +- [x] 手工命名(发信 `session_alias` / `PUT alias`)仍可覆盖平台命名,属显式优先 + +**由 Agent 在邮件正文里主动提议改名**(已完成,与上面的自动同步互补): + +两者分工:自动同步 = 平台起的名字,后台静默生效不打扰人; +正文提议 = Agent 干完活觉得该换个更贴切的名字,需要人点头。 + +**为什么要人点头而不是让 Agent 直接调 `PUT alias`**: +别名是**人**的寻址入口(`name@path.别名`)。Agent 干到一半自己改掉, +人上一秒记住的地址下一秒就失效。提议 + 人确认,既让 Agent 表达意图, +又保证寻址稳定性由人掌握。 + +- [x] 载体是 HTML 注释 `` + - react-markdown 默认不解析 raw HTML,注释在页面上不可见 + - 纯文本客户端里是一行不碍事的注释,不像自造标记那样显眼 + - 不与 Markdown 语法冲突,不会被格式化工具改写 + - **实测它会被转义成可见文本节点而不是被丢弃**,所以必须从入库正文里主动剥掉 +- [x] `handler/rename_proposal.go`:`extractRenameProposal` 解析 + 剥标记 + - 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是结论 + - 别名过 `normalizeAlias` + `validateSessionAlias`,非法的视为无提议但标记仍剥掉 + (与其在正文里留一行乱码,不如当它没提) + - 理由截断到 200 字节(按 UTF-8 边界),否则提示条被撑破 + - 人类发信同样剥标记但不产生提议 —— 人有改名按钮,用不着向自己提议 +- [x] 存在**邮件**上(`mails.rename_alias/rename_reason`)而非会话上: + 邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕 +- [x] `sessions.rename_dismissed` 记下被驳回的建议,提示条不再反复弹同一个 +- [x] `GET /sessions/:id/rename-proposal` 取最新**未处理**提议 + (既不是当前别名 = 未接受,也不在驳回记录里) +- [x] `POST /sessions/:id/rename-proposal/dismiss` 驳回;无待处理时返回 `no_pending`(幂等) +- [x] **接受走已有的 `PUT /sessions/:id/alias`**,不另开端点: + 那条路径已有唯一性校验与 409,复制一遍只会多一个出错的地方。 + 实测提议的名字被别的会话占用时接受返回 409,而不是默默造出重名 +- [x] 插件 `send_mail` 加 `propose_alias` / `propose_reason` 参数,自动拼注释标记; + 发信响应回传**规范化后**的别名(Agent 提的名字可能被改写过) +- [x] 前端 `RenameProposalBar`(会话顶部):显示 旧别名 → 新别名 + 理由 + + 「改名后旧别名立即失效」的后果提示;接受/忽略两个按钮。 + `new_mail` 事件触发重新拉取(新来信可能带新建议) +- [x] **SQLite 老库补列**:`CREATE TABLE IF NOT EXISTS` 不会给已存在的表加列, + 而 SQLite 没有 `ADD COLUMN IF NOT EXISTS`。`db.addMissingColumns` 查 + `pragma_table_info` 后按需 ALTER,实测老库补列成功、旧数据不丢、二次启动不重复补 + +**`sessions.alias_source` —— 生产实测发现的隐患**: + +用户接受改名后,opencode 下一次 `session.updated` 事件会带着平台 slug 再同步一次, +把人刚定的名字冲掉 —— 人上一秒记住的寻址地址下一秒失效。实测确认了这个行为 +(`fix-cache-penetration` 被 `stellar-engine` 覆盖)。 + +- [x] `sessions.alias_source`:`platform`(平台自动同步,可被后续同步覆盖)/ + `manual`(人显式指定,平台同步不得覆盖) +- [x] `UpdateSessionAlias` 与「发信时显式给 `session_alias`」都标 `manual` +- [x] `SyncSessionAlias` 遇到 `manual` 直接返回当前别名,不写入; + **条件放进 `WHERE alias_source <> 'manual'` 并检查 RowsAffected** —— + 并发下用户可能刚好在检查与写入之间接受了提议,分两步会把它冲掉 +- [x] **标题不受此保护**:`subject` 只用于展示,被平台的摘要标题刷新是好事; + 受保护的只有承担寻址职责的别名 +- [x] 老库补列默认 `platform`:无从得知历史别名是人定的还是平台定的, + 而 `platform` 只影响「平台同步能否覆盖」,人随时可手工改名转成 `manual` +- [x] 四个场景实测:platform 可反复覆盖 / 用户改名后平台同步无效 / + 发信显式命名即受保护 / 全流程(平台命名 → Agent 提议 → 用户接受 → 平台再同步不覆盖) + +### 7.5 附件(已完成) + +**内容存磁盘、元数据入库**:附件是「写一次读多次」的冷数据,塞进 SQLite 的 BLOB 只会让 +`.db` 膨胀、WAL 变大、备份变慢,换不来任何好处。 + +- [x] `attachments` 表(两方言各一份);`mail_id` 允许为 NULL 表示「已上传未挂载」 +- [x] `internal/blob`:内容寻址存储,路径由 sha256 派生(`ab/cd/abcd…`) + - 相同内容天然去重,重复上传不占额外空间 + - 路径与用户给的 filename 完全无关,杜绝 `../` 穿越 + - 先写临时文件再按内容哈希 rename:中途崩溃不会留下「哈希对不上内容」的文件 + - 超限即中止并清理临时文件;恰好等于上限放行(边界不误杀) +- [x] **上传与发信两步**:`POST /attachments` 拿 id → 发信时放进 `attachment_ids`。 + Agent 侧工具走 JSON 无法带 multipart,人类侧也需要「写正文前先传文件」 +- [x] 挂载校验:`WHERE mail_id IS NULL AND uploader = ?` 一条 UPDATE 完成判断与写入, + 避免并发下把同一附件挂到两封邮件上;不属于自己 403,已挂载 409 +- [x] 下载鉴权:已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看 +- [x] **下载一律 `octet-stream` + `attachment` + `nosniff`**:绝不按声明的 MIME 内联渲染, + 否则上传一个 `.html`/`.svg` 就能在本站域下执行脚本 +- [x] `Content-Disposition` 双写:`filename*` 承载 UTF-8,`filename=` 兜底且转义引号与控制字符 +- [x] 删除:只允许删自己上传且未挂载的;内容被其他记录共享时不删磁盘文件 +- [x] GC:启动时 + 每小时扫一遍,清理超过 24h 未挂载的记录与无引用文件 +- [x] 单个上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`); + 双层限制:`MaxBytesReader` 卡整个请求体,`blob.Put` 卡单文件内容 +- [x] 插件工具 `upload_attachment` / `download_attachment`;`read_inbox` 列出附件清单含 id +- [x] 前端 `Attachments.tsx`:写信/回复的选择器(XHR 上传进度)+ 邮件详情的下载清单 + + 列表页附件徽标 + +### 7.6 WebAPI 化(已完成) + +**WebUI 调用的就是公开 API,没有仅前端可用的私有通道** —— 第三方客户端拿一把用户密钥 +即可获得与网页完全相同的能力。 + +- [x] 实测前端用到的每个端点都能用 `Authorization: Bearer ` 调通 + (读、写、上传、下载、转发、管理员接口、SSE) +- [x] 两处例外补齐 `?access_token=`:SSE(`EventSource` 不能带自定义头)与 + 附件下载(`` 由浏览器直接发起)。 + **只有这两个端点接受 query 令牌**,其余一律 401 —— URL 里的令牌会进访问日志与 Referer; + 附件下载因此单独挂在 `UserAuthAllowQueryToken` 中间件下 +- [x] CORS 暴露 `Content-Disposition` 与 `Content-Length`(前者是取文件名所必需) +- [x] `web/src/api/config.ts` 集中基地址与令牌: + `VITE_API_BASE` 构建期注入、`window.__AGENTMAIL_API_BASE__` 运行时覆盖、`setToken()` 切凭证。 + 业务代码不感知 Cookie 与密钥的差异,`src/api/` 可整体抽成 SDK +- [x] `docs/API.md`:完整接口清单、三类调用者的认证边界、错误码约定 + +### 7.7 DeepSeek Harness 插件 + +- [ ] 基于 Cordis 框架开发 `dsh-mail-bridge` +- [ ] 利用 `PreToolUse`、`SessionStart` 钩子 +- [ ] 与 Pi 插件共享相同 Gateway API + +### 7.8 跨主机 Agent 发现 + +- [ ] Gateway + Registry 拆分为独立服务 +- [ ] etcd / Consul 服务注册 +- [ ] Agent 跨主机路由 + +### 7.9 插件自动转发 + 会话往返预算(已完成) + +用户提出的三条原则,逐条落地: + +**原则一:插件应当自动转发 Agent 平台原生的问询与权限请求,而不是让模型自己调工具。** + +原实现有个 `request_permission` 工具让模型主动调 —— 这是把 harness 的职责推给模型: +它可能忘了调,也可能在不需要时乱调,而**真正被 opencode 拦下的那次询问反而没人看见**。 + +- [x] 删掉 `request_permission` 工具,改用 `permission.ask` 钩子接管 +- [x] 钩子里只记下待决策项并转出邮件,`output.status` 保持 `"ask"` —— + 不在钩子里阻塞等人回复:那是同步钩子,卡住会把整个 opencode 请求挂死 +- [x] opencode 的三态权限映射成人话:同意 = `once`、一直同意 = `always`、拒绝 = `reject` +- [x] 人类决策后走 SSE 回来,用 `client.postSessionIdPermissionsPermissionId` 回复原生 permission, + 让 opencode 自己恢复原来的工具调用 —— 不再往会话里塞「你的请求已批准」的文字干扰它 +- [x] 转发失败时不接管(保持 `ask`),让本地 TUI 弹窗兜底,而不是让 Agent 干等 +- [x] `permission.replied` 事件清理待决策记录,避免邮件决策回来又去回复一条已结案的 permission + +**原则二:插件应当自动转发 Agent 最后一条总结性消息,且不消耗配额。** + +- [x] 触发点选 `session.idle`(一轮跑完)而不是 `message.updated`: + 后者在流式生成中反复触发,转出去是半截话 +- [x] 只取 `time.completed` 非空的 assistant 消息:未完成/被中断的不转 +- [x] `mailContexts` 记住该会话最近一封来信的发件人与 mail_id, + 总结回给它并带 `reply_to`,回信才落回同一线索 +- [x] 提示语相应改写:告诉模型「回信不用你自己发,把话说完就行」, + 只在需要主动联系他人或带附件时才调 `send_mail` + +**原则三(基本原则):插件自动转发的邮件不消耗配额。** + +配额存在的意义是防止 Agent 无限自我循环。harness 代劳的搬运不属于此列 —— +对它收费会导致配额用尽时 Agent 连交代都做不了,而那正是最需要它说话的时刻。 + +- [x] `mails.relay` + `relay_key` 参数;`relayKinds` 白名单只有 `permission` / `summary` + (不是任意字符串,否则 `relay:"anything"` 就是绕过配额的后门) +- [x] **防滥用不靠计数,靠幂等键**:`relayed_mails(agent_name, relay_key)` 主键唯一。 + relay_key 是上游那条消息的稳定 id(permission id / assistant message id), + 由平台生成、模型伪造不出来。于是插件重试与 SSE 重放不产生第二封, + 想多转就得拿出不同的上游消息 id +- [x] 判断与占用在同一条 INSERT 里(靠唯一约束):分成「先查再插」两步的话, + 插件的两次重试会双双通过检查各插一条 +- [x] 建邮件失败时 `ReleaseRelay` 归还名额,否则那条上游消息永远转不出来了 +- [x] 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 —— + 重复是插件重试的正常结果,不是故障 +- [x] 响应带 `quota_charged: false`,免得插件看到额度没变以为数据错了 +- [x] `permission_decision` 事件回传 `relay_key` + `relay_kind`: + 两边 id 空间不同,插件要拿上游 id 才能回复 opencode; + **这个映射必须服务端持久化**,插件重启后内存映射就没了 + +**配额下沉到会话(用户:配额应当在新建邮件、以及邮件对话页面是可编辑的)** + +配额的真实语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。 +只有 `agents.max_rounds` 一个全局计数器时有两个问题:并行任务互相抢额度; +`used_rounds` 单调递增,跑满就得管理员手工重置才能再干活。 + +- [x] `sessions.max_rounds` / `used_rounds`(0 = 本会话不限) +- [x] 写信时给:`POST /me/mail/send` 的 `max_rounds`,仅新建会话时生效 —— + 续谈也接受的话,每封新信都会悄悄改掉对方正在遵守的预算 +- [x] 对话页里改:`GET/PUT /sessions/{id}/budget`,`max_rounds` 与 `reset` 可同时给 + (「加到 20 并从头算」是一次很自然的操作,拆两个请求只多一次往返) +- [x] **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` + 开一串会话每条都是全新预算,全局上限形同虚设 +- [x] 会话预算先扣、全局配额后扣;全局拦下时 `RefundSessionBudget` 退回 —— + 那次往返实际上没有发生,不能白掉一格 +- [x] 判断与自增在同一条 UPDATE(`WHERE used_rounds < max_rounds`), + 40 并发 vs 上限 10 的单测覆盖,`-race` 通过 +- [x] 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图 +- [x] 前端:ComposePage 的「往返预算」输入框 + MailView 会话头部的 `BudgetEditor` + (点徽标就地编辑,可改上限/重置/取消);预算变更广播 `session_update` +- [x] 老库补列默认 0(不限):引入预算不该把已在进行的会话卡死 + +**验证**:单测 `relay_test.go`(10 个用例,含「免配额类型恰好两种」的防扩散断言)+ +`budget_test.go`(6 个,含 40 并发不刷穿)。端到端 8 组: +自主发信扣额 → 用尽后 relay 照样发出且不扣 → 同 key 幂等 → 换 key 可再转 → +参数校验 4 项 → 权限询问幂等 + 决策回传 relay_key → 对话页调预算 → +两层独立且全局拦下时会话退回 → 老库补列不丢数据。 + +--- + +## 里程碑时间线 + +| 时间 | 里程碑 | 验收标准 | +|------|--------|----------| +| Week 1 末 | 后端核心完成 | Gateway 启动 + 所有 API curl 可通 | +| Week 2 末 | 插件完成 | opencode 可收发邮件 + 钩子自动转邮件 | +| Week 3 末 | 前端完成 | 三栏 UI + 三段式补全 + SSE 实时更新 | +| Week 4 末 | 多用户完成 | 两个人类账号互不可见对方邮件,登录鉴权生效 | +| Week 5 末 | 密钥体系完成 | Agent 用密钥注册、用户密钥与 Agent 密钥隔离 | +| Week 6 末 | MVP 闭环 | 人→Agent→人 完整工作流可演示 | +| Week 7+ | 进阶功能 | 对话树 / 转发 / 配额 / DSH 插件 | + +**实际状态**:Phase 1-6 全部完成,已 systemd 部署并端到端验证(含密钥认证、 +session 三态语义、平台命名同步、权限闭环、并发与鉴权边界)。剩余仅 Phase 7。 + +--- + +## 当前进度 + +### 已完成 + +**后端(Go)** +- [x] 项目结构与 MVP 技术规格书 +- [x] schema 两方言各一份(users / user_sessions / agents / sessions / mails / permission_requests) +- [x] 数据模型 + 连接池 + 内嵌迁移器 +- [x] Repository 层(含联系人聚合、归档、三段式补全查询) +- [x] 三维寻址 `name@path.session` 解析器 + 单测(`internal/models/address.go`) +- [x] **session 位三态语义**:省略 → 默认会话(复用最近活跃,无则建);`new` → 强制新建; + 具体别名 → 必须已存在且该收件人参与过,否则 404「无法送达」(不静默新建) +- [x] **会话别名**:`.new` 发信时可传 `session_alias` 命名,`PUT /sessions/:id/alias` 事后改名; + 全局唯一(部分唯一索引),保留字 `new` 与 `.` `/` `@` 空白一律拒绝 +- [x] **别名默认来自 Agent 平台自己的命名机制**:`POST /sessions/:id/sync` 接收平台 slug + 模型生成的摘要标题; + 插件不传占位 title,让平台正常生成;撞名自动追加 -2/-3,同步永不失败 +- [x] 抄送 `cc_list JSONB` + GIN 索引 + 收件箱抄送可见 +- [x] SSE 管理器(按 agent 分流推送 + 心跳保活) +- [x] Agent 认证中间件 + 注册/心跳 +- [x] 邮件收发 / 权限请求与决策 / 会话 / 联系人 / 归档 API +- [x] 静态资源 go:embed,单二进制内含前端 +- [x] 密钥认证体系(agent_keys / user_keys)+ 三种生命周期 + SSE 鉴权加固 +- [x] SQLite 默认后端 + DATABASE_URL 切外部 PostgreSQL,方言差异收在 internal/db +- [x] systemd 部署(deploy/install.sh),opencode serve 加访问密码 + +**前端(React)** +- [x] 三栏布局:60px 图标栏 / 320px 列表 / 右侧主区 +- [x] 新建邮件为右侧**整页**(非弹窗) +- [x] 三段式地址补全(接 `/contacts/suggest`,支持方向键) +- [x] 联系人面板 + 归档(二次确认) +- [x] 邮件详情 / 会话线程 / 权限卡片 / 回复栏 +- [x] 全站纯 SVG 图标,**不使用 emoji** +- [x] SSE 自动重连(指数退避)+ `session_archived` 即时移除 + +**部署** +- [x] 公网 `mail.jianfgit.xyz` 可访问(portal-nginx → `.60:8180`) + +### 下一步 + +MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。剩余工作都在 **Phase 7 进阶功能**: + +- [x] 对话树(不建 tree_nodes 表,用 `parent_mail_id` 递归 CTE + 按方向分块加载) +- [x] 转发(引用原文 + 附件随行;抄送早已完成) +- [x] 配额机制(只限发信不限收信,判断与自增在同一条 UPDATE) +- [x] 附件(内容寻址磁盘存储,上传与发信两步) +- [x] WebAPI 化(WebUI 与第三方客户端同一套 API) +- [x] Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成) +- [x] 插件自动转发平台原生权限询问与最终总结(不消耗配额) +- [x] 配额下沉到会话:写信时给、对话页里随时改 +- [ ] 工作列表卡片视图(中间栏) +- [ ] DeepSeek Harness 插件(`dsh-mail-bridge`) +- [ ] 跨主机 Agent 发现(Gateway + Registry 拆分) + +已知取舍,尚未处理: + +- 前端只有 Markdown XSS 一个回归测试,没有组件级测试 +- 深色主题未做 +- SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用, + 百万级邮件时需要加物化列或换回 PostgreSQL +- 登录限速是进程内内存计数,多实例部署时失效(MVP 单实例,暂不需要) + +--- + +## 文件清单(完整) + +``` +agentmail/ +├── docs/ +│ ├── MVP-SPEC.md # MVP 技术规格书 +│ └── PLAN.md # 本文件 +├── gateway/ +│ ├── cmd/server/main.go # 入口 + 路由编排 +│ ├── internal/ +│ │ ├── config/config.go +│ │ ├── db/ +│ │ │ ├── db.go # 连接 + 方言适配(SQLite / PostgreSQL) +│ │ │ ├── migrate.go +│ │ │ └── migrations/ +│ │ │ ├── init.sql # PostgreSQL schema +│ │ │ └── init_sqlite.sql # SQLite schema(默认) +│ │ ├── models/ +│ │ │ ├── models.go +│ │ │ ├── address.go # name@path.session 解析 +│ │ │ └── address_test.go +│ │ ├── repo/ +│ │ │ ├── repo.go +│ │ │ ├── users.go +│ │ │ └── keys.go # agent_keys / user_keys +│ │ ├── handler/ +│ │ │ ├── helpers.go # 错误映射 + 别名规范化 +│ │ │ ├── alias_test.go +│ │ │ ├── agents.go +│ │ │ ├── mail.go +│ │ │ ├── me.go # 人类自己的邮箱(原 human.go) +│ │ │ ├── contacts.go +│ │ │ ├── permission.go +│ │ │ ├── sessions.go # 含 POST /sessions/:id/sync +│ │ │ ├── events.go # SSE(鉴权后分流) +│ │ │ ├── keys.go # 密钥管理 +│ │ │ ├── ratelimit.go # 登录失败限速 +│ │ │ └── auth.go +│ │ ├── sse/manager.go +│ │ ├── static/static.go # go:embed 前端产物 +│ │ └── middleware/ +│ │ ├── auth.go # Agent 鉴权 +│ │ └── user.go # 人类用户鉴权 +│ └── go.mod / go.sum +├── plugins/ +│ └── opencode-mail-bridge/ # 第一个接入平台 +│ ├── package.json +│ └── index.js # 凭证 + HTTP + SSE + 四个工具 + event 钩子 +├── web/ +│ ├── src/ +│ │ ├── api/ +│ │ │ ├── client.ts +│ │ │ └── sse.ts +│ │ ├── stores/ +│ │ │ ├── mailStore.ts +│ │ │ ├── sessionStore.ts +│ │ │ ├── contactStore.ts +│ │ │ ├── uiStore.ts +│ │ │ └── authStore.ts +│ │ ├── components/ +│ │ │ ├── icons.tsx # 纯 SVG,无 emoji +│ │ │ ├── Sidebar.tsx +│ │ │ ├── MailList.tsx +│ │ │ ├── ContactPanel.tsx +│ │ │ ├── MailView.tsx +│ │ │ ├── ComposePage.tsx # 右侧整页写信 +│ │ │ ├── AddressInput.tsx # 三段式补全 +│ │ │ ├── LoginPage.tsx +│ │ │ ├── SetupPage.tsx # 首启初始化向导 +│ │ │ ├── AccountPage.tsx # 个人中心 + 连接密钥 +│ │ │ ├── AdminUsersPage.tsx # 用户管理 / Agent 密钥 +│ │ │ └── KeyPanel.tsx # 两处共用的密钥面板 +│ │ ├── types/index.ts +│ │ ├── App.tsx +│ │ └── main.tsx +│ ├── test/markdown-xss.test.mjs # XSS 回归(npm test) +│ ├── index.html +│ ├── package.json +│ └── vite.config.ts +├── deploy/ +│ ├── agentmail-gateway.service +│ ├── opencode-serve.service +│ └── install.sh # 构建 + 嵌入 + systemd 一键装 +└── README.md +``` diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go new file mode 100644 index 0000000..1072b8a --- /dev/null +++ b/gateway/cmd/server/main.go @@ -0,0 +1,306 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/agentmail/gateway/internal/blob" + "github.com/agentmail/gateway/internal/config" + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/handler" + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/static" + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func main() { + cfg := config.Load() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := db.Connect(ctx, cfg.DatabaseURL); err != nil { + log.Fatalf("Database connection failed: %v", err) + } + defer db.Close() + + // 第一轮迁移:建表(users 必须先存在,'human' 数据迁移才能找到管理员) + if err := db.Migrate(ctx); err != nil { + log.Fatalf("Migration failed: %v", err) + } + + // 确保存在默认管理员 + bootstrapAdmin(ctx, cfg) + + // 第二轮迁移:此时管理员已存在,历史 'human' 字面量得以重写 + // (仅 PostgreSQL 有该历史包袹;SQLite 是新后端,这一轮是幂等的建表重跑) + if err := db.Migrate(ctx); err != nil { + log.Fatalf("Post-admin migration failed: %v", err) + } + + // 附件存储:内容存盘,数据库只存元数据 + blobs, err := blob.New(cfg.AttachmentDir) + if err != nil { + log.Fatalf("Attachment store failed: %v", err) + } + handler.Blobs = blobs + fmt.Printf("附件存储:%s(单个上限 %.0f MB)\n", + blobs.Root(), float64(cfg.MaxAttachmentBytes)/(1<<20)) + + // 后台 GC:清掉上传后未随邮件发出的孤立附件, + // 否则取消发信与 Agent 崩溃留下的文件会让磁盘单调增长。 + go sweepOrphanAttachments(blobs) + + r := chi.NewRouter() + + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + r.Use(chimw.RequestID) + r.Use(chimw.RealIP) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: cfg.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Agent-Name", "X-Agent-Secret"}, + // 附件下载靠 Content-Disposition 拿文件名;不暂存就拿不到。 + // Content-Length 给进度条用。 + ExposedHeaders: []string{"Link", "Content-Disposition", "Content-Length"}, + AllowCredentials: true, + MaxAge: 300, + })) + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + r.Route("/api/v1", func(r chi.Router) { + // ---- 首次初始化(公开;仅系统无用户时可用) ---- + r.Get("/setup/status", handler.SetupStatus) + r.Post("/setup/admin", handler.SetupAdmin) + + // ---- 认证(公开) ---- + r.Post("/auth/login", handler.Login) + r.Post("/auth/logout", handler.Logout) + + // ---- Agent 注册(凭 secret,非人类登录态) ---- + r.Post("/agent/register", handler.RegisterAgent) + + // ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ---- + r.Group(func(r chi.Router) { + r.Use(middleware.AgentAuth) + r.Post("/agent/heartbeat", handler.HeartbeatAgent) + r.Post("/mail/send", handler.SendMail) + r.Get("/mail/inbox", handler.GetInbox) + r.Post("/mail/{id}/forward", handler.ForwardMail) + r.Post("/permission/request", handler.RequestPermission) + // 附件:先上传拿 id,再在发信时放进 attachment_ids + r.Post("/attachments", handler.UploadAttachment) + r.Get("/attachments/{id}", handler.DownloadAttachment) + // 平台侧会话标题/slug 回写本侧(平台叫什么,本侧就叫什么) + r.Post("/sessions/{id}/sync", handler.SyncSession) + }) + + // ---- 人类登录态 ---- + r.Group(func(r chi.Router) { + r.Use(middleware.UserAuth) + + r.Get("/auth/me", handler.Me) + r.Post("/auth/password", handler.ChangePassword) + + // 自己的邮箱 + r.Post("/me/mail/send", handler.MeSendMail) + r.Get("/me/mail/inbox", handler.MeGetInbox) + r.Get("/me/mail/sent", handler.MeGetSent) + r.Get("/me/sessions", handler.MeGetSessions) + r.Post("/me/mail/{id}/forward", handler.MeForwardMail) + + // 附件 + r.Post("/me/attachments", handler.MeUploadAttachment) + r.Delete("/me/attachments/{id}", handler.MeDeleteAttachment) + + // 自己的客户端连接密钥(仅能用于 /me/* 与会话级接口,不可注册 Agent) + r.Post("/me/keys", handler.CreateMyKey) + r.Get("/me/keys", handler.ListMyKeys) + r.Delete("/me/keys/{id}", handler.DeleteMyKey) + + // 邮件/会话(带会话级鉴权) + r.Get("/mail/{id}", handler.GetMail) + r.Get("/mail/{id}/thread", handler.GetMailThread) + r.Post("/mail/{id}/read", handler.MarkMailRead) + r.Get("/sessions/{id}", handler.GetSession) + r.Get("/sessions/{id}/mails", handler.GetSessionMails) + r.Put("/sessions/{id}/alias", handler.UpdateSessionAlias) + // 本任务的往返预算:在对话页里随时可改 + r.Get("/sessions/{id}/budget", handler.GetSessionBudgetHandler) + r.Put("/sessions/{id}/budget", handler.UpdateSessionBudget) + // Agent 在正文里提的改名建议:读取与驳回(接受走上面的 PUT alias) + r.Get("/sessions/{id}/rename-proposal", handler.GetRenameProposal) + r.Post("/sessions/{id}/rename-proposal/dismiss", handler.DismissRenameProposal) + + // 联系人 name@path.session + r.Get("/contacts", handler.ListContacts) + r.Get("/contacts/suggest", handler.SuggestAddress) + r.Post("/contacts/archive", handler.ArchiveContact) + + // 权限决策 + r.Post("/permission/decide", handler.DecidePermission) + r.Get("/permission/pending", handler.ListPendingPermissions) + + // 在线 Agent 列表(补全用) + r.Get("/agents", handler.ListAgents) + + // 管理员 + r.Group(func(r chi.Router) { + r.Use(middleware.AdminOnly) + r.Get("/admin/users", handler.AdminListUsers) + r.Post("/admin/users", handler.AdminCreateUser) + r.Put("/admin/users/{id}", handler.AdminUpdateUser) + r.Delete("/admin/users/{id}", handler.AdminDisableUser) + r.Post("/admin/users/{id}/reset", handler.AdminResetPassword) + r.Get("/admin/scopes", handler.AdminListScopes) + + // Agent 接入密钥 + r.Post("/admin/agent-keys", handler.CreateAgentKey) + r.Get("/admin/agent-keys", handler.ListAgentKeys) + r.Delete("/admin/agent-keys/{id}", handler.DeleteAgentKey) + r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey) + + // Agent 发信配额 + r.Get("/admin/quotas", handler.AdminListQuotas) + r.Put("/admin/quotas/{name}", handler.AdminSetQuota) + }) + }) + + // ---- SSE:Agent 走 header,人类走 Cookie,内部自行分流 ---- + r.Get("/events/stream", handler.SSEStream) + r.Get("/events/status", handler.SSEStatus) + + // ---- 附件下载:由浏览器直接发起(),无法带 Authorization 头, + // 因此单独挂在允许 ?access_token= 的中间件下 ---- + r.Group(func(r chi.Router) { + r.Use(middleware.UserAuthAllowQueryToken) + r.Get("/me/attachments/{id}", handler.MeDownloadAttachment) + }) + }) + + // 静态前端 + staticRoot := os.Getenv("STATIC_DIR") + var staticMux http.Handler + if staticRoot != "" { + staticMux = http.FileServer(http.Dir(staticRoot)) + } else { + staticMux = static.Handler() + } + r.Handle("/assets/*", staticMux) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(static.GetIndex()) + }) + + addr := fmt.Sprintf(":%s", cfg.Port) + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 0, // SSE 长连接 + IdleTimeout: 120 * time.Second, + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + fmt.Println("\nShutting down...") + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Shutdown(c) + }() + + fmt.Printf("AgentMail Gateway on %s\n", addr) + fmt.Printf(" Health: http://localhost%s/health\n", addr) + fmt.Printf(" API: http://localhost%s/api/v1\n", addr) + + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + fmt.Println("Server stopped") +} + +// bootstrapAdmin 首次启动时创建默认管理员 +func bootstrapAdmin(ctx context.Context, cfg *config.Config) { + n, err := repo.CountAdmins(ctx) + if err != nil { + log.Fatalf("Failed to count admins: %v", err) + } + if n > 0 { + return + } + + pw := cfg.AdminPassword + generated := false + if pw == "" { + pw = repo.RandomPassword(16) + generated = true + } + + u, created, err := repo.EnsureAdminUser(ctx, cfg.AdminUser, pw) + if err != nil { + log.Fatalf("Failed to create admin user: %v", err) + } + if created && u != nil { + fmt.Println("========================================") + fmt.Printf(" 已创建默认管理员: %s\n", u.Username) + if generated { + fmt.Printf(" 初始密码(仅本次显示): %s\n", pw) + fmt.Println(" 请登录后立即通过 /auth/password 修改") + } else { + fmt.Println(" 密码来自环境变量 ADMIN_PASSWORD") + } + fmt.Println("========================================") + } +} + +// sweepOrphanAttachments 周期清理「已上传但从未随邮件发出」的附件。 +// +// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下孤立记录与文件。 +// 保留 24 小时再清:足以覆盖一次正常的写信过程,也不至于让废弃文件长期占盘。 +func sweepOrphanAttachments(blobs *blob.Store) { + const ( + interval = 1 * time.Hour + keepFor = 24 * time.Hour + ) + + sweep := func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sums, err := repo.SweepOrphanAttachments(ctx, keepFor) + if err != nil { + log.Printf("附件 GC 失败: %v", err) + return + } + for _, sum := range sums { + if err := blobs.Remove(sum); err != nil { + log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], err) + } + } + if len(sums) > 0 { + log.Printf("附件 GC 清理了 %d 个孤立文件", len(sums)) + } + } + + // 启动时先扫一遍:上次进程可能是被 kill 掉的,留下的孤立文件不该等一小时 + sweep() + for range time.Tick(interval) { + sweep() + } +} diff --git a/gateway/go.mod b/gateway/go.mod new file mode 100644 index 0000000..b971e2c --- /dev/null +++ b/gateway/go.mod @@ -0,0 +1,28 @@ +module github.com/agentmail/gateway + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.55.0 + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/gateway/go.sum b/gateway/go.sum new file mode 100644 index 0000000..da5df95 --- /dev/null +++ b/gateway/go.sum @@ -0,0 +1,80 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/gateway/internal/blob/store.go b/gateway/internal/blob/store.go new file mode 100644 index 0000000..cf39d3e --- /dev/null +++ b/gateway/internal/blob/store.go @@ -0,0 +1,149 @@ +// Package blob 提供附件文件的内容寻址存储。 +// +// 设计取舍:文件内容存磁盘、数据库只存元数据。 +// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据, +// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。 +// +// 路径由内容的 sha256 派生(ab/cdef...),因此: +// - 相同内容天然去重,重复上传不占额外空间 +// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越 +// - 两级目录前缀避免单目录塞进十万个文件 +package blob + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" +) + +// Store 是附件的磁盘存储。 +type Store struct { + root string +} + +// ErrTooLarge 表示写入的数据超过了给定上限。 +var ErrTooLarge = errors.New("attachment too large") + +var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// New 打开(必要时创建)一个位于 root 的附件库。 +func New(root string) (*Store, error) { + if root == "" { + return nil, errors.New("blob: root 不能为空") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("blob: 创建 %s: %w", root, err) + } + return &Store{root: root}, nil +} + +// Root 返回存储根目录(用于日志与运维排查)。 +func (s *Store) Root() string { return s.root } + +// pathFor 由 sha256 推出磁盘路径。 +// 调用前必须确认 sum 是合法的 64 位十六进制,否则可能被拼出库外路径。 +func (s *Store) pathFor(sum string) (string, error) { + if !sha256Re.MatchString(sum) { + return "", fmt.Errorf("blob: 非法的 sha256 %q", sum) + } + return filepath.Join(s.root, sum[:2], sum[2:4], sum), nil +} + +// Put 把 r 的内容写入存储,返回内容的 sha256 与字节数。 +// +// maxBytes > 0 时超限即中止并清理临时文件(不会留下半个文件)。 +// 先写临时文件再按内容哈希 rename:写入过程中崩溃不会产生一个「哈希对不上内容」的文件。 +func (s *Store) Put(r io.Reader, maxBytes int64) (string, int64, error) { + tmp, err := os.CreateTemp(s.root, ".upload-*") + if err != nil { + return "", 0, fmt.Errorf("blob: 创建临时文件: %w", err) + } + tmpName := tmp.Name() + // 失败路径统一清理;成功时 rename 之后这个 Remove 是无害的 no-op + defer func() { + tmp.Close() + os.Remove(tmpName) + }() + + h := sha256.New() + src := r + if maxBytes > 0 { + // 多读 1 字节用于判断是否超限:LimitReader 到达上限时只会 EOF, + // 无法区分「刚好等于上限」和「超过上限」。 + src = io.LimitReader(r, maxBytes+1) + } + + n, err := io.Copy(io.MultiWriter(tmp, h), src) + if err != nil { + return "", 0, fmt.Errorf("blob: 写入: %w", err) + } + if maxBytes > 0 && n > maxBytes { + return "", 0, ErrTooLarge + } + if err := tmp.Sync(); err != nil { + return "", 0, fmt.Errorf("blob: sync: %w", err) + } + if err := tmp.Close(); err != nil { + return "", 0, fmt.Errorf("blob: close: %w", err) + } + + sum := hex.EncodeToString(h.Sum(nil)) + dst, err := s.pathFor(sum) + if err != nil { + return "", 0, err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return "", 0, fmt.Errorf("blob: 创建目录: %w", err) + } + + // 已存在同内容文件:内容寻址下这就是同一个文件,直接复用 + if _, statErr := os.Stat(dst); statErr == nil { + return sum, n, nil + } + if err := os.Rename(tmpName, dst); err != nil { + return "", 0, fmt.Errorf("blob: rename: %w", err) + } + if err := os.Chmod(dst, 0o600); err != nil { + return "", 0, fmt.Errorf("blob: chmod: %w", err) + } + return sum, n, nil +} + +// Open 打开某个内容的读取句柄。调用方负责 Close。 +func (s *Store) Open(sum string) (*os.File, error) { + p, err := s.pathFor(sum) + if err != nil { + return nil, err + } + return os.Open(p) +} + +// Exists 判断某内容是否已在库中。 +func (s *Store) Exists(sum string) bool { + p, err := s.pathFor(sum) + if err != nil { + return false + } + _, err = os.Stat(p) + return err == nil +} + +// Remove 删除某内容。 +// +// 注意:内容寻址意味着多条附件记录可能指向同一个文件, +// 因此调用方必须先确认没有其他记录引用该 sha256 才能删。 +func (s *Store) Remove(sum string) error { + p, err := s.pathFor(sum) + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/gateway/internal/blob/store_test.go b/gateway/internal/blob/store_test.go new file mode 100644 index 0000000..4c50d47 --- /dev/null +++ b/gateway/internal/blob/store_test.go @@ -0,0 +1,171 @@ +package blob + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func newStore(t *testing.T) *Store { + t.Helper() + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestPutAndOpenRoundTrip(t *testing.T) { + s := newStore(t) + data := []byte("附件内容 with bytes \x00\x01") + + sum, n, err := s.Put(bytes.NewReader(data), 0) + if err != nil { + t.Fatal(err) + } + if n != int64(len(data)) { + t.Errorf("写入 %d 字节,报告 %d", len(data), n) + } + + h := sha256.Sum256(data) + if sum != hex.EncodeToString(h[:]) { + t.Errorf("sha256 = %s,与内容不符", sum) + } + + f, err := s.Open(sum) + if err != nil { + t.Fatal(err) + } + defer f.Close() + got, _ := io.ReadAll(f) + if !bytes.Equal(got, data) { + t.Error("读回的内容与写入不一致") + } +} + +// 相同内容重复上传必须复用同一个文件,不占额外空间。 +func TestPutDeduplicates(t *testing.T) { + s := newStore(t) + data := []byte("same content") + + sum1, _, err := s.Put(bytes.NewReader(data), 0) + if err != nil { + t.Fatal(err) + } + sum2, _, err := s.Put(bytes.NewReader(data), 0) + if err != nil { + t.Fatal(err) + } + if sum1 != sum2 { + t.Fatalf("同内容得到不同哈希: %s vs %s", sum1, sum2) + } + + // 目录里应当只有一个内容文件(外加两级目录) + var files int + filepath.Walk(s.Root(), func(_ string, info os.FileInfo, _ error) error { + if info != nil && !info.IsDir() { + files++ + } + return nil + }) + if files != 1 { + t.Errorf("去重后应只剩 1 个文件,实际 %d", files) + } +} + +// 超限必须拒绝,且不能留下半个临时文件。 +func TestPutTooLargeLeavesNoGarbage(t *testing.T) { + s := newStore(t) + data := bytes.Repeat([]byte("x"), 1024) + + _, _, err := s.Put(bytes.NewReader(data), 512) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("期望 ErrTooLarge,得到 %v", err) + } + + entries, _ := os.ReadDir(s.Root()) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".upload-") { + t.Errorf("超限后残留临时文件 %s", e.Name()) + } + } +} + +// 恰好等于上限应当通过 —— 边界不能误杀。 +func TestPutExactlyAtLimit(t *testing.T) { + s := newStore(t) + data := bytes.Repeat([]byte("y"), 512) + + if _, n, err := s.Put(bytes.NewReader(data), 512); err != nil { + t.Fatalf("恰好等于上限被拒: %v", err) + } else if n != 512 { + t.Errorf("字节数 = %d,want 512", n) + } +} + +// 路径完全由 sha256 派生,任何非法 sum 都不能落到库外。 +func TestPathTraversalRejected(t *testing.T) { + s := newStore(t) + + for _, bad := range []string{ + "../../etc/passwd", + "..", + "/etc/passwd", + "ABCDEF", // 大写非法 + strings.Repeat("g", 64), // 非十六进制 + strings.Repeat("a", 63), // 长度不足 + "", + } { + if _, err := s.pathFor(bad); err == nil { + t.Errorf("pathFor(%q) 应报错", bad) + } + if _, err := s.Open(bad); err == nil { + t.Errorf("Open(%q) 应报错", bad) + } + if s.Exists(bad) { + t.Errorf("Exists(%q) 应为 false", bad) + } + } +} + +// 生成的路径必须落在库根目录之内。 +func TestPathStaysInsideRoot(t *testing.T) { + s := newStore(t) + sum := strings.Repeat("ab", 32) + + p, err := s.pathFor(sum) + if err != nil { + t.Fatal(err) + } + rel, err := filepath.Rel(s.Root(), p) + if err != nil || strings.HasPrefix(rel, "..") { + t.Errorf("路径逃出库根: %s", p) + } +} + +func TestRemove(t *testing.T) { + s := newStore(t) + sum, _, err := s.Put(bytes.NewReader([]byte("z")), 0) + if err != nil { + t.Fatal(err) + } + if !s.Exists(sum) { + t.Fatal("写入后应存在") + } + if err := s.Remove(sum); err != nil { + t.Fatal(err) + } + if s.Exists(sum) { + t.Error("删除后仍存在") + } + // 重复删除应当幂等,不报错 + if err := s.Remove(sum); err != nil { + t.Errorf("重复删除报错: %v", err) + } +} diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go new file mode 100644 index 0000000..ae5c12c --- /dev/null +++ b/gateway/internal/config/config.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Port string + DatabaseURL string + CORSOrigins []string + + // 首次启动时创建的默认管理员 + AdminUser string + AdminPassword string + + // Cookie 是否要求 HTTPS(生产环境置 true) + SecureCookie bool + CookieName string + + // 附件:文件内容存盘,默认与 SQLite 同目录下的 attachments/ + AttachmentDir string + // 单个附件上限(字节)。默认 25MB,与常见邮箱附件限额一致 + MaxAttachmentBytes int64 +} + +var C *Config + +func Load() *Config { + C = &Config{ + Port: getEnv("PORT", "8180"), + // 空值 = 用内置 SQLite(data/agentmail.db,可用 AGENTMAIL_DATA_DIR 改目录)。 + // 想接外部库就给 postgres://…;也接受 sqlite:///path/x.db 与裸路径。 + DatabaseURL: getEnv("DATABASE_URL", ""), + CORSOrigins: splitEnv("CORS_ORIGINS", []string{ + "http://localhost:5173", + "http://127.0.0.1:5173", + }), + AdminUser: strings.ToLower(getEnv("ADMIN_USER", "admin")), + AdminPassword: getEnv("ADMIN_PASSWORD", ""), + SecureCookie: getEnvBool("SECURE_COOKIE", false), + CookieName: getEnv("COOKIE_NAME", "am_session"), + + AttachmentDir: getEnv("AGENTMAIL_ATTACHMENT_DIR", + filepath.Join(getEnv("AGENTMAIL_DATA_DIR", "data"), "attachments")), + MaxAttachmentBytes: getEnvInt64("AGENTMAIL_MAX_ATTACHMENT_BYTES", 25<<20), + } + return C +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} + +func getEnvInt64(key string, fallback int64) int64 { + if v := os.Getenv(key); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + return fallback +} + +func splitEnv(key string, fallback []string) []string { + v := os.Getenv(key) + if v == "" { + return fallback + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return fallback + } + return out +} diff --git a/gateway/internal/db/db.go b/gateway/internal/db/db.go new file mode 100644 index 0000000..3e389b8 --- /dev/null +++ b/gateway/internal/db/db.go @@ -0,0 +1,193 @@ +// Package db 提供数据库连接与方言适配。 +// +// AgentMail 默认用 SQLite(零依赖、单文件,配合 go:embed 的前端就是「一个二进制 + 一个 .db」), +// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。 +// +// 两种方言的差异集中在本包处理,repo 层只写一份 SQL: +// - 占位符:SQLite 也支持 $1/$2,无需改写 +// - NOW() / gen_random_uuid():SQLite 侧注册同名函数补齐 +// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询) +// - 唯一约束冲突:IsUniqueViolation 统一识别 +package db + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + _ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动:pgx + sqlite "modernc.org/sqlite" +) + +type Dialect string + +const ( + Postgres Dialect = "postgres" + SQLite Dialect = "sqlite" +) + +var ( + DB *sql.DB + // D 是当前生效的方言,repo 层据此选择 SQL 片段 + D Dialect +) + +func init() { + // SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。 + // 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。 + sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0, + func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) { + return uuid.NewString(), nil + }) + + // NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻, + // 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。 + sqlite.MustRegisterScalarFunction("now", 0, + func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) { + return time.Now().UTC().Format("2006-01-02 15:04:05"), nil + }) +} + +// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。 +func Connect(ctx context.Context, dsn string) error { + driverName, connStr, dialect, err := resolve(dsn) + if err != nil { + return err + } + + pool, err := sql.Open(driverName, connStr) + if err != nil { + return fmt.Errorf("open %s: %w", dialect, err) + } + + switch dialect { + case Postgres: + pool.SetMaxOpenConns(20) + pool.SetMaxIdleConns(4) + pool.SetConnMaxLifetime(30 * time.Minute) + pool.SetConnMaxIdleTime(5 * time.Minute) + case SQLite: + // SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。 + pool.SetMaxOpenConns(1) + pool.SetMaxIdleConns(1) + pool.SetConnMaxLifetime(0) + } + + if err := pool.PingContext(ctx); err != nil { + pool.Close() + return fmt.Errorf("ping %s: %w", dialect, err) + } + + DB = pool + D = dialect + return nil +} + +// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。 +func resolve(dsn string) (string, string, Dialect, error) { + dsn = strings.TrimSpace(dsn) + + if dsn == "" { + return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil + } + + switch { + case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"): + return "pgx", dsn, Postgres, nil + + case strings.HasPrefix(dsn, "sqlite://"): + return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil + + case strings.HasPrefix(dsn, "sqlite:"): + return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil + + case strings.HasPrefix(dsn, "file:"): + // 已是 SQLite URI,原样透传(调用方自带 pragma) + return "sqlite", dsn, SQLite, nil + + case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"): + return "sqlite", sqliteDSN(dsn), SQLite, nil + } + + return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q:期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn) +} + +// defaultDBPath 返回默认 SQLite 文件位置(AGENTMAIL_DATA_DIR 可覆盖)。 +func defaultDBPath() string { + dir := os.Getenv("AGENTMAIL_DATA_DIR") + if dir == "" { + dir = "data" + } + return filepath.Join(dir, "agentmail.db") +} + +// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI,并确保父目录存在。 +// +// - journal_mode=WAL:读写不互斥,SSE 长连接查询不会被写入阻塞 +// - busy_timeout=5000:并发写时排队 5s 而不是立刻 SQLITE_BUSY +// - foreign_keys=1:SQLite 默认不校验外键,必须显式打开 +func sqliteDSN(path string) string { + if dir := filepath.Dir(path); dir != "" && dir != "." { + os.MkdirAll(dir, 0o755) + } + return "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=busy_timeout(5000)" + + "&_pragma=foreign_keys(1)" +} + +func Close() { + if DB != nil { + DB.Close() + } +} + +// ---------- 方言差异 ---------- + +// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段,argN 是该人名对应的占位符序号。 +// +// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个 +// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。 +// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针;SQLite 侧用 json_each 展开逐项比对。 +func CCHas(col string, argN int) string { + if D == Postgres { + return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN) + } + return fmt.Sprintf( + "EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)", + col, argN) +} + +// JSONCast 返回把占位符转成 JSONB 的后缀(PG 需要 ::jsonb,SQLite 存 TEXT 无需转换)。 +func JSONCast() string { + if D == Postgres { + return "::jsonb" + } + return "" +} + +// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。 +func IsUniqueViolation(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + var liteErr *sqlite.Error + if errors.As(err, &liteErr) { + // SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555 + code := liteErr.Code() + return code == 2067 || code == 1555 + } + return false +} diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go new file mode 100644 index 0000000..7882e91 --- /dev/null +++ b/gateway/internal/db/migrate.go @@ -0,0 +1,118 @@ +package db + +import ( + "context" + _ "embed" + "fmt" + "strings" +) + +//go:embed migrations/init.sql +var initSQLPostgres string + +//go:embed migrations/init_sqlite.sql +var initSQLSQLite string + +// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。 +func Migrate(ctx context.Context) error { + switch D { + case Postgres: + // PG 侧含 DO $$ … $$ 迁移块,必须整体提交 + if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil { + return fmt.Errorf("migrate postgres: %w", err) + } + case SQLite: + // modernc.org/sqlite 的 Exec 不接受多语句,逐条执行 + for i, stmt := range splitStatements(initSQLSQLite) { + if _, err := DB.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err) + } + } + // CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有 + // ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。 + if err := addMissingColumns(ctx); err != nil { + return err + } + default: + return fmt.Errorf("migrate: 未初始化的方言") + } + + fmt.Printf("数据库迁移完成(%s)\n", D) + return nil +} + +// splitStatements 按分号切分 SQL 脚本并剔除注释行。 +// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号, +// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。 +func splitStatements(script string) []string { + var out []string + for _, raw := range strings.Split(script, ";") { + var lines []string + for _, line := range strings.Split(raw, "\n") { + if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") { + continue + } + lines = append(lines, line) + } + if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" { + out = append(out, stmt) + } + } + return out +} + +// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。 +// +// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。 +// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法, +// 只能先查 pragma 再决定加不加。 +// +// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。 +var sqliteAddColumns = []struct{ table, column, ddl string }{ + {"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"}, + {"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"}, + {"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"}, + {"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"}, + // 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。 + {"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"}, + {"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"}, +} + +// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。 +// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。 +var sqliteAddIndexes = []string{ + // 人类决策后要按 mail_id 反查上游 permission id + "CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)", +} + +func addMissingColumns(ctx context.Context) error { + for _, c := range sqliteAddColumns { + has, err := columnExists(ctx, c.table, c.column) + if err != nil { + return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err) + } + if has { + continue + } + if _, err := DB.ExecContext(ctx, c.ddl); err != nil { + return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err) + } + fmt.Printf("补列 %s.%s\n", c.table, c.column) + } + for _, ddl := range sqliteAddIndexes { + if _, err := DB.ExecContext(ctx, ddl); err != nil { + return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err) + } + } + return nil +} + +func columnExists(ctx context.Context, table, column string) (bool, error) { + // pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。 + // table 与 column 都来自上面的硬编码常量表,不存在注入面。 + var n int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, + table, column).Scan(&n) + return n > 0, err +} diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql new file mode 100644 index 0000000..ac9c24a --- /dev/null +++ b/gateway/internal/db/migrations/init.sql @@ -0,0 +1,297 @@ +-- AgentMail MVP Schema +-- PostgreSQL 14+ + +-- Users table(人类多用户;username 与 agents.agent_name 共用命名空间) +CREATE TABLE IF NOT EXISTS users ( + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(128) NOT NULL DEFAULT '', + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL DEFAULT 'user', + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT NOW(), + last_login TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + token VARCHAR(64) PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + user_agent VARCHAR(256) DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at); + +-- 用户权限边界:可调用的 Agent 与可访问的工作区目录 +-- allowed_agents:["deepseekharness","pi"],空数组 = 不限(继承系统默认) +-- allowed_paths :["/program","/home/x"],空数组 = 不限;按前缀匹配 +-- agent_aliases :{"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名 +ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}'; + +-- Agents table +CREATE TABLE IF NOT EXISTS agents ( + agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_name VARCHAR(64) NOT NULL UNIQUE, + secret VARCHAR(128) NOT NULL, + host_url VARCHAR(256) NOT NULL DEFAULT '', + workspaces JSONB NOT NULL DEFAULT '[]', + platform VARCHAR(32) NOT NULL DEFAULT 'pi', + status VARCHAR(16) NOT NULL DEFAULT 'offline', + max_rounds INT NOT NULL DEFAULT 10, + used_rounds INT NOT NULL DEFAULT 0, + last_seen TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Sessions table +CREATE TABLE IF NOT EXISTS sessions ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_alias VARCHAR(128), + from_agent VARCHAR(64) NOT NULL, + subject VARCHAR(512) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + owner_user_id UUID REFERENCES users(user_id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias); +CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); + +-- 会话别名负责寻址(name@path.),必须全局唯一。 +-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq + ON sessions(session_alias) WHERE session_alias IS NOT NULL; + +-- 已存在的库补列(必须先于依赖该列的索引) +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id); + +-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT; + +-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖) +-- 或 'manual'(人显式指定,平台同步不得覆盖)。 +-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform'; + +-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。 +-- +-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性: +-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度, +-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。 +-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。 +-- +-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话, +-- 每条都是全新预算,全局上限就形同虚设。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0; +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id); + +-- Mails table +CREATE TABLE IF NOT EXISTS mails ( + mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(session_id), + parent_mail_id UUID REFERENCES mails(mail_id), + + from_name VARCHAR(64) NOT NULL, + from_workspace VARCHAR(128) DEFAULT '', + to_name VARCHAR(64) NOT NULL, + to_workspace VARCHAR(128) DEFAULT '', + + subject VARCHAR(512) NOT NULL, + body TEXT NOT NULL, + + -- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}] + cc_list JSONB NOT NULL DEFAULT '[]', + + mail_type VARCHAR(32) NOT NULL DEFAULT 'normal', + permission_options JSONB, + permission_result VARCHAR(32), + + status VARCHAR(16) NOT NULL DEFAULT 'unread', + created_at TIMESTAMPTZ DEFAULT NOW(), + + hop_limit INT DEFAULT 5 +); + +CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id); +CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status); +CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id); + +-- 已存在的库补列(重复运行安全) +ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]'; + +-- Agent 在正文里提议改会话别名()。 +-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。 +ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT; +ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT; + +-- 抄送检索:cc_list @> '[{"name":"pi"}]' 走 GIN +CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops); + +-- 历史数据规整:早期写入过首字母大写的键(Raw/Name/Path/Session),统一成小写 +UPDATE mails +SET cc_list = ( + SELECT COALESCE(jsonb_agg( + jsonb_strip_nulls(jsonb_build_object( + 'name', COALESCE(e->>'name', e->>'Name'), + 'path', COALESCE(e->>'path', e->>'Path'), + 'session', COALESCE(e->>'session', e->>'Session'), + 'raw', COALESCE(e->>'raw', e->>'Raw') + )) + ), '[]'::jsonb) + FROM jsonb_array_elements(cc_list) AS e +) +WHERE cc_list @? '$[*].Name'; + +-- Permission requests table +CREATE TABLE IF NOT EXISTS permission_requests ( + request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mail_id UUID NOT NULL REFERENCES mails(mail_id), + session_id UUID NOT NULL REFERENCES sessions(session_id), + agent_name VARCHAR(64) NOT NULL, + question TEXT NOT NULL, + options JSONB NOT NULL DEFAULT '["同意", "拒绝"]', + context TEXT DEFAULT '', + result VARCHAR(32), + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result); +CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL; + +-- 历史邮件里的字面量 'human' 迁移到默认管理员账号 +-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写) +DO $$ +DECLARE + admin_name TEXT; +BEGIN + SELECT username INTO admin_name + FROM users WHERE role = 'admin' AND status = 'active' + ORDER BY created_at ASC LIMIT 1; + + IF admin_name IS NULL THEN + RETURN; -- 还没有管理员,下次迁移再试 + END IF; + + UPDATE mails SET from_name = admin_name WHERE from_name = 'human'; + UPDATE mails SET to_name = admin_name WHERE to_name = 'human'; + UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human'; + + -- 拄送列表里的 human 一并重写 + UPDATE mails + SET cc_list = ( + SELECT COALESCE(jsonb_agg( + CASE WHEN e->>'name' = 'human' + THEN jsonb_set( + jsonb_set(e, '{name}', to_jsonb(admin_name)), + '{raw}', + to_jsonb(admin_name || '@' || COALESCE(e->>'path','') || + CASE WHEN COALESCE(e->>'session','') = '' THEN '' + ELSE '.' || (e->>'session') END)) + ELSE e END + ), '[]'::jsonb) + FROM jsonb_array_elements(cc_list) AS e + ) + WHERE cc_list @> '[{"name":"human"}]'; + + -- 人类发起的会话补上 owner + UPDATE sessions s + SET owner_user_id = u.user_id + FROM users u + WHERE u.username = admin_name + AND s.owner_user_id IS NULL + AND s.from_agent = admin_name; +END $$; + +-- ---------- 密钥认证体系 ---------- +-- +-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys): +-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE +-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent +-- +-- key_type: +-- permanent — 永不过期,可重复使用 +-- one_time — 首次验证后写 used_at,再用即拒 +-- timed — expires_at 之后失效 + +CREATE TABLE IF NOT EXISTS agent_keys ( + key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_token VARCHAR(128) NOT NULL UNIQUE, + agent_name VARCHAR(64), -- NULL = 待绑定 + key_type VARCHAR(16) NOT NULL DEFAULT 'permanent', + label VARCHAR(128) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + used_at TIMESTAMPTZ, + created_by UUID REFERENCES users(user_id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token); +CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name); + +CREATE TABLE IF NOT EXISTS user_keys ( + key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_token VARCHAR(128) NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label VARCHAR(128) NOT NULL DEFAULT '', + key_type VARCHAR(16) NOT NULL DEFAULT 'permanent', + expires_at TIMESTAMPTZ, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token); + +-- ---------- 附件 ---------- +-- +-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。 +-- +-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件: +-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart), +-- 中间态必须允许存在;超时未挂载的由 GC 清掉。 + +-- 插件自动转发的邮件登记表。 +-- +-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则): +-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」 +-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 —— +-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。 +-- +-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。 +-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败: +-- * 插件重试、SSE 重连后重放都不会产生第二封 +-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id +CREATE TABLE IF NOT EXISTS relayed_mails ( + agent_name VARCHAR(64) NOT NULL, + relay_key VARCHAR(160) NOT NULL, + mail_id UUID REFERENCES mails(mail_id), + kind VARCHAR(32) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (agent_name, relay_key) +); + +-- 人类决策后要按 mail_id 反查上游 permission id +CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id); + +CREATE TABLE IF NOT EXISTS attachments ( + attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE, + uploader VARCHAR(64) NOT NULL, + filename VARCHAR(512) NOT NULL, + content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id); +CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256); +CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL; diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql new file mode 100644 index 0000000..aeee2e1 --- /dev/null +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -0,0 +1,241 @@ +-- AgentMail Schema — SQLite(默认后端) +-- +-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言: +-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成) +-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time) +-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索) +-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责) +-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致 +-- +-- 本文件只建表建索引,不含数据迁移:SQLite 是新引入的默认后端,不存在历史库。 + +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME, + + -- 权限边界:空数组 = 不限 + allowed_agents TEXT NOT NULL DEFAULT '[]', + allowed_paths TEXT NOT NULL DEFAULT '[]', + agent_aliases TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + user_agent TEXT DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at); + +CREATE TABLE IF NOT EXISTS agents ( + agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + agent_name TEXT NOT NULL UNIQUE, + secret TEXT NOT NULL, + host_url TEXT NOT NULL DEFAULT '', + workspaces TEXT NOT NULL DEFAULT '[]', + platform TEXT NOT NULL DEFAULT 'pi', + status TEXT NOT NULL DEFAULT 'offline', + max_rounds INTEGER NOT NULL DEFAULT 10, + used_rounds INTEGER NOT NULL DEFAULT 0, + last_seen DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + session_alias TEXT, + from_agent TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + owner_user_id TEXT REFERENCES users(user_id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。 + rename_dismissed TEXT, + + -- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖) + -- 或 'manual'(人显式指定,平台同步不得覆盖)。 + -- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉, + -- 人上一秒记住的寻址地址下一秒失效。 + alias_source TEXT NOT NULL DEFAULT 'platform', + + -- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。 + -- + -- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性: + -- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度, + -- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。 + -- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。 + -- + -- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话, + -- 每条都是全新预算,全局上限就形同虚设。 + max_rounds INTEGER NOT NULL DEFAULT 0, + used_rounds INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias); +CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); +CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id); + +-- 会话别名负责寻址(name@path.),必须全局唯一。 +-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq + ON sessions(session_alias) WHERE session_alias IS NOT NULL; + +CREATE TABLE IF NOT EXISTS mails ( + mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + session_id TEXT NOT NULL REFERENCES sessions(session_id), + parent_mail_id TEXT REFERENCES mails(mail_id), + + from_name TEXT NOT NULL, + from_workspace TEXT DEFAULT '', + to_name TEXT NOT NULL, + to_workspace TEXT DEFAULT '', + + subject TEXT NOT NULL, + body TEXT NOT NULL, + + -- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}] + cc_list TEXT NOT NULL DEFAULT '[]', + + mail_type TEXT NOT NULL DEFAULT 'normal', + permission_options TEXT, + permission_result TEXT, + + status TEXT NOT NULL DEFAULT 'unread', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + hop_limit INTEGER DEFAULT 5, + + -- Agent 在正文里提议改会话别名()。 + -- 存在邮件上而非会话上:邮件是不可篡改的历史记录, + -- 「谁在哪一封里提了什么」应当留痕。 + rename_alias TEXT, + rename_reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id); +CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status); +CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id); +CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at); + +-- 抄送检索无对应索引:SQLite 侧走 json_each 展开。 +-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。 + +CREATE TABLE IF NOT EXISTS permission_requests ( + request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + mail_id TEXT NOT NULL REFERENCES mails(mail_id), + session_id TEXT NOT NULL REFERENCES sessions(session_id), + agent_name TEXT NOT NULL, + question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '["同意","拒绝"]', + context TEXT DEFAULT '', + result TEXT, + decided_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result); +CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL; + +-- ---------- 密钥认证体系 ---------- +-- +-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys): +-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE +-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent +-- +-- key_type: +-- permanent — 永不过期,可重复使用 +-- one_time — 首次验证后写 used_at,再用即拒 +-- timed — expires_at 之后失效 + +CREATE TABLE IF NOT EXISTS agent_keys ( + key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + key_token TEXT NOT NULL UNIQUE, + agent_name TEXT, -- NULL = 待绑定 + key_type TEXT NOT NULL DEFAULT 'permanent', + label TEXT NOT NULL DEFAULT '', + expires_at DATETIME, + used_at DATETIME, + created_by TEXT REFERENCES users(user_id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token); +CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name); + +CREATE TABLE IF NOT EXISTS user_keys ( + key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + key_token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label TEXT NOT NULL DEFAULT '', + key_type TEXT NOT NULL DEFAULT 'permanent', + expires_at DATETIME, + used_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token); + +-- ---------- 附件 ---------- +-- +-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。 +-- 不塞 BLOB:SQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL,而附件是只写一次多次读的冷数据。 +-- +-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件: +-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart), +-- 中间态必须允许存在;超时未挂载的由 GC 清掉。 + +-- 插件自动转发的邮件登记表。 +-- +-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则): +-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」 +-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 —— +-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。 +-- +-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。 +-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败: +-- * 插件重试、SSE 重连后重放都不会产生第二封 +-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id +CREATE TABLE IF NOT EXISTS relayed_mails ( + agent_name TEXT NOT NULL, + relay_key TEXT NOT NULL, + mail_id TEXT REFERENCES mails(mail_id), + kind TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (agent_name, relay_key) +); + +-- 人类决策后要按 mail_id 反查上游 permission id +CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id); + +CREATE TABLE IF NOT EXISTS attachments ( + attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE, + + -- 上传者(Agent 名或用户名),用于「只能挂自己上传的附件」校验 + uploader TEXT NOT NULL, + + filename TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size_bytes INTEGER NOT NULL, + -- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径 + sha256 TEXT NOT NULL, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id); +CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256); +-- GC 扫描待挂载附件用 +CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL; diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go new file mode 100644 index 0000000..dc92fc7 --- /dev/null +++ b/gateway/internal/handler/agents.go @@ -0,0 +1,142 @@ +package handler + +import ( + "net/http" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" +) + +// ---------- Agent ---------- + +type registerRequest struct { + Name string `json:"name"` + Secret string `json:"secret"` + Workspaces []models.Workspace `json:"workspaces"` + Platform string `json:"platform"` +} + +// POST /api/v1/agent/register +// +// 两种认证方式: +// 1. Authorization: Bearer —— 密钥认证(推荐)。 +// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致, +// 否则等于拿别人的密钥冒充新身份。 +// 2. body 里带 secret —— 旧方式,兼容保留。 +func RegisterAgent(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.Name == "" { + Error(w, http.StatusBadRequest, "Missing name") + return + } + + keyToken := middleware.BearerToken(r) + if keyToken == "" && req.Secret == "" { + Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret") + return + } + + if keyToken != "" { + bound, err := repo.VerifyAgentKey(r.Context(), keyToken) + if err != nil { + writeKeyErr(w, err) + return + } + if bound != "" && bound != req.Name { + Error(w, http.StatusForbidden, + "该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"") + return + } + } + + if req.Platform == "" { + req.Platform = "pi" + } + + // 三维地址的 name 位与人类用户名共用命名空间,不得重名 + if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil { + Error(w, http.StatusInternalServerError, "Failed to validate agent name") + return + } else if !ok { + Error(w, http.StatusConflict, "该名称已被人类用户占用") + return + } + if req.Name == "human" { + Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名") + return + } + + // 密钥认证时不需要 secret,但 agents.secret 非空约束仍在; + // 存密钥本身作占位,旧的 name/secret 路径不受影响。 + secret := req.Secret + if secret == "" { + secret = keyToken + } + + if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil { + Error(w, http.StatusInternalServerError, "Failed to register agent") + return + } + + // 待绑定密钥在首次注册成功后落定到该 Agent + if keyToken != "" { + if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil { + Error(w, http.StatusInternalServerError, "Failed to bind key") + return + } + } + + JSON(w, http.StatusOK, map[string]string{ + "status": "registered", + "agent_name": req.Name, + }) +} + +// POST /api/v1/agent/heartbeat +func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + pending, err := repo.HeartbeatAgent(r.Context(), agentName) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to heartbeat") + return + } + + // 心跳回传配额:插件据此把剩余次数注入 Agent 上下文, + // 让它在配额耗尽前主动发总结,而不是撞到 403 才发现。 + quota, qErr := repo.GetQuota(r.Context(), agentName) + if qErr != nil { + // 配额读不到不影响心跳本身,降级为不限额 + quota = repo.Quota{AgentName: agentName, Unlimited: true, Remaining: -1} + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "pending_mails": pending, + "quota": quota, + }) +} + +// GET /api/v1/agents +func ListAgents(w http.ResponseWriter, r *http.Request) { + statusFilter := r.URL.Query().Get("status") + + agents, err := repo.ListAgents(r.Context(), statusFilter) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list agents") + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "agents": emptySlice(agents), + }) +} diff --git a/gateway/internal/handler/alias_test.go b/gateway/internal/handler/alias_test.go new file mode 100644 index 0000000..c72184b --- /dev/null +++ b/gateway/internal/handler/alias_test.go @@ -0,0 +1,69 @@ +package handler + +import "testing" + +// 平台侧 slug/标题不受本侧寻址约束,normalizeAlias 必须把它改写成 +// 能安全出现在 name@path. 末段的形式。 +func TestNormalizeAlias(t *testing.T) { + cases := []struct{ in, want string }{ + // opencode 风格 slug 原样通过 + {"jolly-cactus", "jolly-cactus"}, + {"fix-memory-leak", "fix-memory-leak"}, + + // 非法字符统一换 -,连续的压缩成一个 + {"fix.memory.leak", "fix-memory-leak"}, + {"修复 登录态 丢失", "修复-登录态-丢失"}, + {"a//b..c", "a-b-c"}, + {"user@host", "user-host"}, + + // 首尾的分隔符要去掉 + {".leading", "leading"}, + {"trailing.", "trailing"}, + {" spaced ", "spaced"}, + + // 保留字必须避开,否则会被寻址当成「新建会话」 + {"new", "session-new"}, + + // 全是非法字符 → 空串,交由调用方报错 + {"...", ""}, + {"", ""}, + } + + for _, c := range cases { + if got := normalizeAlias(c.in); got != c.want { + t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// 规范化后的别名必须能通过寻址校验,否则同步会写进一个自己都拒绝的别名。 +func TestNormalizeAliasPassesValidation(t *testing.T) { + for _, in := range []string{ + "jolly-cactus", "fix.memory.leak", "修复 登录态 丢失", "new", "user@host/path", + } { + norm := normalizeAlias(in) + if norm == "" { + continue + } + if err := validateSessionAlias(norm); err != nil { + t.Errorf("normalizeAlias(%q) = %q,但未通过 validateSessionAlias: %v", in, norm, err) + } + } +} + +// 截断长别名时不能切坏多字节字符(session_alias 是 VARCHAR(128))。 +func TestNormalizeAliasTruncatesOnValidUTF8(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "修" // 每个 3 字节,共 300 字节 + } + got := normalizeAlias(long) + if len(got) > 128 { + t.Errorf("normalizeAlias 截断后 %d 字节,超过 128", len(got)) + } + for _, r := range got { + if r == '\uFFFD' { + t.Fatalf("normalizeAlias 截断产生了非法 UTF-8: %q", got) + } + } +} diff --git a/gateway/internal/handler/attachments.go b/gateway/internal/handler/attachments.go new file mode 100644 index 0000000..a93a6f1 --- /dev/null +++ b/gateway/internal/handler/attachments.go @@ -0,0 +1,368 @@ +package handler + +import ( + "errors" + "fmt" + "mime" + "net/http" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/agentmail/gateway/internal/blob" + "github.com/agentmail/gateway/internal/config" + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" +) + +// ---------- 附件 ---------- +// +// 上传与发信是两步: +// 1. POST /attachments (multipart)→ 拿到 attachment_id +// 2. 发信时把 id 放进 attachment_ids +// 之所以不合成一步:Agent 侧的工具接口是 JSON,没法带 multipart; +// 而人类侧若只支持一步,就无法在写信过程中先传文件再改正文。 +// +// 未挂载的附件是合法中间态,超时由 GC 清理(repo.SweepOrphanAttachments)。 + +// Blobs 是附件内容存储,由 main 在启动时注入。 +var Blobs *blob.Store + +// sanitizeFilename 清理用户提供的文件名。 +// +// 文件名只用于展示与下载时的 Content-Disposition,磁盘路径完全由 sha256 派生, +// 因此这里的目的不是防路径穿越(那已由内容寻址杜绝),而是: +// - 去掉目录成分,避免下载时浏览器按 "a/b/c.txt" 解释 +// - 去掉控制字符与换行,避免污染 HTTP 响应头 +// - 限长,避免超出数据库列宽 +func sanitizeFilename(name string) string { + name = strings.TrimSpace(name) + // 同时处理 / 与 \:上传方可能是 Windows 客户端 + if i := strings.LastIndexAny(name, `/\`); i >= 0 { + name = name[i+1:] + } + + var b strings.Builder + for _, r := range name { + if r < 0x20 || r == 0x7f { + continue // 控制字符一律丢弃 + } + b.WriteRune(r) + } + name = strings.TrimSpace(b.String()) + + // "." 与 ".." 作为文件名毫无意义,且容易在各层被特殊解释 + if name == "" || name == "." || name == ".." { + return "unnamed" + } + + const maxBytes = 255 + if len(name) > maxBytes { + cut := name[:maxBytes] + for len(cut) > 0 && !utf8.ValidString(cut) { + cut = cut[:len(cut)-1] + } + name = cut + } + return name +} + +// detectContentType 优先用客户端声明的类型,缺失时按扩展名猜,兜底 octet-stream。 +// 无论如何都不回显未经处理的客户端值到响应头(下载时统一用 octet-stream,见 DownloadAttachment)。 +func detectContentType(declared, filename string) string { + if ct := strings.TrimSpace(declared); ct != "" && ct != "application/octet-stream" { + if parsed, _, err := mime.ParseMediaType(ct); err == nil { + return parsed + } + } + if ext := filepath.Ext(filename); ext != "" { + if byExt := mime.TypeByExtension(ext); byExt != "" { + if parsed, _, err := mime.ParseMediaType(byExt); err == nil { + return parsed + } + } + } + return "application/octet-stream" +} + +// uploadAttachment 是 Agent 与人类两条上传路径的公共实现。 +func uploadAttachment(w http.ResponseWriter, r *http.Request, uploader string) { + if Blobs == nil { + Error(w, http.StatusServiceUnavailable, "附件存储未初始化") + return + } + + max := config.C.MaxAttachmentBytes + + // 双层限制:MaxBytesReader 卡整个请求体(含 multipart 边界与其他字段), + // blob.Put 的 max 卡单个文件内容。少了外层,攻击者可以用超大 multipart 头拖死内存。 + r.Body = http.MaxBytesReader(w, r.Body, max+1<<20) + + // 32MB 内存缓冲上限,超出部分 multipart 会自动落临时文件 + if err := r.ParseMultipartForm(32 << 20); err != nil { + Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)") + return + } + defer func() { + if r.MultipartForm != nil { + r.MultipartForm.RemoveAll() + } + }() + + file, header, err := r.FormFile("file") + if err != nil { + Error(w, http.StatusBadRequest, "缺少 file 字段") + return + } + defer file.Close() + + name := sanitizeFilename(header.Filename) + ctype := detectContentType(header.Header.Get("Content-Type"), name) + + // 先落盘再入库:反过来会出现「库里有记录、磁盘没文件」的下载 500 + sum, size, err := Blobs.Put(file, max) + if errors.Is(err, blob.ErrTooLarge) { + Error(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20))) + return + } + if err != nil { + Error(w, http.StatusInternalServerError, "保存附件失败") + return + } + + a, err := repo.CreateAttachment(r.Context(), uploader, name, ctype, size, sum) + if err != nil { + // 落盘成功但入库失败:留下的孤立文件由 GC 回收,不影响正确性 + Error(w, http.StatusInternalServerError, "登记附件失败") + return + } + + JSON(w, http.StatusOK, map[string]any{"attachment": a}) +} + +// POST /api/v1/attachments —— Agent 侧上传 +func UploadAttachment(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + uploadAttachment(w, r, agentName) +} + +// POST /api/v1/me/attachments —— 人类侧上传 +func MeUploadAttachment(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + uploadAttachment(w, r, user.Username) +} + +// downloadAttachment 是 Agent 与人类两条下载路径的公共实现。 +func downloadAttachment(w http.ResponseWriter, r *http.Request, viewer string) { + if Blobs == nil { + Error(w, http.StatusServiceUnavailable, "附件存储未初始化") + return + } + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + + a, err := repo.GetAttachment(r.Context(), id) + if errors.Is(err, repo.ErrAttachmentNotFound) { + Error(w, http.StatusNotFound, "附件不存在") + return + } + if err != nil { + Error(w, http.StatusInternalServerError, "读取附件失败") + return + } + + allowed, err := repo.AttachmentAccessible(r.Context(), a, viewer) + if err != nil { + Error(w, http.StatusInternalServerError, "校验权限失败") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该附件") + return + } + + f, err := Blobs.Open(a.SHA256) + if err != nil { + // 元数据在库但文件不在盘:说明存储被外部改动过,这是运维问题而非用户输入问题 + Error(w, http.StatusInternalServerError, "附件内容缺失") + return + } + defer f.Close() + + // 一律 octet-stream + attachment:绝不按声明的 MIME 内联渲染。 + // 否则一个上传的 .html/.svg 就能在本站域下执行脚本,等于自带 XSS。 + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Length", fmt.Sprintf("%d", a.SizeBytes)) + w.Header().Set("Content-Disposition", contentDisposition(a.Filename)) + + http.ServeContent(w, r, a.Filename, a.CreatedAt, f) +} + +// contentDisposition 构造下载头。 +// filename* 用 RFC 5987 编码承载非 ASCII 名字,filename= 给只认 ASCII 的老客户端兜底; +// 兜底值里的引号与反斜杠必须去掉,否则能截断响应头。 +func contentDisposition(name string) string { + var ascii strings.Builder + for _, r := range name { + switch { + case r == '"' || r == '\\': + ascii.WriteByte('_') + case r < 0x20 || r > 0x7e: + ascii.WriteByte('_') + default: + ascii.WriteRune(r) + } + } + fallback := ascii.String() + if fallback == "" { + fallback = "attachment" + } + return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, + fallback, urlEncodeRFC5987(name)) +} + +// urlEncodeRFC5987 按 RFC 5987 的 attr-char 集合做百分号编码。 +func urlEncodeRFC5987(s string) string { + const safe = "!#$&+-.^_`|~" // attr-char 中除字母数字外允许的字符 + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + if isAlnum || strings.IndexByte(safe, c) >= 0 { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +// GET /api/v1/attachments/{id} —— Agent 侧下载 +func DownloadAttachment(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + downloadAttachment(w, r, agentName) +} + +// GET /api/v1/me/attachments/{id} —— 人类侧下载 +func MeDownloadAttachment(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + downloadAttachment(w, r, user.Username) +} + +// DELETE /api/v1/me/attachments/{id} —— 删除自己上传且尚未挂载的附件 +// +// 已挂载的不允许删:邮件是不可篡改的历史记录,附件是它的一部分。 +func MeDeleteAttachment(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + + a, err := repo.GetAttachment(r.Context(), id) + if errors.Is(err, repo.ErrAttachmentNotFound) { + Error(w, http.StatusNotFound, "附件不存在") + return + } + if err != nil { + Error(w, http.StatusInternalServerError, "读取附件失败") + return + } + if a.Uploader != user.Username { + Error(w, http.StatusForbidden, "只能删除自己上传的附件") + return + } + if a.MailID != nil { + Error(w, http.StatusConflict, "附件已随邮件发出,不能删除") + return + } + + sum, orphaned, err := repo.DeleteAttachment(r.Context(), id) + if err != nil { + Error(w, http.StatusInternalServerError, "删除附件失败") + return + } + // 内容寻址下多条记录可能共享同一文件,只有最后一条引用消失才删磁盘 + if orphaned && Blobs != nil { + _ = Blobs.Remove(sum) + } + JSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// parseAttachmentIDs 把请求里的附件 id 列表解析为 UUID。 +func parseAttachmentIDs(raw []string) ([]uuid.UUID, error) { + out := make([]uuid.UUID, 0, len(raw)) + for _, s := range raw { + s = strings.TrimSpace(s) + if s == "" { + continue + } + id, err := uuid.Parse(s) + if err != nil { + return nil, fmt.Errorf("非法的 attachment_id %q", s) + } + out = append(out, id) + } + return out, nil +} + +// attachAll 把附件挂到刚创建的邮件上,并把错误翻译成 HTTP 响应。 +// 返回 false 表示已写出错误响应,调用方应立即返回。 +func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []uuid.UUID, uploader string) bool { + if len(ids) == 0 { + return true + } + err := repo.AttachToMail(r.Context(), mailID, ids, uploader) + switch { + case err == nil: + return true + case errors.Is(err, repo.ErrAttachmentNotFound): + Error(w, http.StatusNotFound, "附件不存在") + case errors.Is(err, repo.ErrAttachmentNotOwned): + Error(w, http.StatusForbidden, "只能附加自己上传的附件") + case errors.Is(err, repo.ErrAttachmentAlreadyAttached): + Error(w, http.StatusConflict, "附件已随其他邮件发出") + default: + Error(w, http.StatusInternalServerError, "附加附件失败") + } + return false +} + +// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。 +// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。 +func fillAttachments(r *http.Request, mails ...*models.Mail) { + for _, m := range mails { + if m == nil { + continue + } + if as, err := repo.ListAttachmentsFor(r.Context(), m.ID); err == nil { + m.Attachments = as + } + } +} diff --git a/gateway/internal/handler/attachments_test.go b/gateway/internal/handler/attachments_test.go new file mode 100644 index 0000000..cabee7a --- /dev/null +++ b/gateway/internal/handler/attachments_test.go @@ -0,0 +1,138 @@ +package handler + +import ( + "strings" + "testing" +) + +// 文件名只用于展示与下载头;磁盘路径由 sha256 派生, +// 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。 +func TestSanitizeFilename(t *testing.T) { + cases := []struct{ in, want string }{ + {"report.pdf", "report.pdf"}, + {"中文 文件名.txt", "中文 文件名.txt"}, + + // 目录成分必须剥掉(含 Windows 风格) + {"../../etc/passwd", "passwd"}, + {"/abs/path/x.log", "x.log"}, + {`C:\Users\me\a.txt`, "a.txt"}, + {"a/b/c.txt", "c.txt"}, + + // 控制字符会污染 Content-Disposition + {"bad\r\nname.txt", "badname.txt"}, + {"tab\there.txt", "tabhere.txt"}, + + // 无意义的名字兜底 + {"", "unnamed"}, + {" ", "unnamed"}, + {".", "unnamed"}, + {"..", "unnamed"}, + {"/", "unnamed"}, + } + for _, c := range cases { + if got := sanitizeFilename(c.in); got != c.want { + t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// 超长名字按 UTF-8 边界截断,不产生非法序列。 +func TestSanitizeFilenameTruncates(t *testing.T) { + long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+ + got := sanitizeFilename(long) + if len(got) > 255 { + t.Errorf("截断后 %d 字节,超过 255", len(got)) + } + for _, r := range got { + if r == '\uFFFD' { + t.Fatalf("截断产生非法 UTF-8: %q", got) + } + } +} + +func TestDetectContentType(t *testing.T) { + cases := []struct{ declared, filename, want string }{ + {"application/pdf", "x.pdf", "application/pdf"}, + // 客户端没给类型时按扩展名猜 + {"", "notes.txt", "text/plain"}, + {"application/octet-stream", "data.json", "application/json"}, + // 带参数的声明要剥掉参数 + {"text/plain; charset=utf-8", "a.txt", "text/plain"}, + // 认不出就兜底 + {"", "blob.unknownext", "application/octet-stream"}, + {"garbage//not-a-type", "blob.unknownext", "application/octet-stream"}, + } + for _, c := range cases { + got := detectContentType(c.declared, c.filename) + // mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型 + if !strings.HasPrefix(got, c.want) { + t.Errorf("detectContentType(%q, %q) = %q, want prefix %q", + c.declared, c.filename, got, c.want) + } + } +} + +// Content-Disposition 必须双写:filename* 承载 UTF-8,filename= 给老客户端兜底。 +// 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。 +func TestContentDisposition(t *testing.T) { + got := contentDisposition("报告 v2.pdf") + if !strings.HasPrefix(got, "attachment; ") { + t.Errorf("必须以 attachment 开头: %s", got) + } + if !strings.Contains(got, "filename*=UTF-8''") { + t.Errorf("缺少 RFC 5987 编码: %s", got) + } + // 非 ASCII 不能出现在 filename= 的兜底值里 + ascii := got[:strings.Index(got, "filename*=")] + for _, r := range ascii { + if r > 0x7e { + t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii) + } + } + + // 引号注入不能逃出引号 + evil := contentDisposition(`a"; x="y`) + if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) { + t.Errorf("引号未转义,可截断响应头: %s", evil) + } + + // 控制字符(若绕过 sanitize 直达此处)也不能出现 + ctl := contentDisposition("a\r\nb.txt") + if strings.ContainsAny(ctl, "\r\n") { + t.Errorf("响应头含换行: %q", ctl) + } +} + +func TestURLEncodeRFC5987(t *testing.T) { + cases := []struct{ in, want string }{ + {"abc.txt", "abc.txt"}, + {"a b", "a%20b"}, + {"中", "%E4%B8%AD"}, + {`a"b`, "a%22b"}, + } + for _, c := range cases { + if got := urlEncodeRFC5987(c.in); got != c.want { + t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseAttachmentIDs(t *testing.T) { + valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + + got, err := parseAttachmentIDs([]string{valid, " ", ""}) + if err != nil { + t.Fatalf("合法输入报错: %v", err) + } + if len(got) != 1 { + t.Errorf("空白项应被忽略,得到 %d 个", len(got)) + } + + if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil { + t.Error("非法 UUID 应报错") + } + + if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 { + t.Errorf("nil 应返回空列表,得到 %v, %v", got, err) + } +} diff --git a/gateway/internal/handler/auth.go b/gateway/internal/handler/auth.go new file mode 100644 index 0000000..c8975da --- /dev/null +++ b/gateway/internal/handler/auth.go @@ -0,0 +1,414 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// ---------- 登录 / 登出 / 自身信息 ---------- + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type userOut struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + AllowedAgents []string `json:"allowed_agents"` + AllowedPaths []string `json:"allowed_paths"` + LastLogin string `json:"last_login,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +func toUserOut(u *models.User) userOut { + o := userOut{ + UserID: u.ID.String(), + Username: u.Username, + DisplayName: u.DisplayName, + Role: u.Role, + Status: u.Status, + AllowedAgents: emptySlice(u.AllowedAgents), + AllowedPaths: emptySlice(u.AllowedPaths), + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } + if u.LastLogin != nil { + o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05") + } + return o +} + +// ---------- 首次初始化 ---------- + +// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导 +func SetupStatus(w http.ResponseWriter, r *http.Request) { + needs, err := repo.NeedsSetup(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check setup status") + return + } + JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs}) +} + +type setupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用 +func SetupAdmin(w http.ResponseWriter, r *http.Request) { + var req setupRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(req.Password) < 8 { + Error(w, http.StatusBadRequest, "密码自少 8 位") + return + } + + u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName) + if err != nil { + switch { + case errors.Is(err, repo.ErrAlreadySetup): + Error(w, http.StatusConflict, "系统已初始化,请直接登录") + case errors.Is(err, repo.ErrInvalidUsername): + Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human") + case errors.Is(err, repo.ErrNameTaken): + Error(w, http.StatusConflict, "该名称已被 Agent 占用") + default: + Error(w, http.StatusInternalServerError, "初始化失败") + } + return + } + + // 初始化后直接登录 + token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent()) + if err == nil { + maxAge := int(time.Until(expires).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + middleware.SetSessionCookie(w, token, maxAge) + } + + JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)}) +} + +// POST /api/v1/auth/login +func Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + name := strings.ToLower(strings.TrimSpace(req.Username)) + if name == "" || req.Password == "" { + Error(w, http.StatusBadRequest, "Missing username or password") + return + } + + if locked, remain := limiter.Locked(name); locked { + JSON(w, http.StatusTooManyRequests, map[string]interface{}{ + "error": "尝试过于频繁,请稍后再试", + "retry_after": remain, + }) + return + } + + u, err := repo.Authenticate(r.Context(), name, req.Password) + if err != nil { + switch { + case errors.Is(err, repo.ErrBadCredentials): + limiter.Fail(name) + Error(w, http.StatusUnauthorized, "用户名或密码错误") + case errors.Is(err, repo.ErrUserDisabled): + Error(w, http.StatusForbidden, "账号已被禁用") + default: + Error(w, http.StatusInternalServerError, "登录失败") + } + return + } + limiter.Reset(name) + + token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent()) + if err != nil { + Error(w, http.StatusInternalServerError, "无法创建会话") + return + } + maxAge := int(time.Until(expires).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + middleware.SetSessionCookie(w, token, maxAge) + + JSON(w, http.StatusOK, map[string]interface{}{ + "user": toUserOut(u), + }) +} + +// POST /api/v1/auth/logout +func Logout(w http.ResponseWriter, r *http.Request) { + if token := middleware.SessionToken(r); token != "" { + _ = repo.DeleteUserSession(r.Context(), token) + } + middleware.ClearSessionCookie(w) + JSON(w, http.StatusOK, map[string]string{"status": "logged_out"}) +} + +// GET /api/v1/auth/me +func Me(w http.ResponseWriter, r *http.Request) { + u := middleware.GetUser(r) + if u == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)}) +} + +type changePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +// POST /api/v1/auth/password +func ChangePassword(w http.ResponseWriter, r *http.Request) { + u := middleware.GetUser(r) + if u == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req changePasswordRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(req.NewPassword) < 8 { + Error(w, http.StatusBadRequest, "新密码至少 8 位") + return + } + if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil { + Error(w, http.StatusUnauthorized, "原密码错误") + return + } + if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil { + Error(w, http.StatusInternalServerError, "修改密码失败") + return + } + middleware.ClearSessionCookie(w) + JSON(w, http.StatusOK, map[string]string{"status": "password_changed"}) +} + +// ---------- 管理员:用户管理 ---------- + +// GET /api/v1/admin/users +func AdminListUsers(w http.ResponseWriter, r *http.Request) { + users, err := repo.ListUsers(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list users") + return + } + out := make([]userOut, 0, len(users)) + for i := range users { + out = append(out, toUserOut(&users[i])) + } + JSON(w, http.StatusOK, map[string]interface{}{"users": out}) +} + +type createUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + AllowedAgents []string `json:"allowed_agents"` + AllowedPaths []string `json:"allowed_paths"` +} + +// POST /api/v1/admin/users +func AdminCreateUser(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(req.Password) < 8 { + Error(w, http.StatusBadRequest, "密码至少 8 位") + return + } + + u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role, + req.AllowedAgents, req.AllowedPaths) + if err != nil { + switch { + case errors.Is(err, repo.ErrNameTaken): + Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用") + case errors.Is(err, repo.ErrInvalidUsername): + Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human") + default: + Error(w, http.StatusInternalServerError, "创建用户失败") + } + return + } + JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)}) +} + +type updateUserRequest struct { + DisplayName *string `json:"display_name"` + Role *string `json:"role"` + Status *string `json:"status"` + AllowedAgents *[]string `json:"allowed_agents"` + AllowedPaths *[]string `json:"allowed_paths"` +} + +// PUT /api/v1/admin/users/{id} +func AdminUpdateUser(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + var req updateUserRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + + // 不允许把最后一个管理员降级或禁用 + if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil { + Error(w, http.StatusConflict, err.Error()) + return + } + + u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{ + DisplayName: req.DisplayName, + Role: req.Role, + Status: req.Status, + AllowedAgents: req.AllowedAgents, + AllowedPaths: req.AllowedPaths, + }) + if err != nil { + if errors.Is(err, repo.ErrUserNotFound) { + Error(w, http.StatusNotFound, "用户不存在") + return + } + Error(w, http.StatusInternalServerError, "更新用户失败") + return + } + JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)}) +} + +// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选 +func AdminListScopes(w http.ResponseWriter, r *http.Request) { + agents, err := repo.ListAgents(r.Context(), "") + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list agents") + return + } + names := make([]string, 0, len(agents)) + for _, a := range agents { + names = append(names, a.Name) + } + paths, _ := repo.AllWorkspaceNames(r.Context()) + + JSON(w, http.StatusOK, map[string]interface{}{ + "agents": emptySlice(names), + "paths": emptySlice(paths), + }) +} + +// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史 +func AdminDisableUser(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + disabled := "disabled" + if err := guardLastAdmin(r, id, nil, &disabled); err != nil { + Error(w, http.StatusConflict, err.Error()) + return + } + if err := repo.DisableUser(r.Context(), id); err != nil { + if errors.Is(err, repo.ErrUserNotFound) { + Error(w, http.StatusNotFound, "用户不存在") + return + } + Error(w, http.StatusInternalServerError, "禁用用户失败") + return + } + JSON(w, http.StatusOK, map[string]string{"status": "disabled"}) +} + +type resetPasswordRequest struct { + NewPassword string `json:"new_password"` +} + +// POST /api/v1/admin/users/{id}/reset +func AdminResetPassword(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + var req resetPasswordRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(req.NewPassword) < 8 { + Error(w, http.StatusBadRequest, "密码至少 8 位") + return + } + if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil { + if errors.Is(err, repo.ErrUserNotFound) { + Error(w, http.StatusNotFound, "用户不存在") + return + } + Error(w, http.StatusInternalServerError, "重置密码失败") + return + } + JSON(w, http.StatusOK, map[string]string{"status": "password_reset"}) +} + +// ---------- 辅助 ---------- + +func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) { + id, err := uuid.Parse(chi.URLParam(r, key)) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid "+key) + return uuid.Nil, false + } + return id, true +} + +// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用 +func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error { + demoting := role != nil && *role != "admin" + disabling := status != nil && *status != "active" + if !demoting && !disabling { + return nil + } + + target, err := repo.GetUserByID(r.Context(), id) + if err != nil || !target.IsAdmin() || target.Status != "active" { + return nil + } + n, err := repo.CountAdmins(r.Context()) + if err != nil { + return nil + } + if n <= 1 { + return errors.New("系统至少需要保留一个可用管理员") + } + return nil +} diff --git a/gateway/internal/handler/contacts.go b/gateway/internal/handler/contacts.go new file mode 100644 index 0000000..e4eac95 --- /dev/null +++ b/gateway/internal/handler/contacts.go @@ -0,0 +1,207 @@ +package handler + +import ( + "net/http" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" + "github.com/google/uuid" +) + +// ---------- Contacts(左侧联系人界面,按登录用户隔离) ---------- + +// GET /api/v1/contacts?archived=false +func ListContacts(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + archived := r.URL.Query().Get("archived") == "true" + + // 管理员可用 ?all=true 查看全部 + scope := user.Username + if user.IsAdmin() && r.URL.Query().Get("all") == "true" { + scope = "" + } + + contacts, err := repo.ListContactsFor(r.Context(), scope, archived) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list contacts") + return + } + JSON(w, http.StatusOK, map[string]interface{}{ + "contacts": emptySlice(contacts), + }) +} + +type archiveRequest struct { + Address string `json:"address"` + SessionID string `json:"session_id"` +} + +// POST /api/v1/contacts/archive +// 归档指定 name@path.session:Agent 侧会话归档 + 邮箱界面移除 +func ArchiveContact(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req archiveRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + + var sessionID uuid.UUID + switch { + case req.SessionID != "": + id, err := uuid.Parse(req.SessionID) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid session_id") + return + } + sessionID = id + + case req.Address != "": + addr, err := models.ParseAddress(req.Address) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid address: "+err.Error()) + return + } + id, err := repo.FindSessionByAddress(r.Context(), addr.Name, addr.Path, addr.Session) + if err != nil { + Error(w, http.StatusNotFound, "No session matches "+req.Address) + return + } + sessionID = id + + default: + Error(w, http.StatusBadRequest, "Provide address or session_id") + return + } + + // 鉴权:只能归档自己参与的会话(管理员不限) + allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权归档他人的会话") + return + } + + session, err := repo.GetSessionByID(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusNotFound, "Session not found") + return + } + mails, _ := repo.GetSessionMails(r.Context(), sessionID) + + if err := repo.ArchiveSession(r.Context(), sessionID); err != nil { + Error(w, http.StatusInternalServerError, "Failed to archive session") + return + } + + alias := "" + if session.Alias != nil { + alias = *session.Alias + } + payload := map[string]interface{}{ + "session_id": sessionID.String(), + "session_alias": alias, + "archived_by": user.Username, + } + + // 通知会话内所有参与方(Agent 与人类),各自归档/移除 + notified := map[string]bool{} + for _, m := range mails { + names := append([]string{m.FromName, m.ToName}, ccNames(m.CCList)...) + for _, name := range names { + if name == "" || notified[name] { + continue + } + notified[name] = true + sse.Default.SendToRecipient(name, "session_archived", payload) + } + } + if !notified[user.Username] { + sse.Default.SendToUser(user.Username, "session_archived", payload) + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "status": "archived", + "session_id": sessionID.String(), + "session_alias": alias, + }) +} + +// GET /api/v1/contacts/suggest?name=xxx&path=yyy +// 三段式补全:无 name 给 Agent+人类用户名;有 name 给工作区;两者都有给会话别名(含 new) +func SuggestAddress(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + name := r.URL.Query().Get("name") + path := r.URL.Query().Get("path") + + if name == "" { + agents, err := repo.ListAgents(r.Context(), "") + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list agents") + return + } + users, _ := repo.ListActiveUsernames(r.Context()) + + names := make([]string, 0, len(agents)+len(users)) + for _, a := range agents { + names = append(names, a.Name) + } + for _, u := range users { + if u == user.Username { + continue // 不建议给自己发信 + } + names = append(names, u) + } + JSON(w, http.StatusOK, map[string]interface{}{ + "kind": "name", + "suggestions": emptySlice(names), + }) + return + } + + if path == "" { + // 人类用户没有工作区,直接给空列表(前端会继续走 session 段) + paths, _ := repo.SuggestPaths(r.Context(), name) + JSON(w, http.StatusOK, map[string]interface{}{ + "kind": "path", + "suggestions": emptySlice(paths), + }) + return + } + + sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path) + sessions = append(sessions, "new") + JSON(w, http.StatusOK, map[string]interface{}{ + "kind": "session", + "suggestions": sessions, + }) +} + +func ccNames(list []models.Address) []string { + out := make([]string, 0, len(list)) + for _, a := range list { + if a.Name != "" { + out = append(out, a.Name) + } + } + return out +} diff --git a/gateway/internal/handler/events.go b/gateway/internal/handler/events.go new file mode 100644 index 0000000..d30dfef --- /dev/null +++ b/gateway/internal/handler/events.go @@ -0,0 +1,91 @@ +package handler + +import ( + "net/http" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" +) + +// GET /api/v1/events/stream +// +// 四种凭证,都必须真正验证过身份才能订阅: +// Authorization: Bearer → Agent 通道(密钥认证) +// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容) +// 登录 Cookie 或 Bearer → 人类用户通道 +// ?access_token= → 浏览器 EventSource 专用回退 +// +// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。 +// query 令牌仅本端点接受(EventSource 无法带自定义头),其余接口一律要求请求头, +// 因为 URL 里的令牌会进访问日志与 Referer。 +func SSEStream(w http.ResponseWriter, r *http.Request) { + agentName, ok := resolveStreamAgent(r) + if !ok { + Error(w, http.StatusUnauthorized, "凭证无效") + return + } + + userName := "" + if agentName == "" { + u := middleware.OptionalUserWithQuery(r) + if u == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + userName = u.Username + } + + client := sse.Default.AddClient(w, agentName, userName) + if client == nil { + Error(w, http.StatusInternalServerError, "SSE not supported") + return + } + + <-r.Context().Done() + sse.Default.RemoveClient(client.ID) +} + +// resolveStreamAgent 校验 Agent 侧凭证。 +// 返回 ("", true) 表示这不是 Agent 请求,交给人类用户分支; +// 返回 ("", false) 表示带了 Agent 凭证但验证失败。 +func resolveStreamAgent(r *http.Request) (string, bool) { + // 密钥认证:Bearer 令牌可能是 Agent 密钥,也可能是用户密钥。 + // 先按 Agent 密钥试,失败就落到人类分支(那里会再按用户密钥试)。 + token := middleware.BearerToken(r) + if token == "" { + token = middleware.QueryToken(r) // EventSource 回退 + } + if token != "" { + name, err := repo.VerifyAgentKey(r.Context(), token) + if err == nil && name != "" { + return name, true + } + return "", true + } + + name := r.Header.Get("X-Agent-Name") + if name == "" { + name = r.URL.Query().Get("agent_name") + } + if name == "" { + return "", true // 非 Agent 请求 + } + + secret := r.Header.Get("X-Agent-Secret") + if secret == "" { + return "", false // 报了名字却没给凭证 + } + agent, err := repo.VerifyAgent(r.Context(), name, secret) + if err != nil { + return "", false + } + return agent.Name, true +} + +// GET /api/v1/events/status +func SSEStatus(w http.ResponseWriter, r *http.Request) { + JSON(w, http.StatusOK, map[string]interface{}{ + "connected_clients": sse.Default.ClientCount(), + }) +} diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go new file mode 100644 index 0000000..1a3744f --- /dev/null +++ b/gateway/internal/handler/forward.go @@ -0,0 +1,264 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// ---------- 转发 ---------- +// +// 转发 = 引用原文 + 新收件人。与「回复」的区别: +// 回复(reply_to)落回原会话,收件人是原发件人; +// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。 +// 因此转发不复用 reply_to,而是走完整的三维寻址。 + +type forwardRequest struct { + // To 新收件人,完整三维地址 + To string `json:"to"` + // CC 可选抄送 + CC string `json:"cc"` + // Comment 转发者附加的说明,置于引用原文之前 + Comment string `json:"comment"` + // Subject 可选;留空时自动加 "Fwd: " 前缀 + Subject string `json:"subject"` + // SessionAlias 仅在目标地址以 .new 结尾时生效 + SessionAlias string `json:"session_alias"` +} + +// quoteBody 把原文渲染为 Markdown 引用块。 +// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表, +// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。 +func quoteBody(m *models.Mail) string { + var b strings.Builder + b.WriteString("---\n\n") + b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName)) + if m.FromWorkspace != "" { + b.WriteString("@" + m.FromWorkspace) + } + b.WriteString("\n") + b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject)) + b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05"))) + if len(m.CCList) > 0 { + names := make([]string, 0, len(m.CCList)) + for _, c := range m.CCList { + names = append(names, c.Raw) + } + b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", "))) + } + b.WriteString(">\n") + for _, line := range strings.Split(m.Body, "\n") { + b.WriteString("> " + line + "\n") + } + return b.String() +} + +// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。 +func forwardSubject(custom, original string) string { + if s := strings.TrimSpace(custom); s != "" { + return s + } + if strings.HasPrefix(original, "Fwd: ") { + return original + } + return "Fwd: " + original +} + +// doForward 是 Agent 与人类两条转发路径的公共实现。 +// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。 +func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) { + var req forwardRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if strings.TrimSpace(req.To) == "" { + Error(w, http.StatusBadRequest, "Missing to") + return + } + + src, err := repo.LoadForwardSource(r.Context(), mailID, actor) + switch { + case errors.Is(err, repo.ErrMailNotFound): + Error(w, http.StatusNotFound, "待转发的邮件不存在") + return + case errors.Is(err, repo.ErrForwardNotAllowed): + Error(w, http.StatusForbidden, "只能转发自己参与过的邮件") + return + case err != nil: + Error(w, http.StatusInternalServerError, "Failed to load mail") + return + } + + to, err := models.ParseAddress(req.To) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error()) + return + } + ccList, err := models.ParseAddressList(req.CC) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error()) + return + } + + user := middleware.GetUser(r) + if !isAgent && user != nil { + to = resolveHumanAlias(to, user.Username) + for i := range ccList { + ccList[i] = resolveHumanAlias(ccList[i], user.Username) + } + if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" { + Error(w, http.StatusForbidden, msg) + return + } + } + + subject := forwardSubject(req.Subject, src.Subject) + + // 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话 + sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias) + if err != nil { + writeErr(w, err, "Failed to resolve session") + return + } + + if isAgent { + quota, qErr := repo.ConsumeQuota(r.Context(), actor) + if errors.Is(qErr, repo.ErrQuotaExhausted) { + Error(w, http.StatusForbidden, fmt.Sprintf( + "发信配额已用尽(%d/%d)。请先向人类发送最终总结,或联系管理员重置配额。", + quota.Used, quota.Max)) + return + } + if qErr != nil { + Error(w, http.StatusInternalServerError, "Failed to check quota") + return + } + } else if user != nil { + _ = repo.SetSessionOwner(r.Context(), sessionID, user.ID) + } + + body := quoteBody(src) + if c := strings.TrimSpace(req.Comment); c != "" { + body = c + "\n\n" + body + } + attachedCount := 0 + + // parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来 + newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID, + actor, fromWorkspace, to.Name, to.Path, subject, body, ccList) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to create mail") + return + } + + // 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。 + // 内容寻址下这只是新增元数据,不拷磁盘文件。 + if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil { + Error(w, http.StatusInternalServerError, "复制附件失败") + return + } else { + attachedCount = n + } + + notifyRecipients(to, ccList, sessionID, newID, actor, subject) + + JSON(w, http.StatusOK, map[string]any{ + "mail_id": newID.String(), + "session_id": sessionID.String(), + "session_alias": repo.SessionAliasOf(r.Context(), sessionID), + "forwarded_from": src.ID.String(), + "attachments": attachedCount, + }) +} + +// POST /api/v1/mail/{id}/forward —— Agent 侧转发 +func ForwardMail(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + doForward(w, r, mailID, agentName, agentName, true) +} + +// POST /api/v1/me/mail/{id}/forward —— 人类侧转发 +func MeForwardMail(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + doForward(w, r, mailID, user.Username, "", false) +} + +// ---------- 配额管理(管理员) ---------- + +// GET /api/v1/admin/quotas +func AdminListQuotas(w http.ResponseWriter, r *http.Request) { + quotas, err := repo.ListQuotas(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list quotas") + return + } + JSON(w, http.StatusOK, map[string]any{"quotas": quotas}) +} + +type setQuotaRequest struct { + // MaxRounds 发信配额上限;0 = 不限 + MaxRounds *int `json:"max_rounds"` + // Reset 为 true 时把已用次数归零 + Reset bool `json:"reset"` +} + +// PUT /api/v1/admin/quotas/{name} +func AdminSetQuota(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + Error(w, http.StatusBadRequest, "Missing agent name") + return + } + + var req setQuotaRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.MaxRounds == nil && !req.Reset { + Error(w, http.StatusBadRequest, "需要 max_rounds 或 reset 之一") + return + } + + var ( + q repo.Quota + err error + ) + if req.MaxRounds != nil { + if q, err = repo.SetQuota(r.Context(), name, *req.MaxRounds); err != nil { + Error(w, http.StatusNotFound, err.Error()) + return + } + } + if req.Reset { + if q, err = repo.ResetQuota(r.Context(), name); err != nil { + Error(w, http.StatusNotFound, err.Error()) + return + } + } + JSON(w, http.StatusOK, map[string]any{"quota": q}) +} diff --git a/gateway/internal/handler/forward_test.go b/gateway/internal/handler/forward_test.go new file mode 100644 index 0000000..384b67d --- /dev/null +++ b/gateway/internal/handler/forward_test.go @@ -0,0 +1,77 @@ +package handler + +import ( + "strings" + "testing" + "time" + + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。 +func TestForwardSubject(t *testing.T) { + cases := []struct{ custom, original, want string }{ + {"", "修复登录态", "Fwd: 修复登录态"}, + {"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加 + {"自定义主题", "修复登录态", "自定义主题"}, + {" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定 + } + for _, c := range cases { + if got := forwardSubject(c.custom, c.original); got != c.want { + t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want) + } + } +} + +// 引用块必须逐行加 "> ":原文含代码块或列表时, +// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。 +func TestQuoteBodyPrefixesEveryLine(t *testing.T) { + m := &models.Mail{ + ID: uuid.New(), + FromName: "opencode", + FromWorkspace: "/root", + Subject: "巡检结果", + Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项", + CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC), + CCList: []models.Address{ + {Name: "pi", Path: "root", Raw: "pi@root.new"}, + }, + } + + out := quoteBody(m) + + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if line == "---" || line == "" { + continue + } + if !strings.HasPrefix(line, ">") { + t.Errorf("引用块出现未加前缀的行: %q", line) + } + } + + // 元信息必须齐全,否则收件人不知道这封转发的来路 + for _, want := range []string{"opencode@/root", "巡检结果", "2026-09-02 10:30:00", "pi@root.new"} { + if !strings.Contains(out, want) { + t.Errorf("引用块缺少 %q\n%s", want, out) + } + } + + // 原文正文本身要在引用里 + if !strings.Contains(out, "> fmt.Println(1)") { + t.Errorf("原文代码行未被引用:\n%s", out) + } +} + +// 无抄送时不该渲染出空的「抄送」行。 +func TestQuoteBodyOmitsEmptyCC(t *testing.T) { + m := &models.Mail{ + FromName: "admin", + Subject: "x", + Body: "y", + CreatedAt: time.Now(), + } + if strings.Contains(quoteBody(m), "抄送") { + t.Error("无抄送时不应出现「抄送」行") + } +} diff --git a/gateway/internal/handler/helpers.go b/gateway/internal/handler/helpers.go new file mode 100644 index 0000000..939136e --- /dev/null +++ b/gateway/internal/handler/helpers.go @@ -0,0 +1,128 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "unicode/utf8" + + "github.com/agentmail/gateway/internal/repo" +) + +// JSON 写入 JSON 响应 +func JSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// Error 写入错误响应 +func Error(w http.ResponseWriter, status int, msg string) { + JSON(w, status, map[string]string{"error": msg}) +} + +// Decode 从请求体解析 JSON +func Decode(r *http.Request, v interface{}) error { + return json.NewDecoder(r.Body).Decode(v) +} + +// httpError 携带 HTTP 状态码的错误 +type httpError struct { + status int + msg string +} + +func (e httpError) Error() string { return e.msg } + +func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} } +func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} } +func errConflict(msg string) error { return httpError{http.StatusConflict, msg} } + +// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。 +// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。 +func writeKeyErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, repo.ErrKeyUsed): + Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)") + case errors.Is(err, repo.ErrKeyExpired): + Error(w, http.StatusUnauthorized, "密钥已过期") + case errors.Is(err, repo.ErrKeyNotFound): + Error(w, http.StatusUnauthorized, "密钥无效") + case errors.Is(err, repo.ErrKeyTypeInvalid): + Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed") + case errors.Is(err, repo.ErrKeyNeedsExpiry): + Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours") + case errors.Is(err, repo.ErrKeyTooShort): + Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)") + case errors.Is(err, repo.ErrKeyTokenTaken): + Error(w, http.StatusConflict, "该密钥已登记过") + default: + Error(w, http.StatusInternalServerError, "密钥操作失败") + } +} + +// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path. 的末段。 +// "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。 +func validateSessionAlias(alias string) error { + if alias == "new" { + return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`) + } + if strings.ContainsAny(alias, ". \t/@") { + return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)") + } + return nil +} + +// normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。 +// +// 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让 +// name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。 +// 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。 +func normalizeAlias(s string) string { + var b strings.Builder + lastDash := false + for _, r := range s { + switch { + case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r': + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + default: + b.WriteRune(r) + lastDash = false + } + } + out := strings.Trim(b.String(), "-") + if out == "new" { + return "session-new" + } + // VARCHAR(128) 上限,按字节截断时不能切坏多字节字符 + const maxBytes = 128 + if len(out) > maxBytes { + cut := out[:maxBytes] + for len(cut) > 0 && !utf8.ValidString(cut) { + cut = cut[:len(cut)-1] + } + out = strings.Trim(cut, "-") + } + return out +} + +// writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案 +func writeErr(w http.ResponseWriter, err error, fallback string) { + if he, ok := err.(httpError); ok { + Error(w, he.status, he.msg) + return + } + Error(w, http.StatusInternalServerError, fallback) +} + +// emptySlice 把 nil slice 转为空 JSON 数组 [] +func emptySlice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/gateway/internal/handler/keys.go b/gateway/internal/handler/keys.go new file mode 100644 index 0000000..07760f7 --- /dev/null +++ b/gateway/internal/handler/keys.go @@ -0,0 +1,179 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" +) + +// ---------- 密钥管理 ---------- +// +// 两套接口,权限边界不同: +// /admin/agent-keys —— 管理员签发 Agent 接入密钥 +// /me/keys —— 用户自助签发客户端连接密钥(不能注册 Agent) +// +// 密钥全文只在创建响应里出现一次,列表接口只给前 8 位 hint。 + +type createKeyRequest struct { + // AgentName 仅 Agent 密钥使用;留空表示「待绑定」,首次注册时按注册请求的 name 落定 + AgentName string `json:"agent_name"` + // Label 人类可读备注(如「我的笔记本」「CI 机器」) + Label string `json:"label"` + // KeyType permanent / one_time / timed + KeyType string `json:"key_type"` + // ExpiresHours 仅 timed 使用,必须为正 + ExpiresHours int `json:"expires_hours"` + // KeyToken 仅 Agent 密钥使用:登记一把客户端已在本地生成的密钥。 + // 插件首次安装时自己生成密钥并打印出来,管理员把它填到这里完成登记, + // 密钥全文因此不需要从服务器往客户端传。留空则由服务器生成。 + KeyToken string `json:"key_token"` +} + +// normalizeKeyType 默认给 permanent,避免调用方漏填时落到非法值 +func normalizeKeyType(t string) string { + t = strings.TrimSpace(t) + if t == "" { + return models.KeyPermanent + } + return t +} + +// POST /api/v1/admin/agent-keys +func CreateAgentKey(w http.ResponseWriter, r *http.Request) { + admin := middleware.GetUser(r) + if admin == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req createKeyRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + + key, err := repo.CreateAgentKey(r.Context(), + strings.TrimSpace(req.AgentName), normalizeKeyType(req.KeyType), + strings.TrimSpace(req.Label), req.ExpiresHours, admin.ID, + strings.TrimSpace(req.KeyToken)) + if err != nil { + writeKeyErr(w, err) + return + } + + // 唯一一次回传全文 + JSON(w, http.StatusOK, map[string]any{"key": key}) +} + +// GET /api/v1/admin/agent-keys?agent_name=xxx +func ListAgentKeys(w http.ResponseWriter, r *http.Request) { + keys, err := repo.ListAgentKeys(r.Context(), r.URL.Query().Get("agent_name")) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list keys") + return + } + JSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +// DELETE /api/v1/admin/agent-keys/{id} +func DeleteAgentKey(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + if err := repo.DeleteAgentKey(r.Context(), id); err != nil { + writeKeyErr(w, err) + return + } + JSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +type bindKeyRequest struct { + AgentName string `json:"agent_name"` +} + +// POST /api/v1/admin/agent-keys/{id}/bind +func BindAgentKey(w http.ResponseWriter, r *http.Request) { + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + var req bindKeyRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + name := strings.TrimSpace(req.AgentName) + if name == "" { + Error(w, http.StatusBadRequest, "Missing agent_name") + return + } + if err := repo.BindAgentKey(r.Context(), id, name); err != nil { + writeKeyErr(w, err) + return + } + JSON(w, http.StatusOK, map[string]string{"status": "bound", "agent_name": name}) +} + +// ---------- 用户连接密钥 ---------- + +// POST /api/v1/me/keys +func CreateMyKey(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req createKeyRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + + key, err := repo.CreateUserKey(r.Context(), user.ID, + strings.TrimSpace(req.Label), normalizeKeyType(req.KeyType), req.ExpiresHours) + if err != nil { + writeKeyErr(w, err) + return + } + JSON(w, http.StatusOK, map[string]any{"key": key}) +} + +// GET /api/v1/me/keys +func ListMyKeys(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + keys, err := repo.ListUserKeys(r.Context(), user.ID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list keys") + return + } + JSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +// DELETE /api/v1/me/keys/{id} +func DeleteMyKey(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + id, ok := pathUUID(w, r, "id") + if !ok { + return + } + // repo 层带 user_id 条件,删不到就是不属于自己或不存在,统一 404 + if err := repo.DeleteUserKey(r.Context(), user.ID, id); err != nil { + writeKeyErr(w, err) + return + } + JSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go new file mode 100644 index 0000000..4cc320e --- /dev/null +++ b/gateway/internal/handler/mail.go @@ -0,0 +1,421 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" + "github.com/google/uuid" +) + +// ---------- Mail ---------- + +type sendMailRequest struct { + To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在) + CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session + Subject string `json:"subject"` + Body string `json:"body"` + ReplyTo string `json:"reply_to"` + // SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名, + // 之后即可用 name@path. 续谈。命中已有会话时该字段被忽略。 + SessionAlias string `json:"session_alias"` + // AttachmentIDs 先用 POST /attachments 上传拿到的 id;只能附加自己上传且未挂载的 + AttachmentIDs []string `json:"attachment_ids"` + + // Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。 + // + // 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。 + // 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。 + // + // RelayKey 必須是上游那条消息的稳定标识(permission id / assistant message id): + // 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。 + Relay string `json:"relay"` // "" | "permission" | "summary" + RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填 +} + +// resolveTarget 根据三维地址 name@path.session 决定投递的会话。 +// +// session 位三态语义(设计文档): +// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立 +// - new(pi@root.new) → 强制新建一个会话 +// - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达 +// +// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path. 寻址。 +// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。 +func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string) (uuid.UUID, *uuid.UUID, error) { + if replyTo != "" { + replyID, err := uuid.Parse(replyTo) + if err != nil { + return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID") + } + mail, err := repo.GetMailByID(r.Context(), replyID) + if err != nil { + return uuid.Nil, nil, errNotFound("Parent mail not found") + } + repo.TouchSession(r.Context(), mail.SessionID) + return mail.SessionID, &replyID, nil + } + + switch addr.Mode() { + case models.SessionNew: + // 新建会话:若调用方给了别名,当场命名,之后即可用 name@path. 续谈。 + // 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。 + var aliasPtr *string + if a := strings.TrimSpace(alias); a != "" { + if err := validateSessionAlias(a); err != nil { + return uuid.Nil, nil, err + } + if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil { + return uuid.Nil, nil, errConflict(fmt.Sprintf( + "会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a)) + } + aliasPtr = &a + } + id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject) + return id, nil, err + + case models.SessionDefault: + id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject) + return id, nil, err + + default: // models.SessionNamed + id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session) + if errors.Is(err, repo.ErrSessionNotFound) { + return uuid.Nil, nil, errNotFound(fmt.Sprintf( + "无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位", + addr.Session, addr.Name, addr.Path, addr.Name, addr.Path)) + } + if err != nil { + return uuid.Nil, nil, err + } + repo.TouchSession(r.Context(), id) + return id, nil, nil + } +} + +// POST /api/v1/mail/send +func SendMail(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var req sendMailRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.To == "" || req.Subject == "" || req.Body == "" { + Error(w, http.StatusBadRequest, "Missing to, subject, or body") + return + } + + to, err := models.ParseAddress(req.To) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error()) + return + } + ccList, err := models.ParseAddressList(req.CC) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error()) + return + } + attachIDs, err := parseAttachmentIDs(req.AttachmentIDs) + if err != nil { + Error(w, http.StatusBadRequest, err.Error()) + return + } + + sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias) + if err != nil { + writeErr(w, err, "Failed to resolve session") + return + } + + // 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。 + // 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。 + // + // 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信, + // 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。 + relay, relayKey, err := parseRelay(req.Relay, req.RelayKey) + if err != nil { + writeErr(w, err, "Invalid relay") + return + } + + var quota repo.Quota + var budget repo.SessionBudget + if relay != "" { + // 先占幂等键。重复则说明这条上游消息已经转过, + // 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。 + if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil { + if errors.Is(cErr, repo.ErrRelayDuplicate) { + JSON(w, http.StatusOK, map[string]any{ + "status": "duplicate_relay", + "relay": relay, + "relay_key": relayKey, + "detail": "该上游消息已转发过,本次调用未产生新邮件", + }) + return + } + Error(w, http.StatusInternalServerError, "Failed to claim relay") + return + } + // 仅读快照用于回传,不扣任何一层 + quota, _ = repo.GetQuota(r.Context(), agentName) + budget, _ = repo.GetSessionBudget(r.Context(), sessionID) + } else { + // 两层都要过:会话预算管「这件事值得多少个来回」, + // Agent 全局配额管「这个 Agent 总共能发多少」。 + // 先扣会话、后扣全局;全局拦下时把会话那次退回去 —— + // 那次往返实际上没有发生,不能白掉一格。 + budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID) + if errors.Is(err, repo.ErrSessionBudgetExhausted) { + Error(w, http.StatusForbidden, fmt.Sprintf( + "本会话的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+ + "若需继续主动发信,请让人在对话页调高本会话的预算。", + budget.Used, budget.Max)) + return + } + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check session budget") + return + } + + quota, err = repo.ConsumeQuota(r.Context(), agentName) + if errors.Is(err, repo.ErrQuotaExhausted) { + repo.RefundSessionBudget(r.Context(), sessionID) + Error(w, http.StatusForbidden, fmt.Sprintf( + "Agent 全局发信配额已用尽(%d/%d)。插件代劳转发的权限询问与最终总结不占配额;"+ + "若需继续主动发信请联系管理员重置配额。", + quota.Used, quota.Max)) + return + } + if err != nil { + repo.RefundSessionBudget(r.Context(), sessionID) + Error(w, http.StatusInternalServerError, "Failed to check quota") + return + } + } + + // Agent 可以在正文里提议改会话别名()。 + // 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里 + // (react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。 + // + // 提议只是提议 —— 别名是人的寻址入口,Agent 干到一半自己改掉会让人 + // 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。 + proposal, body := extractRenameProposal(req.Body) + + mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID, + agentName, agentName, to.Name, to.Path, req.Subject, body, ccList) + if err != nil { + // 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了 + if relay != "" { + _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) + } + Error(w, http.StatusInternalServerError, "Failed to create mail") + return + } + if relay != "" { + // 关联失败不影响功能,只是少一条审计记录 + _ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID) + } + if proposal != nil { + // 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息 + _ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason) + } + + if !attachAll(w, r, mailID, attachIDs, agentName) { + return + } + + notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject) + + // 回传会话别名与剩余配额,让发件方知道后续用什么地址续谈、还能发几封 + resp := map[string]any{ + "mail_id": mailID.String(), + "session_id": sessionID.String(), + "session_alias": repo.SessionAliasOf(r.Context(), sessionID), + } + if !quota.Unlimited { + resp["quota_remaining"] = quota.Remaining + resp["quota_used"] = quota.Used + resp["quota_max"] = quota.Max + } + // 会话预算是【本任务】的剩余往返,Agent 更应该看这个而不是全局配额 + if !budget.Unlimited { + resp["budget_remaining"] = budget.Remaining + resp["budget_used"] = budget.Used + resp["budget_max"] = budget.Max + } + if relay != "" { + // 告知本次未扣配额,否则插件看到 quota_remaining 没变会以为数据错了 + resp["relay"] = relay + resp["quota_charged"] = false + } + if proposal != nil { + // 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过, + // 让它知道最终会拿什么去问用户 + resp["rename_proposed"] = proposal.Alias + } + JSON(w, http.StatusOK, resp) +} + +// notifyRecipients 向主收件人与抄送方推送 new_mail,并刷新相关方的会话列表。 +// 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间), +// 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。 +func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) { + payload := func(role string) map[string]interface{} { + return map[string]interface{}{ + "mail_id": mailID.String(), + "session_id": sessionID.String(), + "from_name": from, + "subject": subject, + "mail_type": "normal", + "role": role, // to / cc + } + } + + update := map[string]interface{}{ + "session_id": sessionID.String(), + "status": "active", + } + + // 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱) + seen := map[string]bool{} + + sse.Default.SendToRecipient(to.Name, "new_mail", payload("to")) + sse.Default.SendToRecipient(to.Name, "session_update", update) + seen[to.Name] = true + + for _, c := range cc { + if seen[c.Name] { + continue + } + seen[c.Name] = true + sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc")) + sse.Default.SendToRecipient(c.Name, "session_update", update) + } + + if !seen[from] { + sse.Default.SendToRecipient(from, "session_update", update) + } +} + +// GET /api/v1/mail/inbox +func GetInbox(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + status := r.URL.Query().Get("status") + if status == "" { + status = "unread" + } + limit := 10 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := parseInt(l); err == nil && n > 0 { + limit = n + } + } + + mails, err := repo.ListInbox(r.Context(), agentName, status, limit) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list inbox") + return + } + // Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id + for i := range mails { + fillAttachments(r, &mails[i]) + } + total, _ := repo.CountUnread(r.Context(), agentName) + + JSON(w, http.StatusOK, map[string]interface{}{ + "mails": emptySlice(mails), + "total": total, + }) +} + +// GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限 +func GetMail(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + + mail, err := repo.GetMailByID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Mail not found") + return + } + + allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该邮件") + return + } + fillAttachments(r, mail) + JSON(w, http.StatusOK, mail) +} + +// POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件 +func MarkMailRead(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + + mail, err := repo.GetMailByID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Mail not found") + return + } + allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权操作该邮件") + return + } + + if err := repo.MarkMailRead(r.Context(), mailID); err != nil { + Error(w, http.StatusInternalServerError, "Failed to mark read") + return + } + JSON(w, http.StatusOK, map[string]string{"status": "read"}) +} + +func parseInt(s string) (int, error) { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0, nil + } + n = n*10 + int(c-'0') + } + return n, nil +} diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go new file mode 100644 index 0000000..f6a2880 --- /dev/null +++ b/gateway/internal/handler/me.go @@ -0,0 +1,285 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" +) + +// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ---------- + +type meSendMailRequest struct { + To string `json:"to"` // name@path.session + CC string `json:"cc"` // 多个 name@path.session + Subject string `json:"subject"` + Body string `json:"body"` + ReplyTo string `json:"reply_to"` + // SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名 + SessionAlias string `json:"session_alias"` + // AttachmentIDs 先用 POST /me/attachments 上传拿到的 id + AttachmentIDs []string `json:"attachment_ids"` + // MaxRounds 是本次任务的往返预算(0/省略 = 不限)。 + // + // 配额的真实语义是「这件事值得多少个来回」——那是任务的属性, + // 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。 + // 仅在本次投递【新建】会话时生效;续谈已有会话请用 + // PUT /sessions/{id}/budget(对话页里可随时改)。 + MaxRounds *int `json:"max_rounds"` +} + +// POST /api/v1/me/mail/send +func MeSendMail(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req meSendMailRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.To == "" || req.Subject == "" || req.Body == "" { + Error(w, http.StatusBadRequest, "Missing to, subject, or body") + return + } + + to, err := models.ParseAddress(req.To) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error()) + return + } + ccList, err := models.ParseAddressList(req.CC) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error()) + return + } + attachIDs, err := parseAttachmentIDs(req.AttachmentIDs) + if err != nil { + Error(w, http.StatusBadRequest, err.Error()) + return + } + + // human@ 是兼容别名,人类发信时解析为自己 + to = resolveHumanAlias(to, user.Username) + for i := range ccList { + ccList[i] = resolveHumanAlias(ccList[i], user.Username) + } + + // 权限边界:校验可调用的 Agent 与可访问的目录 + if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" { + Error(w, http.StatusForbidden, msg) + return + } + + sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias) + if err != nil { + writeErr(w, err, "Failed to resolve session") + return + } + // 人类发起的会话归属于该用户 + _ = repo.SetSessionOwner(r.Context(), sessionID, user.ID) + + // 新建会话时接受往返预算。只在新建时设:续谈已有会话若也接受这个字段, + // 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。 + if req.MaxRounds != nil && parentMailID == nil { + if *req.MaxRounds < 0 { + Error(w, http.StatusBadRequest, "max_rounds 不能为负") + return + } + if _, err := repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds); err != nil { + Error(w, http.StatusInternalServerError, "Failed to set session budget") + return + } + } + + // 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议), + // 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。 + _, body := extractRenameProposal(req.Body) + + mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID, + user.Username, "", to.Name, to.Path, req.Subject, body, ccList) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to create mail") + return + } + + if !attachAll(w, r, mailID, attachIDs, user.Username) { + return + } + + notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject) + + resp := map[string]any{ + "mail_id": mailID.String(), + "session_id": sessionID.String(), + "session_alias": repo.SessionAliasOf(r.Context(), sessionID), + } + // 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」 + if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited { + resp["budget_max"] = b.Max + resp["budget_used"] = b.Used + resp["budget_remaining"] = b.Remaining + } + JSON(w, http.StatusOK, resp) +} + +// GET /api/v1/me/mail/inbox +func MeGetInbox(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + status := r.URL.Query().Get("status") + if status == "" { + status = "all" + } + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := parseInt(l); err == nil && n > 0 { + limit = n + } + } + + mails, err := repo.ListInbox(r.Context(), user.Username, status, limit) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list inbox") + return + } + // 列表页要显示附件图标与下载入口 + for i := range mails { + fillAttachments(r, &mails[i]) + } + total, _ := repo.CountUnread(r.Context(), user.Username) + + JSON(w, http.StatusOK, map[string]interface{}{ + "mails": emptySlice(mails), + "total": total, + }) +} + +// GET /api/v1/me/mail/sent +func MeGetSent(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := parseInt(l); err == nil && n > 0 { + limit = n + } + } + + mails, err := repo.ListSentBy(r.Context(), user.Username, limit) + if err == nil { + for i := range mails { + fillAttachments(r, &mails[i]) + } + } + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list sent") + return + } + JSON(w, http.StatusOK, map[string]interface{}{ + "mails": emptySlice(mails), + }) +} + +// GET /api/v1/me/sessions +func MeGetSessions(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + scope := user.Username + if user.IsAdmin() && r.URL.Query().Get("all") == "true" { + scope = "" + } + + sessions, err := repo.ListSessionsFor(r.Context(), scope, 50) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list sessions") + return + } + + type SessionOut struct { + SessionID uuid.UUID `json:"session_id"` + SessionAlias *string `json:"session_alias"` + FromAgent string `json:"from_agent"` + Subject string `json:"subject"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MailCount int `json:"mail_count"` + UnreadCount int `json:"unread_count"` + } + + result := make([]SessionOut, 0, len(sessions)) + for _, s := range sessions { + unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID) + result = append(result, SessionOut{ + SessionID: s.ID, + SessionAlias: s.Alias, + FromAgent: s.FromAgent, + Subject: s.Subject, + Status: s.Status, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + MailCount: s.MailCount, + UnreadCount: unread, + }) + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "sessions": result, + }) +} + +// resolveHumanAlias 把兼容别名 human 解析为具体用户名 +func resolveHumanAlias(a models.Address, username string) models.Address { + if a.Name != "human" { + return a + } + a.Name = username + a.Raw = username + "@" + a.Path + if a.Session != "" { + a.Raw += "." + a.Session + } + return a +} + +// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。 +// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。 +func checkScope(r *http.Request, user *models.User, addrs []models.Address) string { + if user.IsAdmin() { + return "" + } + for _, a := range addrs { + if a.Name == "" || a.Name == user.Username { + continue + } + isHuman, err := repo.IsHumanUser(r.Context(), a.Name) + if err != nil { + return "无法校验收件人权限" + } + if !isHuman && !user.CanUseAgent(a.Name) { + return "无权调用 Agent: " + a.Name + } + if !user.CanUsePath(a.Path) { + return "无权访问目录: " + a.Path + } + } + return "" +} diff --git a/gateway/internal/handler/permission.go b/gateway/internal/handler/permission.go new file mode 100644 index 0000000..01aceec --- /dev/null +++ b/gateway/internal/handler/permission.go @@ -0,0 +1,279 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" + "github.com/google/uuid" +) + +// ---------- Permission ---------- + +type permissionRequestRequest struct { + Question string `json:"question"` + Options []string `json:"options"` + Context string `json:"context"` + SessionID *string `json:"session_id"` + // 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。 + To string `json:"to"` + // RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。 + // + // 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」), + // 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放, + // 没有幂等键就会给同一次询问生成好几封邮件。 + RelayKey string `json:"relay_key"` +} + +type permissionDecideRequest struct { + MailID string `json:"mail_id"` + Decision string `json:"decision"` + Note string `json:"note"` +} + +// POST /api/v1/permission/request +func RequestPermission(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var req permissionRequestRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.Question == "" { + Error(w, http.StatusBadRequest, "Missing question") + return + } + + options := req.Options + if len(options) == 0 { + options = []string{"同意", "拒绝"} + } + + // 幂等:同一条上游询问只生成一封邮件。 + // 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。 + relayKey := strings.TrimSpace(req.RelayKey) + if relayKey != "" { + if len(relayKey) > 160 { + Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)") + return + } + if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil { + if errors.Is(err, repo.ErrRelayDuplicate) { + JSON(w, http.StatusOK, map[string]any{ + "status": "duplicate_relay", + "relay_key": relayKey, + "detail": "该权限询问已转发过,本次调用未产生新邮件", + }) + return + } + Error(w, http.StatusInternalServerError, "Failed to claim relay") + return + } + } + + // 确定 session + var sessionID uuid.UUID + if req.SessionID != nil && *req.SessionID != "" { + id, err := uuid.Parse(*req.SessionID) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid session_id") + return + } + sessionID = id + repo.TouchSession(r.Context(), sessionID) + } else { + id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to create session") + return + } + sessionID = id + } + + // 决策人:显式指定优先,否则取会话 owner + decider := req.To + if decider == "" || decider == "human" { + owner, err := repo.SessionOwnerUsername(r.Context(), sessionID) + if err == nil && owner != "" { + decider = owner + } + } + if decider == "" { + // 会话无归属(Agent 自发起)时退回默认管理员 + admin, err := repo.FirstAdminUsername(r.Context()) + if err != nil || admin == "" { + Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to") + return + } + decider = admin + } + + body := req.Context + if body == "" { + body = req.Question + } + mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options) + if err != nil { + // 归还幂等键,否则这次询问永远转不出来了 + if relayKey != "" { + _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) + } + Error(w, http.StatusInternalServerError, "Failed to create permission mail") + return + } + if relayKey != "" { + _ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID) + } + if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil { + Error(w, http.StatusInternalServerError, "Failed to create permission request") + return + } + + // 只推给该决策人 + sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{ + "mail_id": mailID.String(), + "session_id": sessionID.String(), + "from_name": agentName, + "subject": "权限请求: " + req.Question, + "mail_type": "permission_request", + "role": "to", + }) + + JSON(w, http.StatusOK, map[string]string{ + "mail_id": mailID.String(), + "session_id": sessionID.String(), + "permission_mail_id": mailID.String(), + "decider": decider, + }) +} + +// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策 +func DecidePermission(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req permissionDecideRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.MailID == "" || req.Decision == "" { + Error(w, http.StatusBadRequest, "Missing mail_id or decision") + return + } + + mailID, err := uuid.Parse(req.MailID) + if err != nil { + Error(w, http.StatusBadRequest, "Invalid mail_id UUID") + return + } + + perm, err := repo.GetPermissionByMailID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Permission request not found") + return + } + if perm.Result != nil && *perm.Result != "" { + Error(w, http.StatusConflict, "该请求已被处理") + return + } + + // 鉴权:必须是这封权限邮件的收件人,或管理员 + mail, err := repo.GetMailByID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Mail not found") + return + } + if !user.IsAdmin() && mail.ToName != user.Username { + Error(w, http.StatusForbidden, "无权决策他人的权限请求") + return + } + + // 决策选项必须在候选内 + if !contains(perm.Options, req.Decision) { + Error(w, http.StatusBadRequest, "决策必须是候选项之一") + return + } + + if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil { + Error(w, http.StatusInternalServerError, "Failed to decide permission") + return + } + + decisionMailID, err := repo.CreateDecisionMail( + r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to create decision mail") + return + } + + // 通知发起 Agent 恢复执行 + // 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。 + // 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上; + // 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。 + payload := map[string]interface{}{ + "mail_id": mailID.String(), + "decision_mail_id": decisionMailID.String(), + "decision": req.Decision, + "note": req.Note, + "decided_by": user.Username, + } + if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" { + payload["relay_key"] = key + payload["relay_kind"] = kind + } + sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload) + // 只刷新决策人自己的界面 + sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{ + "session_id": perm.SessionID.String(), + "status": "active", + }) + + JSON(w, http.StatusOK, map[string]string{ + "status": "decided", + "decision_mail_id": decisionMailID.String(), + }) +} + +// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的 +func ListPendingPermissions(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + + forUser := user.Username + if user.IsAdmin() && r.URL.Query().Get("all") == "true" { + forUser = "" + } + + reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list pending permissions") + return + } + JSON(w, http.StatusOK, map[string]interface{}{ + "requests": emptySlice(reqs), + }) +} + +func contains(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} diff --git a/gateway/internal/handler/ratelimit.go b/gateway/internal/handler/ratelimit.go new file mode 100644 index 0000000..4750bdc --- /dev/null +++ b/gateway/internal/handler/ratelimit.go @@ -0,0 +1,87 @@ +package handler + +import ( + "sync" + "time" +) + +// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间 +const ( + maxLoginFailures = 5 + lockoutDuration = 5 * time.Minute + failureWindow = 15 * time.Minute +) + +type failureRecord struct { + count int + firstSeen time.Time + lockedAt time.Time +} + +type loginLimiter struct { + mu sync.Mutex + recs map[string]*failureRecord +} + +var limiter = &loginLimiter{recs: make(map[string]*failureRecord)} + +// Locked 返回该用户名是否处于锁定期,以及剩余秒数 +func (l *loginLimiter) Locked(name string) (bool, int) { + l.mu.Lock() + defer l.mu.Unlock() + + r, ok := l.recs[name] + if !ok || r.lockedAt.IsZero() { + return false, 0 + } + elapsed := time.Since(r.lockedAt) + if elapsed >= lockoutDuration { + delete(l.recs, name) + return false, 0 + } + return true, int((lockoutDuration - elapsed).Seconds()) +} + +// Fail 记录一次失败,达到阈值则锁定 +func (l *loginLimiter) Fail(name string) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + r, ok := l.recs[name] + if !ok || now.Sub(r.firstSeen) > failureWindow { + l.recs[name] = &failureRecord{count: 1, firstSeen: now} + return + } + r.count++ + if r.count >= maxLoginFailures { + r.lockedAt = now + } +} + +// Reset 登录成功后清除失败计数 +func (l *loginLimiter) Reset(name string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.recs, name) +} + +// 定期清理过期记录,避免 map 无限增长 +func init() { + go func() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for range t.C { + limiter.mu.Lock() + now := time.Now() + for k, r := range limiter.recs { + stale := now.Sub(r.firstSeen) > failureWindow && + (r.lockedAt.IsZero() || now.Sub(r.lockedAt) > lockoutDuration) + if stale { + delete(limiter.recs, k) + } + } + limiter.mu.Unlock() + } + }() +} diff --git a/gateway/internal/handler/relay_test.go b/gateway/internal/handler/relay_test.go new file mode 100644 index 0000000..18e0220 --- /dev/null +++ b/gateway/internal/handler/relay_test.go @@ -0,0 +1,85 @@ +package handler + +import ( + "errors" + "testing" +) + +// parseRelay 是免配额通道的入口校验。白名单 + 强制幂等键这两条必须守住: +// 前者防止 relay 变成任意字符串的后门,后者是「同一条上游消息只转一次」的基础。 +func TestParseRelay(t *testing.T) { + cases := []struct { + name string + kind, key string + wantKind string + wantKey string + wantErr bool + }{ + {name: "都为空 = 普通自主发信,正常扣配额", kind: "", key: "", wantKind: "", wantKey: ""}, + {name: "总结转发", kind: "summary", key: "msg_1", wantKind: "summary", wantKey: "msg_1"}, + {name: "权限转发", kind: "permission", key: "per_1", wantKind: "permission", wantKey: "per_1"}, + {name: "两端空白被裁掉", kind: " summary ", key: " msg_2 ", wantKind: "summary", wantKey: "msg_2"}, + + // 白名单外的类型必须拒:否则 relay:"anything" 就绕过了配额 + {name: "未知类型", kind: "whatever", key: "k", wantErr: true}, + // 没有幂等键就无法阻止同一条上游消息反复转发 + {name: "缺幂等键", kind: "summary", key: "", wantErr: true}, + {name: "只给了键没给类型", kind: "", key: "k", wantErr: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kind, key, err := parseRelay(c.kind, c.key) + if c.wantErr { + if err == nil { + t.Fatalf("期望报错,实际通过:kind=%q key=%q", kind, key) + } + return + } + if err != nil { + t.Fatalf("意外报错: %v", err) + } + if kind != c.wantKind || key != c.wantKey { + t.Fatalf("得到 (%q, %q),期望 (%q, %q)", kind, key, c.wantKind, c.wantKey) + } + }) + } +} + +func TestParseRelayRejectsOverlongKey(t *testing.T) { + long := make([]byte, 161) + for i := range long { + long[i] = 'k' + } + if _, _, err := parseRelay("summary", string(long)); err == nil { + t.Fatal("超长 relay_key 应被拒绝(列宽 160)") + } +} + +// 免配额类型是白名单,不是黑名单。新增一种转发时必须同时更新这里, +// 免得悄悄多出一条不受审视的免费通道。 +func TestRelayKindsIsExactlyTwo(t *testing.T) { + want := map[string]bool{"permission": true, "summary": true} + if len(relayKinds) != len(want) { + t.Fatalf("免配额类型数量变了:%v。新增前请确认它确实是 harness 代劳而非模型自主发信", relayKinds) + } + for k := range want { + if !relayKinds[k] { + t.Fatalf("缺少免配额类型 %q", k) + } + } +} + +// 报错必须是 400 而不是 500:这些都是调用方参数问题 +func TestParseRelayErrorsAreBadRequest(t *testing.T) { + for _, c := range [][2]string{{"whatever", "k"}, {"summary", ""}, {"", "k"}} { + _, _, err := parseRelay(c[0], c[1]) + if err == nil { + t.Fatalf("(%q,%q) 应报错", c[0], c[1]) + } + var he httpError + if !errors.As(err, &he) || he.status != 400 { + t.Fatalf("(%q,%q) 的错误不是 400: %#v", c[0], c[1], err) + } + } +} diff --git a/gateway/internal/handler/rename_proposal.go b/gateway/internal/handler/rename_proposal.go new file mode 100644 index 0000000..cf9d695 --- /dev/null +++ b/gateway/internal/handler/rename_proposal.go @@ -0,0 +1,141 @@ +package handler + +import ( + "regexp" + "strings" +) + +// ---------- Agent 在正文里提议改会话别名 ---------- +// +// 与「平台命名自动同步」(POST /sessions/:id/sync)互补: +// 自动同步 = 平台起的名字,后台静默生效,不打扰人 +// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头 +// +// 为什么走正文而不是让 Agent 直接调 PUT alias: +// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。 +// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。 +// +// 载体选 HTML 注释: +// - react-markdown 默认不解析 raw HTML,注释在页面上不可见(实测渲染为转义文本节点, +// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它) +// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼 +// - 不与 Markdown 语法冲突,不会被格式化工具改写 + +// renameProposalRe 匹配 Agent 提议改名的标记。 +// +// 形如: +// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连 +// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。 +// +// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。 +var renameProposalRe = regexp.MustCompile( + `(?s)`) + +// RenameProposal 是从正文里解析出的一条改名提议。 +type RenameProposal struct { + // Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias + Alias string `json:"alias"` + // Reason 是 Agent 给出的理由,可为空 + Reason string `json:"reason,omitempty"` +} + +// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。 +// +// 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。 +// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里 +// (react-markdown 会把 HTML 注释转义成可见文本)。 +// +// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉: +// 与其在正文里留一行乱码,不如当它没提。 +func extractRenameProposal(body string) (*RenameProposal, string) { + matches := renameProposalRe.FindAllStringSubmatch(body, -1) + cleaned := stripProposalMarkers(body) + if len(matches) == 0 { + return nil, cleaned + } + + last := matches[len(matches)-1] + alias := normalizeAlias(strings.TrimSpace(last[1])) + if alias == "" { + return nil, cleaned + } + if err := validateSessionAlias(alias); err != nil { + return nil, cleaned + } + reason := "" + if len(last) > 2 { + reason = strings.TrimSpace(last[2]) + } + // 理由是展示给人看的一句话,过长会把提示条撑破 + const maxReason = 200 + if len(reason) > maxReason { + reason = preview(reason, maxReason) + } + return &RenameProposal{Alias: alias, Reason: reason}, cleaned +} + +// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。 +func stripProposalMarkers(body string) string { + out := renameProposalRe.ReplaceAllString(body, "") + // 标记独占一行时会留下连续空行,压成一个空行(Markdown 的段落分隔) + for strings.Contains(out, "\n\n\n") { + out = strings.ReplaceAll(out, "\n\n\n", "\n\n") + } + return strings.TrimSpace(out) +} + +// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo +// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。 +func preview(s string, max int) string { + if len(s) <= max { + return s + } + cut := max + for cut > 0 && s[cut]&0xC0 == 0x80 { + cut-- + } + return s[:cut] + "..." +} + +// ---------- 插件代劳转发(免配额通道) ---------- + +// relayKinds 是允许免配额的转发类型。 +// +// 白名单而不是任意字符串:免配额通道必须有明确边界, +// 否则 `relay: "whatever"` 就成了绕过配额的后门。 +// +// permission —— 平台原生的权限询问(opencode 的 permission.updated)。 +// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。 +// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。 +// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时 +// Agent 连交代都做不了。 +var relayKinds = map[string]bool{ + "permission": true, + "summary": true, +} + +// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。 +// 两者都为空表示这是普通的自主发信,正常扣配额。 +func parseRelay(kind, key string) (string, string, error) { + kind = strings.TrimSpace(kind) + key = strings.TrimSpace(key) + + if kind == "" { + if key != "" { + return "", "", errBadRequest("给了 relay_key 却没给 relay 类型") + } + return "", "", nil + } + if !relayKinds[kind] { + return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`) + } + // 幂等键是免配额通道的唯一约束基础,不能省: + // 没有它就无法阻止同一条上游消息被反复转发。 + if key == "" { + return "", "", errBadRequest("relay 转发必须带 relay_key(上游消息的稳定 id)") + } + if len(key) > 160 { + return "", "", errBadRequest("relay_key 过长(上限 160 字节)") + } + return kind, key, nil +} diff --git a/gateway/internal/handler/rename_proposal_test.go b/gateway/internal/handler/rename_proposal_test.go new file mode 100644 index 0000000..5357587 --- /dev/null +++ b/gateway/internal/handler/rename_proposal_test.go @@ -0,0 +1,143 @@ +package handler + +import "testing" + +func TestExtractRenameProposal(t *testing.T) { + cases := []struct { + name string + body string + wantAlias string + wantReason string + wantBody string + }{ + { + name: "无标记时原样返回", + body: "普通正文。", + wantAlias: "", + wantBody: "普通正文。", + }, + { + name: "带理由", + body: "已定位问题。\n\n", + wantAlias: "fix-login-leak", + wantReason: "是登录态泄漏", + wantBody: "已定位问题。", + }, + { + name: "无理由", + body: "\n\n正文在后。", + wantAlias: "cache-eval", + wantBody: "正文在后。", + }, + { + // Agent 在长回复里反复修正措辞,最后写下的才是它的结论 + name: "多条只取最后一条", + body: "\n中间\n", + wantAlias: "second", + wantBody: "中间", + }, + { + // 别名含 . / @ 会让三维地址切分歧义,normalizeAlias 改写为 - + name: "非法字符被规范化", + body: "", + wantAlias: "fix-login-leak-now", + wantBody: "", + }, + { + // "new" 是寻址保留字 + name: "保留字被改写", + body: "", + wantAlias: "session-new", + wantBody: "", + }, + { + // 规范化后为空 → 视为无提议,但标记仍要剥掉 + name: "空别名视为无提议且剥掉标记", + body: "正文\n", + wantAlias: "", + wantBody: "正文", + }, + { + name: "多余空格容错", + body: "", + wantAlias: "ok-name", + wantBody: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, body := extractRenameProposal(c.body) + gotAlias := "" + gotReason := "" + if p != nil { + gotAlias, gotReason = p.Alias, p.Reason + } + if gotAlias != c.wantAlias { + t.Errorf("alias = %q,期望 %q", gotAlias, c.wantAlias) + } + if gotReason != c.wantReason { + t.Errorf("reason = %q,期望 %q", gotReason, c.wantReason) + } + if body != c.wantBody { + t.Errorf("剥标记后正文 = %q,期望 %q", body, c.wantBody) + } + }) + } +} + +// 标记必须从入库正文里彻底消失:react-markdown 不解析 raw HTML, +// 留着会被转义成一行可见的乱码文本,而不是被渲染器吞掉。 +func TestProposalMarkerNeverSurvivesInBody(t *testing.T) { + bodies := []string{ + "", + "前\n\n后", + "", // 无效提议也要剥 + } + for _, b := range bodies { + _, out := extractRenameProposal(b) + if renameProposalRe.MatchString(out) { + t.Errorf("正文里仍残留标记:%q", out) + } + } +} + +// 提议出来的别名必须能直接通过 PUT alias 的校验, +// 否则前端点「接受」时会拿到 400 —— 系统自己造出了自己拒绝的值。 +func TestProposedAliasPassesValidation(t *testing.T) { + inputs := []string{ + "fix login.leak", + "new", + "a@b/c", + " spaced name ", + "正常中文别名", + } + for _, in := range inputs { + p, _ := extractRenameProposal("") + if p == nil { + continue // 规范化后为空,已按无提议处理 + } + if err := validateSessionAlias(p.Alias); err != nil { + t.Errorf("提议 %q → %q 未通过 validateSessionAlias: %v", in, p.Alias, err) + } + } +} + +func TestReasonTruncatedOnUTF8Boundary(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "很长的理由" + } + p, _ := extractRenameProposal("") + if p == nil { + t.Fatal("应当解析出提议") + } + if len(p.Reason) > 210 { // 200 + "..." + t.Errorf("理由未截断:%d 字节", len(p.Reason)) + } + for _, r := range p.Reason { + if r == 0xFFFD { + t.Fatal("截断产生了替换符,说明切在多字节字符中间") + } + } +} diff --git a/gateway/internal/handler/sessions.go b/gateway/internal/handler/sessions.go new file mode 100644 index 0000000..a40cdab --- /dev/null +++ b/gateway/internal/handler/sessions.go @@ -0,0 +1,340 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/sse" + "github.com/google/uuid" +) + +// ---------- Session(均需登录,且做会话级鉴权) ---------- + +// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问 +func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return uuid.Nil, false + } + sessionID, ok := pathUUID(w, r, "id") + if !ok { + return uuid.Nil, false + } + allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return uuid.Nil, false + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该会话") + return uuid.Nil, false + } + return sessionID, true +} + +// GET /api/v1/sessions/{id} +func GetSession(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + + session, err := repo.GetSessionByID(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusNotFound, "Session not found") + return + } + mails, err := repo.GetSessionMails(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to get session mails") + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "session": session, + "mails": emptySlice(mails), + }) +} + +// GET /api/v1/sessions/{id}/mails +func GetSessionMails(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + mails, err := repo.GetSessionMails(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to get mails") + return + } + // 会话线程要展示附件,逐封填充 + for i := range mails { + fillAttachments(r, &mails[i]) + } + JSON(w, http.StatusOK, map[string]interface{}{ + "mails": emptySlice(mails), + }) +} + +type updateAliasRequest struct { + Alias string `json:"alias"` +} + +// PUT /api/v1/sessions/{id}/alias +// +// 会话别名负责三维寻址(name@path.),因此必须全局唯一, +// 且不能叫 "new"(那是寻址保留字)。 +func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + + var req updateAliasRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + alias := strings.TrimSpace(req.Alias) + if alias == "" { + Error(w, http.StatusBadRequest, "Missing alias") + return + } + if err := validateSessionAlias(alias); err != nil { + writeErr(w, err, "Invalid alias") + return + } + + // 被其他会话占用时报 409,而不是默默造出两个同名可寻址会话 + if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID { + Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用") + return + } + + if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil { + Error(w, http.StatusInternalServerError, "Failed to update alias") + return + } + JSON(w, http.StatusOK, map[string]string{ + "status": "updated", + "alias": alias, + }) +} + +// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。 +// +// 各 Agent 平台(opencode / Claude Code / DSH…)都会由模型为会话生成一个摘要标题, +// 并配一个短 slug。不在本侧另造一套命名:平台那边叫什么,本侧就叫什么。 +type syncSessionRequest struct { + // Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。 + Alias string `json:"alias"` + // Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。 + Title string `json:"title"` +} + +// POST /api/v1/sessions/{id}/sync +// +// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。 +// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一), +// 因此本接口不会因撞名失败,响应里回传最终落库的别名。 +func SyncSession(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + sessionID, ok := pathUUID(w, r, "id") + if !ok { + return + } + + // Agent 只能同步自己参与过的会话 + allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权修改未参与的会话") + return + } + + var req syncSessionRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + + resp := map[string]string{"status": "synced"} + + if title := strings.TrimSpace(req.Title); title != "" { + if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil { + Error(w, http.StatusInternalServerError, "Failed to sync title") + return + } + resp["title"] = title + } + + if alias := strings.TrimSpace(req.Alias); alias != "" { + // 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化 + norm := normalizeAlias(alias) + if norm == "" { + Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名") + return + } + final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to sync alias") + return + } + resp["alias"] = final + } + + // 让参与方前端立即看到新标题/别名 + sse.Default.Broadcast("session_update", map[string]interface{}{ + "session_id": sessionID.String(), + "alias": resp["alias"], + "title": resp["title"], + }) + + JSON(w, http.StatusOK, resp) +} + +// GET /api/v1/sessions/{id}/rename-proposal +// +// 返回该会话里最新一条尚未处理的改名提议(Agent 在正文里提的)。 +// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。 +// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。 +func GetRenameProposal(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to load proposal") + return + } + if alias == "" { + JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil}) + return + } + JSON(w, http.StatusOK, map[string]interface{}{ + "proposal": map[string]string{"alias": alias, "reason": reason}, + }) +} + +// POST /api/v1/sessions/{id}/rename-proposal/dismiss +// +// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 —— +// 否则每次打开会话都要重新点一次「忽略」。 +// +// 接受提议走已有的 PUT /sessions/{id}/alias,不另开端点: +// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。 +func DismissRenameProposal(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to load proposal") + return + } + if alias == "" { + // 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功 + JSON(w, http.StatusOK, map[string]string{"status": "no_pending"}) + return + } + if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil { + Error(w, http.StatusInternalServerError, "Failed to dismiss proposal") + return + } + JSON(w, http.StatusOK, map[string]string{ + "status": "dismissed", + "dismissed": alias, + }) +} + +type sessionBudgetRequest struct { + // MaxRounds 是本会话的往返预算上限(0 = 不限)。 + MaxRounds *int `json:"max_rounds"` + // Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给: + // 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。 + Reset bool `json:"reset"` +} + +// GET /api/v1/sessions/{id}/budget +// +// 本会话的往返预算。与 Agent 全局配额是两层,都要过: +// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。 +func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + b, err := repo.GetSessionBudget(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusNotFound, "Session not found") + return + } + JSON(w, http.StatusOK, b) +} + +// PUT /api/v1/sessions/{id}/budget +// +// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方: +// 人看着往来内容才知道这件事还值不值得再来几个回合。 +func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) { + sessionID, ok := requireSessionAccess(w, r) + if !ok { + return + } + var req sessionBudgetRequest + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.MaxRounds == nil && !req.Reset { + Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset") + return + } + + b, err := repo.GetSessionBudget(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusNotFound, "Session not found") + return + } + if req.MaxRounds != nil { + if *req.MaxRounds < 0 { + Error(w, http.StatusBadRequest, "max_rounds 不能为负") + return + } + // 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。 + // 此时剩余为 0,Agent 下次发信即被拦。 + b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to set budget") + return + } + } + if req.Reset { + b, err = repo.ResetSessionBudget(r.Context(), sessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to reset budget") + return + } + } + + // 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算 + sse.Default.Broadcast("session_update", map[string]interface{}{ + "session_id": sessionID.String(), + "budget_max": b.Max, + "budget_used": b.Used, + "budget_remaining": b.Remaining, + }) + JSON(w, http.StatusOK, b) +} diff --git a/gateway/internal/handler/thread.go b/gateway/internal/handler/thread.go new file mode 100644 index 0000000..ac4723c --- /dev/null +++ b/gateway/internal/handler/thread.go @@ -0,0 +1,209 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" +) + +// ---------- 对话树(按方向分块加载) ---------- + +// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 —— +// 那就等于绕过了分块加载。 +const ( + threadDefaultLimit = 40 + threadMaxLimit = 200 +) + +// threadNode 是返回给前端的树节点。 +// +// Detached 表示「这封的父邮件当前不在返回集里」,两种原因: +// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者) +// - 父邮件还没加载(分块加载的边界,往上滑会补上) +// +// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。 +// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。 +type threadNode struct { + repo.TreeMail + Detached bool `json:"detached,omitempty"` + // ParentHidden 为真表示父邮件确实存在但无权查看(不是尚未加载) + ParentHidden bool `json:"parent_hidden,omitempty"` +} + +// GET /api/v1/mail/{id}/thread +// +// 以给定邮件为锚点,按方向分块返回线索: +// +// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用 +// ?dir=up&offset=N 继续往上取祖先(上滑加载) +// ?dir=down&offset=N 继续往下取子孙 +// +// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数), +// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。 +// +// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**, +// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。 +func GetMailThread(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + Error(w, http.StatusUnauthorized, "not authenticated") + return + } + mailID, ok := pathUUID(w, r, "id") + if !ok { + return + } + + // 先确认调用者确实看得到作为锚点的这封邮件,否则等于给了一个 + // 「随便报 mail_id 就能探测线索存在性」的接口 + mail, err := repo.GetMailByID(r.Context(), mailID) + if err != nil { + Error(w, http.StatusNotFound, "Mail not found") + return + } + allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to check permission") + return + } + if !allowed { + Error(w, http.StatusForbidden, "无权访问该邮件") + return + } + + dir := r.URL.Query().Get("dir") + if dir == "" { + dir = "around" + } + if dir != "around" && dir != "up" && dir != "down" { + Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down") + return + } + limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit) + offset := intQuery(r, "offset", 0, 0, 1<<20) + + // 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费 + seen := map[uuid.UUID]bool{} + canSee := func(sid uuid.UUID) bool { + if v, ok := seen[sid]; ok { + return v + } + v, err := repo.UserCanAccessSession(r.Context(), user, sid) + if err != nil { + v = false // 查不出来就当看不到:宁可少给,不可多给 + } + seen[sid] = v + return v + } + + var ( + raw []repo.TreeMail + hasMoreUp bool + hasMoreDn bool + wantUp = dir == "around" || dir == "up" + wantDown = dir == "around" || dir == "down" + upOffset = offset + downOffset = offset + ) + + // around 时两个方向各取一半,避免首屏一次要求 2×limit。 + // 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。 + upLimit, downLimit := limit, limit + if dir == "around" { + upLimit = limit / 2 + if upLimit < 1 { + upLimit = 1 + } + downLimit = limit - upLimit + if downLimit < 1 { + downLimit = 1 + } + upOffset, downOffset = 0, 0 + } + + if wantUp { + anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to load ancestors") + return + } + raw = append(raw, anc...) + hasMoreUp = more + } + if wantDown { + // around 与 down&offset=0 会带上锚点自己(Depth 0); + // up 方向单独请求时不带,前端已经有它了 + desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to load descendants") + return + } + raw = append(raw, desc...) + hasMoreDn = more + } + + // 可见性过滤。父节点是否在**本次返回集**里决定 detached; + // 父存在却不在集里,再判断是"无权看"还是"没加载"。 + visible := map[uuid.UUID]bool{} + for _, m := range raw { + if canSee(m.SessionID) { + visible[m.ID] = true + } + } + + nodes := []threadNode{} + for _, m := range raw { + if !visible[m.ID] { + continue + } + n := threadNode{TreeMail: m} + if m.ParentMailID != nil && !visible[*m.ParentMailID] { + n.Detached = true + // 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看; + // 完全没出现过 = 只是还没加载到,往上滑会补上 + for _, other := range raw { + if other.ID == *m.ParentMailID { + n.ParentHidden = true + break + } + } + } + nodes = append(nodes, n) + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "anchor_mail_id": mailID, + "dir": dir, + "nodes": nodes, + "total": len(nodes), + "hidden": len(raw) - len(nodes), + // 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。 + "has_more_up": hasMoreUp, + "has_more_down": hasMoreDn, + "next_up": upOffset + upLimit, + "next_down": downOffset + downLimit, + }) +} + +// intQuery 读取整数 query 参数并夹到 [min, max]。 +// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。 +func intQuery(r *http.Request, key string, def, min, max int) int { + s := r.URL.Query().Get(key) + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil { + return def + } + if v < min { + return min + } + if v > max { + return max + } + return v +} diff --git a/gateway/internal/handler/thread_test.go b/gateway/internal/handler/thread_test.go new file mode 100644 index 0000000..6c2cdd9 --- /dev/null +++ b/gateway/internal/handler/thread_test.go @@ -0,0 +1,26 @@ +package handler + +import ( + "net/http/httptest" + "testing" +) + +func TestIntQueryClampsAndFallsBack(t *testing.T) { + cases := []struct { + q string + want int + }{ + {"", 40}, // 缺省 + {"limit=10", 10}, // 正常 + {"limit=0", 1}, // 低于下限 → 夹到下限 + {"limit=999", 200}, // 高于上限 → 夹到上限 + {"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400 + {"limit=-5", 1}, + } + for _, c := range cases { + r := httptest.NewRequest("GET", "/x?"+c.q, nil) + if got := intQuery(r, "limit", 40, 1, 200); got != c.want { + t.Fatalf("intQuery(%q) = %d,期望 %d", c.q, got, c.want) + } + } +} diff --git a/gateway/internal/middleware/auth.go b/gateway/internal/middleware/auth.go new file mode 100644 index 0000000..244ffdf --- /dev/null +++ b/gateway/internal/middleware/auth.go @@ -0,0 +1,98 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/repo" +) + +type contextKey string + +const AgentNameKey contextKey = "agent_name" + +// bearerToken 从 Authorization: Bearer 取出令牌,缺失时返回空串。 +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + return "" + } + const p = "Bearer " + if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) { + return strings.TrimSpace(h[len(p):]) + } + return "" +} + +// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。 +func BearerToken(r *http.Request) string { return bearerToken(r) } + +// keyAuthError 把密钥校验错误翻译成对外文案。 +// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了, +// 运维需要据此判断该重新签发还是该检查配置。 +func keyAuthError(err error) string { + switch { + case errors.Is(err, repo.ErrKeyUsed): + return `{"error":"密钥已使用(一次性密钥只能用一次)"}` + case errors.Is(err, repo.ErrKeyExpired): + return `{"error":"密钥已过期"}` + default: + return `{"error":"密钥无效"}` + } +} + +// AgentAuth 验证 Agent 身份,支持两种凭证: +// +// Authorization: Bearer —— 密钥认证(推荐) +// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留) +// +// 用户密钥(user_keys)不接受:两类密钥共享 token 命名空间但走各自的验证表, +// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。 +func AgentAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token := bearerToken(r); token != "" { + agentName, err := repo.VerifyAgentKey(r.Context(), token) + if err != nil { + http.Error(w, keyAuthError(err), http.StatusUnauthorized) + return + } + if agentName == "" { + // 密钥有效但尚未绑定 Agent:注册接口会用请求里的 name 落定它, + // 其余接口无法确定调用者身份,只能拒。 + http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden) + return + } + repo.HeartbeatAgent(r.Context(), agentName) + ctx := context.WithValue(r.Context(), AgentNameKey, agentName) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + agentName := r.Header.Get("X-Agent-Name") + agentSecret := r.Header.Get("X-Agent-Secret") + if agentName == "" || agentSecret == "" { + http.Error(w, `{"error":"Missing Authorization: Bearer or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized) + return + } + + agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret) + if err != nil { + http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized) + return + } + + repo.HeartbeatAgent(r.Context(), agent.Name) + ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// GetAgentName 从 context 中获取 agent_name +func GetAgentName(r *http.Request) string { + if v := r.Context().Value(AgentNameKey); v != nil { + return v.(string) + } + return "" +} diff --git a/gateway/internal/middleware/user.go b/gateway/internal/middleware/user.go new file mode 100644 index 0000000..9545ee2 --- /dev/null +++ b/gateway/internal/middleware/user.go @@ -0,0 +1,161 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/config" + "github.com/agentmail/gateway/internal/models" + "github.com/agentmail/gateway/internal/repo" +) + +const UserKey contextKey = "auth_user" + +// SetSessionCookie 写入登录 Cookie +func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: config.C.CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: config.C.SecureCookie, + SameSite: http.SameSiteLaxMode, + MaxAge: maxAge, + }) +} + +// ClearSessionCookie 清除登录 Cookie +func ClearSessionCookie(w http.ResponseWriter) { + SetSessionCookie(w, "", -1) +} + +// SessionToken 从请求中取出登录令牌 +func SessionToken(r *http.Request) string { + c, err := r.Cookie(config.C.CookieName) + if err != nil { + return "" + } + return c.Value +} + +// QueryToken 从 ?access_token= 取出令牌。 +// +// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时 +// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志, +// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。 +func QueryToken(r *http.Request) string { + return strings.TrimSpace(r.URL.Query().Get("access_token")) +} + +// UserAuth 校验人类用户登录态,把 *models.User 注入 context。 +// 支持两种凭证:浏览器 Cookie,或 Authorization: Bearer (第三方客户端)。 +func UserAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, err := resolve(r) + if err != nil { + // 只有 Cookie 路径才清 Cookie;密钥认证失败不应频带浏览器会话 + if bearerToken(r) == "" { + ClearSessionCookie(w) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"not authenticated"}`)) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u))) + }) +} + +// AdminOnly 叠在 UserAuth 之后,要求 role = admin +func AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := GetUser(r) + if u == nil || !u.IsAdmin() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"admin only"}`)) + return + } + next.ServeHTTP(w, r) + }) +} + +// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。 +// +// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 )。 +// URL 里的令牌会进访问日志与 Referer,所以不能全局开启。 +func UserAuthAllowQueryToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, err := resolve(r) + if err != nil { + if token := QueryToken(r); token != "" { + if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil { + u, err = ku, nil + } + } + } + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"not authenticated"}`)) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u))) + }) +} + +// GetUser 从 context 取登录用户;未登录返回 nil +func GetUser(r *http.Request) *models.User { + if v := r.Context().Value(UserKey); v != nil { + if u, ok := v.(*models.User); ok { + return u + } + } + return nil +} + +// GetUserName 便捷取登录用户名 +func GetUserName(r *http.Request) string { + if u := GetUser(r); u != nil { + return u.Username + } + return "" +} + +// OptionalUser 解析登录态但不拦截(SSE 等需要区分匿名/登录的场景) +func OptionalUser(r *http.Request) *models.User { + u, err := resolve(r) + if err != nil { + return nil + } + return u +} + +// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。 +// 仅 SSE 用:浏览器 EventSource 无法带自定义头。 +func OptionalUserWithQuery(r *http.Request) *models.User { + if u := OptionalUser(r); u != nil { + return u + } + if token := QueryToken(r); token != "" { + if u, err := repo.VerifyUserKey(r.Context(), token); err == nil { + return u + } + } + return nil +} + +// resolve 解析调用者身份:Cookie 优先,其次 Bearer 用户密钥。 +// +// 用户密钥只能走到这里(/me/* 与会话级接口),Agent 密钥只能走 AgentAuth, +// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。 +func resolve(r *http.Request) (*models.User, error) { + if token := SessionToken(r); token != "" { + return repo.ResolveUserSession(r.Context(), token) + } + if token := bearerToken(r); token != "" { + return repo.VerifyUserKey(r.Context(), token) + } + return nil, repo.ErrSessionInvalid +} diff --git a/gateway/internal/models/address.go b/gateway/internal/models/address.go new file mode 100644 index 0000000..402cc5d --- /dev/null +++ b/gateway/internal/models/address.go @@ -0,0 +1,138 @@ +package models + +import ( + "fmt" + "strings" +) + +// Address 是三维寻址 name@path.session 的解析结果 +type Address struct { + Name string `json:"name"` // Agent 实例名(或人类用户名) + Path string `json:"path"` // 工作区路径(可含 /,可为空) + Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话 + Raw string `json:"raw"` // 原始字符串 +} + +// SessionMode 是 session 位的三种语义 +type SessionMode int + +const ( + // SessionDefault:session 位省略 → 投递到 name@path 的默认会话(不存在则建立) + SessionDefault SessionMode = iota + // SessionNew:session 位为 new → 强制新建一个会话 + SessionNew + // SessionNamed:session 位为具体别名 → 必须已存在,否则无法送达 + SessionNamed +) + +// Mode 返回该地址 session 位的语义 +func (a Address) Mode() SessionMode { + switch a.Session { + case "": + return SessionDefault + case "new": + return SessionNew + default: + return SessionNamed + } +} + +// IsNewSession 表示该地址要求新建会话(仅 session == "new")。 +// 注意:session 位省略不等于 new,那是「默认会话」,见 Mode()。 +func (a Address) IsNewSession() bool { + return a.Mode() == SessionNew +} + +// IsDefaultSession 表示该地址省略了 session 位,走默认会话 +func (a Address) IsDefaultSession() bool { + return a.Mode() == SessionDefault +} + +func (a Address) String() string { + return a.Raw +} + +// ParseAddress 解析 name@path.session 三维地址。 +// +// 支持形态: +// +// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature +// pi@root.new → name=pi path=root session=new(新建) +// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak +// human@.new → name=human path="" session=new +// human → name=human path="" session=""(默认会话) +// +// 规则: +// - 第一个 @ 之前是 name(必填) +// - @ 之后按【最后一个 .】切成 path 与 session,因此 path 内可以包含 . 与 / +// - 没有 . 时,整段视为 path,session 为空(默认会话) +// +// session 位三态语义见 Address.Mode():省略=默认会话,new=新建,其他=必须已存在。 +func ParseAddress(s string) (Address, error) { + raw := strings.TrimSpace(s) + if raw == "" { + return Address{}, fmt.Errorf("empty address") + } + + // 只有形如 "@name@path.session" 时才剥掉前导 @; + // "@ModelRouter.new" 缺少 name,应当报错而不是被当成名字。 + trimmed := raw + if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") { + trimmed = raw[1:] + } + + at := strings.Index(trimmed, "@") + if at < 0 { + // 只有名字:human / builder + name := strings.TrimSpace(trimmed) + if name == "" { + return Address{}, fmt.Errorf("missing agent name in %q", raw) + } + return Address{Name: name, Raw: raw}, nil + } + + name := strings.TrimSpace(trimmed[:at]) + if name == "" { + return Address{}, fmt.Errorf("missing agent name in %q", raw) + } + + rest := trimmed[at+1:] + + // 按最后一个 . 切 path / session;path 内允许 / 与 . + var path, session string + if dot := strings.LastIndex(rest, "."); dot >= 0 { + path = rest[:dot] + session = rest[dot+1:] + } else { + path = rest + } + + return Address{ + Name: name, + Path: strings.TrimSpace(path), + Session: strings.TrimSpace(session), + Raw: raw, + }, nil +} + +// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC) +func ParseAddressList(s string) ([]Address, error) { + raw := strings.TrimSpace(s) + if raw == "" { + return nil, nil + } + + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' + }) + + out := make([]Address, 0, len(fields)) + for _, f := range fields { + addr, err := ParseAddress(f) + if err != nil { + return nil, err + } + out = append(out, addr) + } + return out, nil +} diff --git a/gateway/internal/models/address_test.go b/gateway/internal/models/address_test.go new file mode 100644 index 0000000..3f474d6 --- /dev/null +++ b/gateway/internal/models/address_test.go @@ -0,0 +1,94 @@ +package models + +import "testing" + +func TestParseAddress(t *testing.T) { + cases := []struct { + in string + name string + path string + session string + mode SessionMode + }{ + // 用户给出的两个例子 + {"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed}, + {"pi@root.new", "pi", "root", "new", SessionNew}, + + // 常规形态 + {"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed}, + {"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew}, + {"human@.new", "human", "", "new", SessionNew}, + + // 省略 session 位 = 默认会话(不等于 new) + {"human", "human", "", "", SessionDefault}, + {"ops@prod", "ops", "prod", "", SessionDefault}, + + // path 内含 . 与 /(按最后一个 . 切) + {"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed}, + } + + for _, c := range cases { + got, err := ParseAddress(c.in) + if err != nil { + t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err) + } + if got.Name != c.name || got.Path != c.path || got.Session != c.session { + t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}", + c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session) + } + if got.Mode() != c.mode { + t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode) + } + } +} + +// session 位省略与 new 必须是两种不同语义: +// 省略 → 默认会话;new → 强制新建;其他 → 必须已存在。 +func TestSessionModeSemantics(t *testing.T) { + def, _ := ParseAddress("pi@root") + if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() { + t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession()) + } + + new_, _ := ParseAddress("pi@root.new") + if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() { + t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode()) + } + + named, _ := ParseAddress("pi@root.fix-leak") + if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() { + t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode()) + } +} + +func TestParseAddressErrors(t *testing.T) { + for _, in := range []string{"", " ", "@", "@ModelRouter.new"} { + if _, err := ParseAddress(in); err == nil { + t.Errorf("ParseAddress(%q) expected error, got nil", in) + } + } +} + +func TestParseAddressList(t *testing.T) { + list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list) != 3 { + t.Fatalf("got %d addresses, want 3", len(list)) + } + if list[0].Name != "pi" || !list[0].IsNewSession() { + t.Errorf("addr[0] = %+v", list[0]) + } + if list[1].Path != "/program" || list[1].Session != "upadtefeature" { + t.Errorf("addr[1] = %+v", list[1]) + } + if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault { + t.Errorf("addr[2] = %+v", list[2]) + } + + empty, err := ParseAddressList(" ") + if err != nil || empty != nil { + t.Errorf("empty list = %v, %v; want nil, nil", empty, err) + } +} diff --git a/gateway/internal/models/models.go b/gateway/internal/models/models.go new file mode 100644 index 0000000..74d4361 --- /dev/null +++ b/gateway/internal/models/models.go @@ -0,0 +1,242 @@ +package models + +import ( + "strings" + "time" + + "github.com/google/uuid" +) + +// Agent 代表一个已注册的 Agent 实例 +type Agent struct { + ID uuid.UUID `json:"agent_id"` + Name string `json:"agent_name"` + Secret string `json:"-"` + HostURL string `json:"host_url"` + Workspaces []Workspace `json:"workspaces"` + Platform string `json:"platform"` + Status string `json:"status"` + MaxRounds int `json:"max_rounds"` + UsedRounds int `json:"used_rounds"` + LastSeen *time.Time `json:"last_seen"` + CreatedAt time.Time `json:"created_at"` +} + +// Workspace 是 Agent 管理的项目工作区 +type Workspace struct { + Name string `json:"name"` + Path string `json:"path"` +} + +// Session 是有明确边界的任务会话 +type Session struct { + ID uuid.UUID `json:"session_id"` + Alias *string `json:"session_alias"` + FromAgent string `json:"from_agent"` + Subject string `json:"subject"` + Status string `json:"status"` + OwnerUserID *uuid.UUID `json:"owner_user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MailCount int `json:"mail_count,omitempty"` + + // RenameDismissed 是用户驳回过的改名提议。 + // 记下来才能让提示条不再反复弹同一个建议。 + RenameDismissed string `json:"rename_dismissed,omitempty"` + + // AliasSource 记录别名是谁定的: + // platform = Agent 平台自动同步来的,后续同步可以覆盖 + // manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖 + // 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。 + AliasSource string `json:"alias_source,omitempty"` + + // MaxRounds/UsedRounds 是本任务的往返预算(0 = 本会话不限)。 + // 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性, + // 所以在写信时给、在对话页里随时调。 + MaxRounds int `json:"max_rounds"` + UsedRounds int `json:"used_rounds"` +} + +// User 是人类用户(多用户账号体系) +type User struct { + ID uuid.UUID `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + PasswordHash string `json:"-"` + Role string `json:"role"` // admin / user + Status string `json:"status"` // active / disabled + CreatedAt time.Time `json:"created_at"` + LastLogin *time.Time `json:"last_login"` + + // 权限边界:空切片 = 不限 + AllowedAgents []string `json:"allowed_agents"` + AllowedPaths []string `json:"allowed_paths"` +} + +// IsAdmin 判断是否管理员 +func (u User) IsAdmin() bool { return u.Role == "admin" } + +// CanUseAgent 判断用户是否可向指定 Agent 发信 +// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制 +func (u User) CanUseAgent(agentName string) bool { + if u.IsAdmin() || len(u.AllowedAgents) == 0 { + return true + } + for _, a := range u.AllowedAgents { + if a == agentName { + return true + } + } + return false +} + +// CanUsePath 判断用户是否可访问指定工作区。 +// 空白名单 = 不限;管理员不受限;空 path(人类地址)总是允许。 +// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub)。 +func (u User) CanUsePath(path string) bool { + if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" { + return true + } + for _, p := range u.AllowedPaths { + if p == "" { + continue + } + if path == p { + return true + } + prefix := p + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +// Mail 是会话中的一封邮件 +type Mail struct { + ID uuid.UUID `json:"mail_id"` + SessionID uuid.UUID `json:"session_id"` + ParentMailID *uuid.UUID `json:"parent_mail_id"` + FromName string `json:"from_name"` + FromWorkspace string `json:"from_workspace"` + ToName string `json:"to_name"` + ToWorkspace string `json:"to_workspace"` + CCList []Address `json:"cc_list"` + Subject string `json:"subject"` + Body string `json:"body"` + MailType string `json:"mail_type"` + PermOptions []string `json:"permission_options,omitempty"` + PermResult string `json:"permission_result,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + HopLimit int `json:"hop_limit"` + + SessionAlias string `json:"session_alias,omitempty"` + BodyPreview string `json:"body_preview,omitempty"` + + // Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空 + Attachments []Attachment `json:"attachments,omitempty"` + + // RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。 + // 存在邮件上而非会话上:邮件是不可篡改的历史记录, + // 「谁在哪一封里提了什么」应当留痕。 + RenameAlias string `json:"rename_alias,omitempty"` + RenameReason string `json:"rename_reason,omitempty"` +} + +// PermissionRequest 是 Agent 向人类发起的权限请求 +type PermissionRequest struct { + ID uuid.UUID `json:"request_id"` + MailID uuid.UUID `json:"mail_id"` + SessionID uuid.UUID `json:"session_id"` + AgentName string `json:"agent_name"` + Question string `json:"question"` + Options []string `json:"options"` + Context string `json:"context"` + Result *string `json:"result"` + DecidedAt *time.Time `json:"decided_at"` + CreatedAt time.Time `json:"created_at"` +} + +// SSE 事件类型 +const ( + EventNewMail = "new_mail" + EventPermissionDecision = "permission_decision" + EventSessionUpdate = "session_update" + EventAgentOnline = "agent_online" +) + +// ---------- 密钥认证 ---------- + +// 密钥类型:签发时决定其生命周期 +const ( + // KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个) + KeyPermanent = "permanent" + // KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小) + KeyOneTime = "one_time" + // KeyTimed 到 ExpiresAt 之后失效 + KeyTimed = "timed" +) + +// ValidKeyType 判断密钥类型是否受支持 +func ValidKeyType(t string) bool { + return t == KeyPermanent || t == KeyOneTime || t == KeyTimed +} + +// AgentKey 是管理员签发的 Agent 接入密钥。 +// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent, +// 首次注册时由注册请求里的 name 落定。 +type AgentKey struct { + ID uuid.UUID `json:"key_id"` + Token string `json:"key_token,omitempty"` // 仅创建时回显一次 + TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示 + AgentName *string `json:"agent_name"` + KeyType string `json:"key_type"` + Label string `json:"label"` + ExpiresAt *time.Time `json:"expires_at"` + UsedAt *time.Time `json:"used_at"` + CreatedBy *uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。 +type UserKey struct { + ID uuid.UUID `json:"key_id"` + Token string `json:"key_token,omitempty"` // 仅创建时回显一次 + TokenHint string `json:"token_hint"` + UserID uuid.UUID `json:"user_id"` + Label string `json:"label"` + KeyType string `json:"key_type"` + ExpiresAt *time.Time `json:"expires_at"` + UsedAt *time.Time `json:"used_at"` + CreatedAt time.Time `json:"created_at"` +} + +// TokenHint 返回密钥的展示形式:只露前 8 位。 +// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。 +func TokenHint(token string) string { + if len(token) <= 8 { + return token + } + return token[:8] + "…" +} + +// ---------- 附件 ---------- + +// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。 +// +// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作 +//(Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。 +type Attachment struct { + ID uuid.UUID `json:"attachment_id"` + MailID *uuid.UUID `json:"mail_id"` + Uploader string `json:"uploader"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/gateway/internal/repo/attachments.go b/gateway/internal/repo/attachments.go new file mode 100644 index 0000000..8d365dd --- /dev/null +++ b/gateway/internal/repo/attachments.go @@ -0,0 +1,226 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// ---------- 附件 ---------- +// +// 元数据在库、内容在磁盘(internal/blob)。两者的一致性由调用顺序保证: +// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。 +// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。 + +var ( + // ErrAttachmentNotFound 附件不存在 + ErrAttachmentNotFound = errors.New("attachment not found") + // ErrAttachmentNotOwned 附件不属于该上传者 + ErrAttachmentNotOwned = errors.New("attachment not owned by uploader") + // ErrAttachmentAlreadyAttached 附件已挂到别的邮件上 + ErrAttachmentAlreadyAttached = errors.New("attachment already attached") +) + +const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at` + +func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) { + var a models.Attachment + if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename, + &a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil { + return nil, err + } + return &a, nil +} + +// CreateAttachment 登记一条待挂载的附件(mail_id 为空)。 +func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) { + a := &models.Attachment{ + Uploader: uploader, + Filename: filename, + ContentType: contentType, + SizeBytes: size, + SHA256: sum, + } + err := db.DB.QueryRowContext(ctx, ` + INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256) + VALUES ($1, $2, $3, $4, $5) + RETURNING attachment_id, created_at + `, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt) + if err != nil { + return nil, err + } + return a, nil +} + +// GetAttachment 读取一条附件元数据。 +func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) { + a, err := scanAttachment(db.DB.QueryRowContext(ctx, + `SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAttachmentNotFound + } + return a, err +} + +// ListAttachmentsFor 列出某封邮件的附件。 +func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.Attachment{} + for rows.Next() { + a, err := scanAttachment(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} + +// AttachToMail 把一批待挂载附件绑到某封邮件上。 +// +// 每条都要求:存在、属于该上传者、且尚未挂载。 +// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入, +// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。 +func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error { + for _, id := range ids { + tag, err := db.DB.ExecContext(ctx, ` + UPDATE attachments SET mail_id = $1 + WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL + `, mailID, id, uploader) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n > 0 { + continue + } + + // 没改到:查明原因,给调用方一个能照着修的错误 + a, gErr := GetAttachment(ctx, id) + if gErr != nil { + return gErr + } + if a.Uploader != uploader { + return ErrAttachmentNotOwned + } + return ErrAttachmentAlreadyAttached + } + return nil +} + +// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。 +// +// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。 +// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定, +// 而不是沿用原上传者。返回复制的数量。 +func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) { + src, err := ListAttachmentsFor(ctx, srcMailID) + if err != nil { + return 0, err + } + for _, a := range src { + _, err := db.DB.ExecContext(ctx, ` + INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256) + VALUES ($1, $2, $3, $4, $5, $6) + `, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256) + if err != nil { + return 0, err + } + } + return len(src), nil +} + +// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。 +// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。 +func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) { + a, err := GetAttachment(ctx, id) + if err != nil { + return "", false, err + } + if _, err = db.DB.ExecContext(ctx, + `DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil { + return "", false, err + } + + var refs int + if err = db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil { + return "", false, err + } + return a.SHA256, refs == 0, nil +} + +// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录, +// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。 +// +// 上传后没走完发信流程(用户取消、Agent 崩溃)会留下这类记录, +// 不清理的话磁盘只会单调增长。 +func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) { + cutoff := time.Now().Add(-age) + + rows, err := db.DB.QueryContext(ctx, + `SELECT attachment_id, sha256 FROM attachments + WHERE mail_id IS NULL AND created_at < $1`, cutoff) + if err != nil { + return nil, err + } + type orphan struct { + id uuid.UUID + sum string + } + var found []orphan + for rows.Next() { + var o orphan + if err := rows.Scan(&o.id, &o.sum); err != nil { + rows.Close() + return nil, err + } + found = append(found, o) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + var removable []string + for _, o := range found { + if _, err := db.DB.ExecContext(ctx, + `DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil { + return nil, err + } + var refs int + if err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil { + return nil, err + } + if refs == 0 { + removable = append(removable, o.sum) + } + } + return removable, nil +} + +// AttachmentAccessible 判断某人是否有权读取某附件: +// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。 +func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) { + if a.MailID == nil { + return a.Uploader == name, nil + } + var n int + err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM mails m + WHERE m.mail_id = $1 + AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`) + `, *a.MailID, name).Scan(&n) + return n > 0, err +} diff --git a/gateway/internal/repo/budget_test.go b/gateway/internal/repo/budget_test.go new file mode 100644 index 0000000..532a585 --- /dev/null +++ b/gateway/internal/repo/budget_test.go @@ -0,0 +1,165 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "sync" + "testing" + + "github.com/google/uuid" +) + +// setupBudgetDB 复用 quota_test.go 的临时库,再建一个会话。 +// 用真实 SQLite 而非 mock:预算的正确性核心是「判断与自增在同一条 UPDATE 里」, +// 那正是只有真实数据库才能验证的部分。 +func setupBudgetDB(t *testing.T) uuid.UUID { + t.Helper() + setupTestDB(t) + id, err := CreateSession(context.Background(), nil, "bot", "预算测试") + if err != nil { + t.Fatalf("create session: %v", err) + } + return id +} + +func TestSessionBudgetZeroMeansUnlimited(t *testing.T) { + id := setupBudgetDB(t) + ctx := context.Background() + + // 默认 0 = 不限:引入预算不该把已在进行的会话卡死 + b, err := GetSessionBudget(ctx, id) + if err != nil { + t.Fatal(err) + } + if !b.Unlimited || b.Remaining != -1 { + t.Fatalf("默认应为不限:%+v", b) + } + // 不限时反复占用都成功 + for i := 0; i < 5; i++ { + if _, err := ConsumeSessionBudget(ctx, id); err != nil { + t.Fatalf("不限额下第 %d 次占用失败: %v", i+1, err) + } + } +} + +func TestSessionBudgetExhausts(t *testing.T) { + id := setupBudgetDB(t) + ctx := context.Background() + + if _, err := SetSessionBudget(ctx, id, 2); err != nil { + t.Fatal(err) + } + for i := 1; i <= 2; i++ { + b, err := ConsumeSessionBudget(ctx, id) + if err != nil { + t.Fatalf("第 %d 次应成功: %v", i, err) + } + if b.Used != i { + t.Fatalf("第 %d 次后 used = %d", i, b.Used) + } + } + b, err := ConsumeSessionBudget(ctx, id) + if !errors.Is(err, ErrSessionBudgetExhausted) { + t.Fatalf("第 3 次应耗尽,得到 err=%v b=%+v", err, b) + } + if b.Remaining != 0 { + t.Fatalf("耗尽后剩余应为 0:%+v", b) + } +} + +// 判断与自增必须在同一条 UPDATE 里,否则并发下会把预算刷穿 +func TestSessionBudgetConcurrentDoesNotOverdraw(t *testing.T) { + id := setupBudgetDB(t) + ctx := context.Background() + const limit = 10 + if _, err := SetSessionBudget(ctx, id, limit); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + ok := 0 + for i := 0; i < 40; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := ConsumeSessionBudget(ctx, id); err == nil { + mu.Lock() + ok++ + mu.Unlock() + } + }() + } + wg.Wait() + + if ok != limit { + t.Fatalf("40 并发下成功 %d 次,期望恰好 %d 次(预算被刷穿或误拒)", ok, limit) + } + b, _ := GetSessionBudget(ctx, id) + if b.Used != limit { + t.Fatalf("used = %d,期望 %d", b.Used, limit) + } +} + +func TestSessionBudgetResetAndLowerBelowUsed(t *testing.T) { + id := setupBudgetDB(t) + ctx := context.Background() + + SetSessionBudget(ctx, id, 5) + for i := 0; i < 3; i++ { + ConsumeSessionBudget(ctx, id) + } + + // 调到低于已用次数 = 「就到这里为止」,是人的合法意图,不该报错 + b, err := SetSessionBudget(ctx, id, 1) + if err != nil { + t.Fatalf("下调预算不该失败: %v", err) + } + if b.Remaining != 0 { + t.Fatalf("已用 3 上限 1 时剩余应为 0:%+v", b) + } + if _, err := ConsumeSessionBudget(ctx, id); !errors.Is(err, ErrSessionBudgetExhausted) { + t.Fatal("下调后应立即拦住") + } + + // 重置只清已用次数,不动上限 + b, err = ResetSessionBudget(ctx, id) + if err != nil { + t.Fatal(err) + } + if b.Used != 0 || b.Max != 1 { + t.Fatalf("重置后应为 0/1:%+v", b) + } +} + +// 会话预算先扣、全局配额后扣;全局拦下时必须把会话那次退回去, +// 否则那格白掉了 —— 那次往返实际上没有发生 +func TestRefundSessionBudget(t *testing.T) { + id := setupBudgetDB(t) + ctx := context.Background() + SetSessionBudget(ctx, id, 3) + ConsumeSessionBudget(ctx, id) + + RefundSessionBudget(ctx, id) + b, _ := GetSessionBudget(ctx, id) + if b.Used != 0 { + t.Fatalf("退还后 used 应为 0:%+v", b) + } + + // 已经是 0 时再退不该变成负数 + RefundSessionBudget(ctx, id) + b, _ = GetSessionBudget(ctx, id) + if b.Used != 0 { + t.Fatalf("重复退还把 used 变成了 %d", b.Used) + } +} + +func TestGetSessionBudgetMissingSession(t *testing.T) { + setupBudgetDB(t) + if _, err := GetSessionBudget(context.Background(), uuid.New()); err == nil { + t.Fatal("不存在的会话应报错") + } else if errors.Is(err, sql.ErrNoRows) { + t.Fatal("应包装成可读错误而不是裸 sql.ErrNoRows") + } +} diff --git a/gateway/internal/repo/keys.go b/gateway/internal/repo/keys.go new file mode 100644 index 0000000..963fdfb --- /dev/null +++ b/gateway/internal/repo/keys.go @@ -0,0 +1,341 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// ---------- 密钥认证 ---------- +// +// 两类密钥共享一个全局唯一的 token 命名空间:验证时先查 agent_keys 再查 user_keys。 +// 这样一个 token 永远只有一种身份,不会出现「同一串字符既能注册 Agent 又能读人类邮箱」。 + +var ( + // ErrKeyNotFound 密钥不存在 + ErrKeyNotFound = errors.New("key not found") + // ErrKeyUsed 一次性密钥已被使用 + ErrKeyUsed = errors.New("key already used") + // ErrKeyExpired 定时密钥已过期 + ErrKeyExpired = errors.New("key expired") + // ErrKeyTypeInvalid 密钥类型不受支持 + ErrKeyTypeInvalid = errors.New("invalid key type") + // ErrKeyNeedsExpiry timed 密钥缺少有效的 expires_hours + ErrKeyNeedsExpiry = errors.New("timed key requires positive expires_hours") + // ErrKeyTooShort 登记的客户端密钥长度不足 + ErrKeyTooShort = errors.New("key token too short") +) + +// expiryFor 依据密钥类型算出过期时间。 +// 只有 timed 需要 expires_at;permanent 与 one_time 都是 NULL, +// 各自的失效条件由 key_type 本身表达,不混用 expires_at。 +func expiryFor(keyType string, hours int) (*time.Time, error) { + if !models.ValidKeyType(keyType) { + return nil, ErrKeyTypeInvalid + } + if keyType != models.KeyTimed { + return nil, nil + } + if hours <= 0 { + return nil, ErrKeyNeedsExpiry + } + t := time.Now().Add(time.Duration(hours) * time.Hour) + return &t, nil +} + +// checkKeyUsable 判断一条密钥记录当前是否可用。 +func checkKeyUsable(keyType string, expiresAt, usedAt *time.Time) error { + switch keyType { + case models.KeyOneTime: + if usedAt != nil { + return ErrKeyUsed + } + case models.KeyTimed: + if expiresAt == nil || time.Now().After(*expiresAt) { + return ErrKeyExpired + } + } + return nil +} + +// ---------- Agent 密钥(管理员签发) ---------- + +// ErrKeyTokenTaken 登记的密钥已被占用 +var ErrKeyTokenTaken = errors.New("key token already registered") + +// CreateAgentKey 签发一条 Agent 接入密钥。agentName 为空表示待绑定。 +// +// presetToken 非空时登记客户端已在本地生成的密钥(插件首装场景), +// 这样密钥全文只从客户端往服务器走一次,不需要反方向传递;留空则由服务器生成。 +func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expiresHours int, createdBy uuid.UUID, presetToken string) (*models.AgentKey, error) { + expires, err := expiryFor(keyType, expiresHours) + if err != nil { + return nil, err + } + + token := presetToken + if token == "" { + if token, err = newToken(); err != nil { + return nil, err + } + } else if len(token) < 32 { + // 太短的客户端密钥不接受,否则等于把弱口令当凭证 + return nil, ErrKeyTooShort + } + + var namePtr *string + if agentName != "" { + namePtr = &agentName + } + + k := &models.AgentKey{ + Token: token, + TokenHint: models.TokenHint(token), + AgentName: namePtr, + KeyType: keyType, + Label: label, + ExpiresAt: expires, + CreatedBy: &createdBy, + } + err = db.DB.QueryRowContext(ctx, ` + INSERT INTO agent_keys (key_token, agent_name, key_type, label, expires_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING key_id, created_at + `, token, namePtr, keyType, label, expires, createdBy).Scan(&k.ID, &k.CreatedAt) + if db.IsUniqueViolation(err) { + return nil, ErrKeyTokenTaken + } + if err != nil { + return nil, err + } + return k, nil +} + +// ListAgentKeys 列出 Agent 密钥;agentName 非空时按 Agent 过滤。 +// 返回值不含 token 全文,只有 hint。 +func ListAgentKeys(ctx context.Context, agentName string) ([]models.AgentKey, error) { + q := `SELECT key_id, key_token, agent_name, key_type, label, expires_at, used_at, created_by, created_at + FROM agent_keys` + args := []any{} + if agentName != "" { + q += ` WHERE agent_name = $1` + args = append(args, agentName) + } + q += ` ORDER BY created_at DESC` + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + keys := []models.AgentKey{} + for rows.Next() { + var k models.AgentKey + var token string + if err := rows.Scan(&k.ID, &token, &k.AgentName, &k.KeyType, &k.Label, + &k.ExpiresAt, &k.UsedAt, &k.CreatedBy, &k.CreatedAt); err != nil { + return nil, err + } + k.TokenHint = models.TokenHint(token) // 不回传全文 + keys = append(keys, k) + } + return keys, rows.Err() +} + +// DeleteAgentKey 吊销一条 Agent 密钥。 +func DeleteAgentKey(ctx context.Context, id uuid.UUID) error { + tag, err := db.DB.ExecContext(ctx, `DELETE FROM agent_keys WHERE key_id = $1`, id) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n == 0 { + return ErrKeyNotFound + } + return nil +} + +// BindAgentKey 把一条密钥绑定到指定 Agent 名。 +func BindAgentKey(ctx context.Context, id uuid.UUID, agentName string) error { + tag, err := db.DB.ExecContext(ctx, + `UPDATE agent_keys SET agent_name = $2 WHERE key_id = $1`, id, agentName) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n == 0 { + return ErrKeyNotFound + } + return nil +} + +// VerifyAgentKey 校验 Agent 密钥并返回它绑定的 Agent 名(未绑定时返回空串)。 +// +// 一次性密钥在校验通过时立刻写 used_at —— 用 WHERE used_at IS NULL 保证并发下 +// 只有一个请求能把它标记掉,避免两个 Agent 拿同一把一次性密钥同时注册成功。 +func VerifyAgentKey(ctx context.Context, token string) (string, error) { + var ( + id uuid.UUID + agentName *string + keyType string + expiresAt *time.Time + usedAt *time.Time + ) + err := db.DB.QueryRowContext(ctx, ` + SELECT key_id, agent_name, key_type, expires_at, used_at + FROM agent_keys WHERE key_token = $1 + `, token).Scan(&id, &agentName, &keyType, &expiresAt, &usedAt) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrKeyNotFound + } + if err != nil { + return "", err + } + if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil { + return "", err + } + + if keyType == models.KeyOneTime { + tag, err := db.DB.ExecContext(ctx, + `UPDATE agent_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id) + if err != nil { + return "", err + } + if n, _ := tag.RowsAffected(); n == 0 { + return "", ErrKeyUsed // 并发下被别人抢先用掉了 + } + } + + if agentName == nil { + return "", nil + } + return *agentName, nil +} + +// ClaimAgentKey 在待绑定密钥首次注册时把它落定到该 Agent 名。 +// 已绑定的密钥不受影响(WHERE agent_name IS NULL),因此不能借一把已绑定的密钥改注册别的 Agent。 +func ClaimAgentKey(ctx context.Context, token, agentName string) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE agent_keys SET agent_name = $2 WHERE key_token = $1 AND agent_name IS NULL`, + token, agentName) + return err +} + +// ---------- 用户密钥(用户自助签发) ---------- + +// CreateUserKey 为用户签发一条客户端连接密钥。 +func CreateUserKey(ctx context.Context, userID uuid.UUID, label, keyType string, expiresHours int) (*models.UserKey, error) { + expires, err := expiryFor(keyType, expiresHours) + if err != nil { + return nil, err + } + token, err := newToken() + if err != nil { + return nil, err + } + + k := &models.UserKey{ + Token: token, + TokenHint: models.TokenHint(token), + UserID: userID, + Label: label, + KeyType: keyType, + ExpiresAt: expires, + } + err = db.DB.QueryRowContext(ctx, ` + INSERT INTO user_keys (key_token, user_id, label, key_type, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING key_id, created_at + `, token, userID, label, keyType, expires).Scan(&k.ID, &k.CreatedAt) + if err != nil { + return nil, err + } + return k, nil +} + +// ListUserKeys 列出某用户的连接密钥(不含 token 全文)。 +func ListUserKeys(ctx context.Context, userID uuid.UUID) ([]models.UserKey, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT key_id, key_token, user_id, label, key_type, expires_at, used_at, created_at + FROM user_keys WHERE user_id = $1 ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + keys := []models.UserKey{} + for rows.Next() { + var k models.UserKey + var token string + if err := rows.Scan(&k.ID, &token, &k.UserID, &k.Label, &k.KeyType, + &k.ExpiresAt, &k.UsedAt, &k.CreatedAt); err != nil { + return nil, err + } + k.TokenHint = models.TokenHint(token) + keys = append(keys, k) + } + return keys, rows.Err() +} + +// DeleteUserKey 删除自己的一条密钥。带 user_id 条件,避免删掉别人的。 +func DeleteUserKey(ctx context.Context, userID, keyID uuid.UUID) error { + tag, err := db.DB.ExecContext(ctx, + `DELETE FROM user_keys WHERE key_id = $1 AND user_id = $2`, keyID, userID) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n == 0 { + return ErrKeyNotFound + } + return nil +} + +// VerifyUserKey 校验用户密钥并返回对应用户。 +// 用户必须仍处于 active 状态——禁用账号后其密钥应当立即失效。 +func VerifyUserKey(ctx context.Context, token string) (*models.User, error) { + var ( + id uuid.UUID + userID uuid.UUID + keyType string + expiresAt *time.Time + usedAt *time.Time + ) + err := db.DB.QueryRowContext(ctx, ` + SELECT key_id, user_id, key_type, expires_at, used_at + FROM user_keys WHERE key_token = $1 + `, token).Scan(&id, &userID, &keyType, &expiresAt, &usedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrKeyNotFound + } + if err != nil { + return nil, err + } + if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil { + return nil, err + } + + if keyType == models.KeyOneTime { + tag, err := db.DB.ExecContext(ctx, + `UPDATE user_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id) + if err != nil { + return nil, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return nil, ErrKeyUsed + } + } + + u, err := GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + if u.Status != "active" { + return nil, ErrKeyNotFound // 账号已禁用,密钥一并失效 + } + return u, nil +} diff --git a/gateway/internal/repo/quota.go b/gateway/internal/repo/quota.go new file mode 100644 index 0000000..850a918 --- /dev/null +++ b/gateway/internal/repo/quota.go @@ -0,0 +1,282 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// ---------- 配额 ---------- +// +// 配额限制的是 Agent「主动发信」的次数,不限制收信: +// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。 +// +// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。 + +// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。 +var ErrQuotaExhausted = errors.New("quota exhausted") + +// Quota 是一个 Agent 的配额快照。 +type Quota struct { + AgentName string `json:"agent_name"` + Max int `json:"max_rounds"` // 0 = 不限 + Used int `json:"used_rounds"` + Remaining int `json:"remaining"` // 不限时为 -1 + Unlimited bool `json:"unlimited"` +} + +func makeQuota(name string, max, used int) Quota { + q := Quota{AgentName: name, Max: max, Used: used, Unlimited: max <= 0} + if q.Unlimited { + q.Remaining = -1 + return q + } + if r := max - used; r > 0 { + q.Remaining = r + } + return q +} + +// GetQuota 读取某 Agent 的配额状态。 +func GetQuota(ctx context.Context, agentName string) (Quota, error) { + var max, used int + err := db.DB.QueryRowContext(ctx, + `SELECT max_rounds, used_rounds FROM agents WHERE agent_name = $1`, agentName).Scan(&max, &used) + if errors.Is(err, sql.ErrNoRows) { + return Quota{}, fmt.Errorf("agent %q 不存在", agentName) + } + if err != nil { + return Quota{}, err + } + return makeQuota(agentName, max, used), nil +} + +// ConsumeQuota 原子地占用一次发信配额,返回占用后的快照。 +// +// 判断与自增必须在同一条 UPDATE 里完成(WHERE used_rounds < max_rounds), +// 否则并发发信会双双通过检查再各自 +1,把配额刷穿。 +// 配额耗尽时返回 ErrQuotaExhausted,同时给出快照供调用方生成提示。 +func ConsumeQuota(ctx context.Context, agentName string) (Quota, error) { + tag, err := db.DB.ExecContext(ctx, ` + UPDATE agents SET used_rounds = used_rounds + 1 + WHERE agent_name = $1 + AND (max_rounds <= 0 OR used_rounds < max_rounds) + `, agentName) + if err != nil { + return Quota{}, err + } + n, _ := tag.RowsAffected() + if n == 0 { + // 要么 Agent 不存在,要么配额用尽——用快照区分 + q, qErr := GetQuota(ctx, agentName) + if qErr != nil { + return Quota{}, qErr + } + return q, ErrQuotaExhausted + } + return GetQuota(ctx, agentName) +} + +// SetQuota 设置某 Agent 的配额上限(0 = 不限)。 +func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) { + if max < 0 { + max = 0 + } + tag, err := db.DB.ExecContext(ctx, + `UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max) + if err != nil { + return Quota{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return Quota{}, fmt.Errorf("agent %q 不存在", agentName) + } + return GetQuota(ctx, agentName) +} + +// ResetQuota 把已用次数归零(配额上限不变)。 +func ResetQuota(ctx context.Context, agentName string) (Quota, error) { + tag, err := db.DB.ExecContext(ctx, + `UPDATE agents SET used_rounds = 0 WHERE agent_name = $1`, agentName) + if err != nil { + return Quota{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return Quota{}, fmt.Errorf("agent %q 不存在", agentName) + } + return GetQuota(ctx, agentName) +} + +// ListQuotas 列出所有 Agent 的配额(管理员视图)。 +func ListQuotas(ctx context.Context) ([]Quota, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Quota{} + for rows.Next() { + var name string + var max, used int + if err := rows.Scan(&name, &max, &used); err != nil { + return nil, err + } + out = append(out, makeQuota(name, max, used)) + } + return out, rows.Err() +} + +// ---------- 转发 ---------- + +// ForwardSource 是被转发邮件的必要信息。 +type ForwardSource struct { + Mail *models.Mail + Session uuid.UUID +} + +// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件 +//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。 +func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) { + m, err := GetMailByID(ctx, mailID) + if err != nil { + return nil, ErrMailNotFound + } + + if m.FromName == actor || m.ToName == actor { + return m, nil + } + for _, cc := range m.CCList { + if cc.Name == actor { + return m, nil + } + } + return nil, ErrForwardNotAllowed +} + +var ( + // ErrMailNotFound 待转发的邮件不存在 + ErrMailNotFound = errors.New("mail not found") + // ErrForwardNotAllowed 转发者未参与该邮件 + ErrForwardNotAllowed = errors.New("not a participant of that mail") +) + +// ---------- 会话级往返预算 ---------- +// +// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。 +// 只有 agents.max_rounds 一个全局计数器时有两个问题: +// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉 +// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活 +// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。 +// +// **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` 开一串会话 +// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。 + +// SessionBudget 是一个会话的往返预算快照。 +type SessionBudget struct { + SessionID uuid.UUID `json:"session_id"` + Max int `json:"max_rounds"` // 0 = 本会话不限 + Used int `json:"used_rounds"` + Remaining int `json:"remaining"` // 不限时为 -1 + Unlimited bool `json:"unlimited"` +} + +func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget { + b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0} + if b.Unlimited { + b.Remaining = -1 + return b + } + if r := max - used; r > 0 { + b.Remaining = r + } + return b +} + +// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。 +var ErrSessionBudgetExhausted = errors.New("session budget exhausted") + +// GetSessionBudget 读取会话预算。 +func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) { + var max, used int + err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`, + id).Scan(&max, &used) + if errors.Is(err, sql.ErrNoRows) { + return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id) + } + if err != nil { + return SessionBudget{}, err + } + return makeSessionBudget(id, max, used), nil +} + +// ConsumeSessionBudget 原子地占用会话的一次往返。 +// +// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里(WHERE used_rounds < max_rounds), +// 否则并发发信会双双通过检查再各自 +1,把预算刷穿。 +func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) { + tag, err := db.DB.ExecContext(ctx, ` + UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1 + WHERE session_id = $1 + AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds) + `, id) + if err != nil { + return SessionBudget{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + b, bErr := GetSessionBudget(ctx, id) + if bErr != nil { + return SessionBudget{}, bErr + } + return b, ErrSessionBudgetExhausted + } + return GetSessionBudget(ctx, id) +} + +// SetSessionBudget 设置会话预算上限(0 = 不限)。 +// +// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图, +// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0,下次发信即被拦。 +func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) { + if max < 0 { + max = 0 + } + tag, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max) + if err != nil { + return SessionBudget{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id) + } + return GetSessionBudget(ctx, id) +} + +// ResetSessionBudget 把该会话的已用次数归零(上限不变)。 +func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) { + tag, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id) + if err != nil { + return SessionBudget{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id) + } + return GetSessionBudget(ctx, id) +} + +// RefundSessionBudget 退还一次往返。 +// +// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去, +// 否则会话预算白掉一格 —— 那次往返实际上没有发生。 +func RefundSessionBudget(ctx context.Context, id uuid.UUID) { + _, _ = db.DB.ExecContext(ctx, + `UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1 + WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id) +} diff --git a/gateway/internal/repo/quota_test.go b/gateway/internal/repo/quota_test.go new file mode 100644 index 0000000..a611f9d --- /dev/null +++ b/gateway/internal/repo/quota_test.go @@ -0,0 +1,173 @@ +package repo + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/agentmail/gateway/internal/db" +) + +// setupTestDB 起一个临时 SQLite 库并建表,供配额测试使用。 +// 直接用真实的 SQLite 而非 mock:配额的正确性核心在于「判断与自增在同一条 UPDATE 里」, +// 这正是只有真实数据库才能验证的部分。 +func setupTestDB(t *testing.T) { + t.Helper() + + dir := t.TempDir() + if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil { + t.Fatalf("connect: %v", err) + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(db.Close) +} + +func seedAgent(t *testing.T, name string, max int) { + t.Helper() + _, err := db.DB.ExecContext(context.Background(), + `INSERT INTO agents (agent_name, secret, platform, max_rounds) VALUES ($1, 'x', 'test', $2)`, + name, max) + if err != nil { + t.Fatalf("seed agent: %v", err) + } +} + +func TestConsumeQuotaCountsDown(t *testing.T) { + setupTestDB(t) + seedAgent(t, "bot", 3) + ctx := context.Background() + + for i := 1; i <= 3; i++ { + q, err := ConsumeQuota(ctx, "bot") + if err != nil { + t.Fatalf("第 %d 次占用失败: %v", i, err) + } + if q.Used != i || q.Remaining != 3-i { + t.Errorf("第 %d 次: used=%d remaining=%d, want used=%d remaining=%d", + i, q.Used, q.Remaining, i, 3-i) + } + } + + q, err := ConsumeQuota(ctx, "bot") + if !errors.Is(err, ErrQuotaExhausted) { + t.Fatalf("第 4 次应耗尽,得到 err=%v", err) + } + // 耗尽时仍要给出快照,调用方才能在错误文案里写清 used/max + if q.Used != 3 || q.Max != 3 { + t.Errorf("耗尽时快照不对: %+v", q) + } +} + +func TestConsumeQuotaUnlimited(t *testing.T) { + setupTestDB(t) + seedAgent(t, "free", 0) // 0 = 不限 + ctx := context.Background() + + for i := 0; i < 5; i++ { + q, err := ConsumeQuota(ctx, "free") + if err != nil { + t.Fatalf("不限额时不应失败: %v", err) + } + if !q.Unlimited || q.Remaining != -1 { + t.Errorf("不限额快照不对: %+v", q) + } + } +} + +// 配额的核心保证:并发发信不能把额度刷穿。 +// 判断与自增若分成两步(先 SELECT 再 UPDATE),并发下两个请求会双双通过检查。 +func TestConsumeQuotaConcurrentDoesNotOverdraw(t *testing.T) { + setupTestDB(t) + const limit = 10 + seedAgent(t, "racer", limit) + ctx := context.Background() + + const attempts = 40 + var ( + wg sync.WaitGroup + mu sync.Mutex + granted int + ) + for i := 0; i < attempts; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := ConsumeQuota(ctx, "racer"); err == nil { + mu.Lock() + granted++ + mu.Unlock() + } + }() + } + wg.Wait() + + if granted != limit { + t.Errorf("并发 %d 次请求在上限 %d 下放行了 %d 次", attempts, limit, granted) + } + + q, err := GetQuota(ctx, "racer") + if err != nil { + t.Fatal(err) + } + if q.Used != limit { + t.Errorf("used_rounds = %d,应恰好等于上限 %d(未刷穿也未少记)", q.Used, limit) + } +} + +func TestSetAndResetQuota(t *testing.T) { + setupTestDB(t) + seedAgent(t, "bot", 2) + ctx := context.Background() + + if _, err := ConsumeQuota(ctx, "bot"); err != nil { + t.Fatal(err) + } + + q, err := SetQuota(ctx, "bot", 5) + if err != nil { + t.Fatal(err) + } + // 调上限不应清掉已用次数 + if q.Max != 5 || q.Used != 1 || q.Remaining != 4 { + t.Errorf("SetQuota 后 %+v,want max=5 used=1 remaining=4", q) + } + + q, err = ResetQuota(ctx, "bot") + if err != nil { + t.Fatal(err) + } + if q.Used != 0 || q.Remaining != 5 { + t.Errorf("ResetQuota 后 %+v,want used=0 remaining=5", q) + } + + // 负数上限归一为 0(不限),而不是造出一个永远发不出信的 Agent + if q, err = SetQuota(ctx, "bot", -3); err != nil { + t.Fatal(err) + } else if !q.Unlimited { + t.Errorf("负数上限应视为不限,得到 %+v", q) + } +} + +func TestQuotaUnknownAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + if _, err := GetQuota(ctx, "ghost"); err == nil { + t.Error("不存在的 Agent 应报错") + } + if _, err := ConsumeQuota(ctx, "ghost"); err == nil { + t.Error("不存在的 Agent 占用配额应报错") + } + if errors.Is(func() error { _, e := ConsumeQuota(ctx, "ghost"); return e }(), ErrQuotaExhausted) { + t.Error("不存在的 Agent 不该被报成「配额耗尽」") + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/gateway/internal/repo/relay.go b/gateway/internal/repo/relay.go new file mode 100644 index 0000000..1e18c5d --- /dev/null +++ b/gateway/internal/repo/relay.go @@ -0,0 +1,87 @@ +package repo + +import ( + "context" + "errors" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// ---------- 插件自动转发(免配额通道) ---------- +// +// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。** +// +// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列: +// 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到, +// Agent 卡在那里等一个永远不会来的回答 +// 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了, +// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了 +// +// 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识 +// (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次, +// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id —— +// 而那些 id 由平台生成,模型伪造不出来。 + +// ErrRelayDuplicate 表示这条上游消息已经转发过了。 +var ErrRelayDuplicate = errors.New("relay already recorded") + +// ClaimRelay 占用一次免配额转发名额。 +// +// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 —— +// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。 +// +// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错: +// 重复转发是插件重试的正常结果,不是故障。 +func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error { + _, err := db.DB.ExecContext(ctx, + `INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`, + agentName, relayKey, kind) + if err != nil { + if db.IsUniqueViolation(err) { + return ErrRelayDuplicate + } + return err + } + return nil +} + +// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计 +// 「这封免配额的信是从哪条上游消息来的」。 +// +// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。 +func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`, + mailID, agentName, relayKey) + return err +} + +// ReleaseRelay 撤销名额占用。 +// +// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 —— +// 幂等键会一直认为它已经转过。 +func ReleaseRelay(ctx context.Context, agentName, relayKey string) error { + _, err := db.DB.ExecContext(ctx, + `DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`, + agentName, relayKey) + return err +} + +// RelayKeyForMail 反查某封邮件对应的上游消息 id。 +// +// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission —— +// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。 +// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。 +// +// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。 +func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) { + var key, kind string + err := db.DB.QueryRowContext(ctx, + `SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`, + mailID).Scan(&key, &kind) + if err != nil { + return "", "" + } + return key, kind +} diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go new file mode 100644 index 0000000..4d085c8 --- /dev/null +++ b/gateway/internal/repo/repo.go @@ -0,0 +1,1046 @@ +package repo + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// ---------- Agent ---------- + +func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error { + wsJSON, _ := json.Marshal(workspaces) + _, err := db.DB.ExecContext(ctx, ` + INSERT INTO agents (agent_name, secret, workspaces, platform, status, last_seen) + VALUES ($1, $2, $3, $4, 'online', NOW()) + ON CONFLICT (agent_name) DO UPDATE SET + secret = EXCLUDED.secret, + workspaces = EXCLUDED.workspaces, + platform = EXCLUDED.platform, + status = 'online', + last_seen = NOW() + `, name, secret, wsJSON, platform) + return err +} + +func HeartbeatAgent(ctx context.Context, agentName string) (int, error) { + _, err := db.DB.ExecContext(ctx, + `UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`, + agentName) + if err != nil { + return 0, err + } + return CountUnread(ctx, agentName) +} + +func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) { + q := `SELECT agent_id, agent_name, workspaces, platform, status FROM agents` + args := []any{} + if statusFilter != "" { + q += ` WHERE status = $1` + args = append(args, statusFilter) + } + q += ` ORDER BY agent_name` + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + agents := []models.Agent{} + for rows.Next() { + var a models.Agent + var wsJSON []byte + if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status); err != nil { + return nil, err + } + if wsJSON != nil { + json.Unmarshal(wsJSON, &a.Workspaces) + } + agents = append(agents, a) + } + return agents, nil +} + +func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error) { + var a models.Agent + var wsJSON []byte + err := db.DB.QueryRowContext(ctx, + `SELECT agent_id, agent_name, workspaces, platform, status + FROM agents WHERE agent_name = $1 AND secret = $2`, + name, secret, + ).Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status) + if err != nil { + return nil, err + } + if wsJSON != nil { + json.Unmarshal(wsJSON, &a.Workspaces) + } + return &a, nil +} + +// ---------- Session ---------- + +// CreateSession 建会话。 +// +// 显式传了 alias(发信时的 session_alias 参数)= 调用方亲自命名,标为 manual, +// 平台后续自动同步不得覆盖;未传则等待平台命名,标为 platform。 +func CreateSession(ctx context.Context, alias *string, fromAgent, subject string) (uuid.UUID, error) { + source := "platform" + if alias != nil && *alias != "" { + source = "manual" + } + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, + `INSERT INTO sessions (session_alias, from_agent, subject, alias_source) + VALUES ($1, $2, $3, $4) RETURNING session_id`, + alias, fromAgent, subject, source, + ).Scan(&id) + return id, err +} + +func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error) { + var s models.Session + var dismissed *string + err := db.DB.QueryRowContext(ctx, + `SELECT session_id, session_alias, from_agent, subject, status, owner_user_id, + created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'), + COALESCE(max_rounds, 0), COALESCE(used_rounds, 0) + FROM sessions WHERE session_id = $1`, id, + ).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, + &s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource, + &s.MaxRounds, &s.UsedRounds) + if err != nil { + return nil, err + } + if dismissed != nil { + s.RenameDismissed = *dismissed + } + return &s, nil +} + +func TouchSession(ctx context.Context, id uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id) + return err +} + +// UpdateSessionAlias 手工改名(人显式指定)。 +// +// 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。 +// 否则平台下一次 session.updated 会把人刚定的名字冲掉, +// 人上一秒记住的寻址地址下一秒失效。 +func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET session_alias = $1, alias_source = 'manual', updated_at = NOW() + WHERE session_id = $2`, + alias, id) + return err +} + +func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) { + q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status, + s.owner_user_id, s.created_at, s.updated_at, + (SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id) + FROM sessions s` + args := []any{} + if statusFilter != "" { + q += ` WHERE s.status = $1` + args = append(args, statusFilter) + } + q += ` ORDER BY s.updated_at DESC` + if limit > 0 { + q += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + sessions := []models.Session{} + for rows.Next() { + var s models.Session + if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, + &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, + &s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil { + return nil, err + } + sessions = append(sessions, s) + } + return sessions, nil +} + +// ---------- Mail ---------- + +func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID, + fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) { + if ccList == nil { + ccList = []models.Address{} + } + ccJSON, _ := json.Marshal(ccList) + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, + `INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace, + to_name, to_workspace, subject, body, cc_list) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING mail_id`, + sessionID, parentMailID, fromName, fromWorkspace, toName, toWorkspace, subject, body, ccJSON, + ).Scan(&id) + return id, err +} + +// CreatePermissionMail 创建权限请求邮件,toUser 为目标人类用户名 +func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string) (uuid.UUID, error) { + optsJSON, _ := json.Marshal(options) + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, + `INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options) + VALUES ($1, $2, $3, $4, $5, 'permission_request', $6) RETURNING mail_id`, + sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON, + ).Scan(&id) + return id, err +} + +// CreateDecisionMail 创建人类决策邮件(fromUser → toAgent) +func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) { + var id uuid.UUID + body := decision + if note != "" { + body = fmt.Sprintf("%s\n\n备注: %s", decision, note) + } + err := db.DB.QueryRowContext(ctx, + `INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING mail_id`, + sessionID, parentMailID, fromUser, toAgent, "Re: 权限请求 - "+decision, body, + ).Scan(&id) + return id, err +} + +func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) { + var m models.Mail + var alias *string + var ccJSON []byte + var renameAlias, renameReason *string + err := db.DB.QueryRowContext(ctx, + `SELECT m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias, m.rename_alias, m.rename_reason + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE m.mail_id = $1`, id, + ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, + &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, + &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, + &m.Status, &m.CreatedAt, &alias, &renameAlias, &renameReason) + if err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &m.CCList) + } + if m.CCList == nil { + m.CCList = []models.Address{} + } + if alias != nil { + m.SessionAlias = *alias + } + // 改名提议随单封返回,让「谁在哪一封里提了什么」可追溯; + // 【待处理】的提议另有专用端点(GET /sessions/:id/rename-proposal)。 + if renameAlias != nil { + m.RenameAlias = *renameAlias + } + if renameReason != nil { + m.RenameReason = *renameReason + } + return &m, nil +} + +func MarkMailRead(ctx context.Context, id uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, `UPDATE mails SET status = 'read' WHERE mail_id = $1`, id) + return err +} + +func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) { + q := `SELECT m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) + AND s.status <> 'archived'` + args := []any{agentName} + if status != "" && status != "all" { + q += ` AND m.status = $2` + args = append(args, status) + } + q += ` ORDER BY m.created_at DESC` + if limit > 0 { + q += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + mails := []models.Mail{} + for rows.Next() { + var m models.Mail + var alias *string + var ccJSON []byte + if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, + &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, + &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, + &m.Status, &m.CreatedAt, &alias); err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &m.CCList) + } + if m.CCList == nil { + m.CCList = []models.Address{} + } + if alias != nil { + m.SessionAlias = *alias + } + // Body preview + if len(m.Body) > 200 { + m.BodyPreview = m.Body[:200] + "..." + } else { + m.BodyPreview = m.Body + } + mails = append(mails, m) + } + return mails, nil +} + +func CountUnread(ctx context.Context, agentName string) (int, error) { + var count int + err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + AND m.status = 'unread' + AND s.status <> 'archived' + `, agentName).Scan(&count) + return count, err +} + +func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE m.session_id = $1 + ORDER BY m.created_at ASC`, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + mails := []models.Mail{} + for rows.Next() { + var m models.Mail + var alias *string + var ccJSON []byte + if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, + &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, + &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, + &m.Status, &m.CreatedAt, &alias); err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &m.CCList) + } + if m.CCList == nil { + m.CCList = []models.Address{} + } + if alias != nil { + m.SessionAlias = *alias + } + mails = append(mails, m) + } + return mails, nil +} + +// ---------- Permission ---------- + +func CreatePermissionRequest(ctx context.Context, mailID, sessionID uuid.UUID, agentName, question string, options []string, contextStr string) error { + optsJSON, _ := json.Marshal(options) + _, err := db.DB.ExecContext(ctx, + `INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, context) + VALUES ($1, $2, $3, $4, $5, $6)`, + mailID, sessionID, agentName, question, optsJSON, contextStr) + return err +} + +func DecidePermission(ctx context.Context, mailID uuid.UUID, decision string) (*models.PermissionRequest, error) { + var pr models.PermissionRequest + var optsJSON []byte + + err := db.DB.QueryRowContext(ctx, + `UPDATE permission_requests SET result = $1, decided_at = NOW() + WHERE mail_id = $2 + RETURNING request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at`, + decision, mailID, + ).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question, + &optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt) + if err != nil { + return nil, err + } + json.Unmarshal(optsJSON, &pr.Options) + + // Also update the mail + _, _ = db.DB.ExecContext(context.Background(), + `UPDATE mails SET permission_result = $1, status = 'read' WHERE mail_id = $2`, + decision, mailID) + + return &pr, nil +} + +func ListPendingPermissions(ctx context.Context) ([]models.PermissionRequest, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at + FROM permission_requests WHERE result IS NULL + ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + reqs := []models.PermissionRequest{} + for rows.Next() { + var pr models.PermissionRequest + var optsJSON []byte + if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question, + &optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil { + return nil, err + } + json.Unmarshal(optsJSON, &pr.Options) + reqs = append(reqs, pr) + } + return reqs, nil +} + +// ---------- Check permission ownership ---------- + +func GetPermissionByMailID(ctx context.Context, mailID uuid.UUID) (*models.PermissionRequest, error) { + var pr models.PermissionRequest + var optsJSON []byte + err := db.DB.QueryRowContext(ctx, + `SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at + FROM permission_requests WHERE mail_id = $1`, mailID, + ).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question, + &optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt) + if err != nil { + return nil, err + } + json.Unmarshal(optsJSON, &pr.Options) + return &pr, nil +} + +func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*models.Mail, error) { + var m models.Mail + var alias *string + var ccJSON []byte + err := db.DB.QueryRowContext(ctx, + `SELECT m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias + FROM mails m JOIN sessions s ON m.session_id = s.session_id + WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID, + ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, + &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, + &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, + &m.Status, &m.CreatedAt, &alias) + if err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &m.CCList) + } + if m.CCList == nil { + m.CCList = []models.Address{} + } + if alias != nil { + m.SessionAlias = *alias + } + return &m, nil +} + +func FindSessionByAlias(ctx context.Context, alias string) (*models.Session, error) { + var s models.Session + err := db.DB.QueryRowContext(ctx, + `SELECT session_id, session_alias, from_agent, subject, status, owner_user_id, created_at, updated_at + FROM sessions WHERE session_alias = $1 AND status <> 'archived'`, alias, + ).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +// ErrSessionNotFound 表示三维地址里指定的 session 别名不存在(或不属于该收件人)。 +// 调用方应据此回 404「无法送达」,而不是悄悄新建一个会话。 +var ErrSessionNotFound = errors.New("session not found") + +// FindNamedSessionFor 查找收件人 name@path 名下别名为 alias 的会话。 +// 严格匹配:会话必须存在、未归档,且该收件人确实参与过该会话,否则返回 ErrSessionNotFound。 +func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) { + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, ` + SELECT s.session_id + FROM sessions s + WHERE s.session_alias = $1 + AND s.status <> 'archived' + AND EXISTS ( + SELECT 1 FROM mails m + WHERE m.session_id = s.session_id + AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`) + ) + ORDER BY s.updated_at DESC + LIMIT 1 + `, alias, name).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, ErrSessionNotFound + } + return id, err +} + +// FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义: +// 复用 name@path 最近一次活跃的非归档会话;从未通过信则建立一个新的作为默认会话。 +func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) { + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, ` + SELECT s.session_id + FROM sessions s + JOIN mails m ON m.session_id = s.session_id + WHERE s.status <> 'archived' + AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`) + AND (COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2) + ORDER BY s.updated_at DESC + LIMIT 1 + `, name, path).Scan(&id) + if err == nil { + TouchSession(ctx, id) + return id, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, err + } + return CreateSession(ctx, nil, fromAgent, subject) +} + +// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。 +// 仅用于响应体回显,不影响投递路径,所以吞错是可接受的。 +func SessionAliasOf(ctx context.Context, id uuid.UUID) string { + var alias *string + if err := db.DB.QueryRowContext(ctx, + `SELECT session_alias FROM sessions WHERE session_id = $1`, id).Scan(&alias); err != nil { + return "" + } + if alias == nil { + return "" + } + return *alias +} + +// AgentCanAccessSession 判断 Agent 是否参与过该会话(发件/收件/被抄送)。 +// Agent 只能改自己参与的会话的别名,避免跨会话改名。 +func AgentCanAccessSession(ctx context.Context, agentName string, sessionID uuid.UUID) (bool, error) { + var n int + err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM mails m + WHERE m.session_id = $1 + AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`) + `, sessionID, agentName).Scan(&n) + return n > 0, err +} + +// SyncSessionTitle 更新会话主题(Agent 平台生成的摘要标题)。 +func SyncSessionTitle(ctx context.Context, id uuid.UUID, title string) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET subject = $1, updated_at = NOW() WHERE session_id = $2`, + title, id) + return err +} + +// SyncSessionAlias 把 Agent 平台侧的会话标识写为本侧别名。 +// 平台侧标识(如 opencode 的 slug)在平台内不保证全局唯一,而本侧别名负责寻址必须唯一, +// 因此撞名时自动追加 -2、-3… 后缀而不是报错——同步是后台行为,不该因撞名失败。 +// 返回最终落库的别名。该会话已持有目标别名时直接返回,不做无谓写入。 +// +// **人显式定过的别名不覆盖**(alias_source = 'manual'): +// 用户刚接受了 Agent 的改名提议,或手工敲了一个名字,平台下一次 session.updated +// 不该把它冲掉 —— 那会让人上一秒记住的寻址地址下一秒失效。 +// 此时返回当前别名,调用方据此知道同步未生效。 +func SyncSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) { + const maxAttempts = 50 + + var cur *string + var source string + if err := db.DB.QueryRowContext(ctx, + `SELECT session_alias, COALESCE(alias_source, 'platform') FROM sessions WHERE session_id = $1`, + id).Scan(&cur, &source); err != nil { + return "", err + } + if source == "manual" && cur != nil && *cur != "" { + return *cur, nil + } + + for i := 0; i < maxAttempts; i++ { + candidate := want + if i > 0 { + candidate = fmt.Sprintf("%s-%d", want, i+1) + } + + owner, err := aliasOwner(ctx, candidate) + if err != nil { + return "", err + } + if owner != nil { + if *owner == id { + return candidate, nil // 已经是这个别名,无需写入 + } + continue // 被别人占用,试下一个后缀 + } + + // 只在仍是 platform 来源时写入:并发下用户可能刚好接受了改名提议, + // 条件放进 WHERE 才能保证「检查」与「写入」不被插进来的手工改名割开 + res, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET session_alias = $1, updated_at = NOW() + WHERE session_id = $2 AND COALESCE(alias_source, 'platform') <> 'manual'`, + candidate, id) + if err == nil { + if n, _ := res.RowsAffected(); n == 0 { + // 期间变成 manual 了,尊重人的选择 + return SessionAliasOf(ctx, id), nil + } + return candidate, nil + } + // 并发下另一个请求刚占走该别名(唯一索引拦下),继续试下一个后缀 + if db.IsUniqueViolation(err) { + continue + } + return "", err + } + return "", fmt.Errorf("alias %q: 连同 -2..-%d 后缀均被占用", want, maxAttempts) +} + +// aliasOwner 返回持有该别名的会话 ID;无人持有时返回 nil。 +func aliasOwner(ctx context.Context, alias string) (*uuid.UUID, error) { + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, + `SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &id, nil +} + +func SessionMailCount(ctx context.Context, sessionID uuid.UUID) (int, error) { + var count int + err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&count) + return count, err +} + +// ---------- Contacts / Archive ---------- + +// Contact 是「联系人」= 一条 name@path.session 三维地址 +type Contact struct { + SessionID uuid.UUID `json:"session_id"` + AgentName string `json:"agent_name"` + Path string `json:"path"` + SessionAlias string `json:"session_alias"` + Address string `json:"address"` // name@path.session + Status string `json:"status"` + MailCount int `json:"mail_count"` + UnreadCount int `json:"unread_count"` + LastActivity time.Time `json:"last_activity"` +} + +// ListContactsFor 按 (agent, path, session) 聚合出联系人清单。 +// forUser 非空时只列该用户参与的会话(owner / 收发 / 抄送);空表示不限(管理员全局视图)。 +// archived=false 只列活跃会话,true 只列归档会话。 +func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Contact, error) { + op := "<>" + if archived { + op = "=" + } + scope := "" + args := []any{} + if forUser != "" { + scope = ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1) + OR EXISTS ( + SELECT 1 FROM mails mm + WHERE mm.session_id = s.session_id + AND (mm.from_name = $1 OR mm.to_name = $1 + OR `+db.CCHas("mm.cc_list", 1)+`) + ))` + args = append(args, forUser) + } + // 取会话里最早那封邮件作为联系人身份。 + // PG 用 LATERAL 子查询;SQLite 无 LATERAL,改用关联子查询逐列取值 + // (同一个 min(created_at) 子句,四列取自同一行)。 + var firstMail string + if db.D == db.Postgres { + firstMail = ` + JOIN LATERAL ( + SELECT to_name, to_workspace, from_name, from_workspace + FROM mails + WHERE session_id = s.session_id + ORDER BY created_at ASC + LIMIT 1 + ) m ON TRUE` + } else { + firstMail = ` + JOIN mails m ON m.mail_id = ( + SELECT mail_id FROM mails + WHERE session_id = s.session_id + ORDER BY created_at ASC, mail_id ASC + LIMIT 1 + )` + } + + rows, err := db.DB.QueryContext(ctx, ` + SELECT s.session_id, + COALESCE(NULLIF(m.to_name, 'human'), m.from_name) AS agent_name, + COALESCE(NULLIF(m.to_workspace, ''), m.from_workspace) AS path, + COALESCE(s.session_alias, '') AS alias, + s.status, + (SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id), + (SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id AND x.status = 'unread'), + s.updated_at + FROM sessions s`+firstMail+` + WHERE s.status `+op+` 'archived'`+scope+` + ORDER BY s.updated_at DESC + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + contacts := []Contact{} + for rows.Next() { + var c Contact + if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias, + &c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity); err != nil { + return nil, err + } + c.Address = c.AgentName + "@" + c.Path + if c.SessionAlias != "" { + c.Address += "." + c.SessionAlias + } + contacts = append(contacts, c) + } + return contacts, nil +} + +// ArchiveSession 归档一个会话(邮箱界面不再展示,数据保留) +func ArchiveSession(ctx context.Context, sessionID uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET status = 'archived', updated_at = NOW() WHERE session_id = $1`, + sessionID) + if err != nil { + return err + } + // 同时把该会话下的邮件标记为已归档,收件箱不再列出 + _, err = db.DB.ExecContext(ctx, + `UPDATE mails SET status = 'archived' WHERE session_id = $1 AND status <> 'archived'`, + sessionID) + return err +} + +// FindSessionByAddress 按 name@path.session 定位会话 +func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.UUID, error) { + var id uuid.UUID + err := db.DB.QueryRowContext(ctx, ` + SELECT s.session_id + FROM sessions s + JOIN mails m ON m.session_id = s.session_id + WHERE COALESCE(s.session_alias, '') = $1 + AND (m.to_name = $2 OR m.from_name = $2) + AND (COALESCE(m.to_workspace,'') = $3 OR COALESCE(m.from_workspace,'') = $3) + LIMIT 1 + `, alias, name, path).Scan(&id) + return id, err +} + +// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全) +func SuggestPaths(ctx context.Context, agentName string) ([]string, error) { + var wsJSON []byte + err := db.DB.QueryRowContext(ctx, + `SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON) + if err != nil { + return []string{}, err + } + var ws []models.Workspace + if len(wsJSON) > 0 { + json.Unmarshal(wsJSON, &ws) + } + out := make([]string, 0, len(ws)) + for _, w := range ws { + out = append(out, w.Name) + } + return out, nil +} + +// SuggestSessionsFor 返回某 (peer, path) 下当前用户可见的会话别名(用于发信补全) +func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]string, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT DISTINCT s.session_alias + FROM sessions s + JOIN mails m ON m.session_id = s.session_id + WHERE s.session_alias IS NOT NULL + AND s.status <> 'archived' + AND (m.to_name = $1 OR m.from_name = $1) + AND ($2 = '' OR COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2) + AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3) + OR EXISTS ( + SELECT 1 FROM mails mm + WHERE mm.session_id = s.session_id + AND (mm.from_name = $3 OR mm.to_name = $3 + OR `+db.CCHas("mm.cc_list", 3)+`) + )) + ORDER BY s.session_alias + `, peerName, path, forUser) + if err != nil { + return []string{}, err + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var alias string + if err := rows.Scan(&alias); err == nil && alias != "" { + out = append(out, alias) + } + } + return out, nil +} + +// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话 +func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias + FROM mails m + JOIN sessions s ON m.session_id = s.session_id + WHERE m.from_name = $1 AND s.status <> 'archived' + ORDER BY m.created_at DESC + LIMIT $2 + `, fromName, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + mails := []models.Mail{} + for rows.Next() { + var m models.Mail + var alias *string + var ccJSON []byte + if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, + &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, + &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, + &m.Status, &m.CreatedAt, &alias); err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &m.CCList) + } + if m.CCList == nil { + m.CCList = []models.Address{} + } + if alias != nil { + m.SessionAlias = *alias + } + if len(m.Body) > 200 { + m.BodyPreview = m.Body[:200] + "..." + } else { + m.BodyPreview = m.Body + } + mails = append(mails, m) + } + return mails, nil +} + +// ListPendingPermissionsFor 列出待决权限请求;forUser 非空时只列发给该用户的 +func ListPendingPermissionsFor(ctx context.Context, forUser string) ([]models.PermissionRequest, error) { + q := `SELECT pr.request_id, pr.mail_id, pr.session_id, pr.agent_name, pr.question, + pr.options, pr.context, pr.result, pr.decided_at, pr.created_at + FROM permission_requests pr + JOIN mails m ON m.mail_id = pr.mail_id + JOIN sessions s ON s.session_id = pr.session_id + WHERE pr.result IS NULL AND s.status <> 'archived'` + args := []any{} + if forUser != "" { + q += ` AND m.to_name = $1` + args = append(args, forUser) + } + q += ` ORDER BY pr.created_at DESC` + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + reqs := []models.PermissionRequest{} + for rows.Next() { + var pr models.PermissionRequest + var optsJSON []byte + if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question, + &optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil { + return nil, err + } + json.Unmarshal(optsJSON, &pr.Options) + reqs = append(reqs, pr) + } + return reqs, nil +} + +// ListSessionsFor 列出某人类用户参与的会话(owner / 收发 / 抄送);forUser 空表示不限 +func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.Session, error) { + q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status, + s.owner_user_id, s.created_at, s.updated_at, + COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0), + (SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id) + FROM sessions s + WHERE s.status <> 'archived'` + args := []any{} + if forUser != "" { + q += ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1) + OR EXISTS ( + SELECT 1 FROM mails mm + WHERE mm.session_id = s.session_id + AND (mm.from_name = $1 OR mm.to_name = $1 + OR `+db.CCHas("mm.cc_list", 1)+`) + ))` + args = append(args, forUser) + } + q += ` ORDER BY s.updated_at DESC` + if limit > 0 { + q += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := db.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + sessions := []models.Session{} + for rows.Next() { + var s models.Session + if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, + &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, &s.MailCount); err != nil { + return nil, err + } + sessions = append(sessions, s) + } + return sessions, nil +} + +// CountUnreadInSession 统计某人在某会话内的未读数(含被抄送) +func CountUnreadInSession(ctx context.Context, name string, sessionID uuid.UUID) (int, error) { + ccProbe, _ := json.Marshal([]map[string]string{{"name": name}}) + var n int + err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM mails + WHERE session_id = $1 + AND status = 'unread' + AND (to_name = $2 OR cc_list @> $3::jsonb) + `, sessionID, name, string(ccProbe)).Scan(&n) + return n, err +} + +// SetMailRenameProposal 记录某封邮件里 Agent 提议的新会话别名。 +// +// 单独一条 UPDATE 而不是塞进 CreateMail 的参数表:提议是可选的旁支信息, +// 让三个调用点都多传两个几乎总是空串的参数不值当。 +func SetMailRenameProposal(ctx context.Context, mailID uuid.UUID, alias, reason string) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE mails SET rename_alias = $1, rename_reason = $2 WHERE mail_id = $3`, + alias, reason, mailID) + return err +} + +// PendingRenameProposal 返回某会话里**最新一条尚未处理**的改名提议。 +// +// 「尚未处理」= 提议的别名既不是当前别名(已接受),也不在驳回记录里。 +// 无提议时返回 ("", "", nil)。 +// +// 只看最新一条:Agent 干活过程中可能多次提议,最后那条才是它现在的结论。 +func PendingRenameProposal(ctx context.Context, sessionID uuid.UUID) (alias, reason string, err error) { + var cur, dismissed *string + err = db.DB.QueryRowContext(ctx, + `SELECT session_alias, rename_dismissed FROM sessions WHERE session_id = $1`, + sessionID).Scan(&cur, &dismissed) + if err != nil { + return "", "", err + } + + var a, rs *string + err = db.DB.QueryRowContext(ctx, ` + SELECT rename_alias, rename_reason FROM mails + WHERE session_id = $1 AND rename_alias IS NOT NULL AND rename_alias <> '' + ORDER BY created_at DESC, mail_id DESC + LIMIT 1 + `, sessionID).Scan(&a, &rs) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + if err != nil { + return "", "", err + } + if a == nil || *a == "" { + return "", "", nil + } + // 已经改成这个名字了 = 提议已被接受,不必再提示 + if cur != nil && *cur == *a { + return "", "", nil + } + // 用户驳回过这个建议 + if dismissed != nil && *dismissed == *a { + return "", "", nil + } + if rs != nil { + reason = *rs + } + return *a, reason, nil +} + +// DismissRenameProposal 记下用户驳回了哪个建议,好让提示条不再反复弹。 +// +// 只存最后驳回的那一个而不是一张列表:Agent 每次提的名字都不同, +// 攒一张历史表除了占地方没有别的用处 —— 需要判断的只是「当前这条提议是否被否过」。 +func DismissRenameProposal(ctx context.Context, sessionID uuid.UUID, alias string) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET rename_dismissed = $1 WHERE session_id = $2`, + alias, sessionID) + return err +} diff --git a/gateway/internal/repo/thread.go b/gateway/internal/repo/thread.go new file mode 100644 index 0000000..fe14db3 --- /dev/null +++ b/gateway/internal/repo/thread.go @@ -0,0 +1,188 @@ +package repo + +import ( + "context" + "encoding/json" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// 对话树。 +// +// **不另建 tree_nodes 表**:`mails.parent_mail_id` 已经完整编码了树结构 —— +// 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相, +// 两处不一致时无法判断谁对。这里直接用递归 CTE 在 mails 上查。 +// +// 树可以跨会话:转发把线索引到新会话,但 parent 仍指向原件。这正是「对话树」比 +// 「会话内平铺」更有价值的地方 —— 能看出一条线索分叉去了哪里。 +// 也正因如此,读取时必须按会话逐个鉴权(见 handler): +// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。 +// +// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。 +// 按方向分页 —— 祖先向上、子孙向下,各自带游标。 +// +// 游标用「相对锚点的原始层号偏移」而不是 mail_id: +// - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明 +// 「这个 cursor 真的在这条线索上」 +// - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去), +// 那就得单独校验它确实是锚点的祖先,反而更绕 +// - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间 + +// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB, +// 前端点开某封时再单取全文与附件清单。 +type TreeMail struct { + models.Mail + // Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,1 = 子。 + // 不用「距根深度」—— 分块加载时根可能还没取到,绝对深度无从得知。 + Depth int `json:"depth"` + AttachmentCount int `json:"attachment_count"` +} + +// descendantDepthCap 只是数据损坏时的兜底。 +// +// parent_mail_id 正常不成环(新邮件只能指向已存在的旧邮件),但一旦被外部工具改坏, +// 无上限的递归 CTE 会把进程拖死。取得足够大,正常数据碰不到。 +const descendantDepthCap = 10000 + +const threadCols = `m.mail_id, m.session_id, m.parent_mail_id, + m.from_name, m.from_workspace, m.to_name, m.to_workspace, + m.cc_list, m.subject, m.body, m.mail_type, + COALESCE(m.permission_result,'') AS permission_result, + m.status, m.created_at, s.session_alias, + (SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count` + +// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。 +// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。 +// +// **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话, +// 再往上却可能仍是自己参与的往来。过滤放在 handler 层(那里知道调用者是谁)。 +// +// 第二个返回值表示 offset+limit 层之上还有节点。 +func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) { + rows, err := db.DB.QueryContext(ctx, ` + WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS ( + SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1 + UNION ALL + SELECT m.mail_id, m.parent_mail_id, up.lvl + 1 + FROM mails m JOIN up ON m.mail_id = up.parent_mail_id + WHERE up.lvl < $2 + ) + SELECT `+threadCols+`, u.lvl + FROM up u + JOIN mails m ON m.mail_id = u.mail_id + JOIN sessions s ON m.session_id = s.session_id + WHERE u.lvl > $3 + ORDER BY u.lvl ASC + `, anchorID, offset+limit+1, offset) + if err != nil { + return nil, false, err + } + // 多取一层用来判断「上面还有没有」,不返回给调用方 + out, err := scanTreeRows(rows, true) + if err != nil { + return nil, false, err + } + hasMore := len(out) > limit + if hasMore { + out = out[:limit] + } + return out, hasMore, nil +} + +// DescendantsRaw 取锚点及其子孙,BFS 顺序(同层按时间),按节点数分页。 +// +// offset = 0 时结果的第一个是锚点自己(Depth 0)。 +// 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点 +// (别人把线索转走又转回来给我)。 +// +// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一封邮件的子孙通常很少 +// (分支来自转发,不是回复),这个代价可以接受;真出现巨型子树时再加物化。 +func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) { + rows, err := db.DB.QueryContext(ctx, ` + WITH RECURSIVE down(mail_id, lvl) AS ( + SELECT mail_id, 0 FROM mails WHERE mail_id = $1 + UNION ALL + SELECT m.mail_id, down.lvl + 1 + FROM mails m JOIN down ON m.parent_mail_id = down.mail_id + WHERE down.lvl < $2 + ) + SELECT `+threadCols+`, d.lvl + FROM down d + JOIN mails m ON m.mail_id = d.mail_id + JOIN sessions s ON m.session_id = s.session_id + ORDER BY d.lvl ASC, m.created_at ASC, m.mail_id ASC + LIMIT $3 OFFSET $4 + `, anchorID, descendantDepthCap, limit+1, offset) + if err != nil { + return nil, false, err + } + out, err := scanTreeRows(rows, false) + if err != nil { + return nil, false, err + } + hasMore := len(out) > limit + if hasMore { + out = out[:limit] + } + return out, hasMore, nil +} + +// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。 +func scanTreeRows(rows interface { + Next() bool + Scan(...interface{}) error + Err() error + Close() error +}, negate bool) ([]TreeMail, error) { + defer rows.Close() + + out := []TreeMail{} + for rows.Next() { + var t TreeMail + var alias *string + var ccJSON []byte + var lvl int + if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID, + &t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace, + &ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult, + &t.Status, &t.CreatedAt, &alias, &t.AttachmentCount, &lvl); err != nil { + return nil, err + } + if len(ccJSON) > 0 { + json.Unmarshal(ccJSON, &t.CCList) + } + if t.CCList == nil { + t.CCList = []models.Address{} + } + if alias != nil { + t.SessionAlias = *alias + } + if negate { + t.Depth = -lvl + } else { + t.Depth = lvl + } + t.BodyPreview = preview(t.Body, 240) + t.Body = "" // 树视图只要预览,全文按需单取 + out = append(out, t) + } + return out, rows.Err() +} + +// preview 按 UTF-8 边界截断正文。 +// 直接切字节会把多字节字符切成半个,前端渲染出 U+FFFD 替换符。 +func preview(s string, max int) string { + if len(s) <= max { + return s + } + cut := max + for cut > 0 && !utf8Start(s[cut]) { + cut-- + } + return s[:cut] + "..." +} + +// utf8Start 判断某字节是否为一个 UTF-8 序列的首字节 +func utf8Start(b byte) bool { return b&0xC0 != 0x80 } diff --git a/gateway/internal/repo/thread_test.go b/gateway/internal/repo/thread_test.go new file mode 100644 index 0000000..e19a81c --- /dev/null +++ b/gateway/internal/repo/thread_test.go @@ -0,0 +1,23 @@ +package repo + +import "testing" + +func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) { + // 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间 + s := "巡检报告" + got := preview(s, 4) + if got != "巡..." { + t.Fatalf("按 UTF-8 边界截断失败:%q", got) + } + for i, r := range got { + if r == 0xFFFD { + t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i) + } + } +} + +func TestPreviewKeepsShortBodyIntact(t *testing.T) { + if got := preview("短正文", 240); got != "短正文" { + t.Fatalf("未超长却被改动:%q", got) + } +} diff --git a/gateway/internal/repo/users.go b/gateway/internal/repo/users.go new file mode 100644 index 0000000..dfd58bb --- /dev/null +++ b/gateway/internal/repo/users.go @@ -0,0 +1,512 @@ +package repo + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +const ( + bcryptCost = 12 + sessionTTL = 7 * 24 * time.Hour + userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths` +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrBadCredentials = errors.New("invalid username or password") + ErrUserDisabled = errors.New("user disabled") + ErrNameTaken = errors.New("name already taken by an agent or user") + ErrSessionInvalid = errors.New("session invalid or expired") + ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]") + ErrAlreadySetup = errors.New("system already initialized") +) + +// ---------- 命名空间校验 ---------- + +// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一 +func nameTaken(ctx context.Context, name string) (bool, error) { + var n int + err := db.DB.QueryRowContext(ctx, ` + SELECT (SELECT COUNT(*) FROM users WHERE username = $1) + + (SELECT COUNT(*) FROM agents WHERE agent_name = $1) + `, name).Scan(&n) + return n > 0, err +} + +// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名) +func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) { + var n int + err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n) + return n == 0, err +} + +func validUsername(name string) bool { + if len(name) < 2 || len(name) > 64 { + return false + } + for _, r := range name { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' + if !ok { + return false + } + } + // 保留字:human 是兼容别名,不能被真实用户占用 + return name != "human" +} + +// ---------- User CRUD ---------- + +func scanUser(row *sql.Row) (*models.User, error) { + var u models.User + var agentsJSON, pathsJSON []byte + err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, + &u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + u.AllowedAgents = decodeStrList(agentsJSON) + u.AllowedPaths = decodeStrList(pathsJSON) + return &u, nil +} + +func decodeStrList(raw []byte) []string { + out := []string{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &out) + } + if out == nil { + out = []string{} + } + return out +} + +func CreateUser(ctx context.Context, username, password, displayName, role string, + allowedAgents, allowedPaths []string) (*models.User, error) { + username = strings.ToLower(strings.TrimSpace(username)) + if !validUsername(username) { + return nil, ErrInvalidUsername + } + if role != "admin" { + role = "user" + } + if displayName == "" { + displayName = username + } + + taken, err := nameTaken(ctx, username) + if err != nil { + return nil, err + } + if taken { + return nil, ErrNameTaken + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return nil, err + } + + agentsJSON, _ := json.Marshal(normalizeList(allowedAgents)) + pathsJSON, _ := json.Marshal(normalizeList(allowedPaths)) + + row := db.DB.QueryRowContext(ctx, ` + INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING `+userSelectCols, + username, displayName, string(hash), role, agentsJSON, pathsJSON) + return scanUser(row) +} + +func GetUserByName(ctx context.Context, username string) (*models.User, error) { + return scanUser(db.DB.QueryRowContext(ctx, + `SELECT `+userSelectCols+` FROM users WHERE username = $1`, + strings.ToLower(strings.TrimSpace(username)))) +} + +func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) { + return scanUser(db.DB.QueryRowContext(ctx, + `SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id)) +} + +func ListUsers(ctx context.Context) ([]models.User, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []models.User{} + for rows.Next() { + var u models.User + var agentsJSON, pathsJSON []byte + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, + &u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil { + return nil, err + } + u.AllowedAgents = decodeStrList(agentsJSON) + u.AllowedPaths = decodeStrList(pathsJSON) + users = append(users, u) + } + return users, nil +} + +// UserUpdate 描述一次用户更新;nil 字段表示不改 +type UserUpdate struct { + DisplayName *string + Role *string + Status *string + AllowedAgents *[]string + AllowedPaths *[]string +} + +func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) { + var agentsJSON, pathsJSON *string + if up.AllowedAgents != nil { + b, _ := json.Marshal(normalizeList(*up.AllowedAgents)) + s := string(b) + agentsJSON = &s + } + if up.AllowedPaths != nil { + b, _ := json.Marshal(normalizeList(*up.AllowedPaths)) + s := string(b) + pathsJSON = &s + } + + row := db.DB.QueryRowContext(ctx, ` + UPDATE users SET + display_name = COALESCE($2, display_name), + role = COALESCE($3, role), + status = COALESCE($4, status), + allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents), + allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths) + WHERE user_id = $1 + RETURNING `+userSelectCols, + id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON) + return scanUser(row) +} + +// normalizeList 去空白、去空项、去重,保持顺序 +func normalizeList(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]bool{} + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) + if err != nil { + return err + } + tag, err := db.DB.ExecContext(ctx, + `UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash)) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n == 0 { + return ErrUserNotFound + } + // 改密后踢掉该用户所有会话 + _, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id) + return nil +} + +func DisableUser(ctx context.Context, id uuid.UUID) error { + tag, err := db.DB.ExecContext(ctx, + `UPDATE users SET status = 'disabled' WHERE user_id = $1`, id) + if err != nil { + return err + } + if n, _ := tag.RowsAffected(); n == 0 { + return ErrUserNotFound + } + _, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id) + return nil +} + +func CountAdmins(ctx context.Context) (int, error) { + var n int + err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n) + return n, err +} + +// EnsureAdminUser 首次启动时创建默认管理员(幂等) +func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) { + if n, err := CountAdmins(ctx); err != nil { + return nil, false, err + } else if n > 0 { + u, err := GetUserByName(ctx, username) + if err != nil && !errors.Is(err, ErrUserNotFound) { + return nil, false, err + } + return u, false, nil + } + u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil) + if err != nil { + return nil, false, err + } + return u, true, nil +} + +// ---------- 登录 / 会话令牌 ---------- + +func Authenticate(ctx context.Context, username, password string) (*models.User, error) { + u, err := GetUserByName(ctx, username) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + // 统一错误,避免暴露用户是否存在 + return nil, ErrBadCredentials + } + return nil, err + } + if u.Status != "active" { + return nil, ErrUserDisabled + } + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { + return nil, ErrBadCredentials + } + _, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID) + return u, nil +} + +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) { + token, err := newToken() + if err != nil { + return "", time.Time{}, err + } + expires := time.Now().Add(sessionTTL) + if len(userAgent) > 256 { + userAgent = userAgent[:256] + } + _, err = db.DB.ExecContext(ctx, ` + INSERT INTO user_sessions (token, user_id, expires_at, user_agent) + VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent) + if err != nil { + return "", time.Time{}, err + } + // 顺手清理过期令牌 + _, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`) + return token, expires, nil +} + +// ResolveUserSession 校验令牌并滑动续期 +func ResolveUserSession(ctx context.Context, token string) (*models.User, error) { + if token == "" { + return nil, ErrSessionInvalid + } + var userID uuid.UUID + err := db.DB.QueryRowContext(ctx, ` + SELECT user_id FROM user_sessions + WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSessionInvalid + } + return nil, err + } + + u, err := GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + if u.Status != "active" { + return nil, ErrUserDisabled + } + + _, _ = db.DB.ExecContext(ctx, + `UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, + token, time.Now().Add(sessionTTL)) + return u, nil +} + +func DeleteUserSession(ctx context.Context, token string) error { + _, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token) + return err +} + +// ---------- 人类用户候选(供地址补全) ---------- + +func ListActiveUsernames(ctx context.Context) ([]string, error) { + rows, err := db.DB.QueryContext(ctx, + `SELECT username FROM users WHERE status = 'active' ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var s string + if err := rows.Scan(&s); err == nil { + out = append(out, s) + } + } + return out, nil +} + +// ---------- 会话归属 ---------- + +func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`, + sessionID, userID) + return err +} + +// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串 +func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) { + var name *string + err := db.DB.QueryRowContext(ctx, ` + SELECT u.username + FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id + WHERE s.session_id = $1`, sessionID).Scan(&name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("session %s not found", sessionID) + } + return "", err + } + if name == nil { + return "", nil + } + return *name, nil +} + +// UserCanAccessSession 判断用户能否访问该会话:owner、或在邮件收发/抄送中出现,或 admin +func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) { + if u.IsAdmin() { + return true, nil + } + var n int + err := db.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM sessions s + WHERE s.session_id = $1 + AND (s.owner_user_id = $2 + OR EXISTS ( + SELECT 1 FROM mails m + WHERE m.session_id = s.session_id + AND (m.from_name = $3 OR m.to_name = $3 + OR `+db.CCHas("m.cc_list", 3)+`) + )) + `, sessionID, u.ID, u.Username).Scan(&n) + return n > 0, err +} + +// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人) +func FirstAdminUsername(ctx context.Context) (string, error) { + var name string + err := db.DB.QueryRowContext(ctx, ` + SELECT username FROM users + WHERE role = 'admin' AND status = 'active' + ORDER BY created_at ASC LIMIT 1`).Scan(&name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", err + } + return name, nil +} + +// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用) +func RandomPassword(n int) string { + const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "ChangeMe" + fmt.Sprint(time.Now().Unix()) + } + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b) +} + +// ---------- Setup(首次初始化管理员) ---------- + +// NeedsSetup 返回系统是否尚未初始化(没有任何用户) +func NeedsSetup(ctx context.Context) (bool, error) { + var n int + err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n == 0, err +} + +// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。 +// 已初始化时返回 ErrAlreadySetup,避免被用作后门。 +func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) { + empty, err := NeedsSetup(ctx) + if err != nil { + return nil, err + } + if !empty { + return nil, ErrAlreadySetup + } + if displayName == "" { + displayName = username + } + return CreateUser(ctx, username, password, displayName, "admin", nil, nil) +} + +// ---------- 可选目录候选(供权限设置界面) ---------- + +// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录 +func AllWorkspaceNames(ctx context.Context) ([]string, error) { + rows, err := db.DB.QueryContext(ctx, ` + SELECT DISTINCT ws->>'name' AS name + FROM agents, jsonb_array_elements(workspaces) AS ws + WHERE COALESCE(ws->>'name', '') <> '' + ORDER BY name`) + if err != nil { + return []string{}, err + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var s string + if err := rows.Scan(&s); err == nil { + out = append(out, s) + } + } + return out, nil +} + +// IsHumanUser 判断某个三维地址 name 位是否为人类用户 +func IsHumanUser(ctx context.Context, name string) (bool, error) { + var n int + err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n) + return n > 0, err +} diff --git a/gateway/internal/sse/manager.go b/gateway/internal/sse/manager.go new file mode 100644 index 0000000..2927f5f --- /dev/null +++ b/gateway/internal/sse/manager.go @@ -0,0 +1,173 @@ +package sse + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/google/uuid" +) + +// Client 是一个 SSE 连接客户端 +type Client struct { + ID string + AgentName string // 非空 = Agent 侧连接 + UserName string // 非空 = 已登录人类用户的前端连接 + Res http.ResponseWriter + Flusher http.Flusher + done chan struct{} +} + +// Manager 管理所有 SSE 客户端连接 +type Manager struct { + mu sync.RWMutex + clients map[string]*Client +} + +var Default = &Manager{ + clients: make(map[string]*Client), +} + +// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一) +func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client { + flusher, ok := res.(http.Flusher) + if !ok { + return nil + } + + id := uuid.New().String()[:8] + client := &Client{ + ID: id, + AgentName: agentName, + UserName: userName, + Res: res, + Flusher: flusher, + done: make(chan struct{}), + } + + // 设置 SSE 响应头 + res.Header().Set("Content-Type", "text/event-stream") + res.Header().Set("Cache-Control", "no-cache") + res.Header().Set("Connection", "keep-alive") + res.Header().Set("X-Accel-Buffering", "no") + + m.mu.Lock() + m.clients[id] = client + m.mu.Unlock() + + // 发送连接确认 + client.Send("connected", map[string]string{"id": id}) + + // 启动心跳 + go m.heartbeat(client) + + fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName) + return client +} + +// RemoveClient 移除一个客户端 +func (m *Manager) RemoveClient(id string) { + m.mu.Lock() + if c, ok := m.clients[id]; ok { + close(c.done) + delete(m.clients, id) + fmt.Printf("[SSE] Client disconnected: %s\n", id) + } + m.mu.Unlock() +} + +// SendToAgent 向指定 Agent 名的所有客户端推送事件 +func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) { + if agentName == "" { + return + } + m.mu.RLock() + defer m.mu.RUnlock() + + for _, c := range m.clients { + if c.AgentName == agentName { + c.Send(eventType, data) + } + } +} + +// SendToUser 向指定人类用户的所有前端连接推送事件 +func (m *Manager) SendToUser(userName, eventType string, data interface{}) { + if userName == "" { + return + } + m.mu.RLock() + defer m.mu.RUnlock() + + for _, c := range m.clients { + if c.UserName == userName { + c.Send(eventType, data) + } + } +} + +// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道 +// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent) +func (m *Manager) SendToRecipient(name, eventType string, data interface{}) { + if name == "" { + return + } + m.mu.RLock() + defer m.mu.RUnlock() + + for _, c := range m.clients { + if c.AgentName == name || c.UserName == name { + c.Send(eventType, data) + } + } +} + +// Broadcast 向所有客户端广播事件 +func (m *Manager) Broadcast(eventType string, data interface{}) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, c := range m.clients { + c.Send(eventType, data) + } +} + +// ClientCount 返回当前连接数 +func (m *Manager) ClientCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.clients) +} + +// Send 向单个客户端发送事件 +func (c *Client) Send(eventType string, data interface{}) { + defer func() { recover() }() // 防止向已关闭的连接写入 panic + + jsonData, err := json.Marshal(data) + if err != nil { + return + } + + fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData) + c.Flusher.Flush() +} + +// heartbeat 定期发送心跳保活 +func (m *Manager) heartbeat(client *Client) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-client.done: + return + case <-ticker.C: + // SSE 注释行作为心跳 + defer func() { recover() }() + fmt.Fprintf(client.Res, ": heartbeat\n\n") + client.Flusher.Flush() + } + } +} diff --git a/gateway/internal/static/static.go b/gateway/internal/static/static.go new file mode 100644 index 0000000..1861909 --- /dev/null +++ b/gateway/internal/static/static.go @@ -0,0 +1,29 @@ +package static + +import ( + "embed" + "io/fs" + "net/http" + "sync" +) + +//go:embed static +var staticFS embed.FS + +var ( + indexHTML []byte + once sync.Once +) + +func GetIndex() []byte { + once.Do(func() { + data, _ := fs.ReadFile(staticFS, "static/index.html") + indexHTML = data + }) + return indexHTML +} + +func Handler() http.Handler { + sub, _ := fs.Sub(staticFS, "static") + return http.FileServer(http.FS(sub)) +} diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js new file mode 100644 index 0000000..8986c60 --- /dev/null +++ b/plugins/opencode-mail-bridge/index.js @@ -0,0 +1,806 @@ +import { z } from "zod"; +import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; +import { join, dirname, basename } from "node:path"; + +const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180"; +const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode"; +// 收到邮件后自动开会话处理时使用的模型 +const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || "llmsproxy"; +const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || "AUTO"; + +// ─── 凭证 ─── +// +// 优先用 Agent 密钥(Authorization: Bearer)。密钥来源按优先级: +// 1. AGENTMAIL_AGENT_KEY 环境变量(systemd 部署走这条) +// 2. ~/.agentmail/agent.key(首次安装时本地生成并落盘) +// 没有密钥时退回旧的 name/secret 方式,保证老配置不被这次改动打断。 + +const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail"); +const KEY_FILE = join(CONFIG_DIR, "agent.key"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || ""; + +/** 读取本地密钥文件;不存在或损坏时返回 null。 */ +function readLocalKey() { + try { + if (!existsSync(KEY_FILE)) return null; + const raw = JSON.parse(readFileSync(KEY_FILE, "utf8")); + return typeof raw?.key_token === "string" && raw.key_token ? raw.key_token : null; + } catch { + return null; + } +} + +/** 首次安装时本地生成密钥并落盘(0600)。 */ +function generateLocalKey() { + const token = randomBytes(32).toString("hex"); + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync( + KEY_FILE, + JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), + { mode: 0o600 } + ); + console.error(`[mail-bridge] 已在 ${KEY_FILE} 生成本地密钥。`); + console.error(`[mail-bridge] 该密钥需管理员在 AgentMail 后台登记后才能接入:`); + console.error(`[mail-bridge] ${token}`); + return token; +} + +/** 把 gateway 地址与密钥记到 config.json,便于换机时人工核对。 */ +function saveConfig(extra) { + try { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + let cur = {}; + if (existsSync(CONFIG_FILE)) { + try { cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { /* 损坏就重写 */ } + } + writeFileSync( + CONFIG_FILE, + JSON.stringify({ ...cur, gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, ...extra }, null, 2), + { mode: 0o600 } + ); + } catch (e) { + console.error("[mail-bridge] 写 config.json 失败:", e?.message || e); + } +} + +// 当前生效的密钥:环境变量 > 本地文件 > 无(退回 name/secret) +let AGENT_KEY = process.env.AGENTMAIL_AGENT_KEY || readLocalKey() || ""; + +/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */ +function authHeaders() { + if (AGENT_KEY) { + return { Authorization: `Bearer ${AGENT_KEY}`, "X-Agent-Name": AGENT_NAME }; + } + return { "X-Agent-Name": AGENT_NAME, "X-Agent-Secret": AGENT_SECRET }; +} + +// ─── HTTP ─── + +async function apiGet(path) { + const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() }); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json(); +} + +async function apiPost(path, body) { + const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `POST ${path} failed: ${res.status}`); + return data; +} + +// 把 opencode 侧的会话标题/slug 回写到 AgentMail。 +// opencode 会在首轮对话后由模型生成会话标题,并配一个短 slug(如 jolly-cactus)—— +// 不在 AgentMail 侧另造一套命名,平台那边叫什么,这边的 session_alias 就叫什么。 +async function syncSessionNaming(mailSessionID, { alias, title }) { + if (!mailSessionID) return null; + if (!alias && !title) return null; + try { + const res = await apiPost(`/sessions/${mailSessionID}/sync`, { + alias: alias || "", + title: title || "", + }); + return res; + } catch (e) { + // 同步是后台润色,失败不该影响邮件主流程,但必须留痕 + console.error("[mail-bridge] 会话命名同步失败:", e?.message || e); + return null; + } +} + +// ─── Tools(直接用 zod 定义,不依赖 @opencode-ai/plugin) ─── + +const sendMailTool = { + description: "发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,.new 强制新建会话,.具体别名 必须是已存在的会话(否则无法送达)。回复来信请传 reply_to。", + args: { + to: z.string().describe("收件人:name / name@path(默认会话)/ name@path.new(新建)/ name@path.别名(已有会话)"), + subject: z.string().describe("邮件主题"), + body: z.string().describe("邮件正文(Markdown)"), + cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"), + reply_to: z.string().optional().describe("回复某封邮件时传其 mail_id,回信会落回同一会话"), + session_alias: z.string().optional().describe("仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈。别名全局唯一,不可含 . / @ 空白,不可为 new"), + attachment_ids: z.array(z.string()).optional().describe("附件 ID 列表,先用 upload_attachment 上传取得"), + propose_alias: z.string() + .optional() + .describe( + "建议把当前会话改名成这个别名(例如摸清问题后从 witty-planet 改成 fix-login-leak)。" + + "这只是建议:别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new" + ), + propose_reason: z.string().optional().describe("改名理由,一句话,展示给用户看"), + }, + async execute(args) { + // 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 + // 选注释而不是自造标记:react-markdown 不解析 raw HTML, + // 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。 + let body = args.body; + if (args.propose_alias) { + const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符 + const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : ""; + body += `\n\n`; + } + + const result = await apiPost("/mail/send", { + to: args.to, + subject: args.subject, + body, + cc: args.cc || "", + reply_to: args.reply_to || "", + session_alias: args.session_alias || "", + attachment_ids: args.attachment_ids || [], + }); + const alias = result.session_alias + ? `,会话别名 ${result.session_alias}(续谈可用 ${args.to.split(".")[0]}.${result.session_alias})` + : ""; + // 配额剩余必须回给模型:不然它只能撞到 403 才知道额度用完, + // 那时已经没有配额发最终总结了。 + const quota = + typeof result.quota_remaining === "number" + ? `\n发信配额剩余 ${result.quota_remaining}/${result.quota_max}。` + + (result.quota_remaining <= 1 + ? "配额即将用尽,请尽快向人类发送最终总结。" + : "") + : ""; + // 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过 + const proposed = result.rename_proposed + ? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。` + : ""; + return `已发送。Mail ID: ${result.mail_id},Session: ${result.session_id}${alias}${quota}${proposed}`; + }, +}; + +const forwardMailTool = { + description: + "转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。" + + "只能转发自己参与过的邮件。", + args: { + mail_id: z.string().describe("要转发的邮件 ID(从 read_inbox 获得)"), + to: z.string().describe("新收件人的三维地址"), + comment: z.string().optional().describe("转发说明,置于引用原文之前"), + cc: z.string().optional().describe("抄送,逗号分隔多个三维地址"), + subject: z.string().optional().describe("自定义主题;留空则自动加 Fwd: 前缀"), + session_alias: z.string().optional().describe("仅在目标地址以 .new 结尾时生效:给新会话命名"), + }, + async execute(args) { + const result = await apiPost(`/mail/${args.mail_id}/forward`, { + to: args.to, + comment: args.comment || "", + cc: args.cc || "", + subject: args.subject || "", + session_alias: args.session_alias || "", + }); + return `已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`; + }, +}; + +const readInboxTool = { + description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。", + args: { + filter: z.enum(["unread", "all"]).optional().describe("过滤条件,默认 unread"), + limit: z.number().optional().describe("返回数量,默认 5"), + }, + async execute(args) { + const filter = args.filter || "unread"; + const limit = args.limit || 5; + const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}`); + if (!data.mails || data.mails.length === 0) return "收件箱为空。"; + return data.mails.map((m) => { + const lines = [ + `[${m.status}] ${m.from_name}: ${m.subject}`, + `邮件 ID: ${m.mail_id}`, + `会话: #${m.session_alias || "未命名"}`, + ]; + // 必须把 attachment_id 一起给出:不然模型知道「有附件」却无从下载 + if (m.attachments?.length) { + lines.push( + "附件: " + + m.attachments + .map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`) + .join("、") + ); + lines.push("下载附件请用 download_attachment 工具。"); + } + lines.push(`内容: ${(m.body_preview || m.body || "").substring(0, 200)}`); + return lines.join("\n"); + }).join("\n\n"); + }, +}; + +/** 人类可读的字节数,用于附件清单展示。 */ +function formatSize(n) { + if (typeof n !== "number") return "?"; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +const uploadAttachmentTool = { + description: + "上传本地文件作为邮件附件,返回 attachment_id。" + + "拿到 id 后在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。" + + "未随邮件发出的附件 24 小时后自动清理。", + args: { + path: z.string().describe("要上传的本地文件绝对路径"), + filename: z.string().optional().describe("自定义展示文件名,默认取路径的最后一段"), + }, + async execute(args) { + const filePath = args.path; + let stat; + try { + stat = statSync(filePath); + } catch { + return `文件不存在或不可读: ${filePath}`; + } + if (!stat.isFile()) return `不是普通文件: ${filePath}`; + + const name = args.filename || basename(filePath); + // Node 的 Blob 需要完整读入内存。附件上限 25MB,一次性读入可接受; + // 若将来放宽上限,这里要换成流式 multipart。 + const buf = readFileSync(filePath); + const form = new FormData(); + form.append("file", new Blob([buf]), name); + + const res = await fetch(`${GATEWAY_URL}/api/v1/attachments`, { + method: "POST", + headers: authHeaders(), // 不设 Content-Type,交给 FormData 自己带 boundary + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `上传失败: ${res.status}`); + + const a = data.attachment; + return `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + + `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`; + }, +}; + +const downloadAttachmentTool = { + description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。", + args: { + attachment_id: z.string().describe("附件 ID"), + save_to: z.string().describe("保存到的本地绝对路径"), + }, + async execute(args) { + const res = await fetch(`${GATEWAY_URL}/api/v1/attachments/${args.attachment_id}`, { + headers: authHeaders(), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `下载失败: ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + mkdirSync(dirname(args.save_to), { recursive: true }); + writeFileSync(args.save_to, buf); + return `已保存到 ${args.save_to}(${formatSize(buf.length)})`; + }, +}; + +// 平台原生权限询问 → 邮件。 +// +// **不作为工具暴露给模型**:opencode 自己就有权限机制(permission.ask 钩子 / +// permission.updated 事件),模型该做的是正常调工具,由 harness 决定要不要问人。 +// 让模型主动调一个 request_permission 工具是把 harness 的职责推给模型 —— +// 它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。 +// +// relay_key 用 opencode 的 permission.id 做幂等键:permission.updated 会重复触发, +// 插件重连也会重放,没有它同一次询问会生成好几封邮件。 +async function relayPermission({ question, options, context, relayKey }) { + return apiPost("/permission/request", { + question, + options: options && options.length ? options : ["同意", "拒绝"], + context: context || "", + relay_key: relayKey || "", + }); +} + +const connectToServerTool = { + description: + "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" + + "密钥若未在后台登记过,此处会返回需要登记的密钥全文。", + args: { + gateway_url: z.string().optional().describe("Gateway 地址,如 https://mail.example.com;省略则用当前配置"), + key_token: z.string().optional().describe("管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)"), + }, + async execute(args) { + if (args.key_token) { + AGENT_KEY = args.key_token.trim(); + // 管理员给的密钥落盘,重启后仍然可用 + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync( + KEY_FILE, + JSON.stringify({ key_token: AGENT_KEY, created_at: new Date().toISOString() }, null, 2), + { mode: 0o600 } + ); + } else if (!AGENT_KEY) { + AGENT_KEY = generateLocalKey(); + } + + const url = (args.gateway_url || GATEWAY_URL).replace(/\/+$/, ""); + const res = await fetch(`${url}/api/v1/agent/register`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${AGENT_KEY}` }, + body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: "opencode" }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + // 密钥没登记是最常见的失败,直接把要登记的值给出来,省一轮来回 + return [ + `连接失败(HTTP ${res.status}):${data.error || "未知错误"}`, + ``, + `若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`, + AGENT_KEY, + ``, + `密钥文件:${KEY_FILE}`, + ].join("\n"); + } + + saveConfig({ gateway_url: url, registered_at: new Date().toISOString() }); + return `已连接 ${url},注册为 ${data.agent_name || AGENT_NAME}。`; + }, +}; + +// ─── SSE ─── + +let sseAbort = null; + +function startSSE(onEvent) { + if (sseAbort) sseAbort.abort(); + sseAbort = new AbortController(); + + const reconnect = () => { + if (sseAbort?.signal.aborted) return; + + fetch(`${GATEWAY_URL}/api/v1/events/stream`, { + headers: authHeaders(), + signal: sseAbort.signal, + }).then((res) => { + const reader = res.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ""; + + const read = () => { + reader.read().then(({ done, value }) => { + if (done) { setTimeout(reconnect, 3000); return; } + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() || ""; + let evt = "", data = ""; + for (const line of lines) { + if (line.startsWith("event: ")) evt = line.slice(7).trim(); + else if (line.startsWith("data: ")) data = line.slice(6); + else if (line === "" && evt) { + try { onEvent(evt, JSON.parse(data)); } catch {} + evt = ""; data = ""; + } + } + read(); + }).catch(() => setTimeout(reconnect, 5000)); + }; + read(); + }).catch(() => setTimeout(reconnect, 5000)); + }; + + reconnect(); +} + +// ─── Plugin ─── + +// AgentMail 会话 ↔ opencode 会话的绑定。 +// 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定, +// 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。 +const sessionMap = new Map(); // agentmail session_id -> opencode session id +const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名) +const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) + +// 权限询问的双向定位。 +// +// opencode 的 permission.id 与 AgentMail 的 mail_id 是两个 id 空间: +// 转出去时要记住 permission 属于哪个会话(回信地址从那里来), +// 人类决策回来时要用 permission.id 去回复 opencode。 +// 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。 +const pendingPermissions = new Map(); // permission.id -> { sessionID, callID } + +// 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。 +// 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。 +const relayedSummaries = new Map(); // opencode session id -> assistant message id + +// 收到邮件后建立的会话,才需要在 idle 时把总结转回去。 +// 用户在 TUI 里自己开的会话不该被搬进邮件系统。 +const mailDrivenSessions = new Set(); // opencode session id + +async function resolveSessionForMail(client, directory, data, kind) { + const mailSessionID = data.session_id; + const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined; + if (bound) return { sessionID: bound, reused: true }; + + // 故意不传 title:opencode 只在标题缺省时才让模型按首轮对话生成摘要标题, + // 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 session.updated 事件回写。 + const created = await client.session.create({ + query: directory ? { directory } : undefined, + }); + const session = created?.data ?? created; + const sessionID = session?.id; + if (!sessionID) throw new Error("session.create 未返回 id"); + + if (mailSessionID) { + sessionMap.set(mailSessionID, sessionID); + reverseMap.set(sessionID, mailSessionID); + mailDrivenSessions.add(sessionID); + + // slug 在创建时就有(如 nimble-lagoon),立即作为寻址别名回写; + // 标题要等模型生成,走 session.updated 事件。 + if (session.slug) { + syncSessionNaming(mailSessionID, { alias: session.slug }).then((res) => { + if (res?.alias) console.error(`[mail-bridge] 别名同步 ${sessionID} -> ${res.alias}`); + }); + } + } + return { sessionID, reused: false }; +} + +// 把本轮的最终总结转成邮件发回给对方。 +// +// **不消耗配额**(基本原则:配额约束的是模型的自主发信,不是 harness 的转发): +// 模型已经把话说完了,插件只是把它搬到邮件里。对搬运收费会导致配额用尽时 +// Agent 连交代都做不了 —— 而那正是最需要它说话的时刻。 +// +// 触发点是 session.idle:opencode 在一轮跑完(不再有工具调用与生成)时发这个事件, +// 此刻的最后一条 assistant 消息就是本轮结论。 +// 不用 message.updated:那会在流式生成过程中反复触发,转出去的是半截话。 +async function relaySummary(client, directory, sessionID) { + const mailSessionID = reverseMap.get(sessionID); + if (!mailSessionID) return null; // 不是邮件驱动的会话,不碰 + if (!mailDrivenSessions.has(sessionID)) return null; + + // 取最后一条 assistant 文本消息 + const listed = await client.session.messages({ + path: { id: sessionID }, + query: directory ? { directory } : undefined, + }); + const msgs = listed?.data ?? listed ?? []; + let last = null; + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i]; + if (m?.info?.role !== "assistant") continue; + // 未完成的消息(还在生成/被中断)不转:转出去是半截话 + if (!m.info.time?.completed) continue; + const text = (m.parts || []) + .filter(p => p.type === "text" && !p.synthetic && !p.ignored && p.text) + .map(p => p.text) + .join("\n") + .trim(); + if (text) { last = { id: m.info.id, text }; } + break; + } + if (!last) return null; + + // 本地去重(服务端另有 relay_key 幂等兜底,这里只是少打一次网关) + if (relayedSummaries.get(sessionID) === last.id) return null; + + // 回信地址:这轮是谁发起的就回给谁。取该会话最近一封来信的发件人。 + const ctx = mailContexts.get(mailSessionID); + if (!ctx?.replyTo) return null; + + const res = await apiPost("/mail/send", { + to: ctx.replyTo, + subject: ctx.subject ? `Re: ${stripRe(ctx.subject)}` : "本轮工作总结", + body: last.text, + reply_to: ctx.mailID || "", + // relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次 + relay: "summary", + relay_key: last.id, + }); + relayedSummaries.set(sessionID, last.id); + return res; +} + +/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */ +function stripRe(subject) { + return String(subject).replace(/^(\s*Re:\s*)+/i, ""); +} + +// 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。 +// 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。 +const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID } + +// relaySummary 需要 client/directory,而 event 钩子拿不到它们 +// (只在插件初始化时给一次)。插件启动时把它们闭包进来。 +let relaySummaryRef = async () => null; + +// 人类决策回来 → 回复 opencode 的原生权限询问。 +// +// 决策语义映射回 opencode 的三态: +// 同意 → once (仅这一次) +// 一直同意 → always (后续同类不再问) +// 拒绝 → reject +// +// relay_key(= opencode 的 permission.id)由服务端随决策事件回传, +// 所以插件重启丢了 pendingPermissions 也能续上 —— 这个映射不能只存在内存里。 +async function replyPermission(client, directory, data) { + const permID = data.relay_key || ""; + if (!permID) { + // 没有上游 id 说明这条权限请求不是插件转发的(例如模型直接调过老的 + // request_permission,或历史数据)。此时没有可回复的 opencode permission, + // 只能把结论作为一段话送进会话。 + return deliverMail(client, directory, data, "permission"); + } + + const pending = pendingPermissions.get(permID); + const sessionID = pending?.sessionID || sessionMap.get(data.session_id || ""); + if (!sessionID) { + console.error(`[mail-bridge] 权限 ${permID} 找不到对应会话,跳过`); + return null; + } + + const decision = String(data.decision || ""); + const response = + decision === "一直同意" || decision === "always" ? "always" : + decision === "拒绝" || decision === "reject" ? "reject" : "once"; + + await client.postSessionIdPermissionsPermissionId({ + path: { id: sessionID, permissionID: permID }, + query: directory ? { directory } : undefined, + body: { response }, + }); + pendingPermissions.delete(permID); + console.error(`[mail-bridge] 权限 ${permID} -> ${response}(决策人 ${data.decided_by || "?"})`); + return { sessionID }; +} + +// 把一封来信投递给对应的 opencode 会话(已绑定则续谈,未绑定则新开)。 +async function deliverMail(client, directory, data, kind) { + const { sessionID, reused } = await resolveSessionForMail(client, directory, data, kind); + + // 记住这轮该回给谁:idle 时 relaySummary 靠它决定收件人与 reply_to。 + // 一个会话里可能来过多封信,只保留最近那封 —— 回信要落回最新的线索。 + if (kind === "mail" && data.session_id) { + mailContexts.set(data.session_id, { + replyTo: data.from_name || "", + subject: data.subject || "", + mailID: data.mail_id || "", + }); + } + + const text = kind === "permission" + ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || "用户"})。请据此继续后续工作。` + : [ + reused ? `本会话收到一封新邮件(AgentMail 续谈)。` : `你收到一封新邮件(AgentMail)。`, + ``, + `发件人:${data.from_name || "unknown"}`, + `主题:${data.subject || "(无主题)"}`, + `邮件 ID:${data.mail_id || "unknown"}`, + `身份:你是 ${AGENT_NAME}`, + ``, + `请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),然后处理其中的请求。`, + ``, + `**回信不用你自己发**:你把本轮工作做完、把结论正常说出来就行,`, + `插件会在这一轮结束时自动把你最后那段话作为回信发回给 ${data.from_name || "发件人"}(不消耗你的发信配额)。`, + `只有在需要主动联系其他人、或要带附件时才调用 send_mail。`, + ].join("\n"); + + await client.session.promptAsync({ + path: { id: sessionID }, + query: directory ? { directory } : undefined, + body: { + model: { providerID: REPLY_PROVIDER, modelID: REPLY_MODEL }, + parts: [{ type: "text", text }], + }, + }); + + return { sessionID, reused }; +} + +export default async function mailBridge(input) { + const { client, directory } = input; + + // 注册 Agent。无密钥也无 secret 时先本地生成一把密钥, + // 等管理员在后台登记后即可接入(无需重装插件)。 + if (!AGENT_KEY && !AGENT_SECRET) { + AGENT_KEY = generateLocalKey(); + } + try { + await apiPost("/agent/register", { + name: AGENT_NAME, + secret: AGENT_KEY ? "" : AGENT_SECRET, + workspaces: [], + platform: "opencode", + }); + saveConfig({ registered_at: new Date().toISOString() }); + console.error( + `[mail-bridge] 已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}` + + `(${AGENT_KEY ? "密钥认证" : "name/secret 认证"})。` + ); + } catch (e) { + // 密钥未登记时这里会报「密钥无效」——必须说清楚该做什么, + // 否则用户只看到一句 401 不知道要拿密钥去后台登记。 + console.error("[mail-bridge] 注册失败:", e?.message); + if (AGENT_KEY) { + console.error(`[mail-bridge] 若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥(见 ${KEY_FILE})。`); + } + } + + // event 钩子里拿不到 client/directory(它们只在插件初始化时给), + // 所以用一个闭包把 relaySummary 需要的两个参数固定下来。 + relaySummaryRef = (sid) => relaySummary(client, directory, sid); + + // 心跳。响应带回配额,配额将要用尽时留一条日志。 + // 注意插件代劳的转发(权限询问、最终总结)不占配额, + // 所以这条警告只关系到模型主动调 send_mail 的次数。 + let lastQuotaWarn = -1; + const heartbeat = setInterval(() => { + apiPost("/agent/heartbeat", {}) + .then(res => { + const q = res?.quota; + if (!q || q.unlimited) return; + if (q.remaining <= 2 && q.remaining !== lastQuotaWarn) { + lastQuotaWarn = q.remaining; + console.error( + `[mail-bridge] 主动发信配额剩余 ${q.remaining}/${q.max_rounds}` + + `(自动转发的总结与权限询问不占配额)。` + ); + } + }) + .catch(() => {}); + }, 30000); + + startSSE((type, data) => { + // 人类决策了一条权限请求 → 回复 opencode 的原生 permission,让它自己恢复执行。 + // 这条路径不走 deliverMail:opencode 的权限机制会在收到回复后继续原来的工具调用, + // 再往会话里塞一段「你的请求已批准」的文字只会干扰它。 + if (type === "permission_decision") { + replyPermission(client, directory, data).catch((e) => { + console.error("[mail-bridge] 权限决策回传失败:", e?.message || e); + }); + return; + } + + if (type !== "new_mail") return; + deliverMail(client, directory, data, "mail") + .then(({ sessionID, reused }) => { + console.error(`[mail-bridge] ${type} -> ${reused ? "续谈" : "新会话"} ${sessionID}`); + }) + .catch((e) => { + // 失败必须可见,否则邮件会静默丢失 + console.error(`[mail-bridge] ${type} 处理失败:`, e?.message || e); + }); + }); + + process.on("SIGINT", () => { + clearInterval(heartbeat); + if (sseAbort) sseAbort.abort(); + }); + + return { + // 平台原生的权限询问 → 转成邮件问人。 + // + // 这是 harness 的职责,不该让模型自己调一个 request_permission 工具: + // 模型可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。 + // + // 钩子里只**记下**待决策项并转出邮件,status 保持 "ask" —— 不在这里阻塞等人回复: + // permission.ask 是同步钩子,卡在这里会把整个 opencode 请求挂住。 + // 人类决策通过 SSE 回来后,再用 SDK 回复这条 permission。 + async "permission.ask"(input, output) { + if (!mailDrivenSessions.has(input.sessionID)) return; // 非邮件驱动的会话不接管 + const mailSessionID = reverseMap.get(input.sessionID); + if (!mailSessionID) return; + + pendingPermissions.set(input.id, { + sessionID: input.sessionID, + callID: input.callID || "", + }); + + try { + // opencode 的权限语义是三态,映射成人类看得懂的选项: + // 「同意」= once(仅这次),「一直同意」= always(后续同类不再问),「拒绝」= reject + await relayPermission({ + question: input.title || `请求执行 ${input.type}`, + options: ["同意", "一直同意", "拒绝"], + context: [ + `类型:${input.type}`, + input.pattern ? `目标:${Array.isArray(input.pattern) ? input.pattern.join(", ") : input.pattern}` : "", + Object.keys(input.metadata || {}).length + ? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```" + : "", + ].filter(Boolean).join("\n"), + relayKey: input.id, + }); + console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`); + } catch (e) { + // 转不出去就别让 opencode 挂在那儿等:保持 ask 让本地机制接管(TUI 弹窗) + console.error("[mail-bridge] 权限询问转发失败:", e?.message || e); + pendingPermissions.delete(input.id); + return; + } + output.status = "ask"; + }, + + async event({ event }) { + // 1) opencode 生成/更新会话标题时,把标题与 slug 回写成 AgentMail 的会话命名。 + // 首轮对话结束后 opencode 才由模型定标题,所以只能靠事件而非创建时刻拿到。 + if (event?.type === "session.updated") { + const info = event.properties?.info; + if (!info?.id) return; + + const mailSessionID = reverseMap.get(info.id); + if (!mailSessionID) return; // 不是邮件驱动的会话,不碰 + + // "New session - <时间>" 是 opencode 的占位标题,等模型生成真摘要再回写 + const title = typeof info.title === "string" ? info.title : ""; + if (!title || title.startsWith("New session")) return; + + // 同一标题只回写一次,避免 session.updated 高频触发时反复打网关 + if (syncedTitles.get(info.id) === title) return; + syncedTitles.set(info.id, title); + + const res = await syncSessionNaming(mailSessionID, { alias: info.slug || "", title }); + if (res) { + console.error(`[mail-bridge] 会话命名同步 ${info.id} -> alias=${res.alias || "-"} title=${res.title || "-"}`); + } + return; + } + + // 2) 一轮跑完 → 把最后那段话作为回信转出去(不消耗配额)。 + // 用 session.idle 而不是 message.updated:后者在流式生成中反复触发, + // 转出去的会是半截话。 + if (event?.type === "session.idle") { + const sid = event.properties?.sessionID; + if (!sid || !mailDrivenSessions.has(sid)) return; + try { + const res = await relaySummaryRef(sid); + if (res?.mail_id) { + console.error(`[mail-bridge] 总结已回信 ${res.mail_id}(不计配额)`); + } + } catch (e) { + console.error("[mail-bridge] 总结回信失败:", e?.message || e); + } + return; + } + + // 3) 权限被本地机制(TUI)处理掉时,清掉待决策记录, + // 免得之后邮件决策回来又去回复一条已经结案的 permission。 + if (event?.type === "permission.replied") { + const pid = event.properties?.permissionID; + if (pid) pendingPermissions.delete(pid); + return; + } + }, + + tool: { + send_mail: sendMailTool, + read_inbox: readInboxTool, + forward_mail: forwardMailTool, + upload_attachment: uploadAttachmentTool, + download_attachment: downloadAttachmentTool, + connect_to_server: connectToServerTool, + }, + }; +} diff --git a/plugins/opencode-mail-bridge/package-lock.json b/plugins/opencode-mail-bridge/package-lock.json new file mode 100644 index 0000000..e2f0773 --- /dev/null +++ b/plugins/opencode-mail-bridge/package-lock.json @@ -0,0 +1,448 @@ +{ + "name": "opencode-mail-bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-mail-bridge", + "version": "0.1.0", + "dependencies": { + "zod": "^3.25.76" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.15.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.25", + "resolved": "https://registry.npmmirror.com/@opencode-ai/plugin/-/plugin-1.18.25.tgz", + "integrity": "sha512-Kb34zFqYosFNiMd1IuYiZGjX17z+18Srm7tHZMCz+uMVRTYNkEw1FTrfAK2FLbggwYdgzifGwKMNF1slLT8eLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.25", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.25", + "resolved": "https://registry.npmmirror.com/@opencode-ai/sdk/-/sdk-1.18.25.tgz", + "integrity": "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT", + "peer": true + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "peer": true, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "peer": true + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "peer": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT", + "peer": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugins/opencode-mail-bridge/package.json b/plugins/opencode-mail-bridge/package.json new file mode 100644 index 0000000..d84a88d --- /dev/null +++ b/plugins/opencode-mail-bridge/package.json @@ -0,0 +1,13 @@ +{ + "name": "opencode-mail-bridge", + "version": "0.1.0", + "description": "Opencode plugin: 邮件驱动多智能体协作平台桥接", + "type": "module", + "main": "index.js", + "dependencies": { + "zod": "^3.25.76" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.15.0" + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..50efb42 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + AgentMail + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..35f8b3d --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4228 @@ +{ + "name": "agentmail-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentmail-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.418", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c61d9ee --- /dev/null +++ b/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "agentmail-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "node test/markdown-xss.test.mjs" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..3bb7f2a --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,165 @@ +import { useEffect } from 'react'; +import { connectSSE } from './api/sse'; +import { useAuthStore } from './stores/authStore'; +import { useMailStore } from './stores/mailStore'; +import { useSessionStore } from './stores/sessionStore'; +import { useContactStore } from './stores/contactStore'; +import { useUIStore } from './stores/uiStore'; +import Sidebar from './components/Sidebar'; +import MailList from './components/MailList'; +import ContactPanel from './components/ContactPanel'; +import MailView from './components/MailView'; +import ComposePage from './components/ComposePage'; +import LoginPage from './components/LoginPage'; +import SetupPage from './components/SetupPage'; +import AccountPage from './components/AccountPage'; +import AdminUsersPage from './components/AdminUsersPage'; + +export default function App() { + const phase = useAuthStore(s => s.phase); + const bootstrap = useAuthStore(s => s.bootstrap); + const user = useAuthStore(s => s.user); + + const viewMode = useUIStore(s => s.viewMode); + const composing = useUIStore(s => s.composing); + const resetUI = useUIStore(s => s.reset); + + const fetchInbox = useMailStore(s => s.fetchInbox); + const fetchSent = useMailStore(s => s.fetchSent); + const dropMailSession = useMailStore(s => s.dropSession); + + const fetchSessions = useSessionStore(s => s.fetchSessions); + const dropSessionIfCurrent = useSessionStore(s => s.dropSessionIfCurrent); + const refreshRenameProposal = useSessionStore(s => s.refreshRenameProposal); + const refreshBudget = useSessionStore(s => s.refreshBudget); + + const fetchContacts = useContactStore(s => s.fetchContacts); + const removeSessionLocally = useContactStore(s => s.removeSessionLocally); + + // 启动时检测初始化状态 / 登录态 + useEffect(() => { + bootstrap(); + }, []); + + // 登出后清理客户端状态,避免脏数据残留 + useEffect(() => { + if (phase === 'anonymous') { + resetUI(); + } + }, [phase]); + + // 登录态就绪后拉取数据 + SSE + useEffect(() => { + if (phase !== 'authenticated') return; + + fetchInbox('all'); + fetchSent(); + fetchSessions(); + fetchContacts(); + + return connectSSE((type, data) => { + switch (type) { + case 'new_mail': + fetchInbox('all'); + fetchSessions(); + fetchContacts(); + // 新来信可能带改名建议;Agent 发信也会消耗本任务的往返预算 + refreshRenameProposal(); + refreshBudget(); + break; + case 'session_update': + // 别人(或另一个标签页)改了预算/别名 + refreshBudget(); + fetchInbox('all'); + fetchSessions(); + fetchContacts(); + break; + case 'permission_decision': + fetchInbox('all'); + fetchSessions(); + fetchContacts(); + break; + case 'session_archived': { + const id = typeof data.session_id === 'string' ? data.session_id : ''; + if (!id) break; + removeSessionLocally(id); + dropMailSession(id); + dropSessionIfCurrent(id); + break; + } + } + }); + }, [phase]); + + // loading 阶段 + if (phase === 'checking') { + return ( +
+

加载中

+
+ ); + } + + // 未登录 + if (phase === 'anonymous') { + return ; + } + + // 已登录主界面 + return ( +
+ + {viewMode === 'contacts' ? ( + + ) : viewMode === 'inbox' || viewMode === 'sent' ? ( + + ) : null} + {composing ? ( + + ) : viewMode === 'account' ? ( + + ) : viewMode === 'admin' && user?.role === 'admin' ? ( + + ) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? ( + + ) : ( + + )} +
+ ); +} + +/** 未登录时:先查 needs_setup,决定展示初始化向导还是登录页 */ +function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) { + // bootstrap 已经在 App 里调过了,此处仅判断路由 + // 若 bootstrap 已经把 phase 推到 anonymous,needs_setup 需独立查询 + // 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑 + return ; +} + +import { setupStatus } from './api/client'; +function LoginOrSetup({ onDone }: { onDone: () => void }) { + const [needsSetup, setNeedsSetup] = useState(null); + + useEffect(() => { + setupStatus() + .then(r => setNeedsSetup(r.needs_setup)) + .catch(() => setNeedsSetup(false)); + }, []); + + if (needsSetup === null) { + return ( +
+

加载中

+
+ ); + } + + if (needsSetup) { + return ; + } + + return ; +} + +import { useState } from 'react'; diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..55d74cb --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,489 @@ +import type { User } from '../types'; +import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types'; +import { API_BASE, authHeaders, withToken } from './config'; + +export { API_BASE, setToken, getToken, authHeaders, withToken } from './config'; + +const BASE = API_BASE; + +export class ApiError extends Error { + status: number; + retryAfter?: number; + constructor(status: number, message: string, retryAfter?: number) { + super(message); + this.status = status; + this.retryAfter = retryAfter; + } +} + +let onUnauthorized: (() => void) | null = null; +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn; +} + +async function request(method: string, path: string, body?: unknown): Promise { + const init: RequestInit = { + method, + // Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害 + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...authHeaders() } + }; + if (body !== undefined) init.body = JSON.stringify(body); + + const res = await fetch(`${BASE}${path}`, init); + + if (!res.ok) { + const payload = await res.json().catch(() => ({ error: res.statusText })); + if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) { + onUnauthorized?.(); + } + throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after); + } + return res.json(); +} + +// ---------- 首次初始化 ---------- + +export async function setupStatus() { + return request<{ needs_setup: boolean }>('GET', '/setup/status'); +} + +export async function setupAdmin(payload: { + username: string; + password: string; + display_name?: string; +}) { + return request<{ user: User }>('POST', '/setup/admin', payload); +} + +// ---------- 认证 ---------- + +export async function login(username: string, password: string) { + return request<{ user: User }>('POST', '/auth/login', { username, password }); +} + +export async function logout() { + return request<{ status: string }>('POST', '/auth/logout'); +} + +export async function me() { + return request<{ user: User }>('GET', '/auth/me'); +} + +export async function changePassword(oldPassword: string, newPassword: string) { + return request<{ status: string }>('POST', '/auth/password', { + old_password: oldPassword, + new_password: newPassword + }); +} + +// ---------- 管理员:用户管理 ---------- + +export async function adminListUsers() { + return request<{ users: User[] }>('GET', '/admin/users'); +} + +export async function adminCreateUser(payload: { + username: string; + password: string; + display_name?: string; + role?: string; + allowed_agents?: string[]; + allowed_paths?: string[]; +}) { + return request<{ user: User }>('POST', '/admin/users', payload); +} + +export async function adminUpdateUser( + id: string, + payload: { + display_name?: string; + role?: string; + status?: string; + allowed_agents?: string[]; + allowed_paths?: string[]; + } +) { + return request<{ user: User }>('PUT', `/admin/users/${id}`, payload); +} + +export async function adminDisableUser(id: string) { + return request<{ status: string }>('DELETE', `/admin/users/${id}`); +} + +export async function adminResetPassword(id: string, newPassword: string) { + return request<{ status: string }>('POST', `/admin/users/${id}/reset`, { + new_password: newPassword + }); +} + +export async function adminListScopes() { + const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes'); + return r; +} + +// ---------- 密钥 ---------- + +export type KeyType = 'permanent' | 'one_time' | 'timed'; + +/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */ +export interface AgentKey { + key_id: string; + key_token?: string; + token_hint: string; + agent_name: string | null; + key_type: KeyType; + label: string; + expires_at: string | null; + used_at: string | null; + created_at: string; +} + +export interface UserKey { + key_id: string; + key_token?: string; + token_hint: string; + label: string; + key_type: KeyType; + expires_at: string | null; + used_at: string | null; + created_at: string; +} + +export interface CreateKeyPayload { + key_type: KeyType; + label?: string; + /** 仅 timed 需要 */ + expires_hours?: number; + /** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */ + agent_name?: string; + /** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */ + key_token?: string; +} + +export async function adminListAgentKeys(agentName?: string) { + const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : ''; + return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`); +} + +export async function adminCreateAgentKey(payload: CreateKeyPayload) { + return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload); +} + +export async function adminDeleteAgentKey(id: string) { + return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`); +} + +export async function adminBindAgentKey(id: string, agentName: string) { + return request<{ status: string; agent_name: string }>( + 'POST', + `/admin/agent-keys/${id}/bind`, + { agent_name: agentName } + ); +} + +export async function listMyKeys() { + return request<{ keys: UserKey[] }>('GET', '/me/keys'); +} + +export async function createMyKey(payload: CreateKeyPayload) { + return request<{ key: UserKey }>('POST', '/me/keys', payload); +} + +export async function deleteMyKey(id: string) { + return request<{ status: string }>('DELETE', `/me/keys/${id}`); +} + +// ---------- Agents ---------- + +export async function listAgents(status?: string) { + const q = status ? `?status=${encodeURIComponent(status)}` : ''; + return request<{ agents: Agent[] }>('GET', `/agents${q}`); +} + +// ---------- 自己的邮箱 ---------- + +export interface SendMailOpts { + cc?: string; + reply_to?: string; + /** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */ + session_alias?: string; + /** 先用 uploadAttachment 上传取得的 id 列表 */ + attachment_ids?: string[]; + /** + * 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效; + * 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。 + */ + max_rounds?: number; +} + +export async function sendMail( + to: string, + subject: string, + body: string, + opts: SendMailOpts = {} +) { + return request<{ + mail_id: string; + session_id: string; + session_alias: string; + budget_max?: number; + budget_used?: number; + budget_remaining?: number; + }>('POST', '/me/mail/send', { + to, + subject, + body, + cc: opts.cc ?? '', + reply_to: opts.reply_to ?? '', + session_alias: opts.session_alias ?? '', + attachment_ids: opts.attachment_ids ?? [], + // null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」 + max_rounds: opts.max_rounds ?? null + }); +} + +export async function getInbox(status = 'all', limit = 50) { + return request<{ mails: Mail[]; total: number }>( + 'GET', + `/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}` + ); +} + +export async function getSent() { + return request<{ mails: Mail[] }>('GET', '/me/mail/sent'); +} + +export async function getMail(id: string) { + return request('GET', `/mail/${id}`); +} + +export async function markMailRead(id: string) { + return request<{ status: string }>('POST', `/mail/${id}/read`); +} + +/** + * 取线索的一块。 + * + * dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。 + * 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。 + */ +export async function getMailThread( + id: string, + opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {} +) { + const q = new URLSearchParams(); + if (opts.dir) q.set('dir', opts.dir); + if (opts.offset !== undefined) q.set('offset', String(opts.offset)); + if (opts.limit !== undefined) q.set('limit', String(opts.limit)); + const qs = q.toString(); + return request('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`); +} + +// ---------- 附件 ---------- + +/** + * 上传附件,返回 attachment_id。 + * + * 不能走 request():那里固定 Content-Type: application/json, + * 而 multipart 必须让浏览器自己带 boundary。 + */ +export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) { + const form = new FormData(); + form.append('file', file); + + // 需要进度就用 XHR —— fetch 至今没有上传进度事件 + if (onProgress) { + return new Promise<{ attachment: Attachment }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + 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 => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => { + let payload: { attachment?: Attachment; error?: string } = {}; + try { + payload = JSON.parse(xhr.responseText); + } catch { + /* 非 JSON 响应按状态码处理 */ + } + if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) { + resolve({ attachment: payload.attachment }); + } else { + if (xhr.status === 401) onUnauthorized?.(); + reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new ApiError(0, '网络错误')); + xhr.send(form); + }); + } + + const res = await fetch(`${BASE}/me/attachments`, { + method: 'POST', + credentials: 'include', + // 不设 Content-Type:multipart 的 boundary 要交给浏览器生成 + headers: authHeaders(), + body: form + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({ error: res.statusText })); + if (res.status === 401) onUnauthorized?.(); + throw new ApiError(res.status, payload.error || `HTTP ${res.status}`); + } + return res.json() as Promise<{ attachment: Attachment }>; +} + +export async function deleteAttachment(id: string) { + return request<{ status: string }>('DELETE', `/me/attachments/${id}`); +} + +/** + * 附件下载链接。由浏览器直接发起(
),因此无法带 Authorization 头: + * Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。 + */ +export function attachmentURL(id: string) { + return withToken(`${BASE}/me/attachments/${id}`); +} + +/** 人类可读的字节数 */ +export function formatSize(n: number) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +export interface ForwardPayload { + /** 新收件人的三维地址 */ + to: string; + /** 转发说明,置于引用原文之前 */ + comment?: string; + cc?: string; + /** 留空则自动加 Fwd: 前缀 */ + subject?: string; + /** 仅当 to 以 .new 结尾时生效 */ + session_alias?: string; +} + +export async function forwardMail(id: string, payload: ForwardPayload) { + return request<{ + mail_id: string; + session_id: string; + session_alias: string; + forwarded_from: string; + }>('POST', `/me/mail/${id}/forward`, { + to: payload.to, + comment: payload.comment ?? '', + cc: payload.cc ?? '', + subject: payload.subject ?? '', + session_alias: payload.session_alias ?? '' + }); +} + +// ---------- 配额 ---------- + +export interface Quota { + agent_name: string; + max_rounds: number; + used_rounds: number; + /** 不限额时为 -1 */ + remaining: number; + unlimited: boolean; +} + +export async function adminListQuotas() { + return request<{ quotas: Quota[] }>('GET', '/admin/quotas'); +} + +/** 设上限(0 = 不限)或把已用次数归零 */ +export async function adminSetQuota( + agentName: string, + payload: { max_rounds?: number; reset?: boolean } +) { + return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload); +} + +// ---------- Sessions ---------- + +export async function getHumanSessions() { + return request<{ sessions: HumanSession[] }>('GET', '/me/sessions'); +} + +export async function getSessionDetail(id: string) { + return request('GET', `/sessions/${id}`); +} + +export async function updateSessionAlias(id: string, alias: string) { + return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias }); +} + +/** + * 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。 + * 已接受(提议就是当前别名)或已驳回的不再返回。 + */ +export async function getRenameProposal(id: string) { + return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`); +} + +/** 本会话(= 本任务)的往返预算。 */ +export async function getSessionBudget(id: string) { + return request('GET', `/sessions/${id}/budget`); +} + +/** + * 改本会话的往返预算。 + * + * max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给 + * (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。 + */ +export async function updateSessionBudget( + id: string, + patch: { max_rounds?: number; reset?: boolean } +) { + return request('PUT', `/sessions/${id}/budget`, patch); +} + +/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */ +export async function dismissRenameProposal(id: string) { + return request<{ status: string; dismissed?: string }>( + 'POST', + `/sessions/${id}/rename-proposal/dismiss` + ); +} + +// ---------- Contacts ---------- + +export async function listContacts(archived = false) { + return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`); +} + +export async function suggestAddress(name?: string, path?: string) { + const p = new URLSearchParams(); + if (name) p.set('name', name); + if (path) p.set('path', path); + return request('GET', `/contacts/suggest?${p.toString()}`); +} + +export async function archiveContact(payload: { address?: string; session_id?: string }) { + return request<{ status: string; session_id: string; session_alias: string }>( + 'POST', + '/contacts/archive', + payload + ); +} + +// ---------- Permission ---------- + +export async function decidePermission(mailId: string, decision: string, note?: string) { + return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', { + mail_id: mailId, + decision, + note: note ?? '' + }); +} + +export async function listPendingPermissions() { + return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending'); +} diff --git a/web/src/api/config.ts b/web/src/api/config.ts new file mode 100644 index 0000000..d725bc2 --- /dev/null +++ b/web/src/api/config.ts @@ -0,0 +1,68 @@ +/** + * 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; + } +} + +/** + * 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。 + * + * 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。 + */ +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(/\/+$/, ''); +} + +export const 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 { + return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}; +} + +/** + * 给 URL 附加认证信息,供无法设置请求头的场景使用: + * - EventSource(SSE)不支持自定义头 + * - / 由浏览器直接发起 + * + * 服务端仅在 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)}`; +} diff --git a/web/src/api/sse.ts b/web/src/api/sse.ts new file mode 100644 index 0000000..af9676a --- /dev/null +++ b/web/src/api/sse.ts @@ -0,0 +1,76 @@ +import { API_BASE, withToken } from './config'; + +export type SSEEventHandler = (eventType: string, data: Record) => void; + +const EVENTS = [ + 'new_mail', + 'permission_decision', + 'session_update', + 'session_archived', + 'agent_online' +] as const; + +let es: EventSource | null = null; +let handlers: SSEEventHandler[] = []; +let retryTimer: ReturnType | null = null; +let backoff = 1000; + +export function connectSSE(onEvent: SSEEventHandler): () => void { + handlers.push(onEvent); + if (!es) open(); + + return () => { + handlers = handlers.filter(h => h !== onEvent); + if (handlers.length === 0) close(); + }; +} + +function open() { + close(false); + // EventSource 无法设置请求头:Cookie 模式靠同源 Cookie, + // 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。 + es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true }); + + es.addEventListener('connected', () => { + backoff = 1000; + }); + + for (const name of EVENTS) { + es.addEventListener(name, (e: MessageEvent) => { + let data: Record = {}; + try { + data = JSON.parse(e.data); + } catch { + /* 忽略非 JSON 负载 */ + } + handlers.forEach(h => h(name, data)); + }); + } + + es.onerror = () => { + close(false); + if (handlers.length === 0) return; + if (retryTimer) return; + retryTimer = setTimeout(() => { + retryTimer = null; + backoff = Math.min(backoff * 2, 15000); + open(); + }, backoff); + }; +} + +function close(clearHandlers = true) { + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + if (es) { + es.close(); + es = null; + } + if (clearHandlers) handlers = []; +} + +export function disconnectSSE() { + close(); +} diff --git a/web/src/components/AccountPage.tsx b/web/src/components/AccountPage.tsx new file mode 100644 index 0000000..a02f7ae --- /dev/null +++ b/web/src/components/AccountPage.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAuthStore } from '../stores/authStore'; +import * as api from '../api/client'; +import { LockIcon } from './icons'; +import KeyPanel from './KeyPanel'; + +/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */ +export default function AccountPage() { + const user = useAuthStore(s => s.user); + const [oldPw, setOldPw] = useState(''); + const [newPw, setNewPw] = useState(''); + const [confirmPw, setConfirmPw] = useState(''); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(null); + + // 密钥面板状态 + const [keys, setKeys] = useState([]); + const [keyBusy, setKeyBusy] = useState(false); + const [keyError, setKeyError] = useState(null); + const [newToken, setNewToken] = useState(null); + + const loadKeys = useCallback(async () => { + try { + const r = await api.listMyKeys(); + setKeys(r.keys); + setKeyError(null); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + loadKeys(); + }, [loadKeys]); + + const createKey = async (payload: api.CreateKeyPayload) => { + setKeyBusy(true); + setKeyError(null); + try { + const r = await api.createMyKey(payload); + // 全文只在创建响应里出现一次,必须当场展示 + setNewToken(r.key.key_token ?? null); + await loadKeys(); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } finally { + setKeyBusy(false); + } + }; + + const deleteKey = async (id: string) => { + setKeyError(null); + try { + await api.deleteMyKey(id); + await loadKeys(); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } + }; + + const mismatch = confirmPw !== '' && newPw !== confirmPw; + const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy; + + const changePw = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setBusy(true); + setError(null); + setMsg(null); + try { + await api.changePassword(oldPw, newPw); + setMsg('密码已修改,请重新登录'); + setOldPw(''); + setNewPw(''); + setConfirmPw(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + if (!user) return null; + + return ( +
+
+

账号信息

+
+ +
+ {/* 基本信息 */} +
+

基本资料

+
+ + + + + + +
+
+ + {/* 权限边界 */} + {user.role !== 'admin' && ( +
+

权限范围

+
+ + +
+
+ )} + + {/* 修改密码 */} +
+

+ + 修改密码 +

+
+
+ + setOldPw(e.target.value)} + autoComplete="current-password" + className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> +
+
+ + setNewPw(e.target.value)} + autoComplete="new-password" + className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> +
+
+ + setConfirmPw(e.target.value)} + autoComplete="new-password" + className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${ + mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400' + }`} + /> + {mismatch &&

两次密码不一致

} +
+ + {error && ( +

+ {error} +

+ )} + {msg && ( +

+ {msg} +

+ )} + + +
+
+ + {/* 客户端连接密钥 */} +
+ setNewToken(null)} + /> +
+
+
+ ); +} + +function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/components/AddressInput.tsx b/web/src/components/AddressInput.tsx new file mode 100644 index 0000000..d56e955 --- /dev/null +++ b/web/src/components/AddressInput.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import * as api from '../api/client'; + +/** + * 三段式地址输入:name -> @path -> .session + * 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。 + * 值本身始终是完整字符串 name@path.session。 + */ +export default function AddressInput({ + value, + onChange, + placeholder, + allowMultiple = false, + autoFocus = false +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + /** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */ + allowMultiple?: boolean; + autoFocus?: boolean; +}) { + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + const [kind, setKind] = useState<'name' | 'path' | 'session'>('name'); + const [active, setActive] = useState(0); + const boxRef = useRef(null); + + // 当前正在编辑的那一段(多地址时取最后一段) + const { head, editing } = useMemo(() => { + if (!allowMultiple) return { head: '', editing: value }; + const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';')); + if (idx < 0) return { head: '', editing: value }; + return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() }; + }, [value, allowMultiple]); + + // 把编辑段拆成 name / path / session 三部分 + const parts = useMemo(() => parseParts(editing), [editing]); + + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + // 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session + const res = parts.hasDot + ? await api.suggestAddress(parts.name, parts.path) + : parts.hasAt + ? await api.suggestAddress(parts.name) + : await api.suggestAddress(); + if (cancelled) return; + + const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name; + const filtered = (res.suggestions || []).filter(s => + s.toLowerCase().includes(frag.toLowerCase()) + ); + setKind(res.kind); + setItems(filtered); + setActive(0); + } catch { + if (!cancelled) setItems([]); + } + }; + const t = setTimeout(run, 120); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]); + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + /** 选中一个候选后拼回完整地址 */ + const apply = (choice: string) => { + let next: string; + if (kind === 'name') { + next = `${choice}@`; + } else if (kind === 'path') { + next = `${parts.name}@${choice}.`; + } else { + next = `${parts.name}@${parts.path}.${choice}`; + } + onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next); + // name/path 选完仍停留在补全态,继续下一段 + setOpen(kind !== 'session'); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!open || items.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive(i => (i + 1) % items.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive(i => (i - 1 + items.length) % items.length); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + apply(items[active]); + } else if (e.key === 'Escape') { + setOpen(false); + } + }; + + const hint = + kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名(new 为新建)'; + + return ( +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + spellCheck={false} + className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> + + {open && items.length > 0 && ( +
+
+ {hint} +
+ {items.map((s, i) => ( + + ))} +
+ )} +
+ ); +} + +/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */ +function parseParts(s: string) { + const at = s.indexOf('@'); + if (at < 0) { + return { name: s, path: '', session: '', hasAt: false, hasDot: false }; + } + const name = s.slice(0, at); + const rest = s.slice(at + 1); + const dot = rest.lastIndexOf('.'); + if (dot < 0) { + return { name, path: rest, session: '', hasAt: true, hasDot: false }; + } + return { + name, + path: rest.slice(0, dot), + session: rest.slice(dot + 1), + hasAt: true, + hasDot: true + }; +} diff --git a/web/src/components/AdminUsersPage.tsx b/web/src/components/AdminUsersPage.tsx new file mode 100644 index 0000000..8ced352 --- /dev/null +++ b/web/src/components/AdminUsersPage.tsx @@ -0,0 +1,421 @@ +import { useCallback, useEffect, useState } from 'react'; +import * as api from '../api/client'; +import type { AdminScopes, User } from '../types'; +import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons'; +import KeyPanel from './KeyPanel'; +import QuotaPanel from './QuotaPanel'; + +type Tab = 'users' | 'keys' | 'quotas'; + +export default function AdminUsersPage() { + const [tab, setTab] = useState('users'); + const [users, setUsers] = useState([]); + const [scopes, setScopes] = useState({ agents: [], paths: [] }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [creating, setCreating] = useState(false); + const [editing, setEditing] = useState(null); + + // Agent 密钥面板 + const [keys, setKeys] = useState([]); + const [keyBusy, setKeyBusy] = useState(false); + const [keyError, setKeyError] = useState(null); + const [newToken, setNewToken] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]); + setUsers(u.users || []); + setScopes(s); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const loadKeys = useCallback(async () => { + try { + const r = await api.adminListAgentKeys(); + setKeys(r.keys); + setKeyError(null); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + if (tab === 'keys') loadKeys(); + }, [tab, loadKeys]); + + const createKey = async (payload: api.CreateKeyPayload) => { + setKeyBusy(true); + setKeyError(null); + try { + const r = await api.adminCreateAgentKey(payload); + // 登记客户端已有密钥时对方已经持有全文,无需再弹一次 + setNewToken(payload.key_token ? null : r.key.key_token ?? null); + await loadKeys(); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } finally { + setKeyBusy(false); + } + }; + + const deleteKey = async (id: string) => { + setKeyError(null); + try { + await api.adminDeleteAgentKey(id); + await loadKeys(); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } + }; + + const bindKey = async (id: string, agentName: string) => { + setKeyError(null); + try { + await api.adminBindAgentKey(id, agentName); + await loadKeys(); + } catch (err) { + setKeyError(err instanceof Error ? err.message : String(err)); + } + }; + + const flash = (msg: string) => { + setNotice(msg); + setTimeout(() => setNotice(null), 2500); + }; + + return ( +
+
+ setTab('users')}> + + 用户管理 + {users.length} + + setTab('keys')}> + + Agent 密钥 + + setTab('quotas')}> + + 发信配额 + +
+ {notice && {notice}} + {tab === 'users' && ( + + )} +
+ + {error &&

{error}

} + + {tab === 'quotas' ? ( +
+ +
+ ) : tab === 'keys' ? ( +
+ setNewToken(null)} + /> +
+ ) : ( + <> + {creating && { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />} + +
+ {users.map(u => ( + setEditing(editing === u.user_id ? null : u.user_id)} + onSaved={flash} onReload={load} setError={setError} /> + ))} + {loading && users.length === 0 &&

加载中

} +
+ + )} +
+ ); +} + +function TabButton({ active, onClick, children }: { + active: boolean; onClick: () => void; children: React.ReactNode; +}) { + return ( + + ); +} + +/* ── UserCard ── */ + +function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: { + user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void; + onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void; +}) { + return ( +
+
+ + {user.username} + {user.display_name} + + {user.role === 'admin' ? '管理员' : '用户'} + + + {user.status === 'active' ? '启用' : '禁用'} + + {user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && ( + 受限 + )} +
+ {user.last_login || '从未登录'} +
+ {expanded && } +
+ ); +} + +/* ── UserEditor ── */ + +function UserEditor({ user, scopes, onSaved, onReload, setError }: { + user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void; +}) { + const [displayName, setDisplayName] = useState(user.display_name); + const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user'); + const [agents, setAgents] = useState(user.allowed_agents); + const [paths, setPaths] = useState(user.allowed_paths); + const [pw, setPw] = useState(''); + const [busy, setBusy] = useState(false); + + const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]); + const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]); + + const save = async () => { + setBusy(true); + try { + await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths }); + onSaved('用户已更新'); await onReload(); + } catch (err) { setError(err instanceof Error ? err.message : String(err)); } + finally { setBusy(false); } + }; + + const disableUser = async () => { + try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); } + catch (err) { setError(err instanceof Error ? err.message : String(err)); } + }; + + const enableUser = async () => { + try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); } + catch (err) { setError(err instanceof Error ? err.message : String(err)); } + }; + + const resetPassword = async () => { + if (pw.length < 8) return; + setBusy(true); + try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); } + catch (err) { setError(err instanceof Error ? err.message : String(err)); } + finally { setBusy(false); } + }; + + return ( +
+
+
+ + setDisplayName(e.target.value)} + className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> +
+
+ + +
+
+ + {user.status === 'active' ? ( + + ) : ( + + )} +
+
+ + {user.role !== 'admin' && ( + <> +
+ +
+ {scopes.agents.map(a => ( + + ))} +
+
+
+ +
+ {scopes.paths.map(p => ( + + ))} +
+
+ + )} + +
+ +
+ + setPw(e.target.value)} placeholder="新密码(至少 8 位)" + className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> + +
+
+
+ ); +} + +/* ── CreateUserForm ── */ + +function CreateUserForm({ scopes, onDone, onError }: { + scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void; +}) { + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'admin' | 'user'>('user'); + const [agents, setAgents] = useState([]); + const [paths, setPaths] = useState([]); + const [busy, setBusy] = useState(false); + + const ok = username.trim().length >= 2 && password.length >= 8 && !busy; + + const submit = async () => { + if (!ok) return; + setBusy(true); + try { + await api.adminCreateUser({ + username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role, + allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths, + }); + setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]); + onDone(); + } catch (err) { onError(err instanceof Error ? err.message : String(err)); } + finally { setBusy(false); } + }; + + return ( +
+
+ + setUsername(e.target.value)} placeholder="alice" spellCheck={false} + className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> + + setDisplayName(e.target.value)} placeholder="Alice" + className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> + setPassword(e.target.value)} + className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> + + + +
+ + {role !== 'admin' && ( + <> + setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" /> + setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" /> + + )} + +
+ +
+
+ ); +} + +function ScopePick({ label, items, selected, onToggle, color }: { + label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green'; +}) { + const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700'; + return ( +
+ +
+ {items.map(i => ( + + ))} +
+
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+ + {hint && {hint}} +
+ {children} +
+ ); +} diff --git a/web/src/components/Attachments.tsx b/web/src/components/Attachments.tsx new file mode 100644 index 0000000..be80ad8 --- /dev/null +++ b/web/src/components/Attachments.tsx @@ -0,0 +1,168 @@ +import { useRef, useState } from 'react'; +import * as api from '../api/client'; +import type { Attachment } from '../types'; +import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons'; + +/** 已发出邮件的附件清单(只读,点击下载)。 */ +export function AttachmentList({ items }: { items: Attachment[] }) { + if (!items || items.length === 0) return null; + + return ( +
+ ); +} + +/** 待发送的附件:已上传到服务器、等着随邮件发出。 */ +export interface PendingAttachment { + id: string; + filename: string; + size: number; +} + +/** + * 写信时的附件选择器。 + * + * 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。 + * 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好, + * 而且上传失败能立刻反馈而不是卡在发送那一刻。 + */ +export function AttachmentPicker({ + items, + onChange, + disabled +}: { + items: PendingAttachment[]; + onChange: (next: PendingAttachment[]) => void; + disabled?: boolean; +}) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null); + const [error, setError] = useState(null); + + const pick = () => inputRef.current?.click(); + + const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + setError(null); + + // 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制 + const added: PendingAttachment[] = []; + for (const file of Array.from(files)) { + setUploading({ name: file.name, pct: 0 }); + try { + const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct })); + added.push({ + id: r.attachment.attachment_id, + filename: r.attachment.filename, + size: r.attachment.size_bytes + }); + } catch (err) { + setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`); + break; // 一个失败就停下,避免连续弹同类错误 + } + } + setUploading(null); + if (added.length > 0) onChange([...items, ...added]); + + // 清空 input,否则重复选同一个文件不会触发 change + if (inputRef.current) inputRef.current.value = ''; + }; + + const remove = async (a: PendingAttachment) => { + // 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC + try { + await api.deleteAttachment(a.id); + } catch { + /* 删不掉也只是留给 GC,不该阻塞用户移除操作 */ + } + onChange(items.filter(x => x.id !== a.id)); + }; + + return ( +
+ handleFiles(e.target.files)} + /> + +
+ + + {uploading && ( + + + {uploading.name} {uploading.pct}% + + )} + + {items.length > 0 && !uploading && ( + + {items.length} 个附件 ·{' '} + {api.formatSize(items.reduce((sum, a) => sum + a.size, 0))} + + )} +
+ + {error &&
{error}
} + + {items.length > 0 && ( +
    + {items.map(a => ( +
  • + + {a.filename} + {api.formatSize(a.size)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/ComposePage.tsx b/web/src/components/ComposePage.tsx new file mode 100644 index 0000000..8ab173d --- /dev/null +++ b/web/src/components/ComposePage.tsx @@ -0,0 +1,275 @@ +import { useEffect, useState } from 'react'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import * as api from '../api/client'; +import { useUIStore } from '../stores/uiStore'; +import { useMailStore } from '../stores/mailStore'; +import { useSessionStore } from '../stores/sessionStore'; +import { useContactStore } from '../stores/contactStore'; +import AddressInput from './AddressInput'; +import { AttachmentPicker, type PendingAttachment } from './Attachments'; +import { ComposeIcon } from './icons'; + +/** 完整的写邮件页面,占据右侧整个区域 */ +export default function ComposePage() { + const prefill = useUIStore(s => s.composePrefill); + const cancelCompose = useUIStore(s => s.cancelCompose); + const fetchInbox = useMailStore(s => s.fetchInbox); + const fetchSent = useMailStore(s => s.fetchSent); + const fetchSessions = useSessionStore(s => s.fetchSessions); + const fetchContacts = useContactStore(s => s.fetchContacts); + + const [to, setTo] = useState(prefill?.to ?? ''); + const [cc, setCc] = useState(prefill?.cc ?? ''); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [sessionAlias, setSessionAlias] = useState(''); + // 本任务的往返预算。空 = 不限。 + // 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给, + // 而不是事后到管理员页面去调某个 Agent 的全局配额。 + const [maxRounds, setMaxRounds] = useState(''); + const [attachments, setAttachments] = useState([]); + const [preview, setPreview] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [okMsg, setOkMsg] = useState(null); + + useEffect(() => { + setTo(prefill?.to ?? ''); + setCc(prefill?.cc ?? ''); + }, [prefill]); + + // 会话别名只在新建会话(地址以 .new 结尾)时有意义; + // 命中已有会话或走默认会话时后端会忽略该字段。 + const isNewSession = /\.new\s*$/.test(to.trim()); + const aliasError = + isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim()) + ? '别名不可含 . 空白 / 或 @' + : isNewSession && sessionAlias.trim() === 'new' + ? '"new" 是寻址保留字' + : null; + + const roundsError = + maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim()) + ? '预算必须是非负整数(0 = 不限)' + : null; + + const canSend = + roundsError === null && + to.trim() !== '' && + subject.trim() !== '' && + body.trim() !== '' && + aliasError === null && + !sending; + + const send = async () => { + if (!canSend) return; + setSending(true); + setError(null); + try { + const res = await api.sendMail(to.trim(), subject.trim(), body, { + cc: cc.trim(), + session_alias: isNewSession ? sessionAlias.trim() : '', + attachment_ids: attachments.map(a => a.id), + // 只在新建会话时提交:续谈已有会话若也带这个字段, + // 每封新信都会悄悄改掉对方正在遵守的预算 + ...(isNewSession && maxRounds.trim() !== '' + ? { max_rounds: Number(maxRounds.trim()) } + : {}) + }); + const where = res.session_alias + ? `会话别名 ${res.session_alias}` + : `会话 ${res.session_id.slice(0, 8)}`; + setOkMsg( + res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}` + ); + await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]); + setTimeout(() => { + setOkMsg(null); + cancelCompose(); + }, 900); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSending(false); + } + }; + + return ( +
+
+ +

新建邮件

+
+ +
+ +
+ + + + + {isNewSession && ( + + setSessionAlias(e.target.value)} + placeholder="refactor-auth" + className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> + {aliasError && {aliasError}} + + )} + + {isNewSession && ( + +
+ setMaxRounds(e.target.value)} + inputMode="numeric" + placeholder="不限" + className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> + + 个来回后 Agent 停止主动发信(自动转发的总结与权限询问不占预算) + +
+ {roundsError && {roundsError}} +
+ )} + + + + + + + setSubject(e.target.value)} + placeholder="更新特性分支" + className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" + /> + +
+ +
+
+ 正文(Markdown) +
+ setPreview(false)}> + 编辑 + + setPreview(true)}> + 预览 + +
+ + {preview ? ( +
+ {body.trim() ? ( + {body} + ) : ( +

暂无内容

+ )} +
+ ) : ( +