# 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 headless(mail-bridge 宿主) - [x] `deploy/install.sh` — 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secret - [x] `OPENCODE_SERVER_PASSWORD` 由安装脚本随机生成(此前裸奔,同机任何进程都能开会话) --- ## Phase 2:Agent 邮件桥接插件 > 计划书原本按 Pi Agent 设计,**实际第一个接入的平台是 opencode**(`plugins/opencode-mail-bridge/`)。 > 结构也从「按文件拆 tools/transport/hooks」收敛为单文件 `index.js` —— > opencode 的插件契约是一个默认导出函数返回 hooks 对象,拆成多文件只会增加跳转成本而无收益。 ### 2.1 插件结构(已落地) ``` plugins/opencode-mail-bridge/ ├── package.json └── index.js # 凭证层 + HTTP + SSE + 四个工具 + event 钩子 ``` - [x] 单文件实现,无需 transport/tools 分层 - [x] 凭证层:密钥优先级 `AGENTMAIL_AGENT_KEY` > `~/.agentmail/agent.key` > 旧 secret > 本地生成 ### 2.2 HTTP 传输层(已落地) - [x] `apiGet` / `apiPost` 封装 `fetch`,认证头由 `authHeaders()` 统一给出 (有密钥走 `Authorization: Bearer`,否则退回 `X-Agent-Name` + `X-Agent-Secret`) - [x] 错误处理:非 2xx 抛出服务端 `error` 文案,调用方能看到「密钥无效」这类可操作信息 ### 2.3 SSE 客户端(已落地) - [x] 用 `fetch` + `ReadableStream` 手工解析 SSE 帧(Node 原生 EventSource 不支持自定义请求头, 而认证头是必需的) - [x] 连接 `/api/v1/events/stream`;断流 3s、出错 5s 后重连;`AbortController` 支持优雅停止 - [x] 事件分发:`new_mail` / `permission_decision` → `deliverMail()` ### 2.4 工具实现(已落地,四个) - [x] `send_mail` — 参数 `to` / `subject` / `body` / `cc?` / `reply_to?` / `session_alias?`; 地址解析在网关侧完成(三维寻址是网关的职责,插件不该各自实现一份解析) - [x] `read_inbox` — 参数 `filter?`(unread/all)/ `limit?`;返回带预览的列表 - [x] `request_permission` — 参数 `question` / `options?` / `context?` - [x] `connect_to_server` — 参数 `gateway_url?` / `key_token?`;登记密钥并完成注册, 失败时直接把待登记的密钥全文打出来,省一轮来回 **与原计划的偏差**:原设想「权限请求 / 提问 / 最终总结」三种行为由钩子自动拦截, Agent 不需要手动调工具。opencode 的插件契约没有提供拦截这三类行为的钩子 (`chat.message` 只能观察不能冻结会话),因此改为: `request_permission` 作为显式工具暴露给 Agent;「提问」与「最终总结」由 Agent 自行用 `send_mail` 表达。这不影响协作语义 —— 邮件本身就是提问与总结的载体。 ### 2.5 插件入口(已落地) - [x] 启动时读凭证 → `POST /agent/register` → 启动 SSE → 30s 心跳定时器 - [x] `new_mail` 事件:**不注入正文**,只给发件人/主题/mail_id 与「先调 read_inbox」的指示; 按 `session_id` 查 `sessionMap` 决定续谈已有 opencode 会话还是新开一个 (网关已按 session 位判好复用/新建,插件只忠实映射) - [x] `permission_decision` 事件:把决策结论作为新一轮 prompt 投给对应会话 - [x] `session.updated` 钩子:把 opencode 侧模型生成的会话标题与 slug 回写为 AgentMail 的会话命名 ### 2.6 测试(已实测) - [x] 工具注册:四个工具出现在模型的工具列表中(用 `llmsproxy/AUTO` 实测) - [x] 端到端收发:人 → Agent → 人 回信闭环,`reply_to` 使回信落回同一会话 - [x] 会话记忆:第一封「记住 42」→ 第二封(省略 session 位)问「刚才的数字」→ 回「42」, 证明续谈映射正确 - [x] 密钥流程:首装本地生成(0600)→ 管理员登记 → 重启注册成功 → 收发邮件与 SSE 全通 - [x] 权限闭环:`request_permission` → `/permission/pending` 可查 → 人类决策 → 插件收到并开会话 **排查记录(两个真实缺陷)**: 1. 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换 `llmsproxy/AUTO` 后正常。 顺带修了 provider 配置里 `baseURL` 缺 `//` 的拼写错误。 2. 「发邮件没回复」是真 bug —— SSE 回调调的 `client.session.message.send(...)` 在 opencode SDK 里 **不存在**(`session.message` 是读消息的 getter),Promise reject 又被空 `.catch(() => {})` 吞掉, 于是邮件到了、SSE 收到了、却没建任何会话也没报错。 正确 API 是 `session.create` + `session.promptAsync`。**教训:跨 SDK 调用不要写空 catch。** --- ## Phase 3:前端(React) > 实际实现比原计划**收敛**:组件拆分粒度更粗(MailItem/MailBody/ReplyEditor/PermissionCard > 都并入了 MailView,因为它们只在这一处使用,独立文件只增加跳转成本), > 且「新建邮件」按用户要求做成右侧整页而非弹窗。 ### 3.1 项目初始化 - [x] `client/electron/` 目录,Vite + React + TypeScript + TailwindCSS ### 3.2 目录结构(已落地) ``` client/electron/src/ ├── api/ client.ts(HTTP)/ sse.ts(EventSource + 指数退避重连) ├── stores/ mail / session / contact / ui / auth(Zustand) ├── components/ Sidebar / MailList / MailView / ComposePage / ContactPanel │ AddressInput / LoginPage / SetupPage / AccountPage │ AdminUsersPage / KeyPanel / icons ├── types/index.ts └── App.tsx ``` ### 3.3 类型定义 - [x] `types/index.ts` — Mail / Session / Contact / User / Agent / AdminScopes 等 ### 3.4 API 客户端 - [x] `api/client.ts` — 统一 `request()`,`credentials: 'include'`,401 统一跳登录; 覆盖邮件/会话/联系人/权限/用户管理/密钥全部端点 ### 3.5 SSE - [x] `api/sse.ts` — 单例 EventSource + 多订阅者;断线指数退避 1s→15s 上限 (没有单独做 `useSSE` hook:订阅者是 store 而非组件,hook 反而多一层) ### 3.6 状态管理 - [x] `stores/mailStore.ts` / `sessionStore.ts` / `contactStore.ts` / `uiStore.ts` / `authStore.ts` ### 3.7 核心组件(已落地) - [x] **App.tsx** — 三栏容器:60px 图标栏 / 320px 列表 / 右侧自适应(替代原计划的 Layout.tsx) - [x] **Sidebar.tsx** — 图标栏(收件/发件/联系人 + 新建 + 账号/管理员入口 + 退出),**纯 SVG 无 emoji** - [x] **MailList.tsx** — 邮件列表,未读蓝色粗体、权限橙色标签、CC 标记 - [x] **MailView.tsx** — 邮件正文(react-markdown)+ 权限卡片 + 回复区,三者合一 - [x] **ComposePage.tsx** — 右侧**整页**写信(非弹窗,用户明确要求),含编辑/预览切换、 会话别名输入(仅地址以 `.new` 结尾时出现) - [x] **AddressInput.tsx** — 三段式补全,接 `/contacts/suggest`,支持方向键 - [x] **ContactPanel.tsx** — 联系人(= 一条 `name@path.session` 三维地址)+ 归档 - [x] **LoginPage / SetupPage / AccountPage / AdminUsersPage / KeyPanel** - [x] **icons.tsx** — 20+ 纯 SVG 图标,全站零 emoji ### 3.8 样式 - [x] TailwindCSS 配置 - [x] 浅色主题(深色留待后续) - [x] 列表 hover/selected 状态 - [x] 权限请求卡片橙色高亮 - [x] 未读蓝色粗体 - [x] 加载/空状态 ### 3.9 开发环境 - [x] Vite proxy:`/api` → `http://localhost:8180`(与 Gateway 默认 PORT 一致) - [x] `npm run typecheck`(tsc --noEmit)与 `npm test`(Markdown XSS 回归) --- ## Phase 4:多用户与鉴权(人类账号体系) > 背景:人类不是单一的 `human`,而是**多用户**,各自账号密码登录,拥有独立收件箱与会话。 > 寻址上人类用户同样是三维地址的 name 位:`jianf@.new`、`alice@.deploy-review`。 ### 4.1 数据模型 - [x] `users` 表 ```sql CREATE TABLE IF NOT EXISTS users ( user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(64) NOT NULL UNIQUE, -- 即寻址的 name 位 display_name VARCHAR(128) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, -- bcrypt role VARCHAR(16) NOT NULL DEFAULT 'user', -- admin / user status VARCHAR(16) NOT NULL DEFAULT 'active', -- active / disabled created_at TIMESTAMPTZ DEFAULT NOW(), last_login TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS user_sessions ( token VARCHAR(64) PRIMARY KEY, -- 随机 token,存 Cookie user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, user_agent VARCHAR(256) DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at); ``` **关键决策**:`username` 与 `agents.agent_name` **共一个命名空间**(注册时互斥校验), 因为二者都出现在三维地址的 name 位,同名会导致路由歧义。 ### 4.2 历史数据迁移 - [x] 现有邮件里的字面量 `'human'` 迁移到默认管理员账号 - 创建 `admin` 用户(首次启动时从环境变量 `ADMIN_USER` / `ADMIN_PASSWORD` 读取) - `UPDATE mails SET from_name = 'admin' WHERE from_name = 'human'`(to_name 同理) - 保留 `human` 作为**兼容别名**:Agent 仍可 `send_mail to="human@"`,网关解析为“当前会话的发起人类用户” ### 4.3 认证 API ``` POST /api/v1/auth/login { username, password } → Set-Cookie: am_session POST /api/v1/auth/logout → 销毁 token GET /api/v1/auth/me → { user_id, username, display_name, role } POST /api/v1/auth/password { old_password, new_password } ``` 管理员专用: ``` GET /api/v1/admin/users → 用户列表 POST /api/v1/admin/users { username, password, display_name, role } PUT /api/v1/admin/users/{id} { display_name, role, status } DELETE /api/v1/admin/users/{id} → 禁用(不物理删除,保留邮件历史) POST /api/v1/admin/users/{id}/reset { new_password } ``` - [x] 密码用 `golang.org/x/crypto/bcrypt`,cost 12 - [x] token 用 `crypto/rand` 32 字节 hex,有效期 7 天,每次请求滞后续期 - [x] Cookie:`HttpOnly` + `SameSite=Lax` + 生产环境 `Secure` - [x] 登录失败限速:同一 username 连续 5 次失败锁 5 分钟(内存计数,MVP 阶段够用) ### 4.4 中间件与会话隔离 - [x] `middleware.UserAuth` —— 从 Cookie 取 token → 查 `user_sessions` → 注入 `username` 到 context - [x] `middleware.AdminOnly` —— 叠在 UserAuth 之后,校验 `role = 'admin'` - [x] **所有 `/human/*` 路由改为 `/me/*` 并强制鉴权**,当前写死的 `"human"` 全部换成登录用户名: - `HumanGetInbox` → `MeGetInbox`:`ListInbox(ctx, username, ...)` - `HumanGetSent` → `MeGetSent`:`ListSentBy(ctx, username, ...)` - `HumanSendMail` → `MeSendMail`:from_name = username - `ListContacts`、`SuggestAddress`、`ArchiveContact` 同样按当前用户过滤 - [x] 权限决策 `POST /permission/decide` 鉴权:只有**该会话的参与人类**或 admin 可决策 - [x] SSE `/events/stream` 鉴权:前端连接按登录用户分流,不再广播给全部前端 - `sse.Manager` 新增 `UserName` 字段,`SendToUser(username, ...)` 取代 `SendToFrontend` ### 4.5 会话归属 - [x] `sessions` 表新增 `owner_user_id UUID REFERENCES users(user_id)` - [x] 人类发起的会话归属于该用户;Agent 发起的会话归属于它寄信的人类 - [x] `ListContacts` 只列当前用户参与的会话(owner 或 在 to/cc/from 中出现) - [x] 归档鉴权:非 owner 且非 admin 不得归档别人的会话 ### 4.6 Agent 侧寻址适配 - [x] Agent 给人类发信时可写具体用户名:`send_mail to="jianf@.new"` - [x] 保留 `human@` 兼容写法 → 解析为当前会话的 owner 用户 - [x] `SuggestAddress` 的 name 层候选同时包含 **在线 Agent + 已启用人类用户** ### 4.7 前端 - [x] `LoginPage.tsx` —— 账号密码表单,失败提示,回车提交 - [x] `authStore.ts` —— `me` / `login` / `logout`;应用启动先拉 `/auth/me` 判断登录态 - [x] `App.tsx` —— 未登录渲染 `LoginPage`,已登录渲染主界面 - [x] `api/client.ts` —— 所有请求带 `credentials: 'include'`;401 统一跳登录 - [x] 侧边栏底部显示当前用户 + 退出按钮(纯 SVG 图标,不用 emoji) - [x] 管理员页:用户列表 / 新建 / 禁用 / 重置密码 ### 4.8 验证 - [x] 单测:bcrypt 校验、token 过期、跨用户访问被拒(403) - [x] 两个人类账号互不可见对方收件箱与联系人 - [x] Agent 寄信给 `jianf@.new` 仅 jianf 收到,SSE 不泄露给其他登录会话 --- ## Phase 5:密钥认证体系(Agent-插件-Gateway 鉴权) > 背景:当前 Agent 注册只需 name+secret 明文,无密钥机制,无法安全接入公网。 > 本 Phase 引入密钥(token)体系,分为两类:**Agent 密钥**(管理员为 Agent 生成,用于注册+通信) > 和 **用户密钥**(用户自己生成,仅用于客户端连接 Gateway,不可注册 Agent)。 > 插件侧首次安装时本地生成密钥对,通过 `connect_to_server` 工具完成握手。 ### 5.1 数据模型(已落地) > 两方言各一份:`internal/db/migrations/init.sql` 与 `init_sqlite.sql`。 > 下方 DDL 为 PostgreSQL 方言;SQLite 侧 `UUID→TEXT`、`TIMESTAMPTZ→DATETIME`。 #### agent_keys 表(Agent 注册密钥) ```sql CREATE TABLE IF NOT EXISTS agent_keys ( key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key_token VARCHAR(128) NOT NULL UNIQUE, -- 密钥令牌(随机 hex) agent_name VARCHAR(64), -- 绑定到的 Agent 名(可为空=未绑定) key_type VARCHAR(16) NOT NULL, -- permanent / one_time / timed expires_at TIMESTAMPTZ, -- timed 类型的过期时间;permanent/one_time 为 NULL used_at TIMESTAMPTZ, -- one_time 类型:首次使用时间(已用=失效) created_by UUID REFERENCES users(user_id), -- 创建者(管理员) created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token); CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name); ``` #### user_keys 表(用户连接密钥) ```sql CREATE TABLE IF NOT EXISTS user_keys ( key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key_token VARCHAR(128) NOT NULL UNIQUE, user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, label VARCHAR(128) NOT NULL DEFAULT '', -- 用户自定义标签(如 "我的笔记本" key_type VARCHAR(16) NOT NULL DEFAULT 'permanent', -- permanent / one_time / timed expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id); CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token); ``` #### 关键约束 - `agent_keys.key_token` 与 `user_keys.key_token` 共享一个全局唯一命名空间(验证时先查 agent_keys,再查 user_keys) - `user_keys` 的密钥 **不能** 用于 `POST /agent/register`(注册时校验来源表) - `agent_keys` 的密钥 **不能** 用于 `/me/*` 人类邮箱接口(agent_auth 中间件与 user_auth 中间件分离) ### 5.2 Gateway API #### 管理员:Agent 密钥管理 ``` POST /api/v1/admin/agent-keys { agent_name?, key_type, label?, expires_hours?, key_token? } GET /api/v1/admin/agent-keys ?agent_name=xxx DELETE /api/v1/admin/agent-keys/{id} POST /api/v1/admin/agent-keys/{id}/bind { agent_name } -- 绑定到 Agent ``` `key_token` 用于**登记客户端已在本地生成的密钥**(插件首装场景):这样密钥全文只从客户端 流向服务器一次,不必反方向传递。留空则由服务器生成(32 字节随机 hex)。 密钥类型: - `permanent`:永不过期,可重复使用,适合正式部署的 Agent - `one_time`:一次性使用,首次验证后标记 `used_at`,再用即 403 - `timed`:创建时指定 `expires_hours`(如 24h),过期后失效 #### 用户:连接密钥管理 ``` POST /api/v1/me/keys { label, key_type, expires_hours? } GET /api/v1/me/keys → 我的密钥列表(只给 token_hint) DELETE /api/v1/me/keys/{id} → 删除密钥(带 user_id 条件,删不到即 404) ``` 用户密钥的 `key_type` 同样支持 permanent / one_time / timed,但只能用于 `/me/*` 接口。 #### Agent 注册(改用密钥认证) ``` POST /api/v1/agent/register Header: Authorization: Bearer Body: { name, workspaces, platform } → Gateway 验证密钥: 1. 查 agent_keys WHERE key_token = token 2. 不存在 → 401 3. one_time 类型且 used_at 非空 → 401(已用过) 4. timed 类型且 expires_at < now() → 401(已过期) 5. 验证通过 → 更新 used_at(one_time)→ 继续注册逻辑 ``` #### Agent 心跳(改用密钥认证) ``` POST /api/v1/agent/heartbeat Header: Authorization: Bearer ``` #### SSE 连接(改用密钥认证) ``` GET /api/v1/events/stream Header: Authorization: Bearer → Agent 侧 SSE:密钥验证后绑定到对应 Agent → 前端 SSE:仍用 Cookie 认证(不变) ``` ### 5.3 插件侧改动 #### 首次安装:本地生成密钥 ``` ~/.agentmail/ ├── config.json # { "gateway_url": "...", "key_token": "...", "agent_name": "..." } └── agent.key # 本地密钥文件(JSON,含 key_token + created_at) ``` 插件启动时: 1. 检查 `~/.agentmail/agent.key` 是否存在 2. 不存在 → 生成随机 32 字节 hex 作为 `key_token`,写入文件(0600)并打印到 stderr 3. 存在 → 读取 key_token 4. 自动调 `/agent/register`;密钥未登记时提示管理员把打印出的密钥填进后台 #### connect_to_server 工具 ```javascript args: { gateway_url: z.string().describe("Gateway 地址,如 http://mail.example.com:8080"), key_token: z.string().optional().describe("密钥令牌(可选,也可从本地配置读取)") } execute: { 1. 读取本地 key_token(参数优先,否则从 ~/.agentmail/agent.key 读取) 2. 调 POST /api/v1/agent/register(带 Authorization: Bearer key_token) 3. 将 gateway_url 和 key_token 保存到 ~/.agentmail/config.json 4. 启动 SSE 监听 5. 返回连接结果 } ``` #### 心跳认证改动 心跳与所有 Agent 接口统一携带 `Authorization: Bearer `。 旧的 `X-Agent-Name / X-Agent-Secret` **保留兼容**(见 5.7 最后一条)。 #### 密钥来源优先级(已落地) 1. `AGENTMAIL_AGENT_KEY` 环境变量 —— systemd 部署走这条 2. `~/.agentmail/agent.key`(`AGENTMAIL_CONFIG_DIR` 可改目录) 3. 都没有时退回 `AGENTMAIL_AGENT_SECRET` 的旧路径;连 secret 也没有才本地生成新密钥 文件权限:`agent.key` 与 `config.json` 均 0600,目录 0700。 ### 5.4 密钥验证中间件(Gateway,已落地) `middleware.AgentAuth` 同时承担密钥与旧凭证两条路径: ``` 有 Authorization: Bearer : → repo.VerifyAgentKey:查 agent_keys → 不存在 → 401 "密钥无效" → one_time 已用 → 401 "密钥已使用(一次性密钥只能用一次)" → timed 已过期 → 401 "密钥已过期" → 通过:one_time 写 used_at(WHERE used_at IS NULL,并发下只有一个请求能标记成功) → agent_name 为空(待绑定)→ 403,提示先调 /agent/register → 注入 agent_name 到 context,刷新 last_seen 无 Bearer:走 X-Agent-Name + X-Agent-Secret 旧路径 ``` `middleware.UserAuth` 同理支持 Cookie 与 `Bearer `。两类密钥各查自己的表, 因此 Agent 密钥读不了人类邮箱,用户密钥也注册不了 Agent。 `/agent/register` 不经中间件(注册时 Agent 还没身份),自行取 Bearer 校验: 密钥已绑定且与请求 name 不符 → 403,防止拿别人的密钥冒充新身份;未绑定则注册成功后落定。 ### 5.5 管理员前端 - [x] 管理员页面拆成「用户管理 / Agent 密钥」两个 tab(`AdminUsersPage.tsx`) - [x] 创建密钥:选类型(长期/一次性/限时)+ 可选绑定 Agent + 限时可填小时数 - [x] **登记客户端已生成的密钥**:插件首装在本地生成密钥并打印,管理员把它填进「登记」框即可, 密钥全文只从客户端流向服务器一次,不需要反方向传递 - [x] 密钥列表:只显示 `token_hint`(前 8 位),全文仅在创建响应里出现一次, 前端用一次性横幅提示「关闭后无法再次查看」 - [x] 绑定 Agent / 吊销密钥 ### 5.6 用户前端 - [x] 个人中心新增「客户端连接密钥」区(`AccountPage.tsx`,与管理员面板共用 `KeyPanel.tsx`) - [x] 创建密钥:填备注 + 选类型;限时可填小时数 - [x] 密钥列表 + 吊销;状态标注「可用 / 已使用 / 已过期」 ### 5.7 验证(已实测) - [x] permanent 密钥 → 注册成功,重复使用仍成功 - [x] one_time 密钥 → 首次成功;再用 401「密钥已使用(一次性密钥只能用一次)」 - [x] timed 密钥(24h)→ 注册成功;手动把 `expires_at` 改到过去 → 401「密钥已过期」 - [x] timed 缺 `expires_hours` → 400;非法 key_type → 400 - [x] 用户密钥 → `/me/mail/send` 正常;→ `/agent/register` 401「密钥无效」 - [x] Agent 密钥 → `/me/mail/inbox` 401(两类密钥各查自己的表,不会串门) - [x] 待绑定密钥首次注册落定为该 Agent;同一密钥改注册别的 name → 403 - [x] 登记重复密钥 → 409;登记过短密钥(<32 位)→ 400 - [x] 密钥列表接口不回传 `key_token` 全文(实测 `回传全文: False`) - [x] **SSE 鉴权加固**:原先只凭 `X-Agent-Name` 就分流,等于任何人报个名字就能读走别人的 新邮件通知。现在必须通过 Bearer 密钥或 name+secret 验证;仅报名字 / 错 secret / 匿名一律 401 - [x] 插件密钥流程端到端:首装本地生成密钥(0600,目录 0700)并打印 → 管理员登记 → 插件重启注册成功 → 用该密钥收发邮件与订阅 SSE 全通 - [x] 旧的 X-Agent-Name/Secret 方式**保留兼容**(与原计划「向后不兼容」不同: 已部署的 Agent 不该因为引入密钥就集体失联,两条路径并存成本很低) --- ## Phase 6:联调 + 部署 ### 6.1 端到端闭环测试 - [x] 启动 Gateway(`go run ./cmd/server`,SQLite 自动建库,无需外部数据库) - [x] 启动前端(`npm run dev`) - [x] 启动 opencode serve + mail-bridge 插件 **测试场景(均已在 systemd 部署态实测):** | # | 步骤 | 结果 | |---|------|------| | 1 | 人类登录后新建邮件,抄送第二个收件人 | session + mail 落库,cc_list 入库 ✅ | | 2 | Agent 收到 SSE 通知 | 插件只注入通知(发件人/主题/mail_id),不注入正文 ✅ | | 3 | Agent 调 read_inbox | 返回邮件详情,含被抄送的那封 ✅ | | 4 | Agent 调 send_mail 回复人类 | 发起任务的用户收到,`reply_to` 使回信落回同一会话 ✅ | | 5 | request_permission | 权限请求邮件入库,`/permission/pending` 可查 ✅ | | 6 | 人类决策 | `/permission/decide` 生成决策邮件,Agent 侧收到 ✅ | | 7 | 归档 name@path.session | 会话与邮件均标记 archived,归档后该别名不可寻址(404)✅ | **session 位三态实测:** 省略 → 复用默认会话;`.new` → 每次新建;具名别名 → 命中已有, 不存在则 404「无法送达」;跨收件人借用别名同样 404。 **平台命名同步实测:** 发信不指定别名 → opencode 侧 slug 回写为 `calm-meadow`, 模型生成的摘要标题「生产环境联调邮件回复」回写为 subject; 再用 `opencode@/root.calm-meadow` 续谈,Agent 正确引用上一封内容。 ### 6.2 部署编排(改为 systemd,不用容器) 原计划的 docker-compose 已作废:数据库换成内置 SQLite 后,部署产物就是「一个二进制 + 一个 .db」, 再套一层容器编排只是徒增运维层级。见 1.9。 - [x] `deploy/install.sh` 一键安装(构建 → 嵌入 → systemd) - [x] `deploy/*.service` 两个单元:gateway 与 opencode-serve - [x] 删除 `docker-compose.yml` ### 6.3 一键启动脚本 - [x] `deploy/install.sh` 覆盖构建 + 安装 + 启动 - [x] 开发态无需脚本:`go run ./cmd/server` 即起(SQLite 自动建库),前端 `npm run dev` 代理到 8180 ### 6.4 错误处理与边界 - [x] Agent 离线时邮件投递:邮件先入库,Agent 心跳响应回传未读数,上线后 `read_inbox` 补齐 - [x] Gateway 重启后 SSE 客户端重连:前端 `api/sse.ts` 指数退避(1s→15s 上限), 插件侧 `startSSE` 断流 3s / 出错 5s 后重连 - [x] 无效 Agent 名/密钥处理:错密钥与不存在的 Agent 统一回 `Invalid credentials`(不区分,避免探测账号存在性); 缺头回 `Missing X-Agent-Name or X-Agent-Secret header` - [x] 畸形三维地址:缺 name 位、空 to、别名不存在均有明确错误文案 - [x] 邮件内容 XSS 防护:react-markdown 默认不解析 raw HTML 且清空非 http(s)/mailto 协议 URL, 新增回归测试 `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 注释 `` - react-markdown 默认不解析 raw HTML,注释在页面上不可见 - 纯文本客户端里是一行不碍事的注释,不像自造标记那样显眼 - 不与 Markdown 语法冲突,不会被格式化工具改写 - **实测它会被转义成可见文本节点而不是被丢弃**,所以必须从入库正文里主动剥掉 - [x] `handler/rename_proposal.go`:`extractRenameProposal` 解析 + 剥标记 - 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是结论 - 别名过 `normalizeAlias` + `validateSessionAlias`,非法的视为无提议但标记仍剥掉 (与其在正文里留一行乱码,不如当它没提) - 理由截断到 200 字节(按 UTF-8 边界),否则提示条被撑破 - 人类发信同样剥标记但不产生提议 —— 人有改名按钮,用不着向自己提议 - [x] 存在**邮件**上(`mails.rename_alias/rename_reason`)而非会话上: 邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕 - [x] `sessions.rename_dismissed` 记下被驳回的建议,提示条不再反复弹同一个 - [x] `GET /sessions/:id/rename-proposal` 取最新**未处理**提议 (既不是当前别名 = 未接受,也不在驳回记录里) - [x] `POST /sessions/:id/rename-proposal/dismiss` 驳回;无待处理时返回 `no_pending`(幂等) - [x] **接受走已有的 `PUT /sessions/:id/alias`**,不另开端点: 那条路径已有唯一性校验与 409,复制一遍只会多一个出错的地方。 实测提议的名字被别的会话占用时接受返回 409,而不是默默造出重名 - [x] 插件 `send_mail` 加 `propose_alias` / `propose_reason` 参数,自动拼注释标记; 发信响应回传**规范化后**的别名(Agent 提的名字可能被改写过) - [x] 前端 `RenameProposalBar`(会话顶部):显示 旧别名 → 新别名 + 理由 + 「改名后旧别名立即失效」的后果提示;接受/忽略两个按钮。 `new_mail` 事件触发重新拉取(新来信可能带新建议) - [x] **SQLite 老库补列**:`CREATE TABLE IF NOT EXISTS` 不会给已存在的表加列, 而 SQLite 没有 `ADD COLUMN IF NOT EXISTS`。`db.addMissingColumns` 查 `pragma_table_info` 后按需 ALTER,实测老库补列成功、旧数据不丢、二次启动不重复补 **`sessions.alias_source` —— 生产实测发现的隐患**: 用户接受改名后,opencode 下一次 `session.updated` 事件会带着平台 slug 再同步一次, 把人刚定的名字冲掉 —— 人上一秒记住的寻址地址下一秒失效。实测确认了这个行为 (`fix-cache-penetration` 被 `stellar-engine` 覆盖)。 - [x] `sessions.alias_source`:`platform`(平台自动同步,可被后续同步覆盖)/ `manual`(人显式指定,平台同步不得覆盖) - [x] `UpdateSessionAlias` 与「发信时显式给 `session_alias`」都标 `manual` - [x] `SyncSessionAlias` 遇到 `manual` 直接返回当前别名,不写入; **条件放进 `WHERE alias_source <> 'manual'` 并检查 RowsAffected** —— 并发下用户可能刚好在检查与写入之间接受了提议,分两步会把它冲掉 - [x] **标题不受此保护**:`subject` 只用于展示,被平台的摘要标题刷新是好事; 受保护的只有承担寻址职责的别名 - [x] 老库补列默认 `platform`:无从得知历史别名是人定的还是平台定的, 而 `platform` 只影响「平台同步能否覆盖」,人随时可手工改名转成 `manual` - [x] 四个场景实测:platform 可反复覆盖 / 用户改名后平台同步无效 / 发信显式命名即受保护 / 全流程(平台命名 → Agent 提议 → 用户接受 → 平台再同步不覆盖) ### 7.5 附件(已完成) **内容存磁盘、元数据入库**:附件是「写一次读多次」的冷数据,塞进 SQLite 的 BLOB 只会让 `.db` 膨胀、WAL 变大、备份变慢,换不来任何好处。 - [x] `attachments` 表(两方言各一份);`mail_id` 允许为 NULL 表示「已上传未挂载」 - [x] `internal/blob`:内容寻址存储,路径由 sha256 派生(`ab/cd/abcd…`) - 相同内容天然去重,重复上传不占额外空间 - 路径与用户给的 filename 完全无关,杜绝 `../` 穿越 - 先写临时文件再按内容哈希 rename:中途崩溃不会留下「哈希对不上内容」的文件 - 超限即中止并清理临时文件;恰好等于上限放行(边界不误杀) - [x] **上传与发信两步**:`POST /attachments` 拿 id → 发信时放进 `attachment_ids`。 Agent 侧工具走 JSON 无法带 multipart,人类侧也需要「写正文前先传文件」 - [x] 挂载校验:`WHERE mail_id IS NULL AND uploader = ?` 一条 UPDATE 完成判断与写入, 避免并发下把同一附件挂到两封邮件上;不属于自己 403,已挂载 409 - [x] 下载鉴权:已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看 - [x] **下载一律 `octet-stream` + `attachment` + `nosniff`**:绝不按声明的 MIME 内联渲染, 否则上传一个 `.html`/`.svg` 就能在本站域下执行脚本 - [x] `Content-Disposition` 双写:`filename*` 承载 UTF-8,`filename=` 兜底且转义引号与控制字符 - [x] 删除:只允许删自己上传且未挂载的;内容被其他记录共享时不删磁盘文件 - [x] GC:启动时 + 每小时扫一遍,清理超过 24h 未挂载的记录与无引用文件 - [x] 单个上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`); 双层限制:`MaxBytesReader` 卡整个请求体,`blob.Put` 卡单文件内容 - [x] 插件工具 `upload_attachment` / `download_attachment`;`read_inbox` 列出附件清单含 id - [x] 前端 `Attachments.tsx`:写信/回复的选择器(XHR 上传进度)+ 邮件详情的下载清单 + 列表页附件徽标 ### 7.6 WebAPI 化(已完成) **WebUI 调用的就是公开 API,没有仅前端可用的私有通道** —— 第三方客户端拿一把用户密钥 即可获得与网页完全相同的能力。 - [x] 实测前端用到的每个端点都能用 `Authorization: Bearer ` 调通 (读、写、上传、下载、转发、管理员接口、SSE) - [x] 两处例外补齐 `?access_token=`:SSE(`EventSource` 不能带自定义头)与 附件下载(`` 由浏览器直接发起)。 **只有这两个端点接受 query 令牌**,其余一律 401 —— URL 里的令牌会进访问日志与 Referer; 附件下载因此单独挂在 `UserAuthAllowQueryToken` 中间件下 - [x] CORS 暴露 `Content-Disposition` 与 `Content-Length`(前者是取文件名所必需) - [x] `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`,可以真的等人 - 拆插件时未决询问一律 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-` —— 每封邮件一个全新的空目录。平台按 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 持密钥主动连 Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证, 那是另一套信任模型 - [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 主动连 Gateway,Gateway 从不外呼。** 远端 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 是上游那条消息的稳定 id(permission id / assistant message id), 由平台生成、模型伪造不出来。于是插件重试与 SSE 重放不产生第二封, 想多转就得拿出不同的上游消息 id - [x] 判断与占用在同一条 INSERT 里(靠唯一约束):分成「先查再插」两步的话, 插件的两次重试会双双通过检查各插一条 - [x] 建邮件失败时 `ReleaseRelay` 归还名额,否则那条上游消息永远转不出来了 - [x] 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 —— 重复是插件重试的正常结果,不是故障 - [x] 响应带 `quota_charged: false`,免得插件看到额度没变以为数据错了 - [x] `permission_decision` 事件回传 `relay_key` + `relay_kind`: 两边 id 空间不同,插件要拿上游 id 才能回复 opencode; **这个映射必须服务端持久化**,插件重启后内存映射就没了 **配额下沉到会话(用户:配额应当在新建邮件、以及邮件对话页面是可编辑的)** 配额的真实语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。 只有 `agents.max_rounds` 一个全局计数器时有两个问题:并行任务互相抢额度; `used_rounds` 单调递增,跑满就得管理员手工重置才能再干活。 - [x] `sessions.max_rounds` / `used_rounds`(0 = 本会话不限) - [x] 写信时给:`POST /me/mail/send` 的 `max_rounds`,仅新建会话时生效 —— 续谈也接受的话,每封新信都会悄悄改掉对方正在遵守的预算 - [x] 对话页里改:`GET/PUT /sessions/{id}/budget`,`max_rounds` 与 `reset` 可同时给 (「加到 20 并从头算」是一次很自然的操作,拆两个请求只多一次往返) - [x] 额度只有这一层(后续修正):原先叠了一层 Agent 终身额度, 但那种额度跑满要人工重置才能再干活,已降级为纯统计。见 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.sh),opencode serve 加访问密码 **前端(React)** - [x] 三栏布局:60px 图标栏 / 320px 列表 / 右侧主区 - [x] 新建邮件为右侧**整页**(非弹窗) - [x] 三段式地址补全(接 `/contacts/suggest`,支持方向键) - [x] 联系人面板 + 归档(二次确认) - [x] 邮件详情 / 会话线程 / 权限卡片 / 回复栏 - [x] 全站纯 SVG 图标,**不使用 emoji** - [x] SSE 自动重连(指数退避)+ `session_archived` 即时移除 **部署** - [x] 公网 `mail.jianfgit.xyz` 可访问(portal-nginx → `.60:8180`) ### 下一步 MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。剩余工作都在 **Phase 7 进阶功能**: - [x] 对话树(不建 tree_nodes 表,用 `parent_mail_id` 递归 CTE + 按方向分块加载) - [x] 转发(引用原文 + 附件随行;抄送早已完成) - [x] 配额机制(只限发信不限收信,判断与自增在同一条 UPDATE) - [x] 附件(内容寻址磁盘存储,上传与发信两步) - [x] WebAPI 化(WebUI 与第三方客户端同一套 API) - [x] Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成) - [x] 插件自动转发平台原生权限询问与最终总结(不消耗配额) - [x] 配额下沉到会话:写信时给、对话页里随时改 - [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 在 390px(iPhone 14 Pro)与 320px(iPhone 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] pi:tool_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 3–5 秒重连、心跳下一周期再试,不把短暂网络抖动升级成 进程重启。 验收: - [ ] 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 ```