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 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

50
.gitignore vendored Normal file
View File

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

220
README.md Normal file
View File

@ -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 等平台
本就会由模型为会话生成摘要标题和 slugAgentMail 直接复用,不另造一套。
## 快速开始
### 开发
```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) — 数据模型与接口细节

View File

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

98
deploy/install.sh Executable file
View File

@ -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 "需要 rootsudo $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" <<EOF
# AgentMail Gateway 环境变量
ADMIN_USER=admin
ADMIN_PASSWORD=$ADMIN_PASS
# 生产环境走 HTTPS 时置 trueCookie 才会带 Secure 标记
SECURE_COOKIE=false
# 允许的前端跨域来源(逗号分隔);单二进制自带前端时通常无需配置
# CORS_ORIGINS=https://mail.example.com
EOF
chmod 0600 "$ETC/gateway.env"
echo " 已生成 $ETC/gateway.env管理员 admin / $ADMIN_PASS"
else
echo " $ETC/gateway.env 已存在,保留不动"
fi
if [[ ! -f "$ETC/opencode.env" ]]; then
cat > "$ETC/opencode.env" <<EOF
# opencode mail-bridge 插件配置
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
AGENTMAIL_AGENT_NAME=opencode
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
# 拿着它到 Web 后台「Agent 密钥」登记即可接入journalctl -u opencode-serve | grep mail-bridge
# 也可以先在后台签发密钥,再把它填在这里。
AGENTMAIL_AGENT_KEY=
AGENTMAIL_CONFIG_DIR=/opt/agentmail/agent-config
# 处理来信时使用的模型
AGENTMAIL_REPLY_PROVIDER=llmsproxy
AGENTMAIL_REPLY_MODEL=AUTO
# opencode serve 绑在 127.0.0.1,但同机任何进程都能调它开会话,
# 因此仍然设置访问密码。
OPENCODE_SERVER_PASSWORD=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
EOF
chmod 0600 "$ETC/opencode.env"
install -d -m 0700 "$PREFIX/agent-config"
echo " 已生成 $ETC/opencode.envserver password 为随机值;接入密钥首启时本地生成)"
else
echo " $ETC/opencode.env 已存在,保留不动"
fi
echo "==> 安装 systemd 单元"
install -m 0644 "$REPO/deploy/agentmail-gateway.service" /etc/systemd/system/
install -m 0644 "$REPO/deploy/opencode-serve.service" /etc/systemd/system/
systemctl daemon-reload
echo "==> 启用并启动"
systemctl enable --now agentmail-gateway.service
if command -v opencode >/dev/null 2>&1; then
systemctl enable --now opencode-serve.service
else
echo " 未找到 opencode跳过 opencode-serve装好后执行systemctl enable --now opencode-serve"
fi
sleep 3
echo
echo "==> 状态"
systemctl --no-pager --lines=0 status agentmail-gateway.service || true
curl -sf -m 5 http://127.0.0.1:8180/health && echo " 健康检查通过" || echo " 健康检查失败查看journalctl -u agentmail-gateway -n 50"

View File

@ -0,0 +1,30 @@
[Unit]
Description=opencode headless server (AgentMail mail-bridge host)
After=network-online.target agentmail-gateway.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/program/agentmail
ExecStart=/usr/local/bin/opencode serve --port 4097 --hostname 127.0.0.1
# opencode 靠 HOME 定位 ~/.config/opencode插件列表、provider 配置、认证)。
# systemd 不会自动注入 HOME不显式给就加载不到 mail-bridge 插件。
Environment=HOME=/root
# mail-bridge 插件凭这组凭证向 Gateway 注册并收发邮件
EnvironmentFile=/etc/agentmail/opencode.env
Restart=always
RestartSec=10
# opencode 惰加载插件:进程启动时不载 plugin要等第一个 /session 请求。
# 不预热的后果是重启后紧接着到的第一封邮件会丢mail-bridge 还没订阅 SSE
# 失败不能拘到单元本身,所以结尾固定 true。
ExecStartPost=/bin/sh -c 'for i in $(seq 1 30); do \
curl -sf -m 3 -u "opencode:$OPENCODE_SERVER_PASSWORD" http://127.0.0.1:4097/session >/dev/null && exit 0; \
sleep 1; \
done; true'
[Install]
WantedBy=multi-user.target

377
docs/API.md Normal file
View File

@ -0,0 +1,377 @@
# AgentMail WebAPI
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
这份文档描述如何以纯 API 方式接入。
基地址:`{host}/api/v1`
## 一、认证
三类调用者,各有凭证,互不越界:
| 调用者 | 凭证 | 可访问 |
|--------|------|--------|
| 浏览器WebUI | 登录 Cookie`am_session`HttpOnly | 人类接口 |
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `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`<a download>` 由浏览器直接发起,无法设置请求头。
只有这两个端点额外接受 query 令牌:
- `GET /events/stream?access_token=<token>`
- `GET /me/attachments/{id}?access_token=<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": "<mail_id>",
"session_alias": "fix-leak",
"attachment_ids": ["<attachment_id>"],
"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 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
**从入库正文里剥掉标记**并记在该封邮件上。
```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 <agent_key> 或 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__ = '<user_key>'; // 省略则走 Cookie
```
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。

796
docs/MVP-SPEC.md Normal file
View File

@ -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 用于注册/心跳/SSEuser_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 合并为单体服务
- 前端用 SSEServer-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.<alias>必须唯一未命名会话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. 返回挂起状态给 AgentAgent 不再继续执行)
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简单够用
- **实时推送**: EventSourceSSE 原生 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 APImail CRUD + session | 可用的 REST API |
| Agent 注册/心跳 API | Agent 可注册并保持在线 |
| SSE 推送机制 | /events/stream 端点 |
| 权限请求/决策 API | 完整的权限闭环 |
### Week 2Pi 插件 + 前端骨架
| 任务 | 产出 |
|------|------|
| 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 3Phase 2 后 2 周)
- 配额机制
- 跨主机 Agent 发现Gateway + Registry 拆分)
- Agent 间通信签名验证
- Hop-limit 防循环
### Phase 4持续
- 监控面板
- 审计日志查询
- 智能路由推荐
- Agent 自动会话别名建议
---
文档版本v0.1 (MVP)
基于:完整设计文档 v2.0
日期2026-09-01

1207
docs/PLAN.md Normal file

File diff suppressed because it is too large Load Diff

306
gateway/cmd/server/main.go Normal file
View File

@ -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)
})
})
// ---- SSEAgent 走 header人类走 Cookie内部自行分流 ----
r.Get("/events/stream", handler.SSEStream)
r.Get("/events/status", handler.SSEStatus)
// ---- 附件下载:由浏览器直接发起(<a download>),无法带 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()
}
}

28
gateway/go.mod Normal file
View File

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

80
gateway/go.sum Normal file
View File

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

View File

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

View File

@ -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("字节数 = %dwant 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)
}
}

View File

@ -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"),
// 空值 = 用内置 SQLitedata/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
}

193
gateway/internal/db/db.go Normal file
View File

@ -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=1SQLite 默认不校验外键,必须显式打开
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 需要 ::jsonbSQLite 存 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
}

View File

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

View File

@ -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.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话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 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
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;

View File

@ -0,0 +1,241 @@
-- AgentMail Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- 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.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话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 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
-- 「谁在哪一封里提了什么」应当留痕。
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 派生),数据库只存元数据。
-- 不塞 BLOBSQLite 的 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;

View File

@ -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 <agent_key_token> —— 密钥认证(推荐)。
// 密钥未绑定时用本请求的 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),
})
}

View File

@ -0,0 +1,69 @@
package handler
import "testing"
// 平台侧 slug/标题不受本侧寻址约束normalizeAlias 必须把它改写成
// 能安全出现在 name@path.<alias> 末段的形式。
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)
}
}
}

View File

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

View File

@ -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-8filename= 给老客户端兜底。
// 兜底值里的引号/反斜杠/非 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)
}
}

View File

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

View File

@ -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.sessionAgent 侧会话归档 + 邮箱界面移除
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
}

View File

@ -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_key_token> → Agent 通道(密钥认证)
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
// ?access_token=<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(),
})
}

View File

@ -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})
}

View File

@ -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("无抄送时不应出现「抄送」行")
}
}

View File

@ -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.<alias> 的末段。
// "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
}

View File

@ -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"})
}

View File

@ -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.<alias> 续谈。命中已有会话时该字段被忽略。
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"` // 上游消息 idrelay 非空时必填
}
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
//
// session 位三态语义(设计文档):
// - 省略pi@root → 投递到 name@path 的默认会话;从未通信则建立
// - newpi@root.new → 强制新建一个会话
// - 具体别名pi@root.fix-leak→ 必须已存在且该收件人参与过,否则 404 无法送达
//
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
// 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.<alias> 续谈。
// 别名全局唯一(负责寻址),已被占用时报 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 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
// 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
}

