Files
MailUI4Agents/docs/PLAN.md
JianFeeeee 07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00

72 KiB
Raw Blame History

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

逻辑:

  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 路由

  • 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 编译验证

  • go build ./cmd/server 编译通过
  • 启动服务(默认内置 SQLite无需外部依赖
  • curl 手动测试每个 API 端点

1.8 数据库后端SQLite 默认 + 外部库可选

决策:默认 SQLiteDATABASE_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_arraySQLite 用 json_each
    • 唯一冲突:db.IsUniqueViolation 同时识别 PG 23505 与 SQLite 2067/1555
    • JOIN LATERALSQLite 无此语法,联系人聚合改用关联子查询
  • 两份 schemamigrations/init.sqlPGmigrations/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=strictReadWritePaths=data/
  • deploy/opencode-serve.service — 托管 opencode headlessmail-bridge 宿主)
  • deploy/install.sh — 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secret
  • OPENCODE_SERVER_PASSWORD 由安装脚本随机生成(此前裸奔,同机任何进程都能开会话)

Phase 2Agent 邮件桥接插件

计划书原本按 Pi Agent 设计,实际第一个接入的平台是 opencodeplugins/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_decisiondeliverMail()

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_idsessionMap 决定续谈已有 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 可查 → 人类决策 → 插件收到并开会话

排查记录(两个真实缺陷)

  1. 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换 llmsproxy/AUTO 后正常。 顺带修了 provider 配置里 baseURL// 的拼写错误。
  2. 「发邮件没回复」是真 bug —— SSE 回调调的 client.session.message.send(...) 在 opencode SDK 里 不存在session.message 是读消息的 getterPromise reject 又被空 .catch(() => {}) 吞掉, 于是邮件到了、SSE 收到了、却没建任何会话也没报错。 正确 API 是 session.create + session.promptAsync教训:跨 SDK 调用不要写空 catch。

Phase 3前端React

实际实现比原计划收敛组件拆分粒度更粗MailItem/MailBody/ReplyEditor/PermissionCard 都并入了 MailView因为它们只在这一处使用独立文件只增加跳转成本 且「新建邮件」按用户要求做成右侧整页而非弹窗。

3.1 项目初始化

  • web/ 目录Vite + React + TypeScript + TailwindCSS

3.2 目录结构(已落地)

web/src/
├── api/          client.tsHTTP/ sse.tsEventSource + 指数退避重连)
├── stores/       mail / session / contact / ui / authZustand
├── components/   Sidebar / MailList / MailView / ComposePage / ContactPanel
│                 AddressInput / LoginPage / SetupPage / AccountPage
│                 AdminUsersPage / KeyPanel / icons
├── types/index.ts
└── App.tsx

3.3 类型定义

  • 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 上限 (没有单独做 useSSE hook订阅者是 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/apihttp://localhost:8180(与 Gateway 默认 PORT 一致)
  • npm run typechecktsc --noEmitnpm testMarkdown XSS 回归)

Phase 4多用户与鉴权人类账号体系

背景:人类不是单一的 human,而是多用户,各自账号密码登录,拥有独立收件箱与会话。 寻址上人类用户同样是三维地址的 name 位:jianf@.newalice@.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);

关键决策usernameagents.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/bcryptcost 12
  • token 用 crypto/rand 32 字节 hex有效期 7 天,每次请求滞后续期
  • CookieHttpOnly + SameSite=Lax + 生产环境 Secure
  • 登录失败限速:同一 username 连续 5 次失败锁 5 分钟内存计数MVP 阶段够用)

