Files
MailUI4Agents/docs/PLAN.md
JianFeeeee 9d4718a412 feat: SSE Last-Event-ID 补投 + 连接状态指示 + 限速器 DB 化
## SSE Last-Event-ID 补投

EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了——
所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。

改为 per-user 事件环形缓冲区(500 条,~100KB/用户,20 在线 ≈ 2MB):
每次 Broadcast/SendToUser/SendToAgent 同时写入对应用户的缓冲区;
AddClient 时取 Last-Event-ID 头,找到该 ID 的位置后从下一条回放。
找不到 ID 说明事件已被覆盖(缓冲区溢出),从头回放全部。

事件 ID 用全局递增序列号(非 UUID),EventSource 的 Last-Event-ID
就是靠这个 ID 记住断点的。

新增测试:缓冲区回放、溢出行为、并发安全(10 goroutine × 200 次 push)、
端到端重连验证(SendToUser → 带 Last-Event-ID 的 AddClient → 补投)。

## 连接状态指示器

Sidebar 用户头像右下角的小圆点:绿=已连接,黄=连接中,橙=重连中,红=断开。
NarrowNav 底栏也有(移动端)。

SSE 模块新增 onSSEStatus/getSSEStatus 接口,onerror/onopen 驱动状态变化。
状态点用 absolute 定位在头像边缘,不遮挡文字。

## 限速器 DB 化(解决多实例部署时的计数漂移)

原实现:LoginLimiter 与 sessionRateLimiter 都是进程内内存计数器。
多实例部署时各自独立计数,等效上限变成 N 倍。

改为 rate_limits 表(bucket + ts),两个限速器共享同一套基础设施:
- LoginLimiter:bucket="login:<username>",COUNT(*) >= 5 → 锁定 5 分钟
- sessionRateLimiter:bucket="session:<agent_name>",COUNT(*) >= 20/h → 拒绝

判断与写入在同一个 BEGIN IMMEDIATE 事务里——SQLite 的 IMMEDIATE
在事务开始时获取 RESERVED 锁,防并发写事务同时进入 COMMIT 阶段。
实测 80 并发下恰好放行 20 次(旧内存版同样通过,但 DB 版才能多实例共享)。

DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
新建 rate_limits 表迁移(SQLite + PG 两版)。
2026-09-02 14:33:41 +08:00

1346 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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