View File

@ -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 ""
}

View File

@ -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 是上游那条权限询问的稳定 idopencode 的 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
}

View File

@ -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()
}
}()
}

View File

@ -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)
}
}
}

View File

@ -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 提议改名的标记。
//
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
//
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
var renameProposalRe = regexp.MustCompile(
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\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
}

View File

@ -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<!-- agentmail:rename-session alias=\"fix-login-leak\" reason=\"是登录态泄漏\" -->",
wantAlias: "fix-login-leak",
wantReason: "是登录态泄漏",
wantBody: "已定位问题。",
},
{
name: "无理由",
body: "<!-- agentmail:rename-session alias=\"cache-eval\" -->\n\n正文在后。",
wantAlias: "cache-eval",
wantBody: "正文在后。",
},
{
// Agent 在长回复里反复修正措辞,最后写下的才是它的结论
name: "多条只取最后一条",
body: "<!-- agentmail:rename-session alias=\"first\" -->\n中间\n<!-- agentmail:rename-session alias=\"second\" -->",
wantAlias: "second",
wantBody: "中间",
},
{
// 别名含 . / @ 会让三维地址切分歧义normalizeAlias 改写为 -
name: "非法字符被规范化",
body: "<!-- agentmail:rename-session alias=\"fix login.leak/now\" -->",
wantAlias: "fix-login-leak-now",
wantBody: "",
},
{
// "new" 是寻址保留字
name: "保留字被改写",
body: "<!-- agentmail:rename-session alias=\"new\" -->",
wantAlias: "session-new",
wantBody: "",
},
{
// 规范化后为空 → 视为无提议,但标记仍要剥掉
name: "空别名视为无提议且剥掉标记",
body: "正文\n<!-- agentmail:rename-session alias=\"\" -->",
wantAlias: "",
wantBody: "正文",
},
{
name: "多余空格容错",
body: "<!-- agentmail:rename-session alias=\"ok-name\" -->",
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{
"<!-- agentmail:rename-session alias=\"a\" -->",
"前\n<!-- agentmail:rename-session alias=\"a\" reason=\"r\" -->\n后",
"<!-- agentmail:rename-session alias=\"\" -->", // 无效提议也要剥
}
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("<!-- agentmail:rename-session alias=\"" + in + "\" -->")
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("<!-- agentmail:rename-session alias=\"x\" reason=\"" + long + "\" -->")
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("截断产生了替换符,说明切在多字节字符中间")
}
}
}

View File

@ -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.<alias>),因此必须全局唯一,
// 且不能叫 "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
}
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
// 此时剩余为 0Agent 下次发信即被拦。
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)
}

View File

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

View File

@ -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)
}
}
}

View File

@ -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 <token> 取出令牌,缺失时返回空串。
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 <agent_key_token> —— 密钥认证(推荐)
// 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 <key> 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 ""
}

View File

@ -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 <user_key_token>(第三方客户端)。
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=。
//
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
// 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
}

View File

@ -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 (
// SessionDefaultsession 位省略 → 投递到 name@path 的默认会话(不存在则建立)
SessionDefault SessionMode = iota
// SessionNewsession 位为 new → 强制新建一个会话
SessionNew
// SessionNamedsession 位为具体别名 → 必须已存在,否则无法送达
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 内可以包含 . 与 /
// - 没有 . 时,整段视为 pathsession 为空(默认会话)
//
// 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 / sessionpath 内允许 / 与 .
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
}

View File

@ -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)
}
}

View File

@ -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"`
}

View File

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

View File

@ -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")
}
}

View File

@ -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_atpermanent 与 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
}

View File

@ -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)
}

View File

@ -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 后 %+vwant 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 后 %+vwant 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())
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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)
}
}

View File

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

View File

@ -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()
}
}
}

View File

@ -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))
}

View File

