两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和 代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的 逻辑提到共用模块。 ## docs/PLUGIN-GUIDE.md 八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、 平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。 三条设计原则贯穿全文,后面每一节都是它们的推论: 1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供 request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」 的平台信号上自动转发) 2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key 3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制) 「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。 ## 共用模块提取 `lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为, 而它们与平台 SDK 无关: - 附件必须带 attachment_id(只说「有附件」模型无从下载) - 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方) - 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读 会让下一轮的新邮件混在里面认不出来) 顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批), 且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。 `lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为 (workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory, DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。 opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」 这两条保护。 ## deploy/check-shared-libs.sh `lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现, 只能靠 diff。 ## 文档同步 - PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与 7.7.2(平台会话快照)两节,记录根因而非只记改法 - API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与 permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途) - PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展 (repo 层已就绪,handler/插件/前端待做) - README 文档索引与项目结构 验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿; 端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
79 KiB
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 项目初始化 ✅
- 创建项目目录
gateway/ go mod init github.com/agentmail/gateway- 目录结构规划:
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 配置管理
internal/config/config.go- 数据库连接串、端口、CORS 配置等
- 从环境变量读取,提供默认值
- 结构体单例 +
Load()函数
1.3 数据库层
internal/db/db.go— database/sql 连接 + 方言适配(SQLite 默认 / PostgreSQL 可选)internal/db/migrate.go— embed SQL + 按方言执行迁移internal/db/migrations/init.sql+init_sqlite.sql— 6 张表 (users/user_sessions/agents/sessions/mails/permission_requests)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 连接管理
internal/sse/manager.go
功能:
- 管理所有 SSE 客户端连接(Agent 侧 + 前端)
- 按
agentName路由推送(Agent 只收自己的邮件通知,前端收所有) - 心跳保活(每 30s 发
: heartbeat) - 客户端断开自动清理
数据结构:
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 中间件
internal/middleware/auth.go
逻辑:
- 从 header 读
X-Agent-Name+X-Agent-Secret - 调
repo.VerifyAgent验证 - 验证通过 → 更新
last_seen,把agentName注入 context - 失败 → 返回 401
注意: 这个中间件只用于 Agent 侧的 API。前端 API 用另一个认证(MVP 阶段可先不实现前端认证)。
1.6 HTTP 路由
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 连接状态
实现顺序:
- 健康检查 + 静态路由
- Agent 注册/心跳
- 邮件发送/收件箱
- 权限请求/决策
- 会话管理
- SSE 端点
1.7 Go 编译验证
go build ./cmd/server编译通过- 启动服务(默认内置 SQLite,无需外部依赖)
- curl 手动测试每个 API 端点
1.8 数据库后端:SQLite 默认 + 外部库可选
决策:默认 SQLite,DATABASE_URL 非空时切外部 PostgreSQL。
前端已 go:embed 进二进制,数据库再挂 Docker 就自相矛盾;部署产物应当是「一个二进制 + 一个 .db」。
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同时识别 PG23505与 SQLite2067/1555 JOIN LATERAL:SQLite 无此语法,联系人聚合改用关联子查询
- 占位符:SQLite 也支持
- 两份 schema:
migrations/init.sql(PG)与migrations/init_sqlite.sql DATABASE_URL识别postgres://、sqlite://、file:、裸.db路径;空值 = 内置 SQLite- SQLite 连接开
WAL+busy_timeout=5000+foreign_keys=ON,连接池限 1(单写者) - 删除
docker-compose.yml与重复的gateway/migrations/
1.9 部署:systemd 单元
deploy/agentmail-gateway.service— 含ProtectSystem=strict与ReadWritePaths=data/deploy/opencode-serve.service— 托管 opencode headless(mail-bridge 宿主)deploy/install.sh— 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secretOPENCODE_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 钩子
- 单文件实现,无需 transport/tools 分层
- 凭证层:密钥优先级
AGENTMAIL_AGENT_KEY>~/.agentmail/agent.key> 旧 secret > 本地生成
2.2 HTTP 传输层(已落地)
apiGet/apiPost封装fetch,认证头由authHeaders()统一给出 (有密钥走Authorization: Bearer,否则退回X-Agent-Name+X-Agent-Secret)- 错误处理:非 2xx 抛出服务端
error文案,调用方能看到「密钥无效」这类可操作信息
2.3 SSE 客户端(已落地)
- 用
fetch+ReadableStream手工解析 SSE 帧(Node 原生 EventSource 不支持自定义请求头, 而认证头是必需的) - 连接
/api/v1/events/stream;断流 3s、出错 5s 后重连;AbortController支持优雅停止 - 事件分发:
new_mail/permission_decision→deliverMail()
2.4 工具实现(已落地,四个)
send_mail— 参数to/subject/body/cc?/reply_to?/session_alias?; 地址解析在网关侧完成(三维寻址是网关的职责,插件不该各自实现一份解析)read_inbox— 参数filter?(unread/all)/limit?;返回带预览的列表request_permission— 参数question/options?/context?connect_to_server— 参数gateway_url?/key_token?;登记密钥并完成注册, 失败时直接把待登记的密钥全文打出来,省一轮来回
与原计划的偏差:原设想「权限请求 / 提问 / 最终总结」三种行为由钩子自动拦截,
Agent 不需要手动调工具。opencode 的插件契约没有提供拦截这三类行为的钩子
(chat.message 只能观察不能冻结会话),因此改为:
request_permission 作为显式工具暴露给 Agent;「提问」与「最终总结」由 Agent 自行用
send_mail 表达。这不影响协作语义 —— 邮件本身就是提问与总结的载体。
2.5 插件入口(已落地)
- 启动时读凭证 →
POST /agent/register→ 启动 SSE → 30s 心跳定时器 new_mail事件:不注入正文,只给发件人/主题/mail_id 与「先调 read_inbox」的指示; 按session_id查sessionMap决定续谈已有 opencode 会话还是新开一个 (网关已按 session 位判好复用/新建,插件只忠实映射)permission_decision事件:把决策结论作为新一轮 prompt 投给对应会话session.updated钩子:把 opencode 侧模型生成的会话标题与 slug 回写为 AgentMail 的会话命名
2.6 测试(已实测)
- 工具注册:四个工具出现在模型的工具列表中(用
llmsproxy/AUTO实测) - 端到端收发:人 → Agent → 人 回信闭环,
reply_to使回信落回同一会话 - 会话记忆:第一封「记住 42」→ 第二封(省略 session 位)问「刚才的数字」→ 回「42」, 证明续谈映射正确
- 密钥流程:首装本地生成(0600)→ 管理员登记 → 重启注册成功 → 收发邮件与 SSE 全通
- 权限闭环:
request_permission→/permission/pending可查 → 人类决策 → 插件收到并开会话
排查记录(两个真实缺陷):
- 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换
llmsproxy/AUTO后正常。 顺带修了 provider 配置里baseURL缺//的拼写错误。 - 「发邮件没回复」是真 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 项目初始化
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 类型定义
types/index.ts— Mail / Session / Contact / User / Agent / AdminScopes 等
3.4 API 客户端
api/client.ts— 统一request(),credentials: 'include',401 统一跳登录; 覆盖邮件/会话/联系人/权限/用户管理/密钥全部端点
3.5 SSE
api/sse.ts— 单例 EventSource + 多订阅者;断线指数退避 1s→15s 上限 (没有单独做useSSEhook:订阅者是 store 而非组件,hook 反而多一层)
3.6 状态管理
stores/mailStore.ts/sessionStore.ts/contactStore.ts/uiStore.ts/authStore.ts
3.7 核心组件(已落地)
- App.tsx — 三栏容器:60px 图标栏 / 320px 列表 / 右侧自适应(替代原计划的 Layout.tsx)
- Sidebar.tsx — 图标栏(收件/发件/联系人 + 新建 + 账号/管理员入口 + 退出),纯 SVG 无 emoji
- MailList.tsx — 邮件列表,未读蓝色粗体、权限橙色标签、CC 标记
- MailView.tsx — 邮件正文(react-markdown)+ 权限卡片 + 回复区,三者合一
- ComposePage.tsx — 右侧整页写信(非弹窗,用户明确要求),含编辑/预览切换、
会话别名输入(仅地址以
.new结尾时出现) - AddressInput.tsx — 三段式补全,接
/contacts/suggest,支持方向键 - ContactPanel.tsx — 联系人(= 一条
name@path.session三维地址)+ 归档 - LoginPage / SetupPage / AccountPage / AdminUsersPage / KeyPanel
- icons.tsx — 20+ 纯 SVG 图标,全站零 emoji
3.8 样式
- TailwindCSS 配置
- 浅色主题(深色留待后续)
- 列表 hover/selected 状态
- 权限请求卡片橙色高亮
- 未读蓝色粗体
- 加载/空状态
3.9 开发环境
- Vite proxy:
/api→http://localhost:8180(与 Gateway 默认 PORT 一致) npm run typecheck(tsc --noEmit)与npm test(Markdown XSS 回归)
Phase 4:多用户与鉴权(人类账号体系)
背景:人类不是单一的
human,而是多用户,各自账号密码登录,拥有独立收件箱与会话。 寻址上人类用户同样是三维地址的 name 位:jianf@.new、alice@.deploy-review。
4.1 数据模型
users表
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 历史数据迁移
- 现有邮件里的字面量
'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 }
- 密码用
golang.org/x/crypto/bcrypt,cost 12 - token 用
crypto/rand32 字节 hex,有效期 7 天,每次请求滞后续期 - Cookie:
HttpOnly+SameSite=Lax+ 生产环境Secure - 登录失败限速:同一 username 连续 5 次失败锁 5 分钟(内存计数,MVP 阶段够用)
4.4 中间件与会话隔离
middleware.UserAuth—— 从 Cookie 取 token → 查user_sessions→ 注入username到 contextmiddleware.AdminOnly—— 叠在 UserAuth 之后,校验role = 'admin'- 所有
/human/*路由改为/me/*并强制鉴权,当前写死的"human"全部换成登录用户名:HumanGetInbox→MeGetInbox:ListInbox(ctx, username, ...)HumanGetSent→MeGetSent:ListSentBy(ctx, username, ...)HumanSendMail→MeSendMail:from_name = usernameListContacts、SuggestAddress、ArchiveContact同样按当前用户过滤
- 权限决策
POST /permission/decide鉴权:只有该会话的参与人类或 admin 可决策 - SSE
/events/stream鉴权:前端连接按登录用户分流,不再广播给全部前端sse.Manager新增UserName字段,SendToUser(username, ...)取代SendToFrontend
4.5 会话归属
sessions表新增owner_user_id UUID REFERENCES users(user_id)- 人类发起的会话归属于该用户;Agent 发起的会话归属于它寄信的人类
ListContacts只列当前用户参与的会话(owner 或 在 to/cc/from 中出现)- 归档鉴权:非 owner 且非 admin 不得归档别人的会话
4.6 Agent 侧寻址适配
- Agent 给人类发信时可写具体用户名:
send_mail to="jianf@.new" - 保留
human@兼容写法 → 解析为当前会话的 owner 用户 SuggestAddress的 name 层候选同时包含 在线 Agent + 已启用人类用户
4.7 前端
LoginPage.tsx—— 账号密码表单,失败提示,回车提交authStore.ts——me/login/logout;应用启动先拉/auth/me判断登录态App.tsx—— 未登录渲染LoginPage,已登录渲染主界面api/client.ts—— 所有请求带credentials: 'include';401 统一跳登录- 侧边栏底部显示当前用户 + 退出按钮(纯 SVG 图标,不用 emoji)
- 管理员页:用户列表 / 新建 / 禁用 / 重置密码
4.8 验证
- 单测:bcrypt 校验、token 过期、跨用户访问被拒(403)
- 两个人类账号互不可见对方收件箱与联系人
- 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 注册密钥)
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 表(用户连接密钥)
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:永不过期,可重复使用,适合正式部署的 Agentone_time:一次性使用,首次验证后标记used_at,再用即 403timed:创建时指定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)
插件启动时:
- 检查
~/.agentmail/agent.key是否存在 - 不存在 → 生成随机 32 字节 hex 作为
key_token,写入文件(0600)并打印到 stderr - 存在 → 读取 key_token
- 自动调
/agent/register;密钥未登记时提示管理员把打印出的密钥填进后台
connect_to_server 工具
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 最后一条)。
密钥来源优先级(已落地)
AGENTMAIL_AGENT_KEY环境变量 —— systemd 部署走这条~/.agentmail/agent.key(AGENTMAIL_CONFIG_DIR可改目录)- 都没有时退回
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 管理员前端
- 管理员页面拆成「用户管理 / Agent 密钥」两个 tab(
AdminUsersPage.tsx) - 创建密钥:选类型(长期/一次性/限时)+ 可选绑定 Agent + 限时可填小时数
- 登记客户端已生成的密钥:插件首装在本地生成密钥并打印,管理员把它填进「登记」框即可, 密钥全文只从客户端流向服务器一次,不需要反方向传递
- 密钥列表:只显示
token_hint(前 8 位),全文仅在创建响应里出现一次, 前端用一次性横幅提示「关闭后无法再次查看」 - 绑定 Agent / 吊销密钥
5.6 用户前端
- 个人中心新增「客户端连接密钥」区(
AccountPage.tsx,与管理员面板共用KeyPanel.tsx) - 创建密钥:填备注 + 选类型;限时可填小时数
- 密钥列表 + 吊销;状态标注「可用 / 已使用 / 已过期」
5.7 验证(已实测)
- permanent 密钥 → 注册成功,重复使用仍成功
- one_time 密钥 → 首次成功;再用 401「密钥已使用(一次性密钥只能用一次)」
- timed 密钥(24h)→ 注册成功;手动把
expires_at改到过去 → 401「密钥已过期」 - timed 缺
expires_hours→ 400;非法 key_type → 400 - 用户密钥 →
/me/mail/send正常;→/agent/register401「密钥无效」 - Agent 密钥 →
/me/mail/inbox401(两类密钥各查自己的表,不会串门) - 待绑定密钥首次注册落定为该 Agent;同一密钥改注册别的 name → 403
- 登记重复密钥 → 409;登记过短密钥(<32 位)→ 400
- 密钥列表接口不回传
key_token全文(实测回传全文: False) - SSE 鉴权加固:原先只凭
X-Agent-Name就分流,等于任何人报个名字就能读走别人的 新邮件通知。现在必须通过 Bearer 密钥或 name+secret 验证;仅报名字 / 错 secret / 匿名一律 401 - 插件密钥流程端到端:首装本地生成密钥(0600,目录 0700)并打印 → 管理员登记 → 插件重启注册成功 → 用该密钥收发邮件与订阅 SSE 全通
- 旧的 X-Agent-Name/Secret 方式保留兼容(与原计划「向后不兼容」不同: 已部署的 Agent 不该因为引入密钥就集体失联,两条路径并存成本很低)
Phase 6:联调 + 部署
6.1 端到端闭环测试
- 启动 Gateway(
go run ./cmd/server,SQLite 自动建库,无需外部数据库) - 启动前端(
npm run dev) - 启动 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。
deploy/install.sh一键安装(构建 → 嵌入 → systemd)deploy/*.service两个单元:gateway 与 opencode-serve- 删除
docker-compose.yml
6.3 一键启动脚本
deploy/install.sh覆盖构建 + 安装 + 启动- 开发态无需脚本:
go run ./cmd/server即起(SQLite 自动建库),前端npm run dev代理到 8180
6.4 错误处理与边界
- Agent 离线时邮件投递:邮件先入库,Agent 心跳响应回传未读数,上线后
read_inbox补齐 - Gateway 重启后 SSE 客户端重连:前端
api/sse.ts指数退避(1s→15s 上限), 插件侧startSSE断流 3s / 出错 5s 后重连 - 无效 Agent 名/密钥处理:错密钥与不存在的 Agent 统一回
Invalid credentials(不区分,避免探测账号存在性); 缺头回Missing X-Agent-Name or X-Agent-Secret header - 畸形三维地址:缺 name 位、空 to、别名不存在均有明确错误文案
- 邮件内容 XSS 防护:react-markdown 默认不解析 raw HTML 且清空非 http(s)/mailto 协议 URL,
新增回归测试
web/test/markdown-xss.test.mjs(npm test)守住这两个前提, 防止日后为「支持 HTML 邮件」加上 rehype-raw 而无声开口子 - 并发安全:
sse.Manager用sync.RWMutex;go build -race通过; 实测 10 客户端并发连接(断开后计数归零)+ 20 封并发发信全部成功(SQLite WAL + busy_timeout)
6.5 公网反代(已完成)
mail.jianfgit.xyz→ portal-nginx(.106:3080) →.60:8180- 列入 portal 认证豁免名单(应用自带账号体系,不叠 portal 登录)
Phase 7:进阶功能(MVP 后迭代)
7.1 对话树(已完成)
不建 tree_nodes 表(偏离原计划):mails.parent_mail_id 已经完整编码了树结构 ——
回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相,
两处不一致时无法判断谁对。直接用递归 CTE 在 mails 上查(idx_mails_parent 已有)。
repo/thread.go:AncestorsRaw(向上)/DescendantsRaw(向下)两个方向的递归 CTEGET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N- 树可跨会话:转发把线索引到新会话,但 parent 仍指向原件。 这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里
- 按会话逐个鉴权:A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
被过滤的节点计入
hidden,鉴权结果按会话缓存(一条线索里同一会话通常有多封) detached/parent_hidden两个标记:前者表示父不在当前已加载集合里, 后者区分「无权查看」(永久)与「尚未加载」(随上滑补齐)。 不能因为找不到父节点就把子节点悄悄丢掉- 前端
ThreadView.tsx:缩进 + 连接线,不引图形库 (邮件树又浅又窄,一条主链加几个转发分支,缩进足够表达层级, 省掉渲染 SVG 的依赖与它带来的布局/缩放问题)
分块加载(用户要求:展示完整节点,首屏只加载当前屏幕,上滑逐步补齐):
原先实现是深度上限 100 直接截断,超长线索看不到全貌。改为按方向分页:
- 首屏
dir=around取锚点附近一块(limit 在两个方向各分一半);dir=up|down+offset增量加载,两端各自带has_more_*与next_* - 游标用「相对锚点的层号/节点偏移」而不是 mail_id: 偏移量每次从锚点重走一遍,无状态、不可伪造; 用 mail_id 做游标就必须允许传入不可见的邮件(不可见的中间段要穿过去), 那还得单独证明它确实是锚点的祖先,反而更绕。 祖先方向的层号天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间
depth改为相对锚点(0 = 锚点,负 = 祖先,正 = 子孙): 分块加载时根可能还没取到,绝对深度无从得知- repo 层不做可见性过滤:不可见的中间段必须能穿过(转发把线索引进别人的会话, 再往上却可能仍是自己参与的往来)。过滤放在 handler 层,那里才知道调用者是谁
- 前端
IntersectionObserver双哨兵(rootMargin 200px 提前触发)+ 向上加载的滚动位置补偿(顶部插入内容后按 scrollHeight 增量修正 scrollTop, 否则视线会被内容顶走)+busyref 防两个哨兵同时触发并发请求 (setState异步,读 state 会双双看到 false) - 实测 251 封长链:7 页取完,depth 连续无缺口,取到真正的根;
311 封(含 60 个转发分支)9 页取完,
depth=1恰为 61 个 limit夹到 [1,200],非法值回落默认 40(分页参数不该因笔误让整个请求失败);limit=1时两方向各保底 1,否则算出downLimit=0连锚点自己都不返回
工作列表卡片视图(已完成):
中间栏原先只有一种呈现 —— 紧凑列表行,三行显示 agent / path / .alias 与「N 封 · 时间」。
它答得了「跟谁在聊」,答不了「在聊什么、进展如何」:一条线索是一件正在进行的工作,
而工作的状态在列表上完全看不见,必须逐条点开。
WorkCard.tsx:主题(平台模型生成的摘要)+ 最新一封的发件人与摘要 + 往返预算徽标- 两种视图共用同一份数据与同一套动作(打开/写信/归档),只有单项渲染不同;
容器(滚动/空态/归档确认)留在
ContactPanel - 归档确认框抽成
ArchiveConfirm两视图共用:归档是破坏性操作, 换个视图就换套确认 UI 只会让人对「自己点了什么」更没底 - 视图偏好存
localStorage:纯展示偏好不值得建表加 API, 而每次刷新退回默认视图会让人反复点同一个按钮;读写都容错(隐私模式会抛异常) - 卡片视图把中间栏从 320px 放宽到 400px(两行摘要 + 预算条挤不下);
窄屏仍是
w-full - 预算徽标在「不限」(max=0)时不显示:一个对每张卡片都成立的「0/0」是纯噪声。 剩 1 个来回转橙、用尽转红 —— 那是需要人介入的时刻
- 数据一次取回(
ListContactsFor增补subject/max_rounds/used_rounds/last_from/last_preview),不让卡片为每条会话再打一次库; 摘要按 rune 截断(中文一字三字节,裸切会留 U+FFFD)
顺带修掉的时间戳精度问题:卡片的「最新进展」要取会话里最后一封邮件,
而 SQLite 的 CURRENT_TIMESTAMP 只有秒精度 —— 同一秒内插入的多封邮件
按 created_at 排序结果不确定(实测同秒插 5 封,顺序是乱的,由随机 UUID 决定)。
「最早那封」(决定联系人身份)同样会取错。
db.NOW()升到微秒(毫秒不够:一次插入只要几十到几百微秒, 循环里连插几封会落在同一毫秒)。实测确认驱动能原样扫回time.Timemails的三条 INSERT 显式传NOW():改 schema 默认值只对新库生效 ——CREATE TABLE IF NOT EXISTS不改已存在的表,而 SQLite 没有ALTER COLUMN- 所有
ORDER BY created_at补mail_id兜底:老数据仍是秒精度, 没有第二排序键时同秒行的顺序由存储引擎决定,翻页会重复或漏行 - 测试用显式发号的时钟而非挂钟:测试在循环里连插几封很可能落在同一微秒, 而生产里两封邮件之间至少隔着一次模型推理
7.2 抄送(已完成)/ 转发
cc_list JSONB字段加入 mails 表- 发送时支持抄送多个三维地址
- 前端邮件详情显示抄送列表
- 收件箱检索覆盖被抄送邮件(
to_name = $1 OR cc_list @> ...) - 转发功能(引用原文 + 新收件人 + 附件随行)
POST /mail/{id}/forward(Agent)与POST /me/mail/{id}/forward(人类)- 与「回复」的区别:回复落回原会话,转发按目标地址另行寻址 —— 它是一条新线索
- 只能转发自己参与过的邮件(发件/收件/被抄送之一),否则 403
- 引用块逐行加
>前缀:原文含代码块或列表时,只有逐行前缀才保持引用语义 Fwd:前缀不叠加;parent_mail_id指向原邮件以便回溯- 附件一同带过去(内容寻址下只新增元数据,不拷磁盘文件)
7.3 配额机制(已完成;语义经两轮修正)
最终形态:额度只有一层 —— 本任务(会话)的往返预算。
第一版做的是 agents.max_rounds 终身额度,用户两次纠正后才对齐到正确模型:
- 「配额应当是在新建邮件、以及邮件对话页面是可编辑的」→ 预算下沉到会话
- 「为什么会有全局配额?不是每次单独配置配额,然后有一个默认配额吗?」→ 终身额度整个是错的工具
为什么终身额度是错的:它跑满后要管理员手工重置才能再干活, 而 Agent 是长期在线的 —— 那是把一次性资源的模型套在长期服务上。 而且一个全局计数器让并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉。
sessions.max_rounds/used_rounds:真正的额度,每条会话独立计数agents.default_rounds(默认 20):派给该 Agent 的新任务默认几个来回。 按 Agent 配而不是全站一个数 —— 跑测试的小工具与重构整个模块的 Agent, 合理来回数差一个量级- 写信不给
max_rounds时取收件 Agent 的默认值;写信页把它显示为 输入框 placeholder(人该看得到「不填会是多少」,否则得先去管理员页查) agents.used_rounds降级为纯统计:只累加、不拦请求。 保留是因为「这个 Agent 一共发了多少信」有观测价值;BumpSentCount连 error 都不返回 —— 统计写失败不该让邮件发不出去- 管理员页 tab 从「发信配额」改名「默认预算」,列出 默认来回数 + 进行中任务数 + 累计发信数;不再有「重置」按钮 (累计数是历史,归零它只会销毁信息)
PUT /admin/quotas/{name}兼容旧字段名max_rounds: 已部署的前端与脚本不该因为改名就难以察觉地失效- 心跳不再回传额度(额度不属于 Agent);剩余往返随发信响应的
budget_remaining回传,在那里才有意义
新建会话速率限制(替代终身额度的防滥用手段):
预算按会话计,Agent 就可以用 .new 开一串新会话,每条都是全新预算。
repo/sessionrate.go:滑动窗口,同一 Agent 1 小时最多新建 20 条,超出 429- 判断与记账在同一把锁里:分开的话并发请求会双双通过检查把上限刷穿 —— 与配额那条 UPDATE 同样的道理。单测用 80 并发验证恰好放行 20 次
- 建会话失败时
ReleaseNewSession归还名额(那次新建实际上没发生) - 用 429 而不是 403:前者表示「稍后再来」,后者表示「你没这个权限」, 客户端据此决定重试还是放弃
- 被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀; 也不禁止 Agent 主动开新会话,那会堵死 Agent 之间的主动协作
- 人类不受此限:手工点「新建邮件」的频率天然受限, 加限制只会在批量派活时误伤(实测人类连开 25 条会话全通)
- 省略 session 位的「默认会话」不计入:一个
name@path只有一条, 不构成暴开手段 - 已知取舍:进程内内存计数,与登录限速同一取舍。多实例部署时各自计数, 等效上限变成 N 倍
实测 11 组:新注册默认 20 / 按 Agent 分别设(tiny=5)/ 派活自动取默认值 / 显式值优先 / 22 封连发确认终身额度不再拦(第 21 封被会话预算拦下)/ 对话页调高后可继续 / 暴开 24 条会话恰好放行 20 / 人类连开 25 条全通 / 被限速仍能回信 / 默认会话不计入 / 老库补 default_rounds 且旧统计不丢。
7.4 会话别名动态更新(已完成)
会话别名的基础能力已在 Phase 1 落地(见「三维寻址 session 位三态」):
- 发信时用
session_alias为.new新建的会话命名,响应回传session_alias PUT /sessions/:id/alias事后改名,唯一性冲突返回 409- 别名全局唯一(
idx_sessions_alias_uniq部分唯一索引,NULL 不受约束) - 保留字与字符校验:不可为
new,不可含./@与空白(否则地址切分歧义) - 前端 ComposePage 在地址以
.new结尾时才显示「会话别名」输入框
别名/标题的默认来源 = Agent 平台自己的命名机制(不在本侧另造一套):
POST /sessions/:id/sync(Agent 认证)接收平台侧alias+title- opencode 插件建会话时不传 title,让平台按首轮对话由模型生成摘要标题;
创建即拿到的 slug(如
witty-planet)立即回写为寻址别名,标题随session.updated事件回写 - 平台 slug 不保证全局唯一,本侧别名必须唯一 →
SyncSessionAlias撞名自动追加-2/-3,同步永不失败 normalizeAlias把平台命名改写为合法寻址别名(非法字符换-、压缩连续-、避开保留字new、按 UTF-8 边界截断 128 字节)- 手工命名(发信
session_alias/PUT alias)仍可覆盖平台命名,属显式优先
由 Agent 在邮件正文里主动提议改名(已完成,与上面的自动同步互补):
两者分工:自动同步 = 平台起的名字,后台静默生效不打扰人; 正文提议 = Agent 干完活觉得该换个更贴切的名字,需要人点头。
为什么要人点头而不是让 Agent 直接调 PUT alias:
别名是人的寻址入口(name@path.别名)。Agent 干到一半自己改掉,
人上一秒记住的地址下一秒就失效。提议 + 人确认,既让 Agent 表达意图,
又保证寻址稳定性由人掌握。
- 载体是 HTML 注释
<!-- agentmail:rename-session alias="x" reason="y" -->- react-markdown 默认不解析 raw HTML,注释在页面上不可见
- 纯文本客户端里是一行不碍事的注释,不像自造标记那样显眼
- 不与 Markdown 语法冲突,不会被格式化工具改写
- 实测它会被转义成可见文本节点而不是被丢弃,所以必须从入库正文里主动剥掉
handler/rename_proposal.go:extractRenameProposal解析 + 剥标记- 只认最后一条:Agent 在长回复里可能反复修正措辞,最后写下的才是结论
- 别名过
normalizeAlias+validateSessionAlias,非法的视为无提议但标记仍剥掉 (与其在正文里留一行乱码,不如当它没提) - 理由截断到 200 字节(按 UTF-8 边界),否则提示条被撑破
- 人类发信同样剥标记但不产生提议 —— 人有改名按钮,用不着向自己提议
- 存在邮件上(
mails.rename_alias/rename_reason)而非会话上: 邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕 sessions.rename_dismissed记下被驳回的建议,提示条不再反复弹同一个GET /sessions/:id/rename-proposal取最新未处理提议 (既不是当前别名 = 未接受,也不在驳回记录里)POST /sessions/:id/rename-proposal/dismiss驳回;无待处理时返回no_pending(幂等)- 接受走已有的
PUT /sessions/:id/alias,不另开端点: 那条路径已有唯一性校验与 409,复制一遍只会多一个出错的地方。 实测提议的名字被别的会话占用时接受返回 409,而不是默默造出重名 - 插件
send_mail加propose_alias/propose_reason参数,自动拼注释标记; 发信响应回传规范化后的别名(Agent 提的名字可能被改写过) - 前端
RenameProposalBar(会话顶部):显示 旧别名 → 新别名 + 理由 + 「改名后旧别名立即失效」的后果提示;接受/忽略两个按钮。new_mail事件触发重新拉取(新来信可能带新建议) - 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 覆盖)。
sessions.alias_source:platform(平台自动同步,可被后续同步覆盖)/manual(人显式指定,平台同步不得覆盖)UpdateSessionAlias与「发信时显式给session_alias」都标manualSyncSessionAlias遇到manual直接返回当前别名,不写入; 条件放进WHERE alias_source <> 'manual'并检查 RowsAffected —— 并发下用户可能刚好在检查与写入之间接受了提议,分两步会把它冲掉- 标题不受此保护:
subject只用于展示,被平台的摘要标题刷新是好事; 受保护的只有承担寻址职责的别名 - 老库补列默认
platform:无从得知历史别名是人定的还是平台定的, 而platform只影响「平台同步能否覆盖」,人随时可手工改名转成manual - 四个场景实测:platform 可反复覆盖 / 用户改名后平台同步无效 / 发信显式命名即受保护 / 全流程(平台命名 → Agent 提议 → 用户接受 → 平台再同步不覆盖)
7.5 附件(已完成)
内容存磁盘、元数据入库:附件是「写一次读多次」的冷数据,塞进 SQLite 的 BLOB 只会让
.db 膨胀、WAL 变大、备份变慢,换不来任何好处。
attachments表(两方言各一份);mail_id允许为 NULL 表示「已上传未挂载」internal/blob:内容寻址存储,路径由 sha256 派生(ab/cd/abcd…)- 相同内容天然去重,重复上传不占额外空间
- 路径与用户给的 filename 完全无关,杜绝
../穿越 - 先写临时文件再按内容哈希 rename:中途崩溃不会留下「哈希对不上内容」的文件
- 超限即中止并清理临时文件;恰好等于上限放行(边界不误杀)
- 上传与发信两步:
POST /attachments拿 id → 发信时放进attachment_ids。 Agent 侧工具走 JSON 无法带 multipart,人类侧也需要「写正文前先传文件」 - 挂载校验:
WHERE mail_id IS NULL AND uploader = ?一条 UPDATE 完成判断与写入, 避免并发下把同一附件挂到两封邮件上;不属于自己 403,已挂载 409 - 下载鉴权:已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看
- 下载一律
octet-stream+attachment+nosniff:绝不按声明的 MIME 内联渲染, 否则上传一个.html/.svg就能在本站域下执行脚本 Content-Disposition双写:filename*承载 UTF-8,filename=兜底且转义引号与控制字符- 删除:只允许删自己上传且未挂载的;内容被其他记录共享时不删磁盘文件
- GC:启动时 + 每小时扫一遍,清理超过 24h 未挂载的记录与无引用文件
- 单个上限 25MB(
AGENTMAIL_MAX_ATTACHMENT_BYTES); 双层限制:MaxBytesReader卡整个请求体,blob.Put卡单文件内容 - 插件工具
upload_attachment/download_attachment;read_inbox列出附件清单含 id - 前端
Attachments.tsx:写信/回复的选择器(XHR 上传进度)+ 邮件详情的下载清单 + 列表页附件徽标
7.6 WebAPI 化(已完成)
WebUI 调用的就是公开 API,没有仅前端可用的私有通道 —— 第三方客户端拿一把用户密钥 即可获得与网页完全相同的能力。
- 实测前端用到的每个端点都能用
Authorization: Bearer <user_key>调通 (读、写、上传、下载、转发、管理员接口、SSE) - 两处例外补齐
?access_token=:SSE(EventSource不能带自定义头)与 附件下载(<a download>由浏览器直接发起)。 只有这两个端点接受 query 令牌,其余一律 401 —— URL 里的令牌会进访问日志与 Referer; 附件下载因此单独挂在UserAuthAllowQueryToken中间件下 - CORS 暴露
Content-Disposition与Content-Length(前者是取文件名所必需) web/src/api/config.ts集中基地址与令牌:VITE_API_BASE构建期注入、window.__AGENTMAIL_API_BASE__运行时覆盖、setToken()切凭证。 业务代码不感知 Cookie 与密钥的差异,src/api/可整体抽成 SDKdocs/API.md:完整接口清单、三类调用者的认证边界、错误码约定
7.7 DeepSeek Harness 插件(已完成)
plugins/dsh-mail-bridge/,Cordis 插件框架 + TypeScript。
适配方法与踩坑记录已固化为 docs/PLUGIN-GUIDE.md,
后续接入新平台按那份清单走。
- 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()取:会话上报只是补全体验, 不该能把邮件投递整体拘死 - 四个工具经
defineTool注册(send_mail/read_inbox/upload_attachment/download_attachment) - 直接给ctx.tools.register原始对象会报parameters must be lossless JSON before schema projection- 还必须声明output: { schema, render } 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 反而多余agent/status→idle时自动转发最后一条 assistant 消息 (对应 opencode 的session.idle),复用lib/relay-dedup.js让位于 模型的主动回信,带relay: 'summary'走免配额通道approval/request钩子把权限询问转成邮件问人 - 与 opencode 的关键差异:那边的permission.ask是同步钩子, 卡住会挂死整个请求,只能「转出去 + 立即返回 ask」; DSH 这边是异步 waterfall,返回Promise<ApprovalOutcome>,可以真的等人 - 拆插件时未决询问一律 fail closed(unavailable), 否则 DSH 侧那些await永不返回 - DSH 不给询问发 id,用会话:工具:callId作幂等键- 会话别名由模型生成的标题派生(与「别名复用平台命名」的既定决策一致)
-
slugFromTitle保留中文(转拼音后既不好读也不好打, 而三维地址按最后一个.切分,中文不影响解析) - 但必须去掉.@/等寻址分隔符 —— 留在别名里会让它自己被解析器切开 - fallback 占位标题不派生别名:DSH 在模型生成真标题前会先落一个 内容是「用户第一句话截断」的标题,而那句话是插件自己拼的提示词 - 补上心跳 —— 之前完全没有,Gateway 靠
last_seen判在线, 一直靠注册那一次撑着 - 与 opencode 插件共用
lib/下的纯函数模块(逐字节相同)
7.7.1 工作目录归属(修复)
症状:dsh 指定工作目录完全失效,所有会话落进「未分组」。
根因两层:
- 插件建会话时的 cwd 是自己拼的
~/.dsh/mail-sessions/mail-<uuid>—— 每封邮件一个全新的空目录。平台按 cwd 给会话分组,于是所有邮件会话 既不属于任何项目、彼此也不同组 - Gateway 从来没把地址的 path 位发给插件:
notifyRecipients的 payload 只有mail_id/session_id/from_name/subject,to_workspace虽然入库了 却不在 SSE 事件里 —— 插件即使想用也拿不到
- SSE
new_mail事件加to_workspace。每个收件方拿到自己那个地址的 path, 不是主收件人的 —— 抄送给opencode@/a与主发给dsh@/b是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位(共用
lib/workspace.js) - 不存在的目录不创建而是回退到兜底目录:一个笔误
(
/home/porgram/x)不该在磁盘上落下真目录,Agent 会在里面一无所获地干活 - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是
/
7.7.2 平台会话快照上报(新增)
症状:会话别名列不出工作区下的历史会话,无法选择。
人直接在平台界面上开的会话,Gateway 一无所知;而邮件驱动的那些也因为
workspace 没存在会话上(只在 mails.to_workspace,且 Agent 回信的
from_workspace 填的是 Agent 名而不是路径)而匹配不上。
sessions.workspace新列,CreateSession从地址的 path 位带入agent_platform_sessions镜像表 + 心跳携带platform_sessions- 插件上报而非 Gateway 反向拉取:当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证, 那是另一套信任模型
- 与
sessions表分开存:镜像里是别人家的会话,id 属于平台的 id 空间, 没有本侧的 owner/预算/邮件。混进sessions会让每一处「按会话鉴权」 都要先判断这条到底是不是真的本侧会话 - 整表替换而非增量合并:平台侧删掉的会话必须从候选里消失 —— session 位是三态语义,指向不存在的会话直接 404
platform_sessions省略与传空数组语义不同:拉不到列表时省略该字段 (保留镜像),传空数组的语义是「平台侧确实一条会话都没有」- subagent 子会话不上报:实测 DSH 一次列出 49 条子会话,标题就是派活的
提示词前缀(九条都叫
You are auditing ONE file),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - slug 撞名只留最近那条:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选
SuggestSessionCandidates取代SuggestSessionsFor:以会话自己的workspace为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该从候选列表里消失- 补全候选带标题与来源:
suggestions保留纯字符串数组(不打破已部署的前端 与第三方客户端),新增同序的candidates;过滤时标题也参与匹配 —— 人记得的是「缓存选型」而不是brisk-harbor这种随机短名
7.8 跨主机 Agent 发现
- Gateway + Registry 拆分为独立服务
- etcd / Consul 服务注册
- Agent 跨主机路由
7.9 插件自动转发 + 会话往返预算(已完成)
用户提出的三条原则,逐条落地:
原则一:插件应当自动转发 Agent 平台原生的问询与权限请求,而不是让模型自己调工具。
原实现有个 request_permission 工具让模型主动调 —— 这是把 harness 的职责推给模型:
它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见。
- 删掉
request_permission工具,改用permission.ask钩子接管 - 钩子里只记下待决策项并转出邮件,
output.status保持"ask"—— 不在钩子里阻塞等人回复:那是同步钩子,卡住会把整个 opencode 请求挂死 - opencode 的三态权限映射成人话:同意 =
once、一直同意 =always、拒绝 =reject - 人类决策后走 SSE 回来,用
client.postSessionIdPermissionsPermissionId回复原生 permission, 让 opencode 自己恢复原来的工具调用 —— 不再往会话里塞「你的请求已批准」的文字干扰它 - 转发失败时不接管(保持
ask),让本地 TUI 弹窗兜底,而不是让 Agent 干等 permission.replied事件清理待决策记录,避免邮件决策回来又去回复一条已结案的 permission
原则二:插件应当自动转发 Agent 最后一条总结性消息,且不消耗配额。
- 触发点选
session.idle(一轮跑完)而不是message.updated: 后者在流式生成中反复触发,转出去是半截话 - 只取
time.completed非空的 assistant 消息:未完成/被中断的不转 mailContexts记住该会话最近一封来信的发件人与 mail_id, 总结回给它并带reply_to,回信才落回同一线索- 提示语相应改写:告诉模型「回信不用你自己发,把话说完就行」,
只在需要主动联系他人或带附件时才调
send_mail
原则三(基本原则):插件自动转发的邮件不消耗配额。
配额存在的意义是防止 Agent 无限自我循环。harness 代劳的搬运不属于此列 —— 对它收费会导致配额用尽时 Agent 连交代都做不了,而那正是最需要它说话的时刻。
mails.relay+relay_key参数;relayKinds白名单只有permission/summary(不是任意字符串,否则relay:"anything"就是绕过配额的后门)- 防滥用不靠计数,靠幂等键:
relayed_mails(agent_name, relay_key)主键唯一。 relay_key 是上游那条消息的稳定 id(permission id / assistant message id), 由平台生成、模型伪造不出来。于是插件重试与 SSE 重放不产生第二封, 想多转就得拿出不同的上游消息 id - 判断与占用在同一条 INSERT 里(靠唯一约束):分成「先查再插」两步的话, 插件的两次重试会双双通过检查各插一条
- 建邮件失败时
ReleaseRelay归还名额,否则那条上游消息永远转不出来了 - 重复转发返回
200 {"status":"duplicate_relay"}而非报错 —— 重复是插件重试的正常结果,不是故障 - 响应带
quota_charged: false,免得插件看到额度没变以为数据错了 permission_decision事件回传relay_key+relay_kind: 两边 id 空间不同,插件要拿上游 id 才能回复 opencode; 这个映射必须服务端持久化,插件重启后内存映射就没了
配额下沉到会话(用户:配额应当在新建邮件、以及邮件对话页面是可编辑的)
配额的真实语义是「这件事值得多少个来回」—— 那是任务的属性,不是 Agent 的属性。
只有 agents.max_rounds 一个全局计数器时有两个问题:并行任务互相抢额度;
used_rounds 单调递增,跑满就得管理员手工重置才能再干活。
sessions.max_rounds/used_rounds(0 = 本会话不限)- 写信时给:
POST /me/mail/send的max_rounds,仅新建会话时生效 —— 续谈也接受的话,每封新信都会悄悄改掉对方正在遵守的预算 - 对话页里改:
GET/PUT /sessions/{id}/budget,max_rounds与reset可同时给 (「加到 20 并从头算」是一次很自然的操作,拆两个请求只多一次往返) - 额度只有这一层(后续修正):原先叠了一层 Agent 终身额度, 但那种额度跑满要人工重置才能再干活,已降级为纯统计。见 7.3
- 绕过手段(Agent 用
.new开一串会话)由新建会话速率限制堵住,见 7.3 - 判断与自增在同一条 UPDATE(
WHERE used_rounds < max_rounds), 40 并发 vs 上限 10 的单测覆盖,-race通过 - 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图
- 前端:ComposePage 的「往返预算」输入框 + MailView 会话头部的
BudgetEditor(点徽标就地编辑,可改上限/重置/取消);预算变更广播session_update - 老库补列默认 0(不限):引入预算不该把已在进行的会话卡死
Agent 侧标记已读(这一轮顺带修的真实缺陷)
原实现 Agent 只能读收件箱,没有任何办法把邮件标掉 —— 生产库里 opencode 名下
积了 31 封未读,每次 read_inbox 都把同一批旧邮件重新捞出来,
处理过的信和新来的信混在一起,模型分不清哪封该回;心跳里的未读数也只增不减。
POST /mail/read(Agent 认证):给mail_ids标指定几封,不给则全部标掉- 鉴权写进
UPDATE的WHERE(to_name = $1 OR cc 含 $1)而不是先查后改: 不是发给自己的邮件根本改不动,既省一次查询,也没有「查完到改之间邮件被转走」的窗口 - 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败, 而 Agent 通常把上一轮列出的 id 原样传回,其中可能混着已读的(幂等)
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到, 标了只会让「标记了 N 封」与用户看到的对不上
- 一批上限 200;畸形 id 一律 400(不静默跳过,那会让调用方以为标成功了)
- 插件
read_inbox读完自动标掉本次列出的那些(不是全部未读 —— limit 之外的还没看过,一并标掉等于让它们凭空消失); 标记失败不让read_inbox失败,代价只是下次重复看到 markread_test.go5 个用例 + 端到端 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)
- 项目结构与 MVP 技术规格书
- schema 两方言各一份(users / user_sessions / agents / sessions / mails / permission_requests)
- 数据模型 + 连接池 + 内嵌迁移器
- Repository 层(含联系人聚合、归档、三段式补全查询)
- 三维寻址
name@path.session解析器 + 单测(internal/models/address.go) - session 位三态语义:省略 → 默认会话(复用最近活跃,无则建);
new→ 强制新建; 具体别名 → 必须已存在且该收件人参与过,否则 404「无法送达」(不静默新建) - 会话别名:
.new发信时可传session_alias命名,PUT /sessions/:id/alias事后改名; 全局唯一(部分唯一索引),保留字new与./@空白一律拒绝 - 别名默认来自 Agent 平台自己的命名机制:
POST /sessions/:id/sync接收平台 slug + 模型生成的摘要标题; 插件不传占位 title,让平台正常生成;撞名自动追加 -2/-3,同步永不失败 - 抄送
cc_list JSONB+ GIN 索引 + 收件箱抄送可见 - SSE 管理器(按 agent 分流推送 + 心跳保活)
- Agent 认证中间件 + 注册/心跳
- 邮件收发 / 权限请求与决策 / 会话 / 联系人 / 归档 API
- 静态资源 go:embed,单二进制内含前端
- 密钥认证体系(agent_keys / user_keys)+ 三种生命周期 + SSE 鉴权加固
- SQLite 默认后端 + DATABASE_URL 切外部 PostgreSQL,方言差异收在 internal/db
- systemd 部署(deploy/install.sh),opencode serve 加访问密码
前端(React)
- 三栏布局:60px 图标栏 / 320px 列表 / 右侧主区
- 新建邮件为右侧整页(非弹窗)
- 三段式地址补全(接
/contacts/suggest,支持方向键) - 联系人面板 + 归档(二次确认)
- 邮件详情 / 会话线程 / 权限卡片 / 回复栏
- 全站纯 SVG 图标,不使用 emoji
- SSE 自动重连(指数退避)+
session_archived即时移除
部署
- 公网
mail.jianfgit.xyz可访问(portal-nginx →.60:8180)
下一步
MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。剩余工作都在 Phase 7 进阶功能:
- 对话树(不建 tree_nodes 表,用
parent_mail_id递归 CTE + 按方向分块加载) - 转发(引用原文 + 附件随行;抄送早已完成)
- 配额机制(只限发信不限收信,判断与自增在同一条 UPDATE)
- 附件(内容寻址磁盘存储,上传与发信两步)
- WebAPI 化(WebUI 与第三方客户端同一套 API)
- Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成)
- 插件自动转发平台原生权限询问与最终总结(不消耗配额)
- 配额下沉到会话:写信时给、对话页里随时改
- 工作列表卡片视图(中间栏,与列表视图切换)
- DeepSeek Harness 插件(
dsh-mail-bridge) - 平台会话快照同步:工作区下的历史会话可在写信时选中
- 插件适配方法固化为
docs/PLUGIN-GUIDE.md - 跨主机 Agent 发现(Gateway + Registry 拆分)
7.10 窄屏适配(已完成)
原实现只有三栏并排:60(导航)+ 320(列表)+ 详情,在 375px 屏上详情栏被挤到 不足 0 —— 用户反馈「窄屏基本不可用」。
第一版我做成了「窄屏一次只显示一栏」(分栏切换),用户纠正应当是 新页面覆盖老页面并带动画,于是重做为覆盖式。
useIsNarrow():matchMedia('(max-width: 767px)')。 用 matchMedia 而不是监听 resize —— 后者每变化一像素都触发还得自己节流, 前者只在跨过阈值时回调一次NarrowStack:底层(列表)始终挂载,覆盖层(详情)绝对定位盖在上面。 两个实际好处:列表滚动位置与选中态天然保留;退出动画有东西可播 —— 直接卸载再渲染另一个组件的话,没有任何一帧能让旧页面往右滑出去- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」: 关闭时先播 200ms 滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧
「在右侧之外」的状态,否则挂载与
translate-x-0在同一帧内完成, transition 根本不触发(单层 rAF 在 Safari 上偶尔仍被合帧) motion-reduce:transition-none尊重prefers-reduced-motion- 打开覆盖层时底层
aria-hidden,否则屏幕阅读器会读到两层内容 - 导航:竖条在窄屏退化为抽屉(60px 在手机上白占一成宽度),
日常切换交给底部
NarrowNav(拇指够得到); 抽屉带遮罩,点空白处收起 env(safe-area-inset-bottom):iPhone 手势条会盖住最后一排- 窄屏专属控件用条件渲染而非
md:hidden:后者只是视觉隐藏, 元素仍在 DOM 与 tab 序列里,宽屏用户按 Tab 会聚焦到看不见的返回按钮上。 为此抽了NarrowOnly/BackButton/NavToggle三个组件 - 列表栏
w-full md:w-[320px];各页横向内边距px-4 md:px-6(px-6 在 375px 屏上白吃 48px) - 管理页的 3/4 列 grid 改响应式;列表行
flex-wrap(宁可占两行,不要把每列挤成看不清的窄条) - 详情页与写信页都有返回出口 —— 否则窄屏进去就出不来。
写信页用
cancelCompose而不是showList:写信态要一起结束, 只滑走覆盖层的话下次进列表又会弹回来 narrowPane在宽屏下也维护:否则从窄屏拖宽再拖回来, 用户会发现自己回到了列表,刚打开的邮件不见了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