Files
MailUI4Agents/docs/PLAN.md

1831 lines
105 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] 创建项目目录 `server/`
- [x] `go mod init github.com/agentmail/gateway`
- [x] 目录结构规划:
```
server/
├── 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` 与重复的 `server/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] `client/electron/` 目录Vite + React + TypeScript + TailwindCSS
### 3.2 目录结构(已落地)
```
client/electron/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
新增回归测试 `client/electron/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` 连锚点自己都不返回
**工作列表卡片视图(已完成)**
中间栏原先只有一种呈现 —— 紧凑列表行三行显示 `agent / path / .alias` N · 时间」。
它答得了跟谁在聊」,答不了在聊什么进展如何」:一条线索是一件正在进行的工作
而工作的状态在列表上完全看不见必须逐条点开
- [x] `WorkCard.tsx`主题平台模型生成的摘要+ 最新一封的发件人与摘要 + 往返预算徽标
- [x] **两种视图共用同一份数据与同一套动作**打开/写信/归档只有单项渲染不同
容器滚动/空态/归档确认留在 `ContactPanel`
- [x] **归档确认框抽成 `ArchiveConfirm` 两视图共用**归档是破坏性操作
换个视图就换套确认 UI 只会让人对自己点了什么更没底
- [x] 视图偏好存 `localStorage`纯展示偏好不值得建表加 API
而每次刷新退回默认视图会让人反复点同一个按钮读写都容错隐私模式会抛异常
- [x] 卡片视图把中间栏从 320px 放宽到 400px两行摘要 + 预算条挤不下
窄屏仍是 `w-full`
- [x] 预算徽标在不限」(max=0时**不显示**一个对每张卡片都成立的「0/0」是纯噪声。
1 个来回转橙用尽转红 —— 那是需要人介入的时刻
- [x] 数据一次取回`ListContactsFor` 增补 `subject`/`max_rounds`/`used_rounds`/
`last_from`/`last_preview`不让卡片为每条会话再打一次库
摘要** rune 截断**中文一字三字节裸切会留 U+FFFD
**顺带修掉的时间戳精度问题**卡片的最新进展要取会话里最后一封邮件
SQLite `CURRENT_TIMESTAMP` 只有****精度 —— 同一秒内插入的多封邮件
`created_at` 排序结果不确定实测同秒插 5 顺序是乱的由随机 UUID 决定)。
最早那封」(决定联系人身份同样会取错
- [x] `db.NOW()` 升到**微秒**毫秒不够一次插入只要几十到几百微秒
循环里连插几封会落在同一毫秒)。实测确认驱动能原样扫回 `time.Time`
- [x] `mails` 的三条 INSERT **显式传 `NOW()`** schema 默认值只对新库生效 ——
`CREATE TABLE IF NOT EXISTS` 不改已存在的表 SQLite 没有 `ALTER COLUMN`
- [x] 所有 `ORDER BY created_at` `mail_id` 兜底老数据仍是秒精度
没有第二排序键时同秒行的顺序由存储引擎决定翻页会重复或漏行
- [x] 测试用**显式发号的时钟**而非挂钟测试在循环里连插几封很可能落在同一微秒
而生产里两封邮件之间至少隔着一次模型推理
### 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 配额机制(已完成;语义经两轮修正)
**最终形态:额度只有一层 —— 本任务(会话)的往返预算。**
第一版做的是 `agents.max_rounds` 终身额度用户两次纠正后才对齐到正确模型
1. 配额应当是在新建邮件以及邮件对话页面是可编辑的」→ 预算下沉到会话
2. 为什么会有全局配额不是每次单独配置配额然后有一个默认配额吗?」→
终身额度整个是错的工具
**为什么终身额度是错的**它跑满后要管理员手工重置才能再干活
Agent 是长期在线的 —— 那是把一次性资源的模型套在长期服务上
而且一个全局计数器让并行任务互相抢额度给紧急任务留的份被另一条线索吃掉
- [x] `sessions.max_rounds` / `used_rounds`真正的额度每条会话独立计数
- [x] `agents.default_rounds`默认 20派给该 Agent **新任务**默认几个来回
Agent 配而不是全站一个数 —— 跑测试的小工具与重构整个模块的 Agent
合理来回数差一个量级
- [x] 写信不给 `max_rounds` 时取收件 Agent 的默认值写信页把它显示为
输入框 placeholder人该看得到不填会是多少」,否则得先去管理员页查
- [x] `agents.used_rounds` **降级为纯统计**只累加不拦请求
保留是因为这个 Agent 一共发了多少信有观测价值
`BumpSentCount` error 都不返回 —— 统计写失败不该让邮件发不出去
- [x] 管理员页 tab 发信配额改名默认预算」,列出
默认来回数 + 进行中任务数 + 累计发信数不再有重置按钮
累计数是历史归零它只会销毁信息
- [x] `PUT /admin/quotas/{name}` 兼容旧字段名 `max_rounds`
已部署的前端与脚本不该因为改名就难以察觉地失效
- [x] 心跳不再回传额度额度不属于 Agent剩余往返随发信响应的
`budget_remaining` 回传在那里才有意义
**新建会话速率限制**替代终身额度的防滥用手段
预算按会话计Agent 就可以用 `.new` 开一串新会话每条都是全新预算
- [x] `repo/sessionrate.go`滑动窗口同一 Agent 1 小时最多新建 20 超出 429
- [x] **判断与记账在同一把锁里**分开的话并发请求会双双通过检查把上限刷穿 ——
与配额那条 UPDATE 同样的道理单测用 80 并发验证恰好放行 20
- [x] 建会话失败时 `ReleaseNewSession` 归还名额那次新建实际上没发生
- [x] 429 而不是 403前者表示稍后再来」,后者表示你没这个权限」,
客户端据此决定重试还是放弃
- [x] 被限速的 Agent **仍可在已有会话里回信** —— 不是全面封杀
也不禁止 Agent 主动开新会话那会堵死 Agent 之间的主动协作
- [x] 人类不受此限手工点新建邮件的频率天然受限
加限制只会在批量派活时误伤实测人类连开 25 条会话全通
- [x] 省略 session 位的默认会话不计入一个 `name@path` 只有一条
不构成暴开手段
- [x] 已知取舍进程内内存计数与登录限速同一取舍多实例部署时各自计数
等效上限变成 N
**实测 11 组**新注册默认 20 / Agent 分别设tiny=5/ 派活自动取默认值 /
显式值优先 / 22 封连发确认终身额度不再拦 21 封被会话预算拦下/
对话页调高后可继续 / 暴开 24 条会话恰好放行 20 / 人类连开 25 条全通 /
被限速仍能回信 / 默认会话不计入 / 老库补 default_rounds 且旧统计不丢
### 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] `client/electron/src/api/config.ts` 集中基地址与令牌
`VITE_API_BASE` 构建期注入`window.__AGENTMAIL_API_BASE__` 运行时覆盖`setToken()` 切凭证
业务代码不感知 Cookie 与密钥的差异`src/api/` 可整体抽成 SDK
- [x] `docs/API.md`完整接口清单三类调用者的认证边界错误码约定
### 7.7 DeepSeek Harness 插件(已完成)
`plugins/dsh-mail-bridge/`Cordis 插件框架 + TypeScript
适配方法与踩坑记录已固化为 **[`docs/PLUGIN-CONTRACT.md`](PLUGIN-CONTRACT.md)**
后续接入新平台按那份清单走
- [x] Cordis 插件骨架`export const inject` + `export const name` + `apply(ctx, config)`
- 没有 `inject` `ctx.tools` / `ctx.agents` 根本不存在
`cannot get property "tools" without inject`
- **可选服务不能写进 `inject`** —— 那是硬依赖服务没挂载时整个插件不启动
`sessionQuery` `ctx.get()` 会话上报只是补全体验
不该能把邮件投递整体拘死
- [x] 四个工具经 `defineTool` 注册`send_mail` / `read_inbox` /
`upload_attachment` / `download_attachment`
- 直接给 `ctx.tools.register` 原始对象会报
`parameters must be lossless JSON before schema projection`
- 还必须声明 `output: { schema, render }`
- [x] `ctx.agents.create()` 建会话 + `agent.followup()` 投递消息
- **`followup()` 要完整的 `UserMessage``content` + `source`**
不是 opencode 那种 parts 数组传错不当场报错而是在 agent-loop
`preStep` 里抛 `Cannot read properties of undefined (reading 'kind')` ——
错误落在框架内部不指向调用点turn start end模型请求根本不发出去
这个坑花了一下午已用 `lib/message.js` + 测试钉住
- `setup` 留空base bundle 已注册 agent-loop / llm / tools
`agentOptions: { provider, model }` 就够了 preset 反而多余
- [x] `agent/status` `idle` 时自动转发最后一条 assistant 消息
对应 opencode `session.idle`复用 `lib/relay-dedup.js` 让位于
模型的主动回信 `relay: 'summary'` 走免配额通道
- [x] `approval/request` 钩子把权限询问转成邮件问人
- opencode 的关键差异那边的 `permission.ask` **同步**钩子
卡住会挂死整个请求只能转出去 + 立即返回 ask」;
DSH 这边是**异步 waterfall**返回 `Promise<ApprovalOutcome>`可以真的等人
- 拆插件时未决询问一律 fail closed`unavailable`
否则 DSH 侧那些 `await` 永不返回
- DSH 不给询问发 id `会话:工具:callId` 作幂等键
- [x] 会话别名由**模型生成的标题**派生别名复用平台命名的既定决策一致
- `slugFromTitle` 保留中文转拼音后既不好读也不好打
而三维地址按最后一个 `.` 切分中文不影响解析
- 但必须去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开
- fallback 占位标题不派生别名DSH 在模型生成真标题前会先落一个
内容是用户第一句话截断的标题而那句话是插件自己拼的提示词
- [x] **补上心跳** —— 之前完全没有Gateway `last_seen` 判在线
一直靠注册那一次撑着
- [x] opencode 插件共用 `lib/` 下的纯函数模块逐字节相同
### 7.7.1 工作目录归属(修复)
**症状**dsh 指定工作目录完全失效所有会话落进未分组」。
**根因两层**
1. 插件建会话时的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录平台按 cwd 给会话分组于是所有邮件会话
既不属于任何项目彼此也不同组
2. Gateway 从来没把地址的 path 位发给插件`notifyRecipients` payload
只有 `mail_id`/`session_id`/`from_name`/`subject``to_workspace` 虽然入库了
却不在 SSE 事件里 —— 插件即使想用也拿不到
- [x] SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**
不是主收件人的 —— 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个工作区
- [x] 两个插件的 cwd 都改为取寻址的 path 共用 `lib/workspace.js`
- [x] 不存在的目录**不创建**而是回退到兜底目录一个笔误
`/home/porgram/x`不该在磁盘上落下真目录Agent 会在里面一无所获地干活
- [x] 拒绝相对路径cwd 的相对基准是 harness 进程的启动目录systemd 下通常是 `/`
### 7.7.2 平台会话快照上报(新增)
**症状**会话别名列不出工作区下的历史会话无法选择
人直接在平台界面上开的会话Gateway 一无所知而邮件驱动的那些也因为
`workspace` 没存在会话上只在 `mails.to_workspace` Agent 回信的
`from_workspace` 填的是 Agent 名而不是路径而匹配不上
- [x] `sessions.workspace` 新列`CreateSession` 从地址的 path 位带入
- [x] `agent_platform_sessions` 镜像表 + 心跳携带 `platform_sessions`
- [x] **插件上报而非 Gateway 反向拉取**当前架构是单向的Agent 持密钥主动连
GatewayGateway 从不外呼反向拉取需要它保存各平台的地址与凭证
那是另一套信任模型
- [x] `sessions` **分开存**镜像里是别人家的会话id 属于平台的 id 空间
没有本侧的 owner/预算/邮件混进 `sessions` 会让每一处按会话鉴权
都要先判断这条到底是不是真的本侧会话
- [x] **整表替换而非增量合并**平台侧删掉的会话必须从候选里消失 ——
session 位是三态语义指向不存在的会话直接 404
- [x] **`platform_sessions` 省略与传空数组语义不同**拉不到列表时省略该字段
保留镜像传空数组的语义是平台侧确实一条会话都没有
- [x] **subagent 子会话不上报**实测 DSH 一次列出 49 条子会话标题就是派活的
提示词前缀九条都叫 `You are auditing ONE file`slug 全撞名
它们是父 agent 内部的工作单元人往里发邮件毫无意义
- [x] **slug 撞名只留最近那条**服务端只能取其中一条上报同名项只会让补全里
出现几个点哪个都不确定的候选
- [x] `SuggestSessionCandidates` 取代 `SuggestSessionsFor`以会话自己的
`workspace` 为权威历史会话该列为空回退到 mails 反推 ——
升级后老会话不该从候选列表里消失
- [x] 补全候选带标题与来源`suggestions` 保留纯字符串数组不打破已部署的前端
与第三方客户端新增同序的 `candidates`过滤时标题也参与匹配 ——
人记得的是缓存选型而不是 `brisk-harbor` 这种随机短名
### 7.8 跨主机 Agent协议层面已支持注册中心不做
原计划的 Gateway + Registry 拆分与 etcd/Consul 注册**取消**。
它要解决Gateway 怎么找到 Agent」,而这个问题在本架构里不存在
**连接方向是单向的 —— Agent 主动连 GatewayGateway 从不外呼。**
远端 Agent 只需要一个公网 URL 加一把密钥被叫方自己会打进来
- [x] Agent 从另一台主机经公网完成注册 / 心跳 / 收件箱 / SSE 长连
2026-09-02 `deploy/remote-agent-demo.py` 验证纯标准库 60
- [x] 心跳带模型目录与平台会话快照同样跨主机可用
- [ ] 运维便利不阻塞一条命令为远端主机建密钥并打印环境变量
- [ ] 运维便利不阻塞密钥轮换
- [ ] 运维便利不阻塞`agents.host_url` 由心跳自报填充
细节见 `docs/PHASE7-REMAINING.md` 7.8 跨主机 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 终身额度
但那种额度跑满要人工重置才能再干活已降级为纯统计 7.3
- [x] 绕过手段Agent `.new` 开一串会话由新建会话速率限制堵住 7.3
- [x] 判断与自增在同一条 UPDATE`WHERE used_rounds < max_rounds`
40 并发 vs 上限 10 的单测覆盖`-race` 通过
- [x] 允许把上限调到低于已用次数那表示就到这里为止」,是人的合法意图
- [x] 前端ComposePage 往返预算输入框 + MailView 会话头部的 `BudgetEditor`
点徽标就地编辑可改上限/重置/取消预算变更广播 `session_update`
- [x] 老库补列默认 0不限引入预算不该把已在进行的会话卡死
**Agent 侧标记已读(这一轮顺带修的真实缺陷)**
原实现 Agent 只能读收件箱没有任何办法把邮件标掉 —— 生产库里 `opencode` 名下
积了 31 封未读每次 `read_inbox` 都把同一批旧邮件重新捞出来
处理过的信和新来的信混在一起模型分不清哪封该回心跳里的未读数也只增不减
- [x] `POST /mail/read`Agent 认证 `mail_ids` 标指定几封不给则全部标掉
- [x] **鉴权写进 `UPDATE` 的 `WHERE`**`to_name = $1 OR cc 含 $1`而不是先查后改
不是发给自己的邮件根本改不动既省一次查询也没有查完到改之间邮件被转走的窗口
- [x] 别人的 id 混在批次里不报错只是不被标掉 —— 报错会让整批失败
Agent 通常把上一轮列出的 id 原样传回其中可能混着已读的幂等
- [x] 全部标掉排除已归档会话那些邮件在收件箱里看不到
标了只会让标记了 N 与用户看到的对不上
- [x] 一批上限 200畸形 id 一律 400不静默跳过那会让调用方以为标成功了
- [x] 插件 `read_inbox` 读完自动标掉**本次列出的那些**不是全部未读 ——
limit 之外的还没看过一并标掉等于让它们凭空消失
标记失败不让 `read_inbox` 失败代价只是下次重复看到
- [x] `markread_test.go` 5 个用例 + 端到端 10 含抄送归档 Agent 越权
**验证**单测 `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] 配额下沉到会话写信时给对话页里随时改
- [x] 工作列表卡片视图中间栏与列表视图切换
- [x] DeepSeek Harness 插件`dsh-mail-bridge`
- [x] 平台会话快照同步工作区下的历史会话可在写信时选中
- [x] 插件适配方法固化为 `docs/PLUGIN-CONTRACT.md`能力矩阵 / 行为约定 /
降级语义 / 线协议 / 不变量 / 验收清单
- [ ] 跨主机 Agent 发现Gateway + Registry 拆分
### 7.10 窄屏适配(已完成)
原实现只有三栏并排60导航+ 320列表+ 详情 375px 屏上详情栏被挤到
不足 0 —— 用户反馈窄屏基本不可用」。
第一版我做成了窄屏一次只显示一栏」(分栏切换用户纠正应当是
**新页面覆盖老页面并带动画**于是重做为覆盖式
- [x] `useIsNarrow()``matchMedia('(max-width: 767px)')`
matchMedia 而不是监听 resize —— 后者每变化一像素都触发还得自己节流
前者只在跨过阈值时回调一次
- [x] `NarrowStack`底层列表**始终挂载**覆盖层详情绝对定位盖在上面
两个实际好处列表滚动位置与选中态天然保留退出动画有东西可播 ——
直接卸载再渲染另一个组件的话没有任何一帧能让旧页面往右滑出去
- [x] 因此必须区分逻辑上是否打开是否还在 DOM 」:
关闭时先播 200ms 滑出动画结束才卸载
- [x] **入场用双层 requestAnimationFrame**必须让浏览器至少绘制一帧
在右侧之外的状态否则挂载与 `translate-x-0` 在同一帧内完成
transition 根本不触发单层 rAF Safari 上偶尔仍被合帧
- [x] `motion-reduce:transition-none` 尊重 `prefers-reduced-motion`
- [x] 打开覆盖层时底层 `aria-hidden`否则屏幕阅读器会读到两层内容
- [x] 导航竖条在窄屏让位给底部 `NarrowNav`60px 在手机上白占一成宽度
而底部横排拇指够得到)。**曾经还有一个抽屉式侧栏后来删掉了 —— 7.10.1**
- [x] `env(safe-area-inset-bottom)`iPhone 手势条会盖住最后一排
- [x] **窄屏专属控件用条件渲染而非 `md:hidden`**后者只是视觉隐藏
元素仍在 DOM tab 序列里宽屏用户按 Tab 会聚焦到看不见的返回按钮上
为此抽了 `NarrowOnly` / `BackButton` 两个组件
原先还有 `NavToggle`随抽屉一起删除
- [x] 列表栏 `w-full md:w-[320px]`各页横向内边距 `px-4 md:px-6`
px-6 375px 屏上白吃 48px
- [x] 管理页的 3/4 grid 改响应式列表行 `flex-wrap`
宁可占两行不要把每列挤成看不清的窄条
- [x] 详情页与写信页都有返回出口 —— 否则窄屏进去就出不来
写信页用 `cancelCompose` 而不是 `showList`写信态要一起结束
只滑走覆盖层的话下次进列表又会弹回来
- [x] `narrowPane` 在宽屏下**也维护**否则从窄屏拖宽再拖回来
用户会发现自己回到了列表刚打开的邮件不见了
- [x] `client/electron/test/narrow-layout.test.mjs`结构性断言 28
钉住覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden
无裸 px-6等不变量不做像素级视觉快照 —— 字体差异下极脆
### 7.10.1 真机尺寸实测与修复2026-09-02
上面那些都是照着规则写对」,实际用 playwright 连本机共享 Chromium
390pxiPhone 14 Pro 320pxiPhone SE量了一遍**发现一个功能性 bug
加五处可用性问题**。详细记录见 `docs/PHASE7-REMAINING.md`
- [x] **删掉抽屉式侧栏**它是 `fixed ... z-50` 且铺满视口高度把底部导航
最左那一项盖住点不到`elementFromPoint` 命中抽屉里的 SVG)。
修法不是给导航加 z-index 而是删掉抽屉它装的六项与底部导航完全重复
唯一独有的是退出登录 —— 为一个按钮维护一套 fixed 层级 + 遮罩不划算
而它还附带了Esc 关不掉」「底层未锁滚两个毛病
- [x] 退出登录移到我的与密码密钥同属账号自身」,
而那页此前**根本没有退出入口**
- [x] **`.tap` 工具类**44x44 触摸命中区用居中的透明伪元素实现
视觉尺寸一像素不动实测详情页工具按钮只有 15-16px
(「抄送20x15直接加 padding 会把头部撑散320px 下换行
只在 `max-width: 767px` 生效 —— 桌面精度足够且扩大后的命中区
在密排工具栏里会互相重叠
- [x] **`.reveal` 工具类**`opacity-0 group-hover:opacity-100` 在没有 hover
的设备上永远透明**却仍然接收点击** —— 一个看不见却按得动的归档
比没有按钮更糟改为默认可见只在
`(hover: hover) and (pointer: fine)` 时隐藏单看 hover 会把带触摸板的
平板算进去
- [x] 对话树缩进随屏宽自适应固定每级 20px上限 8 320px
把卡片压到 110px 可用宽度发件人一行直接被 truncate 吃掉
窄屏改为每级 10px上限 5
- [x] 对话树补返回出口原先只有关闭」,而两者语义不同 ——
返回退出整个详情栏关闭只收起树留在这封邮件上
- [x] **`AccountPage` 根本没有滚动容器**用户反馈我的页进去后无法滑动」)。
窄屏外壳是 `h-full flex flex-col overflow-hidden`页面是
`flex-1 flex flex-col`中间没有一层 `overflow-y-auto` ——
内容超出的部分**直接被裁**滚不到也点不到
实测 390px 下内容需 860px容器 795px,「退出登录连同下面 65px 一起消失
1280x800 的桌面上同样看不到其余六个页面级组件都有这一层只有它漏了
- [x] 登录页与初始化页在**矮屏**横屏手机软键盘弹出后滚不到底
卡片高约 371px `h-full flex items-center` 在内容超高时让它上下**同时**
溢出溢出到顶部那段滚不到`scrollTop` 最小是 0)。实测 568x280
登录按钮完全在视口外改用卡片自己的 `my-auto` —— auto margin
空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局
而高屏仍然垂直居中390x844 1280x800 实测 centered=true
验证`client/electron/test/manual/``npm run test:narrow` / `test:wide`
窄屏 18 + 宽屏 5 项全通过结构性断言从 20 条扩到 37
把每一条修复都钉住
滚动那一项两边都加了检查结构断言查七个页面级组件都含 overflow-y-auto」,
实测脚本的 `scrollHealth()` 每页都有滚动容器且没有内容被 overflow-hidden
的父级裁掉」。判据刻意是「****滚动容器而不是当前正在滚动」——
内容暂时不够高时后者为假但页面是健康的
那套脚本刻意留在仓库里而不是用完就删结构性断言守不住按钮实际多大
点下去命中谁」,而这次最严重的 bug 恰好只有 `elementFromPoint` 能发现
它不进 `npm test` —— 要一个跑着的浏览器加一个活的 Gateway
### 7.11 权限档位plan / workspace / full
人在派活时声明这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的
沙箱/审批配置三档
| 档位 | 语义 | 权限询问 |
|---|---|---|
| `plan` | 只读查资料读代码出方案一个字都不许写 | **不产生** —— 直接拒绝模型该把方案写在回信里 |
| `workspace` | 本目录内可动手越界要问人**默认档** | 越界时产生 |
| `full` | 自动放行 | **不产生** —— 已声明全权再问是噪音 |
#### 为什么需要它
此前根本没有权限模型能不能跑 bash 完全由各平台自己的本地配置决定
dsh `settings.yaml`opencode `opencode.jsonc`pi 硬编码守卫三个工具名)。
发件人对此毫无控制也毫不知情 —— 派一件只是看一下的活对方可能直接改文件
#### 架构AgentMail 声明,平台执行,插件只翻译
**不让插件按工具名自己猜着拦**那会同时违反 `I-1`平台原生信号是唯一真相来源
`I-4`插件只搬运不决策而且四个插件对workspace 到底管什么必然各猜一套 ——
同一封 workspace 档的邮件在 A 平台被拦 B 平台放行
平台原生能力调研决定了整个架构
| 平台 | 原生机制 | 能否只读 | 能否管目录边界 |
|---|---|---|---|
| dsh | `setSandboxMode(session, mode)`三档写死 | 真沙箱 | 真沙箱 |
| opencode | `session.create({permission:[…]})`action allow/ask/deny | | glob |
| pi | 只有 `tool_call` 钩子 `{block:true}` | 按工具名 | write/edit 能查 `input.path`**bash 不能** |
| homeagent | **无任何拦截点** | 不能 | 不能 |
dsh 原生三档`read-only` / `workspace-write` / `danger-full-access`
plan/workspace/full **一一对应** —— 不是巧合是同一个问题的同一个答案
#### 五条设计决定
1. **档位挂 `sessions.permission_mode`,不挂每封邮件**与配额同理它是**任务**
属性续谈的信若也能带档位每封新信都会悄悄改掉对方正在遵守的规则 ——
plan 档的会话里模型已被告知只许看」,第二封信改成 full 是在一段已有
上下文里换规则新建时设续谈忽略对话页里显式编辑
2. **Agent 不能自己指定档位,新会话从父会话继承**`ModeAtMost(父档, 请求档)`)。
否则发一封 `mode=full` 的信就自我提权了继承保证 plan 档派不出 full 档子任务 ——
`hop_limit` 同形约束必须沿链条传递
3. **平台表达不出精确档位时向更严取整,并如实上报实际强制力**pi bash
workspace 档只能退回每条都问人」。不定这条规则四个插件会朝不同方向取整
而往宽松取整是静默失效人以为收紧了实际没有)。
4. **`agents.mode_enforcement`心跳自报 native/advisory**。homeagent advisory ——
发件人以为 plan 档管住了它实际管不住两个字段要求档位 / 实际强制力都要
上界面差异可见才符合 `I-5`
5. **只有 workspace 档需要人**这一条直接决定找不到人类时怎么办」:plan 档当场
拒绝full 档自动放行两者都不问人所以只有 workspace 档会走到这条链上有没有
人类」,找不到就是 409
#### 顺带修掉的两处死代码
- **`permission.go` 的管理员兜底吃掉了 409 分支**。原顺序是 `req.To 会话 owner
第一个 active admin IsHuman`,第三步让第四步永远为真,`NearestHumanInThread`
与那段 409 从未被执行。实测确认pi 给自己新开会话派活跑 bash权限邮件
`to_name=jianf`,点同意后真跑了。而那段 409 的注释本身就在论证兜底是错的
(「管理员对这条 Agent 链的上下文一无所知」)—— 两条策略互相矛盾,先执行的那条
把后写的那条变成了死代码。删掉兜底后 `repo.FirstAdminUsername` 也随之失去唯一
调用点,一并删除。
- **`repo.ListSessions` 从初始提交就是坏的**SELECT 9 列、`Scan` 11 个参数,零调用点。
与 `ListSessionsFor` 当年真出过的事故同一个坑(加了预算两列没加进 Scan
`/me/sessions` 整个 500。留着就得给它也加档位两列等于维护一个坏且没人用的
东西,删掉更诚实。
#### 实测发现opencode 的六条,不实测就会做出「看起来对但管不住」的东西)
`session.create({permission:[…]})` 确实生效,但:
1. **规则是 `findLast` 胜出** → **deny 必须放前面、allow 放后面**。反了的话连
本该允许的路径也被拒(第一版就写反了,模型自己报「按规则本该通过但实际被拒」)。
2. **pattern 匹配 worktree 相对路径**`patterns:[relative(y.worktree, file)]`)→
写 `/tmp/**` 这种绝对 pattern **永远匹配不上**。这条最隐蔽:配置看着对,全不生效。
3. **write / edit / patch 共用 `edit` 一个权限名**。
4. **全 deny 让工具从模型清单里消失**模型自述「I don't have a bash tool available
in this session」部分 deny 则工具保留、越界调用才报错。plan 档用前者更好:
模型不会浪费轮次去试。
5. **task子代理能绕过父会话权限** —— 实测中模型发现自己没 write**主动委派给
一个带 write 的子代理写成了**。plan/workspace 必须 `task deny *`。
6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 deny 的文件。
所以 workspace 档必须同时管 bash只管 edit 没用。
opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档**撞名但语义
不同**(那是它自己的计划模式开关),不碰。
附带收益dsh 本机配的是 `danger-full-access` → `approval: "never"`,而
`ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
**在 waterfall 之前短路** —— 所以整个「权限转邮件」链路在 dsh 上从未真正跑起来过。
按档位下发 `sandbox/mode` 后workspace 档的会话才会拿到 `approval: ask`。
#### 已完成
- [x] `models/permission_mode.go`:三档常量、`NormalizePermissionMode`(非法值
fail-closed 到默认档而非 full、`ModeAtMost`(继承与取整共用一个判据)、
`ModeNeedsHuman`、native/advisory 强制力。12 例测试 + 2 组负向对照
- [x] schema 三处同步:`sessions.permission_mode`(旧库默认 workspace不追授全权
`sessions.permission_enforcement`(旧库默认 advisory不替没自报的插件宣称
「档位在这里是被强制的」)、`agents.mode_enforcement`
- [x] `repo/permission_mode.go`:读写 + `InheritedMode` 继承 + `AgentModeEnforcement`
- [x] 读路径三处加列:`GetSessionByID` / `ListSessionsFor` / `ListContactsFor`
- [x] `me.go` 人发信可指定档位(人是权限的源头);非法值报 400 而不是静默用默认档
—— 他以为给了 plan 实际拿到 workspace比报错更坏
- [x] `permission.go` 按档位决定这次询问该不该存在删管理员兜底409 恢复可达
- [x] 日历会话给人类创建者设 owner否则删掉兜底后人建的提醒触发时 Agent 的
权限询问会因发件人是 `calendar` 而在线索上找不到人类 → 误伤成 409
- [x] SSE 与补拉路径下发 `permission_mode` / `permission_enforcement`
(补拉路径必须有:否则 plan 档的任务在插件重启后悄悄变成 workspace 档)
- [x] `lib/permission-mode.js` 四平台翻译表(三方逐字节相同,已纳入
`check-shared-libs.sh`。32 例测试 + 4 组负向对照,六条实测结论逐条钉死
#### P0判据错误不修则以上代码失效
- [ ] **`parentMailID == nil` 不等于「新建会话」** —— 省略 session 位复用默认会话时
它也是 nil。实测第一封 `max_rounds=7` → 第二封省略该字段 → **预算被冲成 20**。
这是**预存 bug**(配额那段注释正在论证这不该发生,守卫写错了),而我的
`permission_mode` 抄了同一个守卫 —— 第二封信会静默把 plan 档改成 workspace。
修法:`resolveTarget` 返回 `created bool`,只有真新建才设预算与档位
- [ ] **四个插件 `send_mail` 补传 `from_session_id`** —— 否则 `InheritedMode` 永远走
回落分支,继承是假的。这是唯一可靠来源:一个 Agent 可同时有多条活跃会话,
服务端猜不出它此刻属于哪条
#### P1三条建会话路径漏设档位
- [x] `forward.go` 转发 —— `LoadForwardSource` 已返回源邮件(含 `SessionID`
用 `InheritedMode(&src.SessionID, 默认档)`。不修则 plan 档转发出去就升到 workspace
- [x] `calendar_events` 加列 `permission_mode`schema 三处同步)+ 日历投递接线:
- 人建日程可指定;**Agent 建日程用它当时所处会话的档位定死,不许自选**
- 投递时新建会话 → 用事件档位;**复用会话 → `ModeAtMost(会话现档, 事件档)`**
取更严,不能因复用而提权
- 堵住提权路径plan 档的 Agent 建一个日程,触发时新会话拿默认档 workspace ——
它绕过 plan 档去写文件了,只是延迟了几分钟
- [x] adopt 接管平台会话 —— 无父会话,用默认档,在 `AdoptPlatformSession` 内显式写入
而不是靠 DB 默认值
#### P2数据出不去
- [x] `GetSessionMails`(会话视图)与 `ListSentBy`(发件箱)的 `models.Mail` 补两列 ——
只改了 `ListInbox`,前端要显示档位徽标时这两条路径拿不到值
- [x] `PUT /sessions/{id}/permission` 端点 —— 已在 `me.go` 注释里引用但未实现,
对话页要靠它改档
#### P3测试
- [x] `repo/permission_mode_test.go`:继承、取更严、脏值回落
- [x] handler 档位判定 + 409 可达性(负向对照:恢复管理员兜底 → 用例必须失败)
- [x] `notify` 两个新字段(挂真实 SSE 客户端读帧,沿用 `notify_test.go` 现有手法)
- [x] 预算不被冲的回归用例(钉住 P0 第一项)
#### P4插件接线
- [x] dsh`presets.mount` 注册工具 + `applyPermissionMode`sandbox/mode + approval/policy
+ 心跳报 `native`af61a37
- [x] opencode`session.create({permission})` 按六条实测结论下发 + 心跳报 `native`3bb419f
- [x] pitool_call hook 按档位判定plan 直接 block / workspace 问人 / full 放行)
+ 心跳报 `native`0c98fab
- [x] homeagent`permission_mode.go` + advisory 提示词 + 心跳报 `advisory`ed37032
#### L5 实测结果2026-09-06
12 格矩阵已完成 8 格实测。关键发现:
| 平台 | plan | workspace | full | enforcement |
|---|---|---|---|---|
| **pi** | ✅ bash block无权限询问 | ✅ bash 一律问人(无法静态判路径) | ✅ bash 直接执行 | native |
| **opencode** | ✅ briefing 生效,模型自愿遵守 | ✅ briefing 生效 | ✅ briefing 生效 | advisory |
| **dsh** | ✅ 写文件真被拦read-only 沙箱echo 命令仍可跑) | ✅ 目录内可写;/tmp 属允许临时目录不询问 | ✅ danger-full-access | native |
| **homeagent** | ⚠️ 只在提示词告知,模型不遵守 | ⚠️ 只在提示词告知 | ⚠️ 只在提示词告知 | advisory |
*opencode session.create 1.18.29 不支持 permission 参数,改为提示词 advisory 路径模型自愿遵守plan 档回信说明被拒。dsh Landlock ABI 版本旧导致 partial enforcement。
结论:**pi 是唯一 100% 平台级强制**。opencode advisory 路径模型自愿遵守。dsh 有真沙箱但内核 ABI 限制了完整性。
#### P5前端与文档
- [x] types / API / 卡片档位徽标 / 对话页档位选择器 / 新建邮件档位选择
(两个字段成对显示:要求档位 + 实际强制力be61724
- [x] `docs/PLUGIN-CONTRACT.md` 新章节 + 平台差异表补一行(已有)
---
已知取舍,尚未处理:
- 前端有 Markdown XSS 与窄屏结构性回归,但没有**渲染组件跑断言**的测试
- 深色主题未做
- 窄屏实测脚本已入库(`client/electron/test/manual/`),但没进 CI ——
要一个 headless 环境加一个测试用 Gateway 实例
- 登录限速与新建会话限速已改为 DB 事务rate_limits 表),多实例部署不再各自计数
- SQLite 抄送查询走 `json_each` 全表展开,无索引;单机量级下够用,
百万级邮件时需要加物化列或换回 PostgreSQL
---
### 7.12 全面修正:让平台真正可用(本轮)
本轮起因是排查附件链路,结果连带挖出四类问题。它们的共同形状是**静默成功** ——
请求返回 200、日志干净、界面看着正常而实际的事没有发生。这类 bug 能活很久,
因为没人会去核对一个成功的请求。
#### A. 附件链路
| # | 问题 | 状态 |
|---|---|---|
| A-1 | homeagent 按**顶层** `attachment_id` 解上传响应,而服务端返回 `{"attachment":{…}}` → 三个字段全零值,模型看到 `id= filename= size=0KB` | 已修 |
| A-2 | homeagent 的 `send_mail` **根本没声明** `attachment_ids` 参数,提示词还教模型传 `attachments:[{…}]` | 已修 |
| A-3 | `size/1024` 让 800 字节的附件显示成 `0KB` | 已修(`formatSize` |
| A-4 | 挂载失败403/409时**邮件已入库、已通知、预算已扣** —— 收件方收到一封没有附件的邮件,发件方收到 4xx 以为没发出去 | 本轮修 |
| A-5 | 磁盘上 7 个 blob 没有任何库记录指向GC 永远扫不到(它只按库记录走) | 本轮修 |
A-1 + A-2 叠起来意味着 **homeagent 的附件发送从来没成功过一次**。
没有任何一层报错HTTP 200、文件落盘、库里登记只是那个 id 是空串,
24 小时后 GC 把没人引用的文件清掉,现场不留痕迹。
#### B. 请求解析:未知字段必须报错
A-2 之所以能活那么久,根因在服务端:`json.Decoder` 默认**忽略未知字段**。
```
$ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
HTTP 200 {"mail_id":"2a64fdc8…", …}
$ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
0
```
这是 `I-5`(失败必须当场可见)在请求解析层的落点。改法:`Decode` 打开
`DisallowUnknownFields`,并把**本端点接受的字段一并列出来** —— 只说「不认识 x」
的话,调用方仍要去翻服务端源码才知道对的拼法,而拼错字段名恰恰是最容易犯、
最难自查的错。
心跳是唯一的例外(`DecodeLenient`):那条路径的职责是「我还活着」,插件比服务端新、
多带一个字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。但**必须在
响应里回报** `unknown_fields`,否则又变成一次静默忽略。
严格化的爆炸半径已逐个核对(前端 30 个写端点 + 四桥所有 payload + demo 脚本 +
三种嵌套结构),只有一处真的会被打破:**日历创建端点缺 `status` 字段**
而前端 `CalendarEventEditor` 无条件发它。顺带把 `status` 的取值也校验上 ——
此前 update 端点接受任意字符串,写进库就成了一个调度器不认识的状态。
#### C. 人 / Agent 的区分在读路径上缺失
`addr-verify` 报的两项失败追下去是**数据完整性问题而非显示问题**
前端用 `mail.to_workspace ? ws : ''` 当「这一方是不是 Agent」的判据。
而 `to_workspace` 为空的 Agent 收件人有 **25/118 封**(人给 homeagent 发信、
权限决策回信、Agent 间转发…都不带 path 位),于是 `pi` 被渲染成裸名字、
会话别名跟到了人身上。
判据本身选错了。服务端早就有 `IsHumanUser`,也已经在收件箱列表路径上算过
`from_human`,但另外**四个读路径**(单封读、会话读、发件箱、会话内单封读)都没带。
补 `from_human` + 新增 `to_human`,前端改用它。
> 不能用 `session.from_agent` 代替:它的语义是「谁发起了这条会话」,
> 实测有 45 封邮件的收件 Agent 不等于 `from_agent`。
#### D. 「Agent 间不自动转发」没有写进契约
代码四桥齐全(`lib/relay-policy.js` + `relay_policy.go`),但 `docs/PLUGIN-CONTRACT.md`
里**只有共用模块表的一句括注**,没有规范条款。更糟的是 `B-3.4` 仍无条件写着
「提示词里写明回信由插件自动发」—— 与规则直接矛盾。照文档实现的新插件会做错。
连带三处:
- SSE `new_mail` 的字段表缺 `from_human` / `in_reply_to` /
`permission_mode` / `permission_enforcement`(四个都已在下发,文档没跟上)
- `deploy/remote-agent-demo.py` **无条件回信**,两个这样的 demo 对上就是
ping-pong只有会话预算能刹住
- 该脚本指向的 `docs/PLUGIN-GUIDE.md` 已在 `289f37f` 删除
#### 验收
- [ ] A-4附件挂载失败时邮件**不入库**(回滚),预算不扣,`relay_key` 归还
- [ ] A-5`blob.Store` 可枚举 + GC 反向扫盘,一次跑掉 7 个孤儿
- [ ] B`attachments` 这类拼错字段名返回 400 且列出正确字段;日历创建带 `status` 仍 200
- [ ] C`GET /mail/{id}` 返回 `from_human` / `to_human``addr-verify` 两项转绿
- [ ] D契约文档有 `B-5.6` 条款demo 按 `from_human` 决定是否回信
### 7.14 DSH 主动询问邮件桥接(严重阻塞缺口)
本轮生产问题DSH 邮件会话调用 `ask_user_question` 后一直停在询问状态AgentMail
里没有任何待处理邮件。根因不是 SSE 或 Gateway 丢信,而是桥只监听了
`approval/request`(危险工具的审批 seam没有接管 `ask_user_question` 使用的
`ctx.userQuestions` seam。DSH Web UI 是后者唯一 provider邮件驱动会话没有人在 DSH
页面作答,所以工具 Promise 永远不返回。
修复边界:
1. 在 DSH 的 `tools/execute` around-dispatch 中只拦截**邮件驱动会话**的
`ask_user_question`,不替换全局 `userQuestions` provider该服务只允许一个 provider
强行注册会与 Web UI 冲突)。
2. 每个问题按原始 `id/question/options/multi_select` 生成一封 Gateway 待处理邮件;
单选保留选项原文,自由文本与多选用备注输入承载。多问题按顺序询问,全部回答后
还原 DSH 要求的 `{answers:[{id,selected,custom?}]}` 工具结果。
3. Gateway 的权限请求端点增加 `kind=question`:复用现有决策人追溯、幂等 relay_key、
待办列表与 SSE 回传,但**不套权限档位判定**plan/full 也可能需要补充信息)。
`kind=permission` 保持原行为。
4. 待决映射必须在发 HTTP 请求前登记堵住“人快速作答、SSE 先于 map 写入”的竞态;
abort、插件卸载、永久 HTTP 失败均 fail closed不留下悬挂 Promise。
5. 前端把询问类待办显示为“等待回答”,自由文本/多选回答未填写时禁止提交;仍复用
现有待处理入口,避免再造第二套不可见队列。
验收:
- [ ] 单选询问经邮件选择后DSH 工具拿到原始选项标签并继续运行
- [ ] 无选项询问要求填写文本,空回答不能提交
- [ ] 多选询问可提交多个原始标签,未知标签不被伪造为有效选择
- [ ] 两个问题按顺序往返,最终答案数组保持原始 question id 与顺序
- [ ] plan/workspace/full 三档中的主动询问均可送达;危险工具审批仍只在 workspace 产生
- [ ] 非邮件驱动 DSH 会话继续使用 Web UI provider不受桥影响
### 7.13 桥进程异常上报与可靠重试
本轮起因:桥进程意外终止只留在 journal用户邮箱没有任何提示同时“自动重连”
与“处理中任务重试”被混为一谈。现状其实已有两层systemd 对进程做指数退避重启,
四桥 SSE 断线后自动重连;但它们不能回答“刚才正在处理的那封邮件怎么办”。
按三层修复,职责不能混:
1. **进程层由 systemd 自愈**:保留 `Restart=` + `RestartSteps=6` +
`RestartMaxDelaySec=5min`。插件不在进程内部造第二个 supervisor。
2. **异常退出必须发邮件**:共用 `deploy/service-failure-notify.mjs` 挂到
`ExecStopPost`,读取 systemd 的 `SERVICE_RESULT/EXIT_CODE/EXIT_STATUS`;正常停止不报,
exit-code/signal/oom-kill/timeout/watchdog 才报。Gateway 暂不可达时落本地 spool
下次 `ExecStartPost --flush` 补发。通知用稳定 `relay_key` 幂等,避免即时发送与补发重复。
3. **处理中任务必须重试**pi worker 无 `done` 就按 1s/2s 有界重投,最多 3 次;
`done(ok=false)` 属于已经给出明确失败结论,不盲目重跑。重试耗尽后给原发件人一封
故障邮件。整进程重启后仍由未读补拉兜底homeagent 已有落盘 ledger保留现状。
4. **网络层继续重连**SSE 35 秒重连、心跳下一周期再试,不把短暂网络抖动升级成
进程重启。
验收:
- [ ] notifier dry-run 能区分正常停止与异常退出,敏感密钥不进入正文/日志
- [ ] Gateway 暂不可达时报告落盘,`--flush` 后只发一次
- [ ] pi worker 首次异常退出后自动重投,同一会话仍串行
- [ ] 连续三次异常后停止重试并调用失败回报,不形成无限崩溃循环
- [ ] 四个桥宿主服务保留指数退避重启;正常 `systemctl restart` 不产生误报警邮件
---
## 文件清单(完整)
```
agentmail/
├── docs/
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLAN.md # 本文件
├── server/
│ ├── 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 钩子
├── client/electron/
│ ├── 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
```