@ -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<!-- agentmail:rename-session alias="${esc(args.propose_alias)}"${reason} -->`;
}
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 };
// 故意不传 titleopencode 只在标题缺省时才让模型按首轮对话生成摘要标题,
// 传了占位标题就等于掐掉平台自己的命名机制。标题稍后由 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.idleopencode 在一轮跑完(不再有工具调用与生成)时发这个事件,
// 此刻的最后一条 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让它自己恢复执行。
// 这条路径不走 deliverMailopencode 的权限机制会在收到回复后继续原来的工具调用,
// 再往会话里塞一段「你的请求已批准」的文字只会干扰它。
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,
},
};
}

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgentMail</title>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4228
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
web/package.json Normal file
View File

@ -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"
}
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

165
web/src/App.tsx Normal file
View File

@ -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 (
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
<p className="text-sm"></p>
</div>
);
}
// 未登录
if (phase === 'anonymous') {
return <AnonymousRoute onBootDone={bootstrap} />;
}
// 已登录主界面
return (
<div className="h-full flex bg-gray-50">
<Sidebar />
{viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null}
{composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
<MailView />
) : (
<MailView />
)}
</div>
);
}
/** 未登录时:先查 needs_setup决定展示初始化向导还是登录页 */
function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) {
// bootstrap 已经在 App 里调过了,此处仅判断路由
// 若 bootstrap 已经把 phase 推到 anonymousneeds_setup 需独立查询
// 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑
return <LoginOrSetup onDone={onBootDone} />;
}
import { setupStatus } from './api/client';
function LoginOrSetup({ onDone }: { onDone: () => void }) {
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
useEffect(() => {
setupStatus()
.then(r => setNeedsSetup(r.needs_setup))
.catch(() => setNeedsSetup(false));
}, []);
if (needsSetup === null) {
return (
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
<p className="text-sm"></p>
</div>
);
}
if (needsSetup) {
return <SetupPage onDone={onDone} />;
}
return <LoginPage />;
}
import { useState } from 'react';

489
web/src/api/client.ts Normal file
View File

@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
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 而非 00 是「不限」的合法取值,省略才表示「不设置」
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<Mail>('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<ThreadPage>('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-Typemultipart 的 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}`);
}
/**
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 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<SessionDetail>('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<SessionBudget>('GET', `/sessions/${id}/budget`);
}
/**
* 改本会话的往返预算。
*
* max_rounds = 0 表示不限reset 把已用次数归零。两者可同时给
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
*/
export async function updateSessionBudget(
id: string,
patch: { max_rounds?: number; reset?: boolean }
) {
return request<SessionBudget>('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<SuggestResult>('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');
}

68
web/src/api/config.ts Normal file
View File

@ -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<string, string> {
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
}
/**
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
* - EventSourceSSE不支持自定义头
* - <a download> / <img src> 由浏览器直接发起
*
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
*/
export function withToken(url: string): string {
if (!bearerToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
}

76
web/src/api/sse.ts Normal file
View File

@ -0,0 +1,76 @@
import { API_BASE, withToken } from './config';
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => 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<typeof setTimeout> | 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<string, unknown> = {};
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();
}

View File

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
// 密钥面板状态
const [keys, setKeys] = useState<api.UserKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(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 (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-900"></h2>
</div>
<div className="max-w-lg px-6 py-6 space-y-6">
{/* 基本信息 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>
<dl className="text-sm space-y-2">
<Row label="用户名" value={user.username} mono />
<Row label="显示名" value={user.display_name} />
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
<Row label="创建时间" value={user.created_at || '-'} />
<Row label="最后登录" value={user.last_login || '从未登录'} />
</dl>
</section>
{/* 权限边界 */}
{user.role !== 'admin' && (
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>
<dl className="text-sm space-y-2">
<Row
label="可调用 Agent"
value={
user.allowed_agents.length === 0
? '不限(全部可用)'
: user.allowed_agents.join(', ')
}
/>
<Row
label="可访问目录"
value={
user.allowed_paths.length === 0
? '不限(全部可用)'
: user.allowed_paths.join(', ')
}
/>
</dl>
</section>
)}
{/* 修改密码 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
<LockIcon className="w-3.5 h-3.5" />
</h3>
<form onSubmit={changePw} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={oldPw}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"> 8 </label>
<input
type="password"
value={newPw}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={confirmPw}
onChange={e => 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 && <p className="mt-1 text-[10px] text-red-500"></p>}
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
</p>
)}
{msg && (
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
{msg}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{busy ? '保存中' : '保存'}
</button>
</form>
</section>
{/* 客户端连接密钥 */}
<section className="border-t border-gray-200 pt-6">
<KeyPanel
variant="user"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onDismissToken={() => setNewToken(null)}
/>
</section>
</div>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline gap-3">
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
</div>
);
}

View File

@ -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<string[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0);
const boxRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div ref={boxRef} className="relative">
<input
value={value}
autoFocus={autoFocus}
onChange={e => {
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 && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
{hint}
</div>
{items.map((s, i) => (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
>
{s}
{kind === 'session' && s === 'new' && (
<span className="ml-2 text-[10px] text-gray-400 font-sans"></span>
)}
</button>
))}
</div>
)}
</div>
);
}
/** 把 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
};
}

View File

@ -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<Tab>('users');
const [users, setUsers] = useState<User[]>([]);
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
// Agent 密钥面板
const [keys, setKeys] = useState<api.AgentKey[]>([]);
const [keyBusy, setKeyBusy] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(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 (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-1">
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
<span className="text-xs text-gray-400">{users.length}</span>
</TabButton>
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
<KeyIcon className="w-4 h-4" />
Agent
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
</TabButton>
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
{tab === 'users' && (
<button onClick={() => setCreating(v => !v)} className="px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
{creating ? '收起' : '新建用户'}
</button>
)}
</div>
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
{tab === 'quotas' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<QuotaPanel />
</div>
) : tab === 'keys' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<KeyPanel
variant="agent"
keys={keys}
loading={keyBusy}
error={keyError}
newToken={newToken}
onCreate={createKey}
onDelete={deleteKey}
onBind={bindKey}
onDismissToken={() => setNewToken(null)}
/>
</div>
) : (
<>
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{users.map(u => (
<UserCard key={u.user_id} user={u} scopes={scopes}
expanded={editing === u.user_id}
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
onSaved={flash} onReload={load} setError={setError} />
))}
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6"></p>}
</div>
</>
)}
</div>
);
}
function TabButton({ active, onClick, children }: {
active: boolean; onClick: () => void; children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{children}
</button>
);
}
/* ── 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 (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-3">
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
<span className="text-[11px] text-gray-500">{user.display_name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
{user.status === 'active' ? '启用' : '禁用'}
</span>
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
<span className="text-[10px] text-gray-400"></span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
</div>
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
</div>
);
}
/* ── 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<string[]>(user.allowed_agents);
const [paths, setPaths] = useState<string[]>(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 (
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input value={displayName} onChange={e => 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" />
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
{user.status === 'active' ? (
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full"></button>
) : (
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full"></button>
)}
</div>
</div>
{user.role !== 'admin' && (
<>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
Agent {agents.length > 0 && <span className="text-gray-400">{agents.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.agents.map(a => (
<button key={a} onClick={() => toggleAgent(a)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{a}</button>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
访 {paths.length > 0 && <span className="text-gray-400">{paths.length} </span>}
<span className="ml-2 font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{scopes.paths.map(p => (
<button key={p} onClick={() => togglePath(p)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{p}</button>
))}
</div>
</div>
</>
)}
<div className="flex items-center gap-3">
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
<div className="flex items-center gap-1.5 ml-auto">
<LockIcon className="w-3 h-3 text-gray-400" />
<input type="password" value={pw} onChange={e => 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" />
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
<CheckIcon className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}
/* ── 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<string[]>([]);
const [paths, setPaths] = useState<string[]>([]);
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 (
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
<div className="grid grid-cols-4 gap-3">
<Field label="用户名" hint="小写字母数字 . _ -">
<input value={username} onChange={e => 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" />
</Field>
<Field label="显示名"><input value={displayName} onChange={e => 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" /></Field>
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => 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" /></Field>
<Field label="角色">
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
<option value="user"></option>
<option value="admin"></option>
</select>
</Field>
</div>
{role !== 'admin' && (
<>
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
</>
)}
<div className="flex justify-end">
<button onClick={submit} disabled={!ok} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
{busy ? '创建中' : '创建'}
</button>
</div>
</div>
);
}
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 (
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
{label} <span className="font-normal text-gray-400"> = </span>
</label>
<div className="flex flex-wrap gap-1.5">
{items.map(i => (
<button key={i} onClick={() => onToggle(i)}
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>{i}</button>
))}
</div>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-baseline gap-1.5 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
</div>
{children}
</div>
);
}

View File

@ -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 (
<div className="mt-4 border-t border-gray-100 pt-3">
<div className="flex items-center gap-1.5 mb-2">
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[11px] font-medium text-gray-500">
{items.length}
</span>
</div>
<ul className="space-y-1">
{items.map(a => (
<li key={a.attachment_id}>
<a
href={api.attachmentURL(a.attachment_id)}
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
download={a.filename}
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0">
{api.formatSize(a.size_bytes)}
</span>
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
</a>
</li>
))}
</ul>
</div>
);
}
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
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<HTMLInputElement>(null);
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={e => handleFiles(e.target.files)}
/>
<div className="flex items-center gap-2">
<button
onClick={pick}
disabled={disabled || uploading !== null}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
>
<PaperclipIcon className="w-3.5 h-3.5" />
</button>
{uploading && (
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
{uploading.name} {uploading.pct}%
</span>
)}
{items.length > 0 && !uploading && (
<span className="text-[11px] text-gray-400">
{items.length} ·{' '}
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
</span>
)}
</div>
{error && <div className="text-[11px] text-red-600">{error}</div>}
{items.length > 0 && (
<ul className="space-y-1">
{items.map(a => (
<li
key={a.id}
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
>
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
<button
onClick={() => remove(a)}
disabled={disabled}
title="移除"
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -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<PendingAttachment[]>([]);
const [preview, setPreview] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [okMsg, setOkMsg] = useState<string | null>(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 (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<ComposeIcon className="w-4 h-4 text-blue-600" />
<h2 className="text-sm font-semibold text-gray-900"></h2>
<div className="flex-1" />
<button
onClick={() => {
setTo('');
setCc('');
setSubject('');
setBody('');
setSessionAlias('');
setMaxRounds('');
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
setAttachments([]);
setError(null);
}}
className="text-xs text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="px-6 py-4 space-y-3 border-b border-gray-200">
<Field label="收件人" hint="name@path.session省略=默认会话new=新建,别名=已有会话">
<AddressInput
value={to}
onChange={setTo}
autoFocus
placeholder="deepseekharness@/program.upadtefeature"
/>
</Field>
{isNewSession && (
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
<input
value={sessionAlias}
onChange={e => 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 && <span className="text-[10px] text-red-600">{aliasError}</span>}
</Field>
)}
{isNewSession && (
<Field
label="往返预算"
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
>
<div className="flex items-center gap-2">
<input
value={maxRounds}
onChange={e => 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"
/>
<span className="text-[11px] text-gray-400">
Agent
</span>
</div>
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>}
</Field>
)}
<Field label="抄送" hint="多个地址用逗号分隔">
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
</Field>
<Field label="主题">
<input
value={subject}
onChange={e => 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"
/>
</Field>
</div>
<div className="flex-1 min-h-0 px-6 py-3 flex flex-col">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span>
<div className="flex-1" />
<Toggle active={!preview} onClick={() => setPreview(false)}>
</Toggle>
<Toggle active={preview} onClick={() => setPreview(true)}>
</Toggle>
</div>
{preview ? (
<div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none">
{body.trim() ? (
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
) : (
<p className="text-gray-400 text-sm"></p>
)}
</div>
) : (
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
className="flex-1 min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
)}
</div>
<div className="px-6 pb-3">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
</div>
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
{error && <span className="text-xs text-red-600">{error}</span>}
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
<div className="flex-1" />
<button
onClick={cancelCompose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
>
</button>
<button
onClick={send}
disabled={!canSend}
className="px-5 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{sending ? '发送中' : '发送'}
</button>
</div>
</div>
);
}
function Field({
label,
hint,
children
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="flex items-baseline gap-2 mb-1">
<label className="text-[11px] font-medium text-gray-500">{label}</label>
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
</div>
{children}
</div>
);
}
function Toggle({
active,
onClick,
children
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`text-[11px] px-2 py-0.5 rounded ${
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>
{children}
</button>
);
}

View File

@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { useContactStore } from '../stores/contactStore';
import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import type { Contact } from '../types';
import { ArchiveIcon, ComposeIcon, CheckIcon, CloseIcon, ChevronRightIcon } from './icons';
/**
* 左侧联系人面板:列出所有 name@path.session支持
* - 点击进入该会话
* - 写信(预填收件人为该三维地址)
* - 归档Agent 侧会话归档 + 邮箱界面移除)
*/
export default function ContactPanel() {
const contacts = useContactStore(s => s.contacts);
const archivedContacts = useContactStore(s => s.archivedContacts);
const showArchived = useContactStore(s => s.showArchived);
const loading = useContactStore(s => s.loading);
const error = useContactStore(s => s.error);
const pendingArchive = useContactStore(s => s.pendingArchive);
const fetchContacts = useContactStore(s => s.fetchContacts);
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
const requestArchive = useContactStore(s => s.requestArchive);
const cancelArchive = useContactStore(s => s.cancelArchive);
const archive = useContactStore(s => s.archive);
const selectSession = useSessionStore(s => s.selectSession);
const currentSession = useSessionStore(s => s.currentSession);
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
const startCompose = useUIStore(s => s.startCompose);
const cancelCompose = useUIStore(s => s.cancelCompose);
useEffect(() => {
fetchContacts();
}, []);
const open = (c: Contact) => {
cancelCompose();
clearCurrentMail();
selectSession(c.session_id);
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
<div className="flex-1" />
<button
onClick={toggleArchivedView}
className={`text-[11px] px-1.5 py-0.5 rounded ${
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
}`}
>
</button>
</div>
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
{contacts.map(c => (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
confirming={pendingArchive === c.address}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
onCancelArchive={cancelArchive}
onConfirmArchive={() => archive(c)}
/>
))}
{!loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6">
</p>
)}
{showArchived && (
<div className="pt-3 mt-2 border-t border-gray-200">
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
{archivedContacts.length}
</p>
{archivedContacts.map(c => (
<div
key={c.session_id}
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
>
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{c.mail_count} ·
</p>
</div>
))}
{archivedContacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-3"></p>
)}
</div>
)}
</div>
</div>
);
}
function ContactRow({
contact,
active,
confirming,
onOpen,
onCompose,
onRequestArchive,
onCancelArchive,
onConfirmArchive
}: {
contact: Contact;
active: boolean;
confirming: boolean;
onOpen: () => void;
onCompose: () => void;
onRequestArchive: () => void;
onCancelArchive: () => void;
onConfirmArchive: () => void;
}) {
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
if (confirming) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirmArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancelArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
return (
<div
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
}`}
>
<button onClick={onOpen} className="w-full text-left">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">
{contact.agent_name}
</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
{contact.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
{contact.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate">
{contact.session_alias || '(未命名会话)'}
</span>
</div>
<p className="text-[10px] text-gray-400 mt-0.5">
{contact.mail_count} · {time}
</p>
</button>
<div className="flex gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
>
<ComposeIcon className="w-3 h-3" />
</button>
<button
onClick={onRequestArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,311 @@
import { useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
permanent: '长期',
one_time: '一次性',
timed: '限时'
};
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
permanent: '永不过期,可重复使用',
one_time: '首次使用后立即失效',
timed: '指定小时数后过期'
};
/** 一条密钥在列表里的状态:过期/已用完/可用 */
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
return { text: '已过期', cls: 'text-red-500' };
return { text: '可用', cls: 'text-green-600' };
}
/**
* 新签发密钥的一次性展示条。
*
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}
};
return (
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-amber-900">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
{token}
</code>
<button
onClick={copy}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
<button onClick={onDismiss} className="shrink-0 text-xs text-amber-800 hover:underline">
</button>
</div>
</div>
);
}
interface CreateFormProps {
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
variant: 'agent' | 'user';
busy: boolean;
onSubmit: (payload: api.CreateKeyPayload) => void;
}
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
const [open, setOpen] = useState(false);
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
const [label, setLabel] = useState('');
const [hours, setHours] = useState(24);
const [agentName, setAgentName] = useState('');
const [keyToken, setKeyToken] = useState('');
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
);
}
const submit = () => {
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
if (keyType === 'timed') payload.expires_hours = hours;
if (variant === 'agent') {
if (agentName.trim()) payload.agent_name = agentName.trim();
if (keyToken.trim()) payload.key_token = keyToken.trim();
}
onSubmit(payload);
setOpen(false);
setLabel('');
setAgentName('');
setKeyToken('');
};
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
onClick={() => setKeyType(t)}
className={`text-left px-2.5 py-2 rounded border text-xs ${
keyType === t
? 'border-blue-400 bg-white ring-2 ring-blue-100'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
</button>
))}
</div>
<div className="flex gap-2">
<input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="备注(如 我的笔记本 / CI 机器)"
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyType === 'timed' && (
<div className="flex items-center gap-1">
<input
type="number"
min={1}
value={hours}
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<span className="text-[11px] text-gray-500"></span>
</div>
)}
</div>
{variant === 'agent' && (
<div className="space-y-2">
<input
value={agentName}
onChange={e => setAgentName(e.target.value)}
placeholder="绑定到 Agent留空 = 首次注册时自动落定)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<input
value={keyToken}
onChange={e => setKeyToken(e.target.value)}
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex-1" />
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
</button>
<button
onClick={submit}
disabled={busy}
className="text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
</div>
</div>
);
}
/**
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
*/
export default function KeyPanel({
variant,
keys,
loading,
error,
newToken,
onCreate,
onDelete,
onBind,
onDismissToken
}: {
variant: 'agent' | 'user';
keys: (api.AgentKey | api.UserKey)[];
loading: boolean;
error: string | null;
newToken: string | null;
onCreate: (payload: api.CreateKeyPayload) => void;
onDelete: (id: string) => void;
onBind?: (id: string, agentName: string) => void;
onDismissToken: () => void;
}) {
const [bindingID, setBindingID] = useState<string | null>(null);
const [bindName, setBindName] = useState('');
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<KeyIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
</h3>
<div className="flex-1" />
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
</div>
<p className="text-[11px] text-gray-500">
{variant === 'agent'
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
: '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'}
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
{keys.length === 0 ? (
<div className="text-xs text-gray-400 py-3"></div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{keys.map(k => {
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-900 truncate">
{k.label || <span className="text-gray-400"></span>}
</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{KEY_TYPE_LABEL[k.key_type]}
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
{agentKey &&
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
</div>
</div>
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
{agentKey && onBind && bindingID === k.key_id ? (
<div className="flex items-center gap-1 shrink-0">
<input
value={bindName}
onChange={e => setBindName(e.target.value)}
placeholder="Agent 名"
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
/>
<button
onClick={() => {
if (bindName.trim()) onBind(k.key_id, bindName.trim());
setBindingID(null);
setBindName('');
}}
className="text-[11px] text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setBindingID(null)}
className="text-[11px] text-gray-500 hover:underline"
>
</button>
</div>
) : (
agentKey &&
onBind && (
<button
onClick={() => {
setBindingID(k.key_id);
setBindName(agentKey.agent_name ?? '');
}}
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
>
</button>
)
)}
<button
onClick={() => onDelete(k.key_id)}
title="吊销"
className="shrink-0 text-gray-400 hover:text-red-600"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { MailboxIcon, SpinnerIcon } from './icons';
export default function LoginPage() {
const login = useAuthStore(s => s.login);
const error = useAuthStore(s => s.error);
const retryAfter = useAuthStore(s => s.retryAfter);
const submitting = useAuthStore(s => s.submitting);
const clearError = useAuthStore(s => s.clearError);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [countdown, setCountdown] = useState(0);
const userRef = useRef<HTMLInputElement>(null);
useEffect(() => {
userRef.current?.focus();
}, []);
// 被限速时倒计时
useEffect(() => {
if (!retryAfter) return;
setCountdown(retryAfter);
const t = setInterval(() => {
setCountdown(c => {
if (c <= 1) {
clearInterval(t);
clearError();
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(t);
}, [retryAfter]);
const locked = countdown > 0;
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const ok = await login(username, password);
if (!ok) setPassword('');
};
return (
<div className="h-full flex items-center justify-center bg-slate-100">
<div className="w-[380px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" />
</div>
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
<p className="mt-1 text-xs text-gray-500"></p>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
ref={userRef}
value={username}
onChange={e => {
setUsername(e.target.value);
if (error) clearError();
}}
autoComplete="username"
spellCheck={false}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
if (error) clearError();
}}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
{locked && `${countdown} 秒后可重试)`}
</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { useEffect } from 'react';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { ShieldIcon, PaperclipIcon } from './icons';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
const cancelCompose = useUIStore(s => s.cancelCompose);
const inbox = useMailStore(s => s.inbox);
const sent = useMailStore(s => s.sent);
const currentMail = useMailStore(s => s.currentMail);
const selectMail = useMailStore(s => s.selectMail);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const clearSession = useSessionStore(s => s.clearSession);
useEffect(() => {
if (viewMode === 'sent') fetchSent();
else if (viewMode === 'inbox') fetchInbox('all');
}, [viewMode]);
const isSent = viewMode === 'sent';
const list = isSent ? sent : inbox;
const pick = (m: Mail) => {
clearSession();
cancelCompose();
selectMail(m);
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{list.map(m => (
<MailItem
key={m.mail_id}
mail={m}
active={currentMail?.mail_id === m.mail_id}
showTo={isSent}
onClick={() => pick(m)}
/>
))}
{list.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
</div>
</div>
);
}
function MailItem({
mail,
active,
showTo,
onClick
}: {
mail: Mail;
active: boolean;
showTo: boolean;
onClick: () => void;
}) {
const isPermission = mail.mail_type === 'permission_request';
const isUnread = mail.status === 'unread';
const ccCount = mail.cc_list?.length ?? 0;
const attachCount = mail.attachments?.length ?? 0;
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const peer = showTo
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`;
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2">
<span
className={`text-xs truncate flex-1 font-mono ${
isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'
}`}
>
{showTo ? '→ ' : ''}
{peer}
</span>
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
{isPermission && (
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
<ShieldIcon className="w-2.5 h-2.5" />
</span>
)}
<span
className={`text-xs truncate ${
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
}`}
>
{mail.subject}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{mail.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
)}
{ccCount > 0 && (
<span className="text-[10px] text-gray-400"> {ccCount}</span>
)}
{attachCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{attachCount}
</span>
)}
</div>
</button>
);
}

View File

@ -0,0 +1,578 @@
import { useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
const markRead = useMailStore(s => s.markRead);
const currentSession = useSessionStore(s => s.currentSession);
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
// 转发面板作用于哪封邮件null = 未打开
const [forwarding, setForwarding] = useState<Mail | null>(null);
// 正在看哪封邮件的对话树null = 看正常的邮件视图
const [threadOf, setThreadOf] = useState<string | null>(null);
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
const currentMailID = currentMail?.mail_id;
useEffect(() => {
setThreadOf(null);
}, [currentMailID]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
: '(未命名会话)'}
</span>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400">
{currentSessionMails.length}
</span>
<div className="flex-1" />
<BudgetEditor />
</div>
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} />
))}
</div>
<ReplyBar replyTo={last} />
</div>
);
}
if (!currentMail) {
return (
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
<div className="text-center">
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
<p className="text-sm mt-3"></p>
</div>
</div>
);
}
if (threadOf) {
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
}
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<Header
mail={currentMail}
onRead={() => markRead(currentMail.mail_id)}
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
<AttachmentList items={currentMail.attachments ?? []} />
{currentMail.mail_type === 'permission_request' && (
<PermissionPanel mail={currentMail} />
)}
</div>
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={currentMail} />
)}
</div>
);
}
/**
* 本任务的往返预算编辑器(会话头部)。
*
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
* 而不是「这件事值得多少个来回」。
*/
function BudgetEditor() {
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
if (!budget) return null;
const exhausted = !budget.unlimited && budget.remaining === 0;
const open = () => {
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
setEditing(true);
};
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
setBusy(true);
await setBudget(patch);
setBusy(false);
setEditing(false);
};
if (!editing) {
return (
<button
onClick={open}
title="本任务的往返预算Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
exhausted
? 'border-red-200 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
</button>
);
}
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500"></span>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
disabled={busy || invalid}
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
<button
disabled={busy || budget.used_rounds === 0}
onClick={() => commit({ reset: true })}
title="已用次数归零,上限不变"
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
<button
onClick={() => setEditing(false)}
className="text-[11px] text-gray-400 hover:text-gray-700"
>
</button>
</div>
);
}
/**
* Agent 提议改会话别名的提示条。
*
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口name@path.别名)。
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
function RenameProposalBar() {
const proposal = useSessionStore(s => s.renameProposal);
const current = useSessionStore(s => s.currentSession);
const accept = useSessionStore(s => s.acceptRename);
const dismiss = useSessionStore(s => s.dismissRename);
const [busy, setBusy] = useState(false);
if (!proposal) return null;
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-900">
Agent <span className="font-mono">{from}</span> {' '}
<span className="font-mono font-semibold">.{proposal.alias}</span>
</p>
{proposal.reason && (
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
)}
<p className="text-[10px] text-blue-500 mt-0.5">
name@path.{proposal.alias}
Agent
</p>
</div>
<button
disabled={busy}
onClick={async () => {
setBusy(true);
await accept();
setBusy(false);
}}
className="px-2.5 py-1 rounded bg-blue-500 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
>
{busy ? '改名中' : '接受'}
</button>
<button
onClick={dismiss}
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
>
</button>
</div>
</div>
);
}
/**
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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 submit = async () => {
if (!to.trim() || busy) return;
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, { to: to.trim(), comment: comment.trim() });
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600">
{mail.subject}
</span>
<div className="flex-1" />
<span className="text-[10px] text-gray-400"></span>
</div>
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人pi@root.new" />
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前)"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="flex items-center gap-2">
{error && <span className="text-xs text-red-600">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
</button>
<button
onClick={submit}
disabled={busy || !to.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '转发中' : '转发'}
</button>
</div>
</div>
);
}
function Header({
mail,
onRead,
onForward,
onThread
}: {
mail: Mail;
onRead: () => void;
onForward: () => void;
onThread: () => void;
}) {
const time = new Date(mail.created_at).toLocaleString('zh-CN');
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
mail.session_alias ? '.' + mail.session_alias : ''
}`;
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
return (
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5">
<h2 className="text-sm font-semibold text-gray-900">{mail.subject}</h2>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
</span>
)}
{mail.mail_type === 'permission_request' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
<ShieldIcon className="w-3 h-3" />
</span>
)}
<div className="flex-1" />
{mail.status === 'unread' && (
<button onClick={onRead} className="text-xs text-blue-500 hover:underline">
</button>
)}
<button
onClick={onThread}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
title="沿回复与转发关系展开整条线索"
>
<TreeIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={onForward}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<ForwardIcon className="w-3.5 h-3.5" />
</button>
</div>
<dl className="text-xs text-gray-500 space-y-0.5">
<Row label="发件">{from}</Row>
<Row label="收件">{to}</Row>
{mail.cc_list?.length > 0 && (
<Row label="抄送">{mail.cc_list.map(a => a.raw).join('、')}</Row>
)}
<Row label="时间">{time}</Row>
</dl>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
<dd className="font-mono text-gray-600 break-all">{children}</dd>
</div>
);
}
function ThreadCard({ mail }: { mail: Mail }) {
const isHuman = mail.from_name === 'human';
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
return (
<div
className={`rounded-lg border p-4 ${
isPermission
? 'border-orange-200 bg-orange-50'
: isHuman
? 'border-blue-200 bg-blue-50/60'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-center gap-1.5 mb-2 text-xs">
{isHuman ? (
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
) : (
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
)}
<span className="font-semibold text-gray-800 font-mono">
{isHuman ? 'human' : mail.from_name}
</span>
{isPermission && (
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
</span>
)}
{mail.cc_list?.length > 0 && (
<span className="text-[10px] text-gray-400"> {mail.cc_list.length}</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span>
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
</div>
<AttachmentList items={mail.attachments ?? []} />
{isPermission && <PermissionPanel mail={mail} />}
</div>
);
}
function PermissionPanel({ mail }: { mail: Mail }) {
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
const decide = async (choice: string) => {
setBusy(true);
try {
await api.decidePermission(mail.mail_id, choice, note || undefined);
setDecided(choice);
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
if (decided) {
return (
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
<strong className="text-gray-800">{decided}</strong>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t border-orange-200">
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={opt}
onClick={() => decide(opt)}
disabled={busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
isApprove(opt)
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`}
>
{isApprove(opt) ? (
<CheckIcon className="w-3.5 h-3.5" />
) : (
<CloseIcon className="w-3.5 h-3.5" />
)}
{opt}
</button>
))}
</div>
<input
value={note}
onChange={e => setNote(e.target.value)}
placeholder="备注(可选)"
className="mt-2 w-full text-xs 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"
/>
</div>
);
}
function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
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 selectSession = useSessionStore(s => s.selectSession);
if (!replyTo) return null;
// 回给对端若这封是我human发的则回给收件人否则回给发件人
const peerName = replyTo.from_name === 'human' ? replyTo.to_name : replyTo.from_name;
const peerPath = replyTo.from_name === 'human' ? replyTo.to_workspace : replyTo.from_workspace;
const target = `${peerName}@${peerPath || ''}${
replyTo.session_alias ? '.' + replyTo.session_alias : ''
}`;
const send = async () => {
if (!body.trim()) return;
setBusy(true);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
attachment_ids: attachments.map(a => a.id)
});
setBody('');
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
console.error(err);
} finally {
setBusy(false);
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3">
<p className="text-[10px] text-gray-400 mb-1 font-mono"> {target}</p>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="回复内容Markdown"
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setBody('')}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>
</button>
<button
onClick={send}
disabled={busy || !body.trim()}
className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '发送中' : '发送'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
};
const b = map[status] || map.active;
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
}

View File

@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
/**
* Agent 发信配额面板(管理员)。
*
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
*/
export default function QuotaPanel() {
const [quotas, setQuotas] = useState<api.Quota[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
const r = await api.adminListQuotas();
setQuotas(r.quotas);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
load();
}, [load]);
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
setBusy(name);
setError(null);
try {
await api.adminSetQuota(name, payload);
await load();
setDrafts(d => {
const next = { ...d };
delete next[name];
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{quotas.length}</span>
</div>
<p className="text-[11px] text-gray-500">
Agent 0
Agent
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{quotas.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{quotas.map(q => {
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
const dirty = draft !== String(q.max_rounds);
const exhausted = !q.unlimited && q.remaining === 0;
return (
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{q.agent_name}
</span>
<div className="min-w-0 flex-1">
{q.unlimited ? (
<span className="text-xs text-gray-500"> {q.used_rounds}</span>
) : (
<div className="flex items-center gap-2">
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
style={{
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
}}
/>
</div>
<span
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
>
{q.used_rounds}/{q.max_rounds}
{exhausted && ' · 已用尽'}
</span>
</div>
)}
</div>
<input
type="number"
min={0}
value={draft}
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<button
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
disabled={!dirty || busy === q.agent_name}
title="保存上限"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>
<button
onClick={() => apply(q.agent_name, { reset: true })}
disabled={busy === q.agent_name || q.used_rounds === 0}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { MailboxIcon, SpinnerIcon } from './icons';
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
export default function SetupPage({ onDone }: { onDone: () => void }) {
const bootstrap = useAuthStore(s => s.bootstrap);
const [username, setUsername] = useState('admin');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
const mismatch = confirm !== '' && password !== confirm;
const ok =
username.trim().length >= 2 &&
password.length >= 8 &&
!mismatch &&
!busy;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!ok) return;
setBusy(true);
setError(null);
try {
await api.setupAdmin({
username: username.trim().toLowerCase(),
password,
display_name: displayName.trim()
});
// 初始化后直接登录(后端已经种了 cookie拉取用户态
await bootstrap();
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
return (
<div className="h-full flex items-center justify-center bg-slate-100">
<div className="w-[420px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" />
</div>
<h1 className="mt-3 text-base font-semibold text-gray-900"></h1>
<p className="mt-1 text-xs text-gray-500 text-center">
使 AgentMail
</p>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
<span className="text-red-400"> name </span>
</label>
<input
ref={nameRef}
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="admin"
spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<p className="mt-1 text-[10px] text-gray-400">
. _ -2-64 `admin@.new`
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="系统管理员"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1">
<span className="text-red-400"> 8 </span>
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
autoComplete="new-password"
className={`w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
}`}
/>
{mismatch && <p className="mt-1 text-[10px] text-red-500"></p>}
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
{error}
</p>
)}
<button
type="submit"
disabled={!ok}
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{busy && <SpinnerIcon className="w-3.5 h-3.5" />}
{busy ? '初始化中' : '创建管理员并进入'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
InboxIcon,
SentIcon,
ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon
} from './icons';
const navItems: {
short: string;
title: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function Sidebar() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
const active = viewMode === mode && !composing;
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
}`}
>
<Icon />
<span className="text-[9px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => startCompose()}
title="新建邮件"
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
}`}
>
<ComposeIcon />
<span className="text-[9px] leading-none"></span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
<button
onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing
? 'bg-blue-500 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
}`}
>
{(user?.display_name || user?.username || '?').slice(0, 2)}
</button>
<button
onClick={logout}
title="退出登录"
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
>
<LogoutIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
/**
* 对话树视图(按方向分块加载)。
*
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
*
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
*
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
*/
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
const [nodes, setNodes] = useState<ThreadNode[]>([]);
const [hidden, setHidden] = useState(0);
const [moreUp, setMoreUp] = useState(false);
const [moreDown, setMoreDown] = useState(false);
const [nextUp, setNextUp] = useState(0);
const [nextDown, setNextDown] = useState(0);
const [err, setErr] = useState('');
const [initial, setInitial] = useState(true);
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinel = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
// 请求代次mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
const gen = useRef(0);
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
// 读 state 会双双看到 false 而并发发两个请求。
const busy = useRef(false);
const merge = useCallback((incoming: ThreadNode[]) => {
setNodes(prev => {
const seen = new Set(prev.map(n => n.mail_id));
const added = incoming.filter(n => !seen.has(n.mail_id));
// 按相对深度排;同深度保持服务端给的时间序
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
});
}, []);
// 首屏
useEffect(() => {
const myGen = ++gen.current;
setNodes([]);
setHidden(0);
setErr('');
setInitial(true);
busy.current = true;
api
.getMailThread(mailID, { dir: 'around', limit: 40 })
.then(p => {
if (gen.current !== myGen) return;
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
setHidden(p.hidden);
setMoreUp(p.has_more_up);
setMoreDown(p.has_more_down);
setNextUp(p.next_up);
setNextDown(p.next_down);
})
.catch(e => {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (gen.current === myGen) {
setInitial(false);
busy.current = false;
}
});
}, [mailID]);
const loadMore = useCallback(
async (dir: 'up' | 'down') => {
if (busy.current) return;
if (dir === 'up' && !moreUp) return;
if (dir === 'down' && !moreDown) return;
const myGen = gen.current;
busy.current = true;
setLoading(true);
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
const el = scrollRef.current;
const beforeHeight = el?.scrollHeight ?? 0;
const beforeTop = el?.scrollTop ?? 0;
try {
const p = await api.getMailThread(mailID, {
dir,
offset: dir === 'up' ? nextUp : nextDown,
limit: 40
});
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
if (dir === 'up') {
setMoreUp(p.has_more_up);
setNextUp(p.next_up);
} else {
setMoreDown(p.has_more_down);
setNextDown(p.next_down);
}
if (dir === 'up' && el) {
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
requestAnimationFrame(() => {
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
});
}
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
},
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
);
// 两端各一个哨兵,进入视口就加载对应方向。
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting) continue;
if (e.target === topSentinel.current) loadMore('up');
if (e.target === bottomSentinel.current) loadMore('down');
}
},
{ root, rootMargin: '200px' }
);
if (topSentinel.current) obs.observe(topSentinel.current);
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
return () => obs.disconnect();
}, [loadMore]);
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
// 否则祖先未加载时首屏内容会整体缩进一大截。
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
{(moreUp || moreDown) && ',滑动加载更多'}
{hidden > 0 && `${hidden} 封无权查看`}
</span>
<div className="flex-1" />
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
</div>
)}
{err && <p className="text-xs text-red-600">{err}</p>}
{!initial && (
<>
<div ref={topSentinel} className="h-px" />
{moreUp && (
<button
onClick={() => loadMore('up')}
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div className="space-y-1.5">
{nodes.map(n => (
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
))}
</div>
{moreDown && (
<button
onClick={() => loadMore('down')}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
</button>
)}
<div ref={bottomSentinel} className="h-px" />
</>
)}
</div>
</div>
);
}
function Node({
node,
baseDepth,
anchorID
}: {
node: ThreadNode;
baseDepth: number;
anchorID: string;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return (
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
<button
onClick={() => openMailByID(node.mail_id)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center gap-1.5">
{node.from_workspace ? (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
<span className="text-xs font-mono text-gray-700 truncate">
{node.from_name}
{node.from_workspace && `@${node.from_workspace}`}
</span>
<span className="text-[10px] text-gray-400"></span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
title="上一封不在你的可见范围内"
>
</span>
)}
{isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
<ShieldIcon className="w-2.5 h-2.5" />
</span>
)}
{node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count}
</span>
)}
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}
{node.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,289 @@
// 纯 SVG 图标,全站不使用 emoji
type P = { className?: string };
const D = 'w-5 h-5';
function Svg({ className = D, children }: P & { children: React.ReactNode }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export function InboxIcon(p: P) {
return (
<Svg {...p}>
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</Svg>
);
}
export function SentIcon(p: P) {
return (
<Svg {...p}>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</Svg>
);
}
export function ContactsIcon(p: P) {
return (
<Svg {...p}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Svg>
);
}
export function ComposeIcon(p: P) {
return (
<Svg {...p}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</Svg>
);
}
export function MailIcon(p: P) {
return (
<Svg {...p}>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</Svg>
);
}
export function ShieldIcon(p: P) {
return (
<Svg {...p}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</Svg>
);
}
export function PersonIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Svg>
);
}
export function BotIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="18" height="10" x="3" y="11" rx="2" />
<circle cx="12" cy="5" r="2" />
<path d="M12 7v4" />
<path d="M8 16h.01" />
<path d="M16 16h.01" />
</Svg>
);
}
export function CheckIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M20 6 9 17l-5-5" />
</Svg>
);
}
export function CloseIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</Svg>
);
}
export function TrashIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</Svg>
);
}
export function ArchiveIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</Svg>
);
}
export function ChevronRightIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m9 18 6-6-6-6" />
</Svg>
);
}
export function LogoutIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</Svg>
);
}
export function UsersIcon({ className = D }: P) {
return (
<Svg className={className}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Svg>
);
}
export function LockIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="18" height="11" x="3" y="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Svg>
);
}
export function MailboxIcon({ className = 'w-8 h-8' }: P) {
return (
<Svg className={className}>
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z" />
<path d="M6 8h4" />
<path d="M12 19V5" />
</Svg>
);
}
export function SpinnerIcon({ className = 'w-4 h-4' }: P) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
export function KeyIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3 21 2" />
<path d="m17 6 3 3" />
<path d="m14 9 3 3" />
</Svg>
);
}
export function CopyIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect width="12" height="12" x="9" y="9" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</Svg>
);
}
export function PlusIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 5v14M5 12h14" />
</Svg>
);
}
export function ForwardIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m15 17 5-5-5-5" />
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
</Svg>
);
}
export function PaperclipIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M13.2 2.8a5 5 0 0 1 7 7l-8.5 8.5a3.2 3.2 0 0 1-4.5-4.5l8-8a1.4 1.4 0 0 1 2 2l-7.5 7.5" />
</Svg>
);
}
export function DownloadIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 3v12" />
<path d="m7 11 5 5 5-5" />
<path d="M4 20h16" />
</Svg>
);
}
export function FileIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
</Svg>
);
}
export function TreeIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="9" y="3" width="6" height="4" rx="1" />
<rect x="3" y="17" width="6" height="4" rx="1" />
<rect x="15" y="17" width="6" height="4" rx="1" />
<path d="M12 7v4M6 17v-3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v3" />
</Svg>
);
}
export function TagIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0l-7.2-7.2A2 2 0 0 1 3 12V4a1 1 0 0 1 1-1h8a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z" />
<circle cx="7.5" cy="7.5" r="1" />
</Svg>
);
}
export function GaugeIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M12 14 15.5 9" />
<path d="M3.5 17a9 9 0 1 1 17 0" />
<circle cx="12" cy="14" r="1.2" />
</Svg>
);
}

13
web/src/index.css Normal file
View File

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body, #root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,70 @@
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api/client';
import { ApiError } from '../api/client';
type Phase = 'checking' | 'anonymous' | 'authenticated';
interface AuthState {
phase: Phase;
user: User | null;
error: string | null;
retryAfter: number | null;
submitting: boolean;
bootstrap: () => Promise<void>;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
/** 401 时由 api client 回调 */
markAnonymous: () => void;
}
export const useAuthStore = create<AuthState>(set => ({
phase: 'checking',
user: null,
error: null,
retryAfter: null,
submitting: false,
bootstrap: async () => {
try {
const { user } = await api.me();
set({ phase: 'authenticated', user, error: null });
} catch {
set({ phase: 'anonymous', user: null });
}
},
login: async (username, password) => {
set({ submitting: true, error: null, retryAfter: null });
try {
const { user } = await api.login(username.trim(), password);
set({ phase: 'authenticated', user, submitting: false });
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const retry = err instanceof ApiError ? err.retryAfter ?? null : null;
set({ error: msg, retryAfter: retry, submitting: false });
return false;
}
},
logout: async () => {
try {
await api.logout();
} catch {
/* 即使请求失败也在前端登出 */
}
set({ phase: 'anonymous', user: null, error: null });
},
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })
}));
// 注册全局 401 处理:任何接口返回 401 即回到登录页
api.setUnauthorizedHandler(() => {
useAuthStore.getState().markAnonymous();
});

View File

@ -0,0 +1,81 @@
import { create } from 'zustand';
import type { Contact } from '../types';
import * as api from '../api/client';
interface ContactState {
contacts: Contact[];
archivedContacts: Contact[];
showArchived: boolean;
loading: boolean;
error: string | null;
/** 正在等待归档确认的地址 */
pendingArchive: string | null;
fetchContacts: () => Promise<void>;
fetchArchived: () => Promise<void>;
toggleArchivedView: () => void;
requestArchive: (address: string) => void;
cancelArchive: () => void;
archive: (contact: Contact) => Promise<void>;
/** SSE session_archived 到达时本地即时移除 */
removeSessionLocally: (sessionId: string) => void;
}
export const useContactStore = create<ContactState>((set, get) => ({
contacts: [],
archivedContacts: [],
showArchived: false,
loading: false,
error: null,
pendingArchive: null,
fetchContacts: async () => {
set({ loading: true, error: null });
try {
const { contacts } = await api.listContacts(false);
set({ contacts: contacts || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
fetchArchived: async () => {
try {
const { contacts } = await api.listContacts(true);
set({ archivedContacts: contacts || [] });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
toggleArchivedView: () => {
const next = !get().showArchived;
set({ showArchived: next });
if (next) get().fetchArchived();
},
requestArchive: address => set({ pendingArchive: address }),
cancelArchive: () => set({ pendingArchive: null }),
archive: async contact => {
try {
await api.archiveContact({ session_id: contact.session_id });
// 本地即时移除,不等 SSE
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== contact.session_id),
pendingArchive: null
}));
if (get().showArchived) get().fetchArchived();
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
pendingArchive: null
});
}
},
removeSessionLocally: sessionId =>
set(state => ({
contacts: state.contacts.filter(c => c.session_id !== sessionId)
}))
}));

View File

@ -0,0 +1,91 @@
import { create } from 'zustand';
import type { Mail } from '../types';
import * as api from '../api/client';
interface MailState {
inbox: Mail[];
sent: Mail[];
currentMail: Mail | null;
loading: boolean;
error: string | null;
fetchInbox: (status?: string) => Promise<void>;
fetchSent: () => Promise<void>;
selectMail: (mail: Mail) => void;
/**
* 按 id 打开邮件。
*
* 列表与对话树里的邮件只带正文预览(整棵树带全文可能几百 KB
* 所以点开时得单取一次拿全文与附件清单。
*/
openMailByID: (id: string) => Promise<void>;
clearCurrentMail: () => void;
markRead: (id: string) => Promise<void>;
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
dropSession: (sessionId: string) => void;
}
export const useMailStore = create<MailState>((set, get) => ({
inbox: [],
sent: [],
currentMail: null,
loading: false,
error: null,
fetchInbox: async (status = 'all') => {
set({ loading: true, error: null });
try {
const { mails } = await api.getInbox(status);
set({ inbox: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
fetchSent: async () => {
set({ loading: true, error: null });
try {
const { mails } = await api.getSent();
set({ sent: mails || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
selectMail: mail => set({ currentMail: mail }),
openMailByID: async id => {
try {
const mail = await api.getMail(id);
set({ currentMail: mail });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
clearCurrentMail: () => set({ currentMail: null }),
markRead: async id => {
try {
await api.markMailRead(id);
set(state => ({
inbox: state.inbox.map(m => (m.mail_id === id ? { ...m, status: 'read' as const } : m)),
currentMail:
state.currentMail?.mail_id === id
? { ...state.currentMail, status: 'read' as const }
: state.currentMail
}));
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
dropSession: sessionId => {
const { currentMail } = get();
set(state => ({
inbox: state.inbox.filter(m => m.session_id !== sessionId),
sent: state.sent.filter(m => m.session_id !== sessionId),
currentMail: currentMail?.session_id === sessionId ? null : currentMail
}));
}
}));

View File

@ -0,0 +1,150 @@
import { create } from 'zustand';
import type { HumanSession, Mail, RenameProposal, Session, SessionBudget } from '../types';
import * as api from '../api/client';
interface SessionState {
sessions: HumanSession[];
currentSession: Session | null;
currentSessionMails: Mail[];
loading: boolean;
error: string | null;
/** Agent 在正文里提的改名建议null = 无待处理建议 */
renameProposal: RenameProposal | null;
fetchSessions: () => Promise<void>;
selectSession: (sessionId: string) => Promise<void>;
clearSession: () => void;
/** 重新拉取当前会话的改名建议(收到新邮件时调) */
refreshRenameProposal: () => Promise<void>;
/** 接受建议:改名成功后清掉提示条并刷新会话 */
acceptRename: () => Promise<void>;
/** 驳回建议:服务端记下来,不再反复弹同一个 */
dismissRename: () => Promise<void>;
/** 本任务的往返预算null = 尚未取到 */
budget: SessionBudget | null;
/** 改本会话预算对话页里随时调。reset 把已用次数归零。 */
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
/** 重新拉取预算Agent 发信后剩余会变) */
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
dropSessionIfCurrent: (sessionId: string) => void;
}
export const useSessionStore = create<SessionState>((set, get) => ({
sessions: [],
currentSession: null,
currentSessionMails: [],
renameProposal: null,
budget: null,
loading: false,
error: null,
fetchSessions: async () => {
set({ loading: true, error: null });
try {
const { sessions } = await api.getHumanSessions();
set({ sessions: sessions || [], loading: false });
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
selectSession: async sessionId => {
set({ loading: true, error: null, renameProposal: null, budget: null });
try {
const detail = await api.getSessionDetail(sessionId);
set({
currentSession: detail.session,
currentSessionMails: detail.mails || [],
loading: false
});
// 改名建议与预算单独取:拿不到不该让整个会话打不开
get().refreshRenameProposal();
get().refreshBudget();
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err), loading: false });
}
},
refreshRenameProposal: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const { proposal } = await api.getRenameProposal(id);
// 期间用户可能已切走,别把上一会话的建议贴到新会话上
if (get().currentSession?.session_id === id) {
set({ renameProposal: proposal });
}
} catch {
// 建议是锦上添花,失败静默
}
},
acceptRename: async () => {
const s = get();
const id = s.currentSession?.session_id;
const alias = s.renameProposal?.alias;
if (!id || !alias) return;
try {
await api.updateSessionAlias(id, alias);
set({ renameProposal: null });
// 别名变了,会话详情与列表里的地址都要跟着更新
await get().selectSession(id);
await get().fetchSessions();
} catch (err) {
// 别名被别人占用409等情况要让用户看到不能默默失败
set({ error: err instanceof Error ? err.message : String(err) });
}
},
dismissRename: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
// 先隐藏提示条再发请求:驳回是纯本地意图,等一个往返才消失显得迟钝
set({ renameProposal: null });
try {
await api.dismissRenameProposal(id);
} catch {
// 记不下来最坏的后果是下次打开又弹一次,不值得打扰用户
}
},
refreshBudget: async () => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const b = await api.getSessionBudget(id);
// 期间用户可能已切走,别把上一会话的预算贴到新会话上
if (get().currentSession?.session_id === id) set({ budget: b });
} catch {
// 预算读不到不影响看邮件,静默
}
},
setBudget: async patch => {
const id = get().currentSession?.session_id;
if (!id) return;
try {
const b = await api.updateSessionBudget(id, patch);
set({ budget: b });
// 列表里也显示预算,改完要跟着刷新
await get().fetchSessions();
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) });
}
},
clearSession: () =>
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
dropSessionIfCurrent: sessionId => {
if (get().currentSession?.session_id === sessionId) {
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null });
}
set(state => ({
sessions: state.sessions.filter(s => s.session_id !== sessionId)
}));
}
}));

29
web/src/stores/uiStore.ts Normal file
View File

@ -0,0 +1,29 @@
import { create } from 'zustand';
export type ViewMode = 'inbox' | 'sent' | 'contacts' | 'admin' | 'account';
interface UIState {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
/** 右侧主区域是否处于「新建邮件」整页编写态 */
composing: boolean;
composePrefill: { to?: string; cc?: string } | null;
startCompose: (prefill?: { to?: string; cc?: string }) => void;
cancelCompose: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
export const useUIStore = create<UIState>(set => ({
viewMode: 'inbox',
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
composing: false,
composePrefill: null,
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
cancelCompose: () => set({ composing: false, composePrefill: null }),
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
}));

212
web/src/types/index.ts Normal file
View File

@ -0,0 +1,212 @@
export interface User {
user_id: string;
username: string;
display_name: string;
role: 'admin' | 'user';
status: 'active' | 'disabled';
allowed_agents: string[];
allowed_paths: string[];
last_login?: string;
created_at?: string;
}
export interface Workspace {
name: string;
path: string;
}
export interface Agent {
agent_id?: string;
agent_name: string;
workspaces: Workspace[];
platform: string;
status: string;
}
export interface Address {
name: string;
path: string;
session: string;
raw: string;
}
export interface Session {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count?: number;
/**
* 别名是谁定的:
* platform = Agent 平台自动同步来的,后续同步可以覆盖
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
*/
alias_source?: 'platform' | 'manual';
/** 用户驳回过的改名提议 */
rename_dismissed?: string;
/**
* 本任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
*
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
*/
max_rounds?: number;
used_rounds?: number;
}
/** 会话往返预算快照 */
export interface SessionBudget {
session_id: string;
max_rounds: number;
used_rounds: number;
/** 不限时为 -1 */
remaining: number;
unlimited: boolean;
}
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
export interface Attachment {
attachment_id: string;
/** 为 null 表示已上传但尚未随邮件发出 */
mail_id: string | null;
uploader: string;
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
created_at: string;
}
export interface Mail {
mail_id: string;
session_id: string;
parent_mail_id: string | null;
from_name: string;
from_workspace: string;
to_name: string;
to_workspace: string;
cc_list: Address[];
subject: string;
body: string;
mail_type: 'normal' | 'permission_request';
permission_options: string[] | null;
permission_result: string | null;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;
session_alias?: string;
body_preview?: string;
attachments?: Attachment[];
}
/**
* 对话树节点。
*
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
*
* depth 是**相对锚点**的层级0 = 锚点,负数 = 祖先,正数 = 子孙。
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */
parent_hidden?: boolean;
body?: string;
}
export interface ThreadPage {
anchor_mail_id: string;
dir: 'around' | 'up' | 'down';
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more_up: boolean;
has_more_down: boolean;
/** 下一页 offset原样回传即可 */
next_up: number;
next_down: number;
}
/**
* Agent 在邮件正文里提议的新会话别名。
*
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
* name@path.别名。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
export interface RenameProposal {
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
alias: string;
reason?: string;
}
export interface Contact {
session_id: string;
agent_name: string;
path: string;
session_alias: string;
address: string;
status: string;
mail_count: number;
unread_count: number;
last_activity: string;
}
export interface PermissionRequest {
request_id: string;
mail_id: string;
session_id: string;
agent_name: string;
question: string;
options: string[];
context: string;
result: string | null;
decided_at: string | null;
created_at: string;
}
export interface SessionDetail {
session: Session;
mails: Mail[];
}
export interface HumanSession {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count: number;
unread_count: number;
/** 本任务的往返预算0 = 不限) */
max_rounds?: number;
used_rounds?: number;
}
export type SuggestKind = 'name' | 'path' | 'session';
export interface SuggestResult {
kind: SuggestKind;
suggestions: string[];
}
/** 系统初始化状态 */
export interface SetupStatus {
needs_setup: boolean;
}
/** 管理员可授权范围候选 */
export interface AdminScopes {
agents: string[];
paths: string[];
}

11
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
/** 构建期可注入的环境变量 */
interface ImportMetaEnv {
/** API 基地址;不设则用同源 /api/v1 */
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

8
web/tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {}
},
plugins: []
};

View File

@ -0,0 +1,43 @@
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
// react-markdown 默认不解析 raw HTML无 rehype-raw且用 defaultUrlTransform
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
//
// 运行node test/markdown-xss.test.mjs
import { renderToStaticMarkup } from 'react-dom/server';
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// 只有【真实标签】里的危险内容才算漏。
// 注意不能直接搜 onerror=raw HTML 被转义成 &lt;img … onerror=&quot; 后,
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
const payloads = [
'<script>alert(1)</script>',
'<img src=x onerror="alert(1)">',
'[click](javascript:alert(1))',
'<a href="javascript:alert(1)">x</a>',
'<iframe src="https://evil.com"></iframe>',
'![x](javascript:alert(1))',
'<div onmouseover="alert(1)">hover</div>',
'[ok](https://example.com)',
'**bold** `code`',
];
let leaks = 0;
for (const p of payloads) {
const html = renderToStaticMarkup(
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
);
const bad = dangerous.test(html);
if (bad) leaks++;
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
}
if (leaks > 0) {
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
process.exit(1);
}
console.log('\n通过raw HTML 被转义javascript: URL 被清空');

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

16
web/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// Gateway 默认监听 8180与 config.Load() 的 PORT 默认值一致)
'/api': {
target: 'http://localhost:8180',
changeOrigin: true
}
}
}
});