4.4 中间件与会话隔离

  • middleware.UserAuth —— 从 Cookie 取 token → 查 user_sessions → 注入 username 到 context
  • middleware.AdminOnly —— 叠在 UserAuth 之后,校验 role = 'admin'
  • 所有 /human/* 路由改为 /me/* 并强制鉴权,当前写死的 "human" 全部换成登录用户名:
    • HumanGetInboxMeGetInboxListInbox(ctx, username, ...)
    • HumanGetSentMeGetSentListSentBy(ctx, username, ...)
    • HumanSendMailMeSendMailfrom_name = username
    • ListContactsSuggestAddressArchiveContact 同样按当前用户过滤
  • 权限决策 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.sqlinit_sqlite.sql。 下方 DDL 为 PostgreSQL 方言SQLite 侧 UUID→TEXTTIMESTAMPTZ→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_tokenuser_keys.key_token 共享一个全局唯一命名空间(验证时先查 agent_keys再查 user_keys
  • user_keys 的密钥 不能 用于 POST /agent/register(注册时校验来源表)
  • agent_keys 的密钥 不能 用于 /me/* 人类邮箱接口agent_auth 中间件与 user_auth 中间件分离)

5.2 Gateway API

管理员Agent 密钥管理

POST   /api/v1/admin/agent-keys          { agent_name?, key_type, label?, expires_hours?, key_token? }
GET    /api/v1/admin/agent-keys          ?agent_name=xxx
DELETE /api/v1/admin/agent-keys/{id}
POST   /api/v1/admin/agent-keys/{id}/bind  { agent_name }  -- 绑定到 Agent

key_token 用于登记客户端已在本地生成的密钥(插件首装场景):这样密钥全文只从客户端 流向服务器一次不必反方向传递。留空则由服务器生成32 字节随机 hex

密钥类型:

  • permanent:永不过期,可重复使用,适合正式部署的 Agent
  • one_time:一次性使用,首次验证后标记 used_at,再用即 403
  • timed:创建时指定 expires_hours(如 24h过期后失效

用户:连接密钥管理

POST   /api/v1/me/keys              { label, key_type, expires_hours? }
GET    /api/v1/me/keys              → 我的密钥列表(只给 token_hint
DELETE /api/v1/me/keys/{id}         → 删除密钥(带 user_id 条件,删不到即 404

用户密钥的 key_type 同样支持 permanent / one_time / timed但只能用于 /me/* 接口。

Agent 注册(改用密钥认证)

POST /api/v1/agent/register
Header: Authorization: Bearer <agent_key_token>
Body: { name, workspaces, platform }

→ Gateway 验证密钥:
  1. 查 agent_keys WHERE key_token = token
  2. 不存在 → 401
  3. one_time 类型且 used_at 非空 → 401已用过
  4. timed 类型且 expires_at < now() → 401已过期
  5. 验证通过 → 更新 used_atone_time→ 继续注册逻辑

Agent 心跳(改用密钥认证)

POST /api/v1/agent/heartbeat
Header: Authorization: Bearer <agent_key_token>

SSE 连接(改用密钥认证)

GET /api/v1/events/stream
Header: Authorization: Bearer <agent_key_token>
→ Agent 侧 SSE密钥验证后绑定到对应 Agent
→ 前端 SSE仍用 Cookie 认证(不变)

5.3 插件侧改动

首次安装:本地生成密钥

~/.agentmail/
├── config.json     # { "gateway_url": "...", "key_token": "...", "agent_name": "..." }
└── agent.key       # 本地密钥文件JSON含 key_token + created_at

插件启动时:

  1. 检查 ~/.agentmail/agent.key 是否存在
  2. 不存在 → 生成随机 32 字节 hex 作为 key_token写入文件0600并打印到 stderr
  3. 存在 → 读取 key_token
  4. 自动调 /agent/register;密钥未登记时提示管理员把打印出的密钥填进后台

connect_to_server 工具

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.keyAGENTMAIL_CONFIG_DIR 可改目录)
  3. 都没有时退回 AGENTMAIL_AGENT_SECRET 的旧路径;连 secret 也没有才本地生成新密钥

文件权限:agent.keyconfig.json 均 0600目录 0700。

5.4 密钥验证中间件Gateway已落地

middleware.AgentAuth 同时承担密钥与旧凭证两条路径:

有 Authorization: Bearer <token>
  → repo.VerifyAgentKey查 agent_keys
    → 不存在        → 401 "密钥无效"
    → one_time 已用  → 401 "密钥已使用(一次性密钥只能用一次)"
    → timed 已过期   → 401 "密钥已过期"
    → 通过one_time 写 used_atWHERE used_at IS NULL并发下只有一个请求能标记成功
  → agent_name 为空(待绑定)→ 403提示先调 /agent/register
  → 注入 agent_name 到 context刷新 last_seen

无 Bearer走 X-Agent-Name + X-Agent-Secret 旧路径

middleware.UserAuth 同理支持 Cookie 与 Bearer <user_key_token>。两类密钥各查自己的表, 因此 Agent 密钥读不了人类邮箱,用户密钥也注册不了 Agent。

/agent/register 不经中间件(注册时 Agent 还没身份),自行取 Bearer 校验: 密钥已绑定且与请求 name 不符 → 403防止拿别人的密钥冒充新身份未绑定则注册成功后落定。

5.5 管理员前端

  • 管理员页面拆成「用户管理 / Agent 密钥」两个 tabAdminUsersPage.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/register 401「密钥无效」
  • Agent 密钥 → /me/mail/inbox 401两类密钥各查自己的表不会串门
  • 待绑定密钥首次注册落定为该 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 端到端闭环测试

  • 启动 Gatewaygo run ./cmd/serverSQLite 自动建库,无需外部数据库)
  • 启动前端(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.mjsnpm test)守住这两个前提, 防止日后为「支持 HTML 邮件」加上 rehype-raw 而无声开口子
  • 并发安全:sse.Managersync.RWMutexgo 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.goAncestorsRaw(向上)/ DescendantsRaw(向下)两个方向的递归 CTE
  • GET /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 否则视线会被内容顶走)+ busy ref 防两个哨兵同时触发并发请求 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.Time
  • mails 的三条 INSERT 显式传 NOW():改 schema 默认值只对新库生效 —— CREATE TABLE IF NOT EXISTS 不改已存在的表,而 SQLite 没有 ALTER COLUMN
  • 所有 ORDER BY created_atmail_id 兜底:老数据仍是秒精度, 没有第二排序键时同秒行的顺序由存储引擎决定,翻页会重复或漏行
  • 测试用显式发号的时钟而非挂钟:测试在循环里连插几封很可能落在同一微秒, 而生产里两封邮件之间至少隔着一次模型推理

7.2 抄送(已完成)/ 转发

  • cc_list JSONB 字段加入 mails 表
  • 发送时支持抄送多个三维地址
  • 前端邮件详情显示抄送列表
  • 收件箱检索覆盖被抄送邮件(to_name = $1 OR cc_list @> ...
  • 转发功能(引用原文 + 新收件人 + 附件随行)
    • POST /mail/{id}/forwardAgentPOST /me/mail/{id}/forward(人类)
    • 与「回复」的区别:回复落回原会话,转发按目标地址另行寻址 —— 它是一条新线索
    • 只能转发自己参与过的邮件(发件/收件/被抄送之一),否则 403
    • 引用块逐行加 > 前缀:原文含代码块或列表时,只有逐行前缀才保持引用语义
    • Fwd: 前缀不叠加;parent_mail_id 指向原邮件以便回溯
    • 附件一同带过去(内容寻址下只新增元数据,不拷磁盘文件)

7.3 配额机制(已完成;语义经两轮修正)

最终形态:额度只有一层 —— 本任务(会话)的往返预算。

第一版做的是 agents.max_rounds 终身额度,用户两次纠正后才对齐到正确模型:

  1. 「配额应当是在新建邮件、以及邮件对话页面是可编辑的」→ 预算下沉到会话
  2. 「为什么会有全局配额?不是每次单独配置配额,然后有一个默认配额吗?」→ 终身额度整个是错的工具

为什么终身额度是错的:它跑满后要管理员手工重置才能再干活, 而 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/syncAgent 认证)接收平台侧 alias + title
  • opencode 插件建会话时不传 title,让平台按首轮对话由模型生成摘要标题; 创建即拿到的 slugwitty-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.goextractRenameProposal 解析 + 剥标记
    • 只认最后一条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_mailpropose_alias / propose_reason 参数,自动拼注释标记; 发信响应回传规范化后的别名Agent 提的名字可能被改写过)
  • 前端 RenameProposalBar(会话顶部):显示 旧别名 → 新别名 + 理由 + 「改名后旧别名立即失效」的后果提示;接受/忽略两个按钮。 new_mail 事件触发重新拉取(新来信可能带新建议)
  • SQLite 老库补列CREATE TABLE IF NOT EXISTS 不会给已存在的表加列, 而 SQLite 没有 ADD COLUMN IF NOT EXISTSdb.addMissingColumnspragma_table_info 后按需 ALTER实测老库补列成功、旧数据不丢、二次启动不重复补

sessions.alias_source —— 生产实测发现的隐患

用户接受改名后opencode 下一次 session.updated 事件会带着平台 slug 再同步一次, 把人刚定的名字冲掉 —— 人上一秒记住的寻址地址下一秒失效。实测确认了这个行为 fix-cache-penetrationstellar-engine 覆盖)。

  • sessions.alias_sourceplatform(平台自动同步,可被后续同步覆盖)/ manual(人显式指定,平台同步不得覆盖)
  • UpdateSessionAlias 与「发信时显式给 session_alias」都标 manual
  • SyncSessionAlias 遇到 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-8filename= 兜底且转义引号与控制字符
  • 删除:只允许删自己上传且未挂载的;内容被其他记录共享时不删磁盘文件
  • GC启动时 + 每小时扫一遍,清理超过 24h 未挂载的记录与无引用文件
  • 单个上限 25MBAGENTMAIL_MAX_ATTACHMENT_BYTES 双层限制:MaxBytesReader 卡整个请求体,blob.Put 卡单文件内容
  • 插件工具 upload_attachment / download_attachmentread_inbox 列出附件清单含 id
  • 前端 Attachments.tsx:写信/回复的选择器XHR 上传进度)+ 邮件详情的下载清单 + 列表页附件徽标

7.6 WebAPI 化(已完成)

WebUI 调用的就是公开 API没有仅前端可用的私有通道 —— 第三方客户端拿一把用户密钥 即可获得与网页完全相同的能力。

  • 实测前端用到的每个端点都能用 Authorization: Bearer <user_key> 调通 读、写、上传、下载、转发、管理员接口、SSE
  • 两处例外补齐 ?access_token=SSEEventSource 不能带自定义头)与 附件下载(<a download> 由浏览器直接发起)。 只有这两个端点接受 query 令牌,其余一律 401 —— URL 里的令牌会进访问日志与 Referer 附件下载因此单独挂在 UserAuthAllowQueryToken 中间件下
  • CORS 暴露 Content-DispositionContent-Length(前者是取文件名所必需)
  • web/src/api/config.ts 集中基地址与令牌: VITE_API_BASE 构建期注入、window.__AGENTMAIL_API_BASE__ 运行时覆盖、setToken() 切凭证。 业务代码不感知 Cookie 与密钥的差异,src/api/ 可整体抽成 SDK
  • docs/API.md:完整接口清单、三类调用者的认证边界、错误码约定

7.7 DeepSeek Harness 插件

  • 基于 Cordis 框架开发 dsh-mail-bridge
  • 利用 PreToolUseSessionStart 钩子
  • 与 Pi 插件共享相同 Gateway API

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 是上游那条消息的稳定 idpermission 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_rounds0 = 本会话不限)
  • 写信时给:POST /me/mail/sendmax_rounds,仅新建会话时生效 —— 续谈也接受的话,每封新信都会悄悄改掉对方正在遵守的预算
  • 对话页里改:GET/PUT /sessions/{id}/budgetmax_roundsreset 可同时给 (「加到 20 并从头算」是一次很自然的操作,拆两个请求只多一次往返)
  • 额度只有这一层(后续修正):原先叠了一层 Agent 终身额度, 但那种额度跑满要人工重置才能再干活,已降级为纯统计。见 7.3
  • 绕过手段Agent 用 .new 开一串会话)由新建会话速率限制堵住,见 7.3
  • 判断与自增在同一条 UPDATEWHERE used_rounds < max_rounds 40 并发 vs 上限 10 的单测覆盖,-race 通过
  • 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图
  • 前端ComposePage 的「往返预算」输入框 + MailView 会话头部的 BudgetEditor (点徽标就地编辑,可改上限/重置/取消);预算变更广播 session_update
  • 老库补列默认 0不限引入预算不该把已在进行的会话卡死

Agent 侧标记已读(这一轮顺带修的真实缺陷)

原实现 Agent 只能读收件箱,没有任何办法把邮件标掉 —— 生产库里 opencode 名下 积了 31 封未读,每次 read_inbox 都把同一批旧邮件重新捞出来, 处理过的信和新来的信混在一起,模型分不清哪封该回;心跳里的未读数也只增不减。

  • POST /mail/readAgent 认证):给 mail_ids 标指定几封,不给则全部标掉
  • 鉴权写进 UPDATEWHEREto_name = $1 OR cc 含 $1)而不是先查后改: 不是发给自己的邮件根本改不动,既省一次查询,也没有「查完到改之间邮件被转走」的窗口
  • 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败, 而 Agent 通常把上一轮列出的 id 原样传回,其中可能混着已读的(幂等)
  • 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到, 标了只会让「标记了 N 封」与用户看到的对不上
  • 一批上限 200畸形 id 一律 400不静默跳过那会让调用方以为标成功了
  • 插件 read_inbox 读完自动标掉本次列出的那些(不是全部未读 —— limit 之外的还没看过,一并标掉等于让它们凭空消失); 标记失败不让 read_inbox 失败,代价只是下次重复看到
  • markread_test.go 5 个用例 + 端到端 10 组(含抄送、归档、跨 Agent 越权)

验证:单测 relay_test.go10 个用例,含「免配额类型恰好两种」的防扩散断言)+ budget_test.go6 个,含 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.shopencode 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
  • 跨主机 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.mjs16 条结构性断言, 钉住「覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden」 「无裸 px-6」等不变量。不做视觉快照 —— 那需要 headless 浏览器, 且像素比对在字体差异下极脆

已知取舍,尚未处理:

  • 前端只有 Markdown XSS 一个回归测试,没有组件级测试
  • 深色主题未做
  • 窄屏已适配7.10),但没有真机 / headless 浏览器的视觉回归,只有结构性断言
  • 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