Files
MailUI4Agents/docs/PLAN.md
JianFeeeee 0e754617a4 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 时转发本轮总结)
2026-09-02 10:29:26 +08:00

1208 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# AgentMail 实施计划
> 邮件驱动·多智能体协作平台 — Go 后端 + React 前端 + Pi 插件
> 版本v0.1 | 日期2026-09-01
---
## 总览
```
Phase 1 (Week 1-2) 后端核心 — Go Gateway + SQLite默认/PostgreSQL可选+ SSE
Phase 2 (Week 2-3) Pi Agent 插件 — 两个工具 + 三个钩子拦截器 + SSE 监听
Phase 3 (Week 3-4) 前端 — React 三栏 UI + 三段式补全 + 权限卡片
Phase 4 (Week 4) 多用户与鉴权 — 人类多账号 + 会话隔离 + 登录页
Phase 5 (Week 5) 联调 + 部署 — 端到端闭环 + Docker + 公网反代
Phase 6 (Week 6+) 进阶功能 — 对话树 / 转发 / 配额 / DSH 插件
```
---
## Phase 1后端核心Go Gateway
### 1.1 项目初始化 ✅
- [x] 创建项目目录 `gateway/`
- [x] `go mod init github.com/agentmail/gateway`
- [x] 目录结构规划:
```
gateway/
├── cmd/
│ └── server/
│ └── main.go # 入口,启动 HTTP 服务
├── internal/
│ ├── config/
│ │ └── config.go # 环境变量配置
│ ├── db/
│ │ ├── db.go # 连接池
│ │ ├── migrate.go # 迁移执行
│ │ └── migrations/
│ │ └── init.sql # 建表 SQL
│ ├── models/
│ │ └── models.go # 数据结构
│ ├── repo/
│ │ └── repo.go # 数据库操作层
│ ├── sse/
│ │ └── manager.go # SSE 连接管理
│ └── middleware/
│ └── auth.go # Agent 认证中间件
├── go.mod
├── go.sum
├── Dockerfile
└── Makefile
```
### 1.2 配置管理
- [x] `internal/config/config.go`
- 数据库连接串、端口、CORS 配置等
- 从环境变量读取,提供默认值
- 结构体单例 + `Load()` 函数
### 1.3 数据库层
- [x] `internal/db/db.go` — database/sql 连接 + 方言适配SQLite 默认 / PostgreSQL 可选)
- [x] `internal/db/migrate.go` — embed SQL + 按方言执行迁移
- [x] `internal/db/migrations/init.sql` + `init_sqlite.sql` — 6 张表
users/user_sessions/agents/sessions/mails/permission_requests
- [x] `internal/repo/repo.go` — 全部数据库操作函数(方言差异走 db.CCHas / db.JSONCast
**repo 函数清单:**
| 函数 | 用途 |
|------|------|
| `CreateOrUpdateAgent` | 注册/更新 Agent |
| `VerifyAgent` | 验证 Agent 名+密钥 |
| `HeartbeatAgent` | 更新心跳 + 返回未读数 |
| `ListAgents` | 列出 Agent@补全用 |
| `CreateSession` | 创建新会话 |
| `GetSessionByID` | 查会话详情 |
| `ListSessions` | 列出会话 |
| `TouchSession` | 更新会话时间戳 |
| `UpdateSessionAlias` | 更新会话别名(校验唯一 + 保留字 new + 禁含 . / @ 空白) |
| `FindSessionByAlias` | 按别名查会话(唯一命名空间) |
| `FindNamedSessionFor` | 按 name@path.<别名> 严格寻址;不存在 → 无法送达 |
| `FindOrCreateDefaultSession` | session 位省略时的默认会话(复用最近活跃,无则建) |
| `SyncSessionAlias` | 回写平台侧 slug 为本侧别名,撞名自动追加 -2/-3 |
| `SyncSessionTitle` | 回写平台侧模型生成的摘要标题 |
| `AgentCanAccessSession` | Agent 只能同步自己参与过的会话 |
| `CreateMail` | 创建普通邮件 |
| `CreatePermissionMail` | 创建权限请求邮件 |
| `CreateDecisionMail` | 创建人类决策邮件 |
| `GetMailByID` | 查邮件详情 |
| `ListInbox` | 查收件箱 |
| `MarkMailRead` | 标记已读 |
| `CountUnread` | 统计未读数 |
| `GetSessionMails` | 查会话全部邮件 |
| `CreatePermissionRequest` | 创建权限请求记录 |
| `DecidePermission` | 执行权限决策 |
| `ListPendingPermissions` | 列出待决权限 |
| `GetPermissionByMailID` | 按邮件查权限请求 |
### 1.4 SSE 连接管理
- [x] `internal/sse/manager.go`
**功能:**
- 管理所有 SSE 客户端连接Agent 侧 + 前端)
-`agentName` 路由推送Agent 只收自己的邮件通知,前端收所有)
- 心跳保活(每 30s 发 `: heartbeat`
- 客户端断开自动清理
**数据结构:**
```go
type Client struct {
ID string
AgentName string // 空 = 前端客户端
Res http.ResponseWriter
Flusher http.Flusher
}
type Manager struct {
mu sync.RWMutex
clients map[string]*Client
}
```
**方法:**
- `AddClient(id, agentName, res, flusher)`
- `RemoveClient(id)`
- `SendToAgent(agentName, eventType, data)`
- `SendToFrontend(eventType, data)`
- `Broadcast(eventType, data)`
- `ClientCount() int`
### 1.5 中间件
- [x] `internal/middleware/auth.go`
**逻辑:**
1. 从 header 读 `X-Agent-Name` + `X-Agent-Secret`
2.`repo.VerifyAgent` 验证
3. 验证通过 → 更新 `last_seen`,把 `agentName` 注入 context
4. 失败 → 返回 401
**注意:** 这个中间件只用于 Agent 侧的 API。前端 API 用另一个认证MVP 阶段可先不实现前端认证)。
### 1.6 HTTP 路由
- [x] `cmd/server/main.go`
**路由表:**
```
GET /health → 健康检查
POST /api/v1/agent/register → Agent 注册
POST /api/v1/agent/heartbeat → Agent 心跳(需认证)
GET /api/v1/agents → 在线 Agent 列表
POST /api/v1/mail/send → 发送邮件(需认证)
GET /api/v1/mail/inbox → 收件箱(需认证)
GET /api/v1/mail/:id → 邮件详情(需认证)
POST /api/v1/mail/:id/read → 标记已读(需认证)
POST /api/v1/permission/request → Agent 请求权限(需认证)
POST /api/v1/permission/decide → 人类决策(无需认证)
GET /api/v1/permission/pending → 待决权限列表(无需认证)
GET /api/v1/sessions → 会话列表
GET /api/v1/sessions/:id → 会话详情 + 邮件
GET /api/v1/sessions/:id/mails → 会话内邮件列表
PUT /api/v1/sessions/:id/alias → 更新会话别名(人类显式命名)
POST /api/v1/sessions/:id/sync → Agent 回写平台侧生成的标题/slug需 Agent 认证)
POST /api/v1/admin/agent-keys → 签发/登记 Agent 接入密钥(管理员)
GET /api/v1/admin/agent-keys → Agent 密钥列表(只给 token_hint
DELETE /api/v1/admin/agent-keys/{id} → 吊销
POST /api/v1/admin/agent-keys/{id}/bind → 绑定到 Agent
POST /api/v1/me/keys → 签发客户端连接密钥(用户自助)
GET /api/v1/me/keys → 我的密钥列表
DELETE /api/v1/me/keys/{id} → 吊销
GET /api/v1/events/stream → SSE 端点
GET /api/v1/events/status → SSE 连接状态
```
**实现顺序:**
1. 健康检查 + 静态路由
2. Agent 注册/心跳
3. 邮件发送/收件箱
4. 权限请求/决策
5. 会话管理
6. SSE 端点
### 1.7 Go 编译验证
- [x] `go build ./cmd/server` 编译通过
- [x] 启动服务(默认内置 SQLite无需外部依赖
- [x] curl 手动测试每个 API 端点
### 1.8 数据库后端SQLite 默认 + 外部库可选
**决策**:默认 SQLite`DATABASE_URL` 非空时切外部 PostgreSQL。
前端已 go:embed 进二进制,数据库再挂 Docker 就自相矛盾;部署产物应当是「一个二进制 + 一个 .db」。
- [x] `internal/db` 收拢方言差异repo 层只写一份 SQL
- 占位符SQLite 也支持 `$1/$2`,无需改写
- `NOW()` / `gen_random_uuid()`SQLite 侧注册同名函数补齐
- JSON 包含判断:`db.CCHas(col, argN)` —— PG 用 `jsonb_build_array`SQLite 用 `json_each`
- 唯一冲突:`db.IsUniqueViolation` 同时识别 PG `23505` 与 SQLite `2067/1555`
- `JOIN LATERAL`SQLite 无此语法,联系人聚合改用关联子查询
- [x] 两份 schema`migrations/init.sql`PG`migrations/init_sqlite.sql`
- [x] `DATABASE_URL` 识别 `postgres://``sqlite://``file:`、裸 `.db` 路径;空值 = 内置 SQLite
- [x] SQLite 连接开 `WAL` + `busy_timeout=5000` + `foreign_keys=ON`,连接池限 1单写者
- [x] 删除 `docker-compose.yml` 与重复的 `gateway/migrations/`
### 1.9 部署systemd 单元
- [x] `deploy/agentmail-gateway.service` — 含 `ProtectSystem=strict``ReadWritePaths=data/`
- [x] `deploy/opencode-serve.service` — 托管 opencode headlessmail-bridge 宿主)
- [x] `deploy/install.sh` — 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secret
- [x] `OPENCODE_SERVER_PASSWORD` 由安装脚本随机生成(此前裸奔,同机任何进程都能开会话)
---
## Phase 2Agent 邮件桥接插件
> 计划书原本按 Pi Agent 设计,**实际第一个接入的平台是 opencode**`plugins/opencode-mail-bridge/`)。
> 结构也从「按文件拆 tools/transport/hooks」收敛为单文件 `index.js` ——
> opencode 的插件契约是一个默认导出函数返回 hooks 对象,拆成多文件只会增加跳转成本而无收益。
### 2.1 插件结构(已落地)
```
plugins/opencode-mail-bridge/
├── package.json
└── index.js # 凭证层 + HTTP + SSE + 四个工具 + event 钩子
```
- [x] 单文件实现,无需 transport/tools 分层
- [x] 凭证层:密钥优先级 `AGENTMAIL_AGENT_KEY` > `~/.agentmail/agent.key` > 旧 secret > 本地生成
### 2.2 HTTP 传输层(已落地)
- [x] `apiGet` / `apiPost` 封装 `fetch`,认证头由 `authHeaders()` 统一给出
(有密钥走 `Authorization: Bearer`,否则退回 `X-Agent-Name` + `X-Agent-Secret`
- [x] 错误处理:非 2xx 抛出服务端 `error` 文案,调用方能看到「密钥无效」这类可操作信息
### 2.3 SSE 客户端(已落地)
- [x]`fetch` + `ReadableStream` 手工解析 SSE 帧Node 原生 EventSource 不支持自定义请求头,
而认证头是必需的)
- [x] 连接 `/api/v1/events/stream`;断流 3s、出错 5s 后重连;`AbortController` 支持优雅停止
- [x] 事件分发:`new_mail` / `permission_decision``deliverMail()`
### 2.4 工具实现(已落地,四个)
- [x] `send_mail` — 参数 `to` / `subject` / `body` / `cc?` / `reply_to?` / `session_alias?`
地址解析在网关侧完成(三维寻址是网关的职责,插件不该各自实现一份解析)
- [x] `read_inbox` — 参数 `filter?`unread/all/ `limit?`;返回带预览的列表
- [x] `request_permission` — 参数 `question` / `options?` / `context?`
- [x] `connect_to_server` — 参数 `gateway_url?` / `key_token?`;登记密钥并完成注册,
失败时直接把待登记的密钥全文打出来,省一轮来回
**与原计划的偏差**:原设想「权限请求 / 提问 / 最终总结」三种行为由钩子自动拦截,
Agent 不需要手动调工具。opencode 的插件契约没有提供拦截这三类行为的钩子
`chat.message` 只能观察不能冻结会话),因此改为:
`request_permission` 作为显式工具暴露给 Agent「提问」与「最终总结」由 Agent 自行用
`send_mail` 表达。这不影响协作语义 —— 邮件本身就是提问与总结的载体。
### 2.5 插件入口(已落地)
- [x] 启动时读凭证 → `POST /agent/register` → 启动 SSE → 30s 心跳定时器
- [x] `new_mail` 事件:**不注入正文**,只给发件人/主题/mail_id 与「先调 read_inbox」的指示
`session_id``sessionMap` 决定续谈已有 opencode 会话还是新开一个
(网关已按 session 位判好复用/新建,插件只忠实映射)
- [x] `permission_decision` 事件:把决策结论作为新一轮 prompt 投给对应会话
- [x] `session.updated` 钩子:把 opencode 侧模型生成的会话标题与 slug 回写为 AgentMail 的会话命名
### 2.6 测试(已实测)
- [x] 工具注册:四个工具出现在模型的工具列表中(用 `llmsproxy/AUTO` 实测)
- [x] 端到端收发:人 → Agent → 人 回信闭环,`reply_to` 使回信落回同一会话
- [x] 会话记忆:第一封「记住 42」→ 第二封(省略 session 位)问「刚才的数字」→ 回「42」
证明续谈映射正确
- [x] 密钥流程首装本地生成0600→ 管理员登记 → 重启注册成功 → 收发邮件与 SSE 全通
- [x] 权限闭环:`request_permission``/permission/pending` 可查 → 人类决策 → 插件收到并开会话
**排查记录(两个真实缺陷)**
1. 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换 `llmsproxy/AUTO` 后正常。
顺带修了 provider 配置里 `baseURL``//` 的拼写错误。
2. 「发邮件没回复」是真 bug —— SSE 回调调的 `client.session.message.send(...)` 在 opencode SDK 里
**不存在**`session.message` 是读消息的 getterPromise reject 又被空 `.catch(() => {})` 吞掉,
于是邮件到了、SSE 收到了、却没建任何会话也没报错。
正确 API 是 `session.create` + `session.promptAsync`。**教训:跨 SDK 调用不要写空 catch。**
---
## Phase 3前端React
> 实际实现比原计划**收敛**组件拆分粒度更粗MailItem/MailBody/ReplyEditor/PermissionCard
> 都并入了 MailView因为它们只在这一处使用独立文件只增加跳转成本
> 且「新建邮件」按用户要求做成右侧整页而非弹窗。
### 3.1 项目初始化
- [x] `web/` 目录Vite + React + TypeScript + TailwindCSS
### 3.2 目录结构(已落地)
```
web/src/
├── api/ client.tsHTTP/ sse.tsEventSource + 指数退避重连)
├── stores/ mail / session / contact / ui / authZustand
├── components/ Sidebar / MailList / MailView / ComposePage / ContactPanel
│ AddressInput / LoginPage / SetupPage / AccountPage
│ AdminUsersPage / KeyPanel / icons
├── types/index.ts
└── App.tsx
```
### 3.3 类型定义
- [x] `types/index.ts` — Mail / Session / Contact / User / Agent / AdminScopes 等
### 3.4 API 客户端
- [x] `api/client.ts` — 统一 `request()``credentials: 'include'`401 统一跳登录;
覆盖邮件/会话/联系人/权限/用户管理/密钥全部端点
### 3.5 SSE
- [x] `api/sse.ts` — 单例 EventSource + 多订阅者;断线指数退避 1s→15s 上限
(没有单独做 `useSSE` hook订阅者是 store 而非组件hook 反而多一层)
### 3.6 状态管理
- [x] `stores/mailStore.ts` / `sessionStore.ts` / `contactStore.ts` / `uiStore.ts` / `authStore.ts`
### 3.7 核心组件(已落地)
- [x] **App.tsx** — 三栏容器60px 图标栏 / 320px 列表 / 右侧自适应(替代原计划的 Layout.tsx
- [x] **Sidebar.tsx** — 图标栏(收件/发件/联系人 + 新建 + 账号/管理员入口 + 退出),**纯 SVG 无 emoji**
- [x] **MailList.tsx** — 邮件列表未读蓝色粗体、权限橙色标签、CC 标记
- [x] **MailView.tsx** — 邮件正文react-markdown+ 权限卡片 + 回复区,三者合一
- [x] **ComposePage.tsx** — 右侧**整页**写信(非弹窗,用户明确要求),含编辑/预览切换、
会话别名输入(仅地址以 `.new` 结尾时出现)
- [x] **AddressInput.tsx** — 三段式补全,接 `/contacts/suggest`,支持方向键
- [x] **ContactPanel.tsx** — 联系人(= 一条 `name@path.session` 三维地址)+ 归档
- [x] **LoginPage / SetupPage / AccountPage / AdminUsersPage / KeyPanel**
- [x] **icons.tsx** — 20+ 纯 SVG 图标,全站零 emoji
### 3.8 样式
- [x] TailwindCSS 配置
- [x] 浅色主题(深色留待后续)
- [x] 列表 hover/selected 状态
- [x] 权限请求卡片橙色高亮
- [x] 未读蓝色粗体
- [x] 加载/空状态
### 3.9 开发环境
- [x] Vite proxy`/api``http://localhost:8180`(与 Gateway 默认 PORT 一致)
- [x] `npm run typecheck`tsc --noEmit`npm test`Markdown XSS 回归)
---
## Phase 4多用户与鉴权人类账号体系
> 背景:人类不是单一的 `human`,而是**多用户**,各自账号密码登录,拥有独立收件箱与会话。
> 寻址上人类用户同样是三维地址的 name 位:`jianf@.new`、`alice@.deploy-review`。
### 4.1 数据模型
- [x] `users`
```sql
CREATE TABLE IF NOT EXISTS users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(64) NOT NULL UNIQUE, -- 即寻址的 name 位
display_name VARCHAR(128) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL, -- bcrypt
role VARCHAR(16) NOT NULL DEFAULT 'user', -- admin / user
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active / disabled
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_sessions (
token VARCHAR(64) PRIMARY KEY, -- 随机 token存 Cookie
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_agent VARCHAR(256) DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
```
**关键决策**`username``agents.agent_name` **共一个命名空间**(注册时互斥校验),
因为二者都出现在三维地址的 name 位,同名会导致路由歧义。
### 4.2 历史数据迁移
- [x] 现有邮件里的字面量 `'human'` 迁移到默认管理员账号
- 创建 `admin` 用户(首次启动时从环境变量 `ADMIN_USER` / `ADMIN_PASSWORD` 读取)
- `UPDATE mails SET from_name = 'admin' WHERE from_name = 'human'`to_name 同理)
- 保留 `human` 作为**兼容别名**Agent 仍可 `send_mail to="human@"`,网关解析为“当前会话的发起人类用户”
### 4.3 认证 API
```
POST /api/v1/auth/login { username, password } → Set-Cookie: am_session
POST /api/v1/auth/logout → 销毁 token
GET /api/v1/auth/me → { user_id, username, display_name, role }
POST /api/v1/auth/password { old_password, new_password }
```
管理员专用:
```
GET /api/v1/admin/users → 用户列表
POST /api/v1/admin/users { username, password, display_name, role }
PUT /api/v1/admin/users/{id} { display_name, role, status }
DELETE /api/v1/admin/users/{id} → 禁用(不物理删除,保留邮件历史)
POST /api/v1/admin/users/{id}/reset { new_password }
```
- [x] 密码用 `golang.org/x/crypto/bcrypt`cost 12
- [x] token 用 `crypto/rand` 32 字节 hex有效期 7 天,每次请求滞后续期
- [x] Cookie`HttpOnly` + `SameSite=Lax` + 生产环境 `Secure`
- [x] 登录失败限速:同一 username 连续 5 次失败锁 5 分钟内存计数MVP 阶段够用)
### 4.4 中间件与会话隔离
- [x] `middleware.UserAuth` —— 从 Cookie 取 token → 查 `user_sessions` → 注入 `username` 到 context
- [x] `middleware.AdminOnly` —— 叠在 UserAuth 之后,校验 `role = 'admin'`
- [x] **所有 `/human/*` 路由改为 `/me/*` 并强制鉴权**,当前写死的 `"human"` 全部换成登录用户名:
- `HumanGetInbox``MeGetInbox``ListInbox(ctx, username, ...)`
- `HumanGetSent``MeGetSent``ListSentBy(ctx, username, ...)`
- `HumanSendMail``MeSendMail`from_name = username
- `ListContacts``SuggestAddress``ArchiveContact` 同样按当前用户过滤
- [x] 权限决策 `POST /permission/decide` 鉴权:只有**该会话的参与人类**或 admin 可决策
- [x] SSE `/events/stream` 鉴权:前端连接按登录用户分流,不再广播给全部前端
- `sse.Manager` 新增 `UserName` 字段,`SendToUser(username, ...)` 取代 `SendToFrontend`
### 4.5 会话归属
- [x] `sessions` 表新增 `owner_user_id UUID REFERENCES users(user_id)`
- [x] 人类发起的会话归属于该用户Agent 发起的会话归属于它寄信的人类
- [x] `ListContacts` 只列当前用户参与的会话owner 或 在 to/cc/from 中出现)
- [x] 归档鉴权:非 owner 且非 admin 不得归档别人的会话
### 4.6 Agent 侧寻址适配
- [x] Agent 给人类发信时可写具体用户名:`send_mail to="jianf@.new"`
- [x] 保留 `human@` 兼容写法 → 解析为当前会话的 owner 用户
- [x] `SuggestAddress` 的 name 层候选同时包含 **在线 Agent + 已启用人类用户**
### 4.7 前端
- [x] `LoginPage.tsx` —— 账号密码表单,失败提示,回车提交
- [x] `authStore.ts` —— `me` / `login` / `logout`;应用启动先拉 `/auth/me` 判断登录态
- [x] `App.tsx` —— 未登录渲染 `LoginPage`,已登录渲染主界面
- [x] `api/client.ts` —— 所有请求带 `credentials: 'include'`401 统一跳登录
- [x] 侧边栏底部显示当前用户 + 退出按钮(纯 SVG 图标,不用 emoji
- [x] 管理员页:用户列表 / 新建 / 禁用 / 重置密码
### 4.8 验证
- [x] 单测bcrypt 校验、token 过期、跨用户访问被拒403
- [x] 两个人类账号互不可见对方收件箱与联系人
- [x] Agent 寄信给 `jianf@.new` 仅 jianf 收到SSE 不泄露给其他登录会话
---
## Phase 5密钥认证体系Agent-插件-Gateway 鉴权)
> 背景:当前 Agent 注册只需 name+secret 明文,无密钥机制,无法安全接入公网。
> 本 Phase 引入密钥token体系分为两类**Agent 密钥**(管理员为 Agent 生成,用于注册+通信)
> 和 **用户密钥**(用户自己生成,仅用于客户端连接 Gateway不可注册 Agent
> 插件侧首次安装时本地生成密钥对,通过 `connect_to_server` 工具完成握手。
### 5.1 数据模型(已落地)
> 两方言各一份:`internal/db/migrations/init.sql` 与 `init_sqlite.sql`。
> 下方 DDL 为 PostgreSQL 方言SQLite 侧 `UUID→TEXT`、`TIMESTAMPTZ→DATETIME`。
#### agent_keys 表Agent 注册密钥)
```sql
CREATE TABLE IF NOT EXISTS agent_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE, -- 密钥令牌(随机 hex
agent_name VARCHAR(64), -- 绑定到的 Agent 名(可为空=未绑定)
key_type VARCHAR(16) NOT NULL, -- permanent / one_time / timed
expires_at TIMESTAMPTZ, -- timed 类型的过期时间permanent/one_time 为 NULL
used_at TIMESTAMPTZ, -- one_time 类型:首次使用时间(已用=失效)
created_by UUID REFERENCES users(user_id), -- 创建者(管理员)
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
```
#### user_keys 表(用户连接密钥)
```sql
CREATE TABLE IF NOT EXISTS user_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
label VARCHAR(128) NOT NULL DEFAULT '', -- 用户自定义标签(如 "我的笔记本"
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent', -- permanent / one_time / timed
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
```
#### 关键约束
- `agent_keys.key_token``user_keys.key_token` 共享一个全局唯一命名空间(验证时先查 agent_keys再查 user_keys
- `user_keys` 的密钥 **不能** 用于 `POST /agent/register`(注册时校验来源表)
- `agent_keys` 的密钥 **不能** 用于 `/me/*` 人类邮箱接口agent_auth 中间件与 user_auth 中间件分离)
### 5.2 Gateway API
#### 管理员Agent 密钥管理
```
POST /api/v1/admin/agent-keys { agent_name?, key_type, label?, expires_hours?, key_token? }
GET /api/v1/admin/agent-keys ?agent_name=xxx
DELETE /api/v1/admin/agent-keys/{id}
POST /api/v1/admin/agent-keys/{id}/bind { agent_name } -- 绑定到 Agent
```
`key_token` 用于**登记客户端已在本地生成的密钥**(插件首装场景):这样密钥全文只从客户端
流向服务器一次不必反方向传递。留空则由服务器生成32 字节随机 hex
密钥类型:
- `permanent`:永不过期,可重复使用,适合正式部署的 Agent
- `one_time`:一次性使用,首次验证后标记 `used_at`,再用即 403
- `timed`:创建时指定 `expires_hours`(如 24h过期后失效
#### 用户:连接密钥管理
```
POST /api/v1/me/keys { label, key_type, expires_hours? }
GET /api/v1/me/keys → 我的密钥列表(只给 token_hint
DELETE /api/v1/me/keys/{id} → 删除密钥(带 user_id 条件,删不到即 404
```
用户密钥的 `key_type` 同样支持 permanent / one_time / timed但只能用于 `/me/*` 接口。
#### Agent 注册(改用密钥认证)
```
POST /api/v1/agent/register
Header: Authorization: Bearer <agent_key_token>
Body: { name, workspaces, platform }
→ Gateway 验证密钥:
1. 查 agent_keys WHERE key_token = token
2. 不存在 → 401
3. one_time 类型且 used_at 非空 → 401已用过
4. timed 类型且 expires_at < now() → 401已过期
5. 验证通过 → 更新 used_atone_time→ 继续注册逻辑
```
#### Agent 心跳(改用密钥认证)
```
POST /api/v1/agent/heartbeat
Header: Authorization: Bearer <agent_key_token>
```
#### SSE 连接(改用密钥认证)
```
GET /api/v1/events/stream
Header: Authorization: Bearer <agent_key_token>
→ Agent 侧 SSE密钥验证后绑定到对应 Agent
→ 前端 SSE仍用 Cookie 认证(不变)
```
### 5.3 插件侧改动
#### 首次安装:本地生成密钥
```
~/.agentmail/
├── config.json # { "gateway_url": "...", "key_token": "...", "agent_name": "..." }
└── agent.key # 本地密钥文件JSON含 key_token + created_at
```
插件启动时:
1. 检查 `~/.agentmail/agent.key` 是否存在
2. 不存在 → 生成随机 32 字节 hex 作为 `key_token`写入文件0600并打印到 stderr
3. 存在 → 读取 key_token
4. 自动调 `/agent/register`;密钥未登记时提示管理员把打印出的密钥填进后台
#### connect_to_server 工具
```javascript
args: {
gateway_url: z.string().describe("Gateway 地址,如 http://mail.example.com:8080"),
key_token: z.string().optional().describe("密钥令牌(可选,也可从本地配置读取)")
}
execute: {
1. 读取本地 key_token参数优先否则从 ~/.agentmail/agent.key 读取)
2. POST /api/v1/agent/register Authorization: Bearer key_token
3. gateway_url key_token 保存到 ~/.agentmail/config.json
4. 启动 SSE 监听
5. 返回连接结果
}
```
#### 心跳认证改动
心跳与所有 Agent 接口统一携带 `Authorization: Bearer <key_token>`
旧的 `X-Agent-Name / X-Agent-Secret` **保留兼容**(见 5.7 最后一条)。
#### 密钥来源优先级(已落地)
1. `AGENTMAIL_AGENT_KEY` 环境变量 —— systemd 部署走这条
2. `~/.agentmail/agent.key``AGENTMAIL_CONFIG_DIR` 可改目录)
3. 都没有时退回 `AGENTMAIL_AGENT_SECRET` 的旧路径;连 secret 也没有才本地生成新密钥
文件权限:`agent.key``config.json` 均 0600目录 0700。
### 5.4 密钥验证中间件Gateway已落地
`middleware.AgentAuth` 同时承担密钥与旧凭证两条路径:
```
有 Authorization: Bearer <token>
→ repo.VerifyAgentKey查 agent_keys
→ 不存在 → 401 "密钥无效"
→ one_time 已用 → 401 "密钥已使用(一次性密钥只能用一次)"
→ timed 已过期 → 401 "密钥已过期"
→ 通过one_time 写 used_atWHERE used_at IS NULL并发下只有一个请求能标记成功
→ agent_name 为空(待绑定)→ 403提示先调 /agent/register
→ 注入 agent_name 到 context刷新 last_seen
无 Bearer走 X-Agent-Name + X-Agent-Secret 旧路径
```
`middleware.UserAuth` 同理支持 Cookie 与 `Bearer <user_key_token>`。两类密钥各查自己的表,
因此 Agent 密钥读不了人类邮箱,用户密钥也注册不了 Agent。
`/agent/register` 不经中间件(注册时 Agent 还没身份),自行取 Bearer 校验:
密钥已绑定且与请求 name 不符 → 403防止拿别人的密钥冒充新身份未绑定则注册成功后落定。
### 5.5 管理员前端
- [x] 管理员页面拆成「用户管理 / Agent 密钥」两个 tab`AdminUsersPage.tsx`
- [x] 创建密钥:选类型(长期/一次性/限时)+ 可选绑定 Agent + 限时可填小时数
- [x] **登记客户端已生成的密钥**:插件首装在本地生成密钥并打印,管理员把它填进「登记」框即可,
密钥全文只从客户端流向服务器一次,不需要反方向传递
- [x] 密钥列表:只显示 `token_hint`(前 8 位),全文仅在创建响应里出现一次,
前端用一次性横幅提示「关闭后无法再次查看」
- [x] 绑定 Agent / 吊销密钥
### 5.6 用户前端
- [x] 个人中心新增「客户端连接密钥」区(`AccountPage.tsx`,与管理员面板共用 `KeyPanel.tsx`
- [x] 创建密钥:填备注 + 选类型;限时可填小时数
- [x] 密钥列表 + 吊销;状态标注「可用 / 已使用 / 已过期」
### 5.7 验证(已实测)
- [x] permanent 密钥 → 注册成功,重复使用仍成功
- [x] one_time 密钥 → 首次成功;再用 401「密钥已使用一次性密钥只能用一次
- [x] timed 密钥24h→ 注册成功;手动把 `expires_at` 改到过去 → 401「密钥已过期」
- [x] timed 缺 `expires_hours` → 400非法 key_type → 400
- [x] 用户密钥 → `/me/mail/send` 正常;→ `/agent/register` 401「密钥无效」
- [x] Agent 密钥 → `/me/mail/inbox` 401两类密钥各查自己的表不会串门
- [x] 待绑定密钥首次注册落定为该 Agent同一密钥改注册别的 name → 403
- [x] 登记重复密钥 → 409登记过短密钥<32 )→ 400
- [x] 密钥列表接口不回传 `key_token` 全文实测 `回传全文: False`
- [x] **SSE 鉴权加固**原先只凭 `X-Agent-Name` 就分流等于任何人报个名字就能读走别人的
新邮件通知现在必须通过 Bearer 密钥或 name+secret 验证仅报名字 / secret / 匿名一律 401
- [x] 插件密钥流程端到端首装本地生成密钥0600目录 0700并打印 管理员登记
插件重启注册成功 用该密钥收发邮件与订阅 SSE 全通
- [x] 旧的 X-Agent-Name/Secret 方式**保留兼容**与原计划向后不兼容不同
已部署的 Agent 不该因为引入密钥就集体失联两条路径并存成本很低
---
## Phase 6联调 + 部署
### 6.1 端到端闭环测试
- [x] 启动 Gateway`go run ./cmd/server`SQLite 自动建库无需外部数据库
- [x] 启动前端`npm run dev`
- [x] 启动 opencode serve + mail-bridge 插件
**测试场景(均已在 systemd 部署态实测):**
| # | 步骤 | 结果 |
|---|------|------|
| 1 | 人类登录后新建邮件抄送第二个收件人 | session + mail 落库cc_list 入库 |
| 2 | Agent 收到 SSE 通知 | 插件只注入通知发件人/主题/mail_id不注入正文 |
| 3 | Agent read_inbox | 返回邮件详情含被抄送的那封 |
| 4 | Agent send_mail 回复人类 | 发起任务的用户收到`reply_to` 使回信落回同一会话 |
| 5 | request_permission | 权限请求邮件入库`/permission/pending` 可查 |
| 6 | 人类决策 | `/permission/decide` 生成决策邮件Agent 侧收到 |
| 7 | 归档 name@path.session | 会话与邮件均标记 archived归档后该别名不可寻址404)✅ |
**session 位三态实测:** 省略 复用默认会话`.new` 每次新建具名别名 命中已有
不存在则 404无法送达」;跨收件人借用别名同样 404
**平台命名同步实测:** 发信不指定别名 opencode slug 回写为 `calm-meadow`
模型生成的摘要标题生产环境联调邮件回复回写为 subject
再用 `opencode@/root.calm-meadow` 续谈Agent 正确引用上一封内容
### 6.2 部署编排(改为 systemd不用容器
原计划的 docker-compose 已作废数据库换成内置 SQLite 部署产物就是一个二进制 + 一个 .db」,
再套一层容器编排只是徒增运维层级 1.9
- [x] `deploy/install.sh` 一键安装构建 嵌入 systemd
- [x] `deploy/*.service` 两个单元gateway opencode-serve
- [x] 删除 `docker-compose.yml`
### 6.3 一键启动脚本
- [x] `deploy/install.sh` 覆盖构建 + 安装 + 启动
- [x] 开发态无需脚本`go run ./cmd/server` 即起SQLite 自动建库前端 `npm run dev` 代理到 8180
### 6.4 错误处理与边界
- [x] Agent 离线时邮件投递邮件先入库Agent 心跳响应回传未读数上线后 `read_inbox` 补齐
- [x] Gateway 重启后 SSE 客户端重连前端 `api/sse.ts` 指数退避1s15s 上限
插件侧 `startSSE` 断流 3s / 出错 5s 后重连
- [x] 无效 Agent /密钥处理错密钥与不存在的 Agent 统一回 `Invalid credentials`不区分避免探测账号存在性
缺头回 `Missing X-Agent-Name or X-Agent-Secret header`
- [x] 畸形三维地址 name to别名不存在均有明确错误文案
- [x] 邮件内容 XSS 防护react-markdown 默认不解析 raw HTML 且清空非 http(s)/mailto 协议 URL
新增回归测试 `web/test/markdown-xss.test.mjs``npm test`守住这两个前提
防止日后为支持 HTML 邮件加上 rehype-raw 而无声开口子
- [x] 并发安全`sse.Manager` `sync.RWMutex``go build -race` 通过
实测 10 客户端并发连接断开后计数归零+ 20 封并发发信全部成功SQLite WAL + busy_timeout
### 6.5 公网反代(已完成)
- [x] `mail.jianfgit.xyz` portal-nginx(.106:3080) `.60:8180`
- [x] 列入 portal 认证豁免名单应用自带账号体系不叠 portal 登录
---
## Phase 7进阶功能MVP 后迭代)
### 7.1 对话树(已完成)
**不建 `tree_nodes` 表**偏离原计划`mails.parent_mail_id` 已经完整编码了树结构 ——
回复指向来信转发指向被转发的原件再维护一张 tree_nodes 就是第二份真相
两处不一致时无法判断谁对直接用递归 CTE mails 上查`idx_mails_parent` 已有)。
- [x] `repo/thread.go``AncestorsRaw`向上/ `DescendantsRaw`向下两个方向的递归 CTE
- [x] `GET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N`
- [x] **树可跨会话**转发把线索引到新会话 parent 仍指向原件
这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里
- [x] **按会话逐个鉴权**A 转发给 B 之后B C 在新会话里的往来不能回流给 A
被过滤的节点计入 `hidden`鉴权结果按会话缓存一条线索里同一会话通常有多封
- [x] `detached` / `parent_hidden` 两个标记前者表示父不在当前已加载集合里
后者区分无权查看」(永久尚未加载」(随上滑补齐)。
不能因为找不到父节点就把子节点悄悄丢掉
- [x] 前端 `ThreadView.tsx`缩进 + 连接线不引图形库
邮件树又浅又窄一条主链加几个转发分支缩进足够表达层级
省掉渲染 SVG 的依赖与它带来的布局/缩放问题
**分块加载(用户要求:展示完整节点,首屏只加载当前屏幕,上滑逐步补齐)**
原先实现是深度上限 100 直接截断超长线索看不到全貌改为按方向分页
- [x] 首屏 `dir=around` 取锚点附近一块limit 在两个方向各分一半
`dir=up|down` + `offset` 增量加载两端各自带 `has_more_*` `next_*`
- [x] **游标用「相对锚点的层号/节点偏移」而不是 mail_id**
偏移量每次从锚点重走一遍无状态不可伪造
mail_id 做游标就必须允许传入**不可见**的邮件不可见的中间段要穿过去
那还得单独证明它确实是锚点的祖先反而更绕
祖先方向的层号天然稳定 —— 新邮件只会追加成叶子不会插进已有链条中间
- [x] `depth` 改为**相对锚点**0 = 锚点,负 = 祖先= 子孙):
分块加载时根可能还没取到绝对深度无从得知
- [x] repo **不做可见性过滤**不可见的中间段必须能穿过转发把线索引进别人的会话
再往上却可能仍是自己参与的往来)。过滤放在 handler 那里才知道调用者是谁
- [x] 前端 `IntersectionObserver` 双哨兵rootMargin 200px 提前触发+
**向上加载的滚动位置补偿**顶部插入内容后按 scrollHeight 增量修正 scrollTop
否则视线会被内容顶走+ `busy` ref 防两个哨兵同时触发并发请求
`setState` 异步 state 会双双看到 false
- [x] 实测 251 封长链7 页取完depth 连续无缺口取到真正的根
311 60 个转发分支9 页取完`depth=1` 恰为 61
- [x] `limit` 夹到 [1,200]非法值回落默认 40分页参数不该因笔误让整个请求失败
`limit=1` 时两方向各保底 1否则算出 `downLimit=0` 连锚点自己都不返回
剩余与树视图独立
- [ ] 工作列表卡片视图中间栏
### 7.2 抄送(已完成)/ 转发
- [x] `cc_list JSONB` 字段加入 mails
- [x] 发送时支持抄送多个三维地址
- [x] 前端邮件详情显示抄送列表
- [x] 收件箱检索覆盖被抄送邮件`to_name = $1 OR cc_list @> ...`
- [x] 转发功能引用原文 + 新收件人 + 附件随行
- `POST /mail/{id}/forward`Agent `POST /me/mail/{id}/forward`人类
- 回复的区别回复落回原会话转发按目标地址另行寻址 —— 它是一条新线索
- 只能转发自己参与过的邮件发件/收件/被抄送之一否则 403
- 引用块逐行加 `> ` 前缀原文含代码块或列表时只有逐行前缀才保持引用语义
- `Fwd:` 前缀不叠加`parent_mail_id` 指向原邮件以便回溯
- 附件一同带过去内容寻址下只新增元数据不拷磁盘文件
### 7.3 配额机制已完成7.9 进一步下沉到会话)
**只限制主动发信,不限制收信** —— 卡住收信只会让邮件凭空消失卡住发信才能阻止 Agent 无限自我循环
- [x] `agents.max_rounds` / `used_rounds` 计数`max_rounds = 0` 表示不限
- [x] 配额用尽后 `send_mail` `forward` 均返回 403文案提示先发最终总结或联系管理员重置
- [x] **判断与自增在同一条 UPDATE 里**`WHERE used_rounds < max_rounds`
分成两步的话并发发信会双双通过检查再各自 +1把配额刷穿单测覆盖此场景
- [x] 剩余次数随发信响应`quota_remaining`与心跳`quota`回传
插件把它写进工具返回值与日志 Agent 在耗尽前主动发总结
- [x] 管理员 API`GET /admin/quotas``PUT /admin/quotas/{name}`设上限或归零
- [x] 前端管理员页新增发信配额tab`QuotaPanel.tsx`进度条 + 就地编辑 + 重置
### 7.4 会话别名动态更新(已完成)
会话别名的**基础能力已在 Phase 1 落地**三维寻址 session 位三态」):
- [x] 发信时用 `session_alias` `.new` 新建的会话命名响应回传 `session_alias`
- [x] `PUT /sessions/:id/alias` 事后改名唯一性冲突返回 409
- [x] 别名全局唯一`idx_sessions_alias_uniq` 部分唯一索引NULL 不受约束
- [x] 保留字与字符校验不可为 `new`不可含 `.` `/` `@` 与空白否则地址切分歧义
- [x] 前端 ComposePage 在地址以 `.new` 结尾时才显示会话别名输入框
**别名/标题的默认来源 = Agent 平台自己的命名机制**不在本侧另造一套
- [x] `POST /sessions/:id/sync`Agent 认证接收平台侧 `alias` + `title`
- [x] opencode 插件建会话时**不传 title**让平台按首轮对话由模型生成摘要标题
创建即拿到的 slug `witty-planet`立即回写为寻址别名标题随 `session.updated` 事件回写
- [x] 平台 slug 不保证全局唯一本侧别名必须唯一 `SyncSessionAlias` 撞名自动追加 `-2`/`-3`同步永不失败
- [x] `normalizeAlias` 把平台命名改写为合法寻址别名非法字符换 `-`压缩连续 `-`避开保留字 `new` UTF-8 边界截断 128 字节
- [x] 手工命名发信 `session_alias` / `PUT alias`仍可覆盖平台命名属显式优先
**由 Agent 在邮件正文里主动提议改名**已完成与上面的自动同步互补
两者分工自动同步 = 平台起的名字,后台静默生效不打扰人;
正文提议 = Agent 干完活觉得该换个更贴切的名字需要人点头
**为什么要人点头而不是让 Agent 直接调 `PUT alias`**
别名是****的寻址入口`name@path.别名`)。Agent 干到一半自己改掉
人上一秒记住的地址下一秒就失效提议 + 人确认既让 Agent 表达意图
又保证寻址稳定性由人掌握
- [x] 载体是 HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`
- react-markdown 默认不解析 raw HTML注释在页面上不可见
- 纯文本客户端里是一行不碍事的注释不像自造标记那样显眼
- 不与 Markdown 语法冲突不会被格式化工具改写
- **实测它会被转义成可见文本节点而不是被丢弃**所以必须从入库正文里主动剥掉
- [x] `handler/rename_proposal.go``extractRenameProposal` 解析 + 剥标记
- 只认**最后一条**Agent 在长回复里可能反复修正措辞最后写下的才是结论
- 别名过 `normalizeAlias` + `validateSessionAlias`非法的视为无提议但标记仍剥掉
与其在正文里留一行乱码不如当它没提
- 理由截断到 200 字节 UTF-8 边界否则提示条被撑破
- 人类发信同样剥标记但不产生提议 —— 人有改名按钮用不着向自己提议
- [x] 存在**邮件**`mails.rename_alias/rename_reason`而非会话上
邮件是不可篡改的历史记录,「谁在哪一封里提了什么应当留痕
- [x] `sessions.rename_dismissed` 记下被驳回的建议提示条不再反复弹同一个
- [x] `GET /sessions/:id/rename-proposal` 取最新**未处理**提议
既不是当前别名 = 未接受,也不在驳回记录里)
- [x] `POST /sessions/:id/rename-proposal/dismiss` 驳回无待处理时返回 `no_pending`幂等
- [x] **接受走已有的 `PUT /sessions/:id/alias`**不另开端点
那条路径已有唯一性校验与 409复制一遍只会多一个出错的地方
实测提议的名字被别的会话占用时接受返回 409而不是默默造出重名
- [x] 插件 `send_mail` `propose_alias` / `propose_reason` 参数自动拼注释标记
发信响应回传**规范化后**的别名Agent 提的名字可能被改写过
- [x] 前端 `RenameProposalBar`会话顶部显示 旧别名 新别名 + 理由 +
改名后旧别名立即失效的后果提示接受/忽略两个按钮
`new_mail` 事件触发重新拉取新来信可能带新建议
- [x] **SQLite 老库补列**`CREATE TABLE IF NOT EXISTS` 不会给已存在的表加列
SQLite 没有 `ADD COLUMN IF NOT EXISTS``db.addMissingColumns`
`pragma_table_info` 后按需 ALTER实测老库补列成功旧数据不丢二次启动不重复补
**`sessions.alias_source` —— 生产实测发现的隐患**
用户接受改名后opencode 下一次 `session.updated` 事件会带着平台 slug 再同步一次
把人刚定的名字冲掉 —— 人上一秒记住的寻址地址下一秒失效实测确认了这个行为
`fix-cache-penetration` `stellar-engine` 覆盖)。
- [x] `sessions.alias_source``platform`平台自动同步可被后续同步覆盖/
`manual`人显式指定平台同步不得覆盖
- [x] `UpdateSessionAlias` 发信时显式给 `session_alias`都标 `manual`
- [x] `SyncSessionAlias` 遇到 `manual` 直接返回当前别名不写入
**条件放进 `WHERE alias_source <> 'manual'` 并检查 RowsAffected** ——
并发下用户可能刚好在检查与写入之间接受了提议分两步会把它冲掉
- [x] **标题不受此保护**`subject` 只用于展示被平台的摘要标题刷新是好事
受保护的只有承担寻址职责的别名
- [x] 老库补列默认 `platform`无从得知历史别名是人定的还是平台定的
`platform` 只影响平台同步能否覆盖」,人随时可手工改名转成 `manual`
- [x] 四个场景实测platform 可反复覆盖 / 用户改名后平台同步无效 /
发信显式命名即受保护 / 全流程平台命名 Agent 提议 用户接受 平台再同步不覆盖
### 7.5 附件(已完成)
**内容存磁盘、元数据入库**附件是写一次读多次的冷数据塞进 SQLite BLOB 只会让
`.db` 膨胀WAL 变大备份变慢换不来任何好处
- [x] `attachments` 两方言各一份`mail_id` 允许为 NULL 表示已上传未挂载
- [x] `internal/blob`内容寻址存储路径由 sha256 派生`ab/cd/abcd…`
- 相同内容天然去重重复上传不占额外空间
- 路径与用户给的 filename 完全无关杜绝 `../` 穿越
- 先写临时文件再按内容哈希 rename中途崩溃不会留下哈希对不上内容的文件
- 超限即中止并清理临时文件恰好等于上限放行边界不误杀
- [x] **上传与发信两步**`POST /attachments` id 发信时放进 `attachment_ids`
Agent 侧工具走 JSON 无法带 multipart人类侧也需要写正文前先传文件
- [x] 挂载校验`WHERE mail_id IS NULL AND uploader = ?` 一条 UPDATE 完成判断与写入
避免并发下把同一附件挂到两封邮件上不属于自己 403已挂载 409
- [x] 下载鉴权已挂载的看邮件所属会话的参与关系未挂载的只有上传者本人能看
- [x] **下载一律 `octet-stream` + `attachment` + `nosniff`**绝不按声明的 MIME 内联渲染
否则上传一个 `.html`/`.svg` 就能在本站域下执行脚本
- [x] `Content-Disposition` 双写`filename*` 承载 UTF-8`filename=` 兜底且转义引号与控制字符
- [x] 删除只允许删自己上传且未挂载的内容被其他记录共享时不删磁盘文件
- [x] GC启动时 + 每小时扫一遍清理超过 24h 未挂载的记录与无引用文件
- [x] 单个上限 25MB`AGENTMAIL_MAX_ATTACHMENT_BYTES`
双层限制`MaxBytesReader` 卡整个请求体`blob.Put` 卡单文件内容
- [x] 插件工具 `upload_attachment` / `download_attachment``read_inbox` 列出附件清单含 id
- [x] 前端 `Attachments.tsx`写信/回复的选择器XHR 上传进度+ 邮件详情的下载清单 +
列表页附件徽标
### 7.6 WebAPI 化(已完成)
**WebUI 调用的就是公开 API没有仅前端可用的私有通道** —— 第三方客户端拿一把用户密钥
即可获得与网页完全相同的能力
- [x] 实测前端用到的每个端点都能用 `Authorization: Bearer <user_key>` 调通
上传下载转发管理员接口SSE
- [x] 两处例外补齐 `?access_token=`SSE`EventSource` 不能带自定义头
附件下载`<a download>` 由浏览器直接发起)。
**只有这两个端点接受 query 令牌**其余一律 401 —— URL 里的令牌会进访问日志与 Referer
附件下载因此单独挂在 `UserAuthAllowQueryToken` 中间件下
- [x] CORS 暴露 `Content-Disposition` `Content-Length`前者是取文件名所必需
- [x] `web/src/api/config.ts` 集中基地址与令牌
`VITE_API_BASE` 构建期注入`window.__AGENTMAIL_API_BASE__` 运行时覆盖`setToken()` 切凭证
业务代码不感知 Cookie 与密钥的差异`src/api/` 可整体抽成 SDK
- [x] `docs/API.md`完整接口清单三类调用者的认证边界错误码约定
### 7.7 DeepSeek Harness 插件
- [ ] 基于 Cordis 框架开发 `dsh-mail-bridge`
- [ ] 利用 `PreToolUse``SessionStart` 钩子
- [ ] Pi 插件共享相同 Gateway API
### 7.8 跨主机 Agent 发现
- [ ] Gateway + Registry 拆分为独立服务
- [ ] etcd / Consul 服务注册
- [ ] Agent 跨主机路由
### 7.9 插件自动转发 + 会话往返预算(已完成)
用户提出的三条原则逐条落地
**原则一:插件应当自动转发 Agent 平台原生的问询与权限请求,而不是让模型自己调工具。**
原实现有个 `request_permission` 工具让模型主动调 —— 这是把 harness 的职责推给模型
它可能忘了调也可能在不需要时乱调**真正被 opencode 拦下的那次询问反而没人看见**。
- [x] 删掉 `request_permission` 工具改用 `permission.ask` 钩子接管
- [x] 钩子里只记下待决策项并转出邮件`output.status` 保持 `"ask"` ——
不在钩子里阻塞等人回复那是同步钩子卡住会把整个 opencode 请求挂死
- [x] opencode 的三态权限映射成人话同意 = `once`、一直同意 = `always`拒绝 = `reject`
- [x] 人类决策后走 SSE 回来 `client.postSessionIdPermissionsPermissionId` 回复原生 permission
opencode 自己恢复原来的工具调用 —— 不再往会话里塞你的请求已批准的文字干扰它
- [x] 转发失败时不接管保持 `ask`让本地 TUI 弹窗兜底而不是让 Agent 干等
- [x] `permission.replied` 事件清理待决策记录避免邮件决策回来又去回复一条已结案的 permission
**原则二:插件应当自动转发 Agent 最后一条总结性消息,且不消耗配额。**
- [x] 触发点选 `session.idle`一轮跑完而不是 `message.updated`
后者在流式生成中反复触发转出去是半截话
- [x] 只取 `time.completed` 非空的 assistant 消息未完成/被中断的不转
- [x] `mailContexts` 记住该会话最近一封来信的发件人与 mail_id
总结回给它并带 `reply_to`回信才落回同一线索
- [x] 提示语相应改写告诉模型回信不用你自己发把话说完就行」,
只在需要主动联系他人或带附件时才调 `send_mail`
**原则三(基本原则):插件自动转发的邮件不消耗配额。**
配额存在的意义是防止 Agent 无限自我循环harness 代劳的搬运不属于此列 ——
对它收费会导致配额用尽时 Agent 连交代都做不了而那正是最需要它说话的时刻
- [x] `mails.relay` + `relay_key` 参数`relayKinds` 白名单只有 `permission` / `summary`
不是任意字符串否则 `relay:"anything"` 就是绕过配额的后门
- [x] **防滥用不靠计数,靠幂等键**`relayed_mails(agent_name, relay_key)` 主键唯一
relay_key 是上游那条消息的稳定 idpermission id / assistant message id
由平台生成模型伪造不出来于是插件重试与 SSE 重放不产生第二封
想多转就得拿出不同的上游消息 id
- [x] 判断与占用在同一条 INSERT 靠唯一约束分成先查再插两步的话
插件的两次重试会双双通过检查各插一条
- [x] 建邮件失败时 `ReleaseRelay` 归还名额否则那条上游消息永远转不出来了
- [x] 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
重复是插件重试的正常结果不是故障
- [x] 响应带 `quota_charged: false`免得插件看到额度没变以为数据错了
- [x] `permission_decision` 事件回传 `relay_key` + `relay_kind`
两边 id 空间不同插件要拿上游 id 才能回复 opencode
**这个映射必须服务端持久化**插件重启后内存映射就没了
**配额下沉到会话(用户:配额应当在新建邮件、以及邮件对话页面是可编辑的)**
配额的真实语义是这件事值得多少个来回」—— 那是**任务**的属性不是 Agent 的属性
只有 `agents.max_rounds` 一个全局计数器时有两个问题并行任务互相抢额度
`used_rounds` 单调递增跑满就得管理员手工重置才能再干活
- [x] `sessions.max_rounds` / `used_rounds`0 = 本会话不限)
- [x] 写信时给`POST /me/mail/send` `max_rounds`仅新建会话时生效 ——
续谈也接受的话每封新信都会悄悄改掉对方正在遵守的预算
- [x] 对话页里改`GET/PUT /sessions/{id}/budget``max_rounds` `reset` 可同时给
(「加到 20 并从头算是一次很自然的操作拆两个请求只多一次往返
- [x] **两层都要过**会话预算 + Agent 全局配额少了后者Agent 自己 `.new`
开一串会话每条都是全新预算全局上限形同虚设
- [x] 会话预算先扣全局配额后扣全局拦下时 `RefundSessionBudget` 退回 ——
那次往返实际上没有发生不能白掉一格
- [x] 判断与自增在同一条 UPDATE`WHERE used_rounds < max_rounds`
40 并发 vs 上限 10 的单测覆盖`-race` 通过
- [x] 允许把上限调到低于已用次数那表示就到这里为止」,是人的合法意图
- [x] 前端ComposePage 往返预算输入框 + MailView 会话头部的 `BudgetEditor`
点徽标就地编辑可改上限/重置/取消预算变更广播 `session_update`
- [x] 老库补列默认 0不限引入预算不该把已在进行的会话卡死
**验证**单测 `relay_test.go`10 个用例免配额类型恰好两种的防扩散断言+
`budget_test.go`6 40 并发不刷穿)。端到端 8
自主发信扣额 用尽后 relay 照样发出且不扣 key 幂等 key 可再转
参数校验 4 权限询问幂等 + 决策回传 relay_key 对话页调预算
两层独立且全局拦下时会话退回 老库补列不丢数据
---
## 里程碑时间线
| 时间 | 里程碑 | 验收标准 |
|------|--------|----------|
| Week 1 | 后端核心完成 | Gateway 启动 + 所有 API curl 可通 |
| Week 2 | 插件完成 | opencode 可收发邮件 + 钩子自动转邮件 |
| Week 3 | 前端完成 | 三栏 UI + 三段式补全 + SSE 实时更新 |
| Week 4 | 多用户完成 | 两个人类账号互不可见对方邮件登录鉴权生效 |
| Week 5 | 密钥体系完成 | Agent 用密钥注册用户密钥与 Agent 密钥隔离 |
| Week 6 | MVP 闭环 | Agent 完整工作流可演示 |
| Week 7+ | 进阶功能 | 对话树 / 转发 / 配额 / DSH 插件 |
**实际状态**Phase 1-6 全部完成 systemd 部署并端到端验证含密钥认证
session 三态语义平台命名同步权限闭环并发与鉴权边界)。剩余仅 Phase 7
---
## 当前进度
### 已完成
**后端Go**
- [x] 项目结构与 MVP 技术规格书
- [x] schema 两方言各一份users / user_sessions / agents / sessions / mails / permission_requests
- [x] 数据模型 + 连接池 + 内嵌迁移器
- [x] Repository 含联系人聚合归档三段式补全查询
- [x] 三维寻址 `name@path.session` 解析器 + 单测`internal/models/address.go`
- [x] **session 位三态语义**省略 默认会话复用最近活跃无则建`new` 强制新建
具体别名 必须已存在且该收件人参与过否则 404无法送达」(不静默新建
- [x] **会话别名**`.new` 发信时可传 `session_alias` 命名`PUT /sessions/:id/alias` 事后改名
全局唯一部分唯一索引保留字 `new` `.` `/` `@` 空白一律拒绝
- [x] **别名默认来自 Agent 平台自己的命名机制**`POST /sessions/:id/sync` 接收平台 slug + 模型生成的摘要标题
插件不传占位 title让平台正常生成撞名自动追加 -2/-3同步永不失败
- [x] 抄送 `cc_list JSONB` + GIN 索引 + 收件箱抄送可见
- [x] SSE 管理器 agent 分流推送 + 心跳保活
- [x] Agent 认证中间件 + 注册/心跳
- [x] 邮件收发 / 权限请求与决策 / 会话 / 联系人 / 归档 API
- [x] 静态资源 go:embed单二进制内含前端
- [x] 密钥认证体系agent_keys / user_keys+ 三种生命周期 + SSE 鉴权加固
- [x] SQLite 默认后端 + DATABASE_URL 切外部 PostgreSQL方言差异收在 internal/db
- [x] systemd 部署deploy/install.shopencode serve 加访问密码
**前端React**
- [x] 三栏布局60px 图标栏 / 320px 列表 / 右侧主区
- [x] 新建邮件为右侧**整页**非弹窗
- [x] 三段式地址补全 `/contacts/suggest`支持方向键
- [x] 联系人面板 + 归档二次确认
- [x] 邮件详情 / 会话线程 / 权限卡片 / 回复栏
- [x] 全站纯 SVG 图标**不使用 emoji**
- [x] SSE 自动重连指数退避+ `session_archived` 即时移除
**部署**
- [x] 公网 `mail.jianfgit.xyz` 可访问portal-nginx `.60:8180`
### 下一步
MVP 计划Phase 1-6已全部落地并在 systemd 部署态实测通过剩余工作都在 **Phase 7 进阶功能**
- [x] 对话树不建 tree_nodes `parent_mail_id` 递归 CTE + 按方向分块加载
- [x] 转发引用原文 + 附件随行抄送早已完成
- [x] 配额机制只限发信不限收信判断与自增在同一条 UPDATE
- [x] 附件内容寻址磁盘存储上传与发信两步
- [x] WebAPI WebUI 与第三方客户端同一套 API
- [x] Agent 在邮件正文里主动提议改会话别名平台命名自动同步已完成
- [x] 插件自动转发平台原生权限询问与最终总结不消耗配额
- [x] 配额下沉到会话写信时给对话页里随时改
- [ ] 工作列表卡片视图中间栏
- [ ] DeepSeek Harness 插件`dsh-mail-bridge`
- [ ] 跨主机 Agent 发现Gateway + Registry 拆分
已知取舍尚未处理
- 前端只有 Markdown XSS 一个回归测试没有组件级测试
- 深色主题未做
- SQLite 抄送查询走 `json_each` 全表展开无索引单机量级下够用
百万级邮件时需要加物化列或换回 PostgreSQL
- 登录限速是进程内内存计数多实例部署时失效MVP 单实例暂不需要
---
## 文件清单(完整)
```
agentmail/
├── docs/
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLAN.md # 本文件
├── gateway/
│ ├── cmd/server/main.go # 入口 + 路由编排
│ ├── internal/
│ │ ├── config/config.go
│ │ ├── db/
│ │ │ ├── db.go # 连接 + 方言适配SQLite / PostgreSQL
│ │ │ ├── migrate.go
│ │ │ └── migrations/
│ │ │ ├── init.sql # PostgreSQL schema
│ │ │ └── init_sqlite.sql # SQLite schema默认
│ │ ├── models/
│ │ │ ├── models.go
│ │ │ ├── address.go # name@path.session 解析
│ │ │ └── address_test.go
│ │ ├── repo/
│ │ │ ├── repo.go
│ │ │ ├── users.go
│ │ │ └── keys.go # agent_keys / user_keys
│ │ ├── handler/
│ │ │ ├── helpers.go # 错误映射 + 别名规范化
│ │ │ ├── alias_test.go
│ │ │ ├── agents.go
│ │ │ ├── mail.go
│ │ │ ├── me.go # 人类自己的邮箱(原 human.go
│ │ │ ├── contacts.go
│ │ │ ├── permission.go
│ │ │ ├── sessions.go # 含 POST /sessions/:id/sync
│ │ │ ├── events.go # SSE鉴权后分流
│ │ │ ├── keys.go # 密钥管理
│ │ │ ├── ratelimit.go # 登录失败限速
│ │ │ └── auth.go
│ │ ├── sse/manager.go
│ │ ├── static/static.go # go:embed 前端产物
│ │ └── middleware/
│ │ ├── auth.go # Agent 鉴权
│ │ └── user.go # 人类用户鉴权
│ └── go.mod / go.sum
├── plugins/
│ └── opencode-mail-bridge/ # 第一个接入平台
│ ├── package.json
│ └── index.js # 凭证 + HTTP + SSE + 四个工具 + event 钩子
├── web/
│ ├── src/
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── sse.ts
│ │ ├── stores/
│ │ │ ├── mailStore.ts
│ │ │ ├── sessionStore.ts
│ │ │ ├── contactStore.ts
│ │ │ ├── uiStore.ts
│ │ │ └── authStore.ts
│ │ ├── components/
│ │ │ ├── icons.tsx # 纯 SVG无 emoji
│ │ │ ├── Sidebar.tsx
│ │ │ ├── MailList.tsx
│ │ │ ├── ContactPanel.tsx
│ │ │ ├── MailView.tsx
│ │ │ ├── ComposePage.tsx # 右侧整页写信
│ │ │ ├── AddressInput.tsx # 三段式补全
│ │ │ ├── LoginPage.tsx
│ │ │ ├── SetupPage.tsx # 首启初始化向导
│ │ │ ├── AccountPage.tsx # 个人中心 + 连接密钥
│ │ │ ├── AdminUsersPage.tsx # 用户管理 / Agent 密钥
│ │ │ └── KeyPanel.tsx # 两处共用的密钥面板
│ │ ├── types/index.ts
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── test/markdown-xss.test.mjs # XSS 回归npm test
│ ├── index.html
│ ├── package.json
│ └── vite.config.ts
├── deploy/
│ ├── agentmail-gateway.service
│ ├── opencode-serve.service
│ └── install.sh # 构建 + 嵌入 + systemd 一键装
└── README.md
```