105 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 项目初始化 ✅
- 创建项目目录
server/ go mod init github.com/agentmail/gateway- 目录结构规划:
server/
├── cmd/
│ └── server/
│ └── main.go # 入口,启动 HTTP 服务
├── internal/
│ ├── config/
│ │ └── config.go # 环境变量配置
│ ├── db/
│ │ ├── db.go # 连接池
│ │ ├── migrate.go # 迁移执行
│ │ └── migrations/
│ │ └── init.sql # 建表 SQL
│ ├── models/
│ │ └── models.go # 数据结构
│ ├── repo/
│ │ └── repo.go # 数据库操作层
│ ├── sse/
│ │ └── manager.go # SSE 连接管理
│ └── middleware/
│ └── auth.go # Agent 认证中间件
├── go.mod
├── go.sum
├── Dockerfile
└── Makefile
1.2 配置管理
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与重复的server/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 项目初始化
client/electron/目录,Vite + React + TypeScript + TailwindCSS
3.2 目录结构(已落地)
client/electron/src/
├── api/ client.ts(HTTP)/ sse.ts(EventSource + 指数退避重连)
├── stores/ mail / session / contact / ui / auth(Zustand)
├── components/ Sidebar / MailList / MailView / ComposePage / ContactPanel
│ AddressInput / LoginPage / SetupPage / AccountPage
│ AdminUsersPage / KeyPanel / icons
├── types/index.ts
└── App.tsx
3.3 类型定义
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,
新增回归测试
client/electron/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(前者是取文件名所必需) client/electron/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-CONTRACT.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 注册取消。 它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在: 连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。 远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
- Agent 从另一台主机经公网完成注册 / 心跳 / 收件箱 / SSE 长连
(2026-09-02 用
deploy/remote-agent-demo.py验证,纯标准库 60 行) - 心跳带模型目录与平台会话快照同样跨主机可用
- (运维便利,不阻塞)一条命令为远端主机建密钥并打印环境变量
- (运维便利,不阻塞)密钥轮换
- (运维便利,不阻塞)
agents.host_url由心跳自报填充
细节见 docs/PHASE7-REMAINING.md 的「7.8 跨主机 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-CONTRACT.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,否则屏幕阅读器会读到两层内容 - 导航:竖条在窄屏让位给底部
NarrowNav(60px 在手机上白占一成宽度, 而底部横排拇指够得到)。曾经还有一个抽屉式侧栏,后来删掉了 —— 见 7.10.1 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在宽屏下也维护:否则从窄屏拖宽再拖回来, 用户会发现自己回到了列表,刚打开的邮件不见了client/electron/test/narrow-layout.test.mjs:结构性断言(现 28 条), 钉住「覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden」 「无裸 px-6」等不变量。不做像素级视觉快照 —— 字体差异下极脆
7.10.1 真机尺寸实测与修复(2026-09-02)
上面那些都是「照着规则写对」,实际用 playwright 连本机共享 Chromium
在 390px(iPhone 14 Pro)与 320px(iPhone SE)量了一遍,发现一个功能性 bug
加五处可用性问题。详细记录见 docs/PHASE7-REMAINING.md。
- 删掉抽屉式侧栏。它是
fixed ... z-50且铺满视口高度,把底部导航 最左那一项盖住点不到(elementFromPoint命中抽屉里的 SVG)。 修法不是给导航加 z-index 而是删掉抽屉:它装的六项与底部导航完全重复, 唯一独有的是退出登录 —— 为一个按钮维护一套 fixed 层级 + 遮罩不划算, 而它还附带了「Esc 关不掉」「底层未锁滚」两个毛病 - 退出登录移到「我的」页:与密码、密钥同属「账号自身」, 而那页此前根本没有退出入口
.tap工具类:44x44 触摸命中区,用居中的透明伪元素实现, 视觉尺寸一像素不动。实测详情页工具按钮只有 15-16px 高 (「抄送」20x15),直接加 padding 会把头部撑散、320px 下换行。 只在max-width: 767px生效 —— 桌面精度足够,且扩大后的命中区 在密排工具栏里会互相重叠.reveal工具类:opacity-0 group-hover:opacity-100在没有 hover 的设备上永远透明却仍然接收点击 —— 一个看不见却按得动的「归档」 比没有按钮更糟。改为默认可见,只在(hover: hover) and (pointer: fine)时隐藏(单看 hover 会把带触摸板的 平板算进去)- 对话树缩进随屏宽自适应:固定「每级 20px、上限 8 级」在 320px 下 把卡片压到 110px 可用宽度,发件人一行直接被 truncate 吃掉。 窄屏改为每级 10px、上限 5 级
- 对话树补返回出口:原先只有「关闭」,而两者语义不同 —— 返回退出整个详情栏,关闭只收起树留在这封邮件上
AccountPage根本没有滚动容器(用户反馈「我的页进去后无法滑动」)。 窄屏外壳是h-full flex flex-col overflow-hidden、页面是flex-1 flex flex-col,中间没有一层overflow-y-auto—— 内容超出的部分直接被裁,滚不到也点不到。 实测 390px 下内容需 860px、容器 795px,「退出登录」连同下面 65px 一起消失; 1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了- 登录页与初始化页在矮屏(横屏手机、软键盘弹出后)滚不到底:
卡片高约 371px,而
h-full flex items-center在内容超高时让它上下同时 溢出,溢出到顶部那段滚不到(scrollTop最小是 0)。实测 568x280 下 「登录」按钮完全在视口外。改用卡片自己的my-auto—— auto margin 在 空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局, 而高屏仍然垂直居中(390x844 与 1280x800 实测 centered=true)
验证:client/electron/test/manual/(npm run test:narrow / test:wide)
窄屏 18 项 + 宽屏 5 项全通过;结构性断言从 20 条扩到 37 条,
把每一条修复都钉住。
滚动那一项两边都加了检查:结构断言查「七个页面级组件都含 overflow-y-auto」,
实测脚本的 scrollHealth() 查「每页都有滚动容器且没有内容被 overflow-hidden
的父级裁掉」。判据刻意是「有滚动容器」而不是「当前正在滚动」——
内容暂时不够高时后者为假,但页面是健康的。
那套脚本刻意留在仓库里而不是用完就删:结构性断言守不住「按钮实际多大、
点下去命中谁」,而这次最严重的 bug 恰好只有 elementFromPoint 能发现。
它不进 npm test —— 要一个跑着的浏览器加一个活的 Gateway。
7.11 权限档位(plan / workspace / full)
人在派活时声明「这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的 沙箱/审批配置。三档:
| 档位 | 语义 | 权限询问 |
|---|---|---|
plan |
只读:查资料、读代码、出方案,一个字都不许写 | 不产生 —— 直接拒绝,模型该把方案写在回信里 |
workspace |
本目录内可动手,越界要问人(默认档) | 越界时产生 |
full |
自动放行 | 不产生 —— 已声明全权,再问是噪音 |
为什么需要它
此前根本没有权限模型:能不能跑 bash 完全由各平台自己的本地配置决定
(dsh 的 settings.yaml、opencode 的 opencode.jsonc、pi 硬编码守卫三个工具名)。
发件人对此毫无控制也毫不知情 —— 派一件「只是看一下」的活,对方可能直接改文件。
架构:AgentMail 声明,平台执行,插件只翻译
不让插件按工具名自己猜着拦:那会同时违反 I-1(平台原生信号是唯一真相来源)
与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管什么」必然各猜一套 ——
同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。
平台原生能力调研(决定了整个架构):
| 平台 | 原生机制 | 能否只读 | 能否管目录边界 |
|---|---|---|---|
| dsh | setSandboxMode(session, mode),三档写死 |
能(真沙箱) | 能(真沙箱) |
| opencode | session.create({permission:[…]}),action ∈ allow/ask/deny |
能 | 能(glob) |
| pi | 只有 tool_call 钩子 {block:true} |
能(按工具名) | write/edit 能查 input.path,bash 不能 |
| homeagent | 无任何拦截点 | 不能 | 不能 |
dsh 原生三档(read-only / workspace-write / danger-full-access)与
plan/workspace/full 一一对应 —— 不是巧合,是同一个问题的同一个答案。
五条设计决定
- 档位挂
sessions.permission_mode,不挂每封邮件。与配额同理:它是任务的 属性。续谈的信若也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里模型已被告知「只许看」,第二封信改成 full 是在一段已有 上下文里换规则。新建时设,续谈忽略,对话页里显式编辑。 - Agent 不能自己指定档位,新会话从父会话继承(
ModeAtMost(父档, 请求档))。 否则发一封mode=full的信就自我提权了。继承保证 plan 档派不出 full 档子任务 —— 与hop_limit同形:约束必须沿链条传递。 - 平台表达不出精确档位时向更严取整,并如实上报实际强制力。pi 的 bash 在 workspace 档只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整, 而往宽松取整是静默失效(人以为收紧了,实际没有)。
agents.mode_enforcement(心跳自报 native/advisory)。homeagent 是 advisory —— 发件人以为 plan 档管住了它,实际管不住。两个字段(要求档位 / 实际强制力)都要 上界面,差异可见才符合I-5。- 只有 workspace 档需要人,这一条直接决定「找不到人类时怎么办」:plan 档当场 拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会走到「这条链上有没有 人类」,找不到就是 409。
顺带修掉的两处死代码
permission.go的管理员兜底吃掉了 409 分支。原顺序是req.To → 会话 owner → 第一个 active admin → 判 IsHuman,第三步让第四步永远为真,NearestHumanInThread与那段 409 从未被执行。实测确认:pi 给自己新开会话派活跑 bash,权限邮件to_name=jianf,点同意后真跑了。而那段 409 的注释本身就在论证兜底是错的 (「管理员对这条 Agent 链的上下文一无所知」)—— 两条策略互相矛盾,先执行的那条 把后写的那条变成了死代码。删掉兜底后repo.FirstAdminUsername也随之失去唯一 调用点,一并删除。repo.ListSessions从初始提交就是坏的:SELECT 9 列、Scan11 个参数,零调用点。 与ListSessionsFor当年真出过的事故同一个坑(加了预算两列没加进 Scan,/me/sessions整个 500)。留着就得给它也加档位两列,等于维护一个坏且没人用的 东西,删掉更诚实。
实测发现(opencode 的六条,不实测就会做出「看起来对但管不住」的东西)
session.create({permission:[…]}) 确实生效,但:
- 规则是
findLast胜出 → deny 必须放前面、allow 放后面。反了的话连 本该允许的路径也被拒(第一版就写反了,模型自己报「按规则本该通过但实际被拒」)。 - pattern 匹配 worktree 相对路径(
patterns:[relative(y.worktree, file)])→ 写/tmp/**这种绝对 pattern 永远匹配不上。这条最隐蔽:配置看着对,全不生效。 - write / edit / patch 共用
edit一个权限名。 - 全 deny 让工具从模型清单里消失(模型自述「I don't have a bash tool available in this session」),部分 deny 则工具保留、越界调用才报错。plan 档用前者更好: 模型不会浪费轮次去试。
- task(子代理)能绕过父会话权限 —— 实测中模型发现自己没 write,主动委派给
一个带 write 的子代理写成了。plan/workspace 必须
task deny *。 - bash 能绕过 edit 的路径限制 —— 模型用 shell 重定向写成了本该被 deny 的文件。 所以 workspace 档必须同时管 bash,只管 edit 没用。
另:opencode 原生有 plan_enter / plan_exit 权限项,与我们的 plan 档撞名但语义
不同(那是它自己的计划模式开关),不碰。
附带收益:dsh 本机配的是 danger-full-access → approval: "never",而
ApprovalService.decide() 里 if (effectivePolicy === "never") return "rejected"
在 waterfall 之前短路 —— 所以整个「权限转邮件」链路在 dsh 上从未真正跑起来过。
按档位下发 sandbox/mode 后,workspace 档的会话才会拿到 approval: ask。
已完成
models/permission_mode.go:三档常量、NormalizePermissionMode(非法值 fail-closed 到默认档而非 full)、ModeAtMost(继承与取整共用一个判据)、ModeNeedsHuman、native/advisory 强制力。12 例测试 + 2 组负向对照- schema 三处同步:
sessions.permission_mode(旧库默认 workspace,不追授全权)、sessions.permission_enforcement(旧库默认 advisory,不替没自报的插件宣称 「档位在这里是被强制的」)、agents.mode_enforcement repo/permission_mode.go:读写 +InheritedMode继承 +AgentModeEnforcement- 读路径三处加列:
GetSessionByID/ListSessionsFor/ListContactsFor me.go人发信可指定档位(人是权限的源头);非法值报 400 而不是静默用默认档 —— 他以为给了 plan 实际拿到 workspace,比报错更坏permission.go按档位决定这次询问该不该存在;删管理员兜底,409 恢复可达- 日历会话给人类创建者设 owner(否则删掉兜底后,人建的提醒触发时 Agent 的
权限询问会因发件人是
calendar而在线索上找不到人类 → 误伤成 409) - SSE 与补拉路径下发
permission_mode/permission_enforcement(补拉路径必须有:否则 plan 档的任务在插件重启后悄悄变成 workspace 档) lib/permission-mode.js四平台翻译表(三方逐字节相同,已纳入check-shared-libs.sh)。32 例测试 + 4 组负向对照,六条实测结论逐条钉死
P0:判据错误(不修则以上代码失效)
parentMailID == nil不等于「新建会话」 —— 省略 session 位复用默认会话时 它也是 nil。实测:第一封max_rounds=7→ 第二封省略该字段 → 预算被冲成 20。 这是预存 bug(配额那段注释正在论证这不该发生,守卫写错了),而我的permission_mode抄了同一个守卫 —— 第二封信会静默把 plan 档改成 workspace。 修法:resolveTarget返回created bool,只有真新建才设预算与档位- 四个插件
send_mail补传from_session_id—— 否则InheritedMode永远走 回落分支,继承是假的。这是唯一可靠来源:一个 Agent 可同时有多条活跃会话, 服务端猜不出它此刻属于哪条
P1:三条建会话路径漏设档位
forward.go转发 ——LoadForwardSource已返回源邮件(含SessionID), 用InheritedMode(&src.SessionID, 默认档)。不修则 plan 档转发出去就升到 workspacecalendar_events加列permission_mode(schema 三处同步)+ 日历投递接线: - 人建日程可指定;Agent 建日程用它当时所处会话的档位定死,不许自选 - 投递时新建会话 → 用事件档位;复用会话 →ModeAtMost(会话现档, 事件档)取更严,不能因复用而提权 - 堵住提权路径:plan 档的 Agent 建一个日程,触发时新会话拿默认档 workspace —— 它绕过 plan 档去写文件了,只是延迟了几分钟- adopt 接管平台会话 —— 无父会话,用默认档,在
AdoptPlatformSession内显式写入 而不是靠 DB 默认值
P2:数据出不去
GetSessionMails(会话视图)与ListSentBy(发件箱)的models.Mail补两列 —— 只改了ListInbox,前端要显示档位徽标时这两条路径拿不到值PUT /sessions/{id}/permission端点 —— 已在me.go注释里引用但未实现, 对话页要靠它改档
P3:测试
repo/permission_mode_test.go:继承、取更严、脏值回落- handler 档位判定 + 409 可达性(负向对照:恢复管理员兜底 → 用例必须失败)
notify两个新字段(挂真实 SSE 客户端读帧,沿用notify_test.go现有手法)- 预算不被冲的回归用例(钉住 P0 第一项)
P4:插件接线
- dsh:
presets.mount注册工具 +applyPermissionMode(sandbox/mode + approval/policy) + 心跳报native(af61a37) - opencode:
session.create({permission})按六条实测结论下发 + 心跳报native(3bb419f) - pi:tool_call hook 按档位判定(plan 直接 block / workspace 问人 / full 放行)
+ 心跳报
native(0c98fab) - homeagent:
permission_mode.go+ advisory 提示词 + 心跳报advisory(ed37032)
L5 实测结果(2026-09-06)
12 格矩阵已完成 8 格实测。关键发现:
| 平台 | plan | workspace | full | enforcement |
|---|---|---|---|---|
| pi | ✅ bash block(无权限询问) | ✅ bash 一律问人(无法静态判路径) | ✅ bash 直接执行 | native |
| opencode | ✅ briefing 生效,模型自愿遵守 | ✅ briefing 生效 | ✅ briefing 生效 | advisory |
| dsh | ✅ 写文件真被拦(read-only 沙箱;echo 命令仍可跑) | ✅ 目录内可写;/tmp 属允许临时目录不询问 | ✅ danger-full-access | native |
| homeagent | ⚠️ 只在提示词告知,模型不遵守 | ⚠️ 只在提示词告知 | ⚠️ 只在提示词告知 | advisory |
*opencode session.create 1.18.29 不支持 permission 参数,改为提示词 advisory 路径,模型自愿遵守(plan 档回信说明被拒)。dsh Landlock ABI 版本旧导致 partial enforcement。
结论:pi 是唯一 100% 平台级强制。opencode advisory 路径模型自愿遵守。dsh 有真沙箱但内核 ABI 限制了完整性。
P5:前端与文档
- types / API / 卡片档位徽标 / 对话页档位选择器 / 新建邮件档位选择 (两个字段成对显示:要求档位 + 实际强制力)(be61724)
docs/PLUGIN-CONTRACT.md新章节 + 平台差异表补一行(已有)
已知取舍,尚未处理:
- 前端有 Markdown XSS 与窄屏结构性回归,但没有渲染组件跑断言的测试
- 深色主题未做
- 窄屏实测脚本已入库(
client/electron/test/manual/),但没进 CI —— 要一个 headless 环境加一个测试用 Gateway 实例 - 登录限速与新建会话限速已改为 DB 事务(rate_limits 表),多实例部署不再各自计数
- SQLite 抄送查询走
json_each全表展开,无索引;单机量级下够用, 百万级邮件时需要加物化列或换回 PostgreSQL
7.12 全面修正:让平台真正可用(本轮)
本轮起因是排查附件链路,结果连带挖出四类问题。它们的共同形状是静默成功 —— 请求返回 200、日志干净、界面看着正常,而实际的事没有发生。这类 bug 能活很久, 因为没人会去核对一个成功的请求。
A. 附件链路
| # | 问题 | 状态 |
|---|---|---|
| A-1 | homeagent 按顶层 attachment_id 解上传响应,而服务端返回 {"attachment":{…}} → 三个字段全零值,模型看到 id= filename= size=0KB |
已修 |
| A-2 | homeagent 的 send_mail 根本没声明 attachment_ids 参数,提示词还教模型传 attachments:[{…}] |
已修 |
| A-3 | size/1024 让 800 字节的附件显示成 0KB |
已修(formatSize) |
| A-4 | 挂载失败(403/409)时邮件已入库、已通知、预算已扣 —— 收件方收到一封没有附件的邮件,发件方收到 4xx 以为没发出去 | 本轮修 |
| A-5 | 磁盘上 7 个 blob 没有任何库记录指向,GC 永远扫不到(它只按库记录走) | 本轮修 |
A-1 + A-2 叠起来意味着 homeagent 的附件发送从来没成功过一次。 没有任何一层报错:HTTP 200、文件落盘、库里登记,只是那个 id 是空串, 24 小时后 GC 把没人引用的文件清掉,现场不留痕迹。
B. 请求解析:未知字段必须报错
A-2 之所以能活那么久,根因在服务端:json.Decoder 默认忽略未知字段。
$ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
HTTP 200 {"mail_id":"2a64fdc8…", …}
$ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
0
这是 I-5(失败必须当场可见)在请求解析层的落点。改法:Decode 打开
DisallowUnknownFields,并把本端点接受的字段一并列出来 —— 只说「不认识 x」
的话,调用方仍要去翻服务端源码才知道对的拼法,而拼错字段名恰恰是最容易犯、
最难自查的错。
心跳是唯一的例外(DecodeLenient):那条路径的职责是「我还活着」,插件比服务端新、
多带一个字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。但必须在
响应里回报 unknown_fields,否则又变成一次静默忽略。
严格化的爆炸半径已逐个核对(前端 30 个写端点 + 四桥所有 payload + demo 脚本 +
三种嵌套结构),只有一处真的会被打破:日历创建端点缺 status 字段,
而前端 CalendarEventEditor 无条件发它。顺带把 status 的取值也校验上 ——
此前 update 端点接受任意字符串,写进库就成了一个调度器不认识的状态。
C. 人 / Agent 的区分在读路径上缺失
addr-verify 报的两项失败追下去是数据完整性问题而非显示问题:
前端用 mail.to_workspace ? ws : '' 当「这一方是不是 Agent」的判据。
而 to_workspace 为空的 Agent 收件人有 25/118 封(人给 homeagent 发信、
权限决策回信、Agent 间转发…都不带 path 位),于是 pi 被渲染成裸名字、
会话别名跟到了人身上。
判据本身选错了。服务端早就有 IsHumanUser,也已经在收件箱列表路径上算过
from_human,但另外四个读路径(单封读、会话读、发件箱、会话内单封读)都没带。
补 from_human + 新增 to_human,前端改用它。
不能用
session.from_agent代替:它的语义是「谁发起了这条会话」, 实测有 45 封邮件的收件 Agent 不等于from_agent。
D. 「Agent 间不自动转发」没有写进契约
代码四桥齐全(lib/relay-policy.js + relay_policy.go),但 docs/PLUGIN-CONTRACT.md
里只有共用模块表的一句括注,没有规范条款。更糟的是 B-3.4 仍无条件写着
「提示词里写明回信由插件自动发」—— 与规则直接矛盾。照文档实现的新插件会做错。
连带三处:
- SSE
new_mail的字段表缺from_human/in_reply_to/permission_mode/permission_enforcement(四个都已在下发,文档没跟上) deploy/remote-agent-demo.py无条件回信,两个这样的 demo 对上就是 ping-pong,只有会话预算能刹住- 该脚本指向的
docs/PLUGIN-GUIDE.md已在289f37f删除
验收
- A-4:附件挂载失败时邮件不入库(回滚),预算不扣,
relay_key归还 - A-5:
blob.Store可枚举 + GC 反向扫盘,一次跑掉 7 个孤儿 - B:
attachments这类拼错字段名返回 400 且列出正确字段;日历创建带status仍 200 - C:
GET /mail/{id}返回from_human/to_human;addr-verify两项转绿 - D:契约文档有
B-5.6条款;demo 按from_human决定是否回信
7.14 DSH 主动询问邮件桥接(严重阻塞缺口)
本轮生产问题:DSH 邮件会话调用 ask_user_question 后一直停在询问状态,AgentMail
里没有任何待处理邮件。根因不是 SSE 或 Gateway 丢信,而是桥只监听了
approval/request(危险工具的审批 seam),没有接管 ask_user_question 使用的
ctx.userQuestions seam。DSH Web UI 是后者唯一 provider;邮件驱动会话没有人在 DSH
页面作答,所以工具 Promise 永远不返回。
修复边界:
- 在 DSH 的
tools/executearound-dispatch 中只拦截邮件驱动会话的ask_user_question,不替换全局userQuestionsprovider(该服务只允许一个 provider, 强行注册会与 Web UI 冲突)。 - 每个问题按原始
id/question/options/multi_select生成一封 Gateway 待处理邮件; 单选保留选项原文,自由文本与多选用备注输入承载。多问题按顺序询问,全部回答后 还原 DSH 要求的{answers:[{id,selected,custom?}]}工具结果。 - Gateway 的权限请求端点增加
kind=question:复用现有决策人追溯、幂等 relay_key、 待办列表与 SSE 回传,但不套权限档位判定(plan/full 也可能需要补充信息)。kind=permission保持原行为。 - 待决映射必须在发 HTTP 请求前登记,堵住“人快速作答、SSE 先于 map 写入”的竞态; abort、插件卸载、永久 HTTP 失败均 fail closed,不留下悬挂 Promise。
- 前端把询问类待办显示为“等待回答”,自由文本/多选回答未填写时禁止提交;仍复用 现有待处理入口,避免再造第二套不可见队列。
验收:
- 单选询问经邮件选择后,DSH 工具拿到原始选项标签并继续运行
- 无选项询问要求填写文本,空回答不能提交
- 多选询问可提交多个原始标签,未知标签不被伪造为有效选择
- 两个问题按顺序往返,最终答案数组保持原始 question id 与顺序
- plan/workspace/full 三档中的主动询问均可送达;危险工具审批仍只在 workspace 产生
- 非邮件驱动 DSH 会话继续使用 Web UI provider,不受桥影响
7.13 桥进程异常上报与可靠重试
本轮起因:桥进程意外终止只留在 journal,用户邮箱没有任何提示;同时“自动重连” 与“处理中任务重试”被混为一谈。现状其实已有两层:systemd 对进程做指数退避重启, 四桥 SSE 断线后自动重连;但它们不能回答“刚才正在处理的那封邮件怎么办”。
按三层修复,职责不能混:
- 进程层由 systemd 自愈:保留
Restart=+RestartSteps=6+RestartMaxDelaySec=5min。插件不在进程内部造第二个 supervisor。 - 异常退出必须发邮件:共用
deploy/service-failure-notify.mjs挂到ExecStopPost,读取 systemd 的SERVICE_RESULT/EXIT_CODE/EXIT_STATUS;正常停止不报, exit-code/signal/oom-kill/timeout/watchdog 才报。Gateway 暂不可达时落本地 spool, 下次ExecStartPost --flush补发。通知用稳定relay_key幂等,避免即时发送与补发重复。 - 处理中任务必须重试:pi worker 无
done就按 1s/2s 有界重投,最多 3 次;done(ok=false)属于已经给出明确失败结论,不盲目重跑。重试耗尽后给原发件人一封 故障邮件。整进程重启后仍由未读补拉兜底;homeagent 已有落盘 ledger,保留现状。 - 网络层继续重连:SSE 3–5 秒重连、心跳下一周期再试,不把短暂网络抖动升级成 进程重启。
验收:
- notifier dry-run 能区分正常停止与异常退出,敏感密钥不进入正文/日志
- Gateway 暂不可达时报告落盘,
--flush后只发一次 - pi worker 首次异常退出后自动重投,同一会话仍串行
- 连续三次异常后停止重试并调用失败回报,不形成无限崩溃循环
- 四个桥宿主服务保留指数退避重启;正常
systemctl restart不产生误报警邮件
文件清单(完整)
agentmail/
├── docs/
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLAN.md # 本文件
├── server/
│ ├── cmd/server/main.go # 入口 + 路由编排
│ ├── internal/
│ │ ├── config/config.go
│ │ ├── db/
│ │ │ ├── db.go # 连接 + 方言适配(SQLite / PostgreSQL)
│ │ │ ├── migrate.go
│ │ │ └── migrations/
│ │ │ ├── init.sql # PostgreSQL schema
│ │ │ └── init_sqlite.sql # SQLite schema(默认)
│ │ ├── models/
│ │ │ ├── models.go
│ │ │ ├── address.go # name@path.session 解析
│ │ │ └── address_test.go
│ │ ├── repo/
│ │ │ ├── repo.go
│ │ │ ├── users.go
│ │ │ └── keys.go # agent_keys / user_keys
│ │ ├── handler/
│ │ │ ├── helpers.go # 错误映射 + 别名规范化
│ │ │ ├── alias_test.go
│ │ │ ├── agents.go
│ │ │ ├── mail.go
│ │ │ ├── me.go # 人类自己的邮箱(原 human.go)
│ │ │ ├── contacts.go
│ │ │ ├── permission.go
│ │ │ ├── sessions.go # 含 POST /sessions/:id/sync
│ │ │ ├── events.go # SSE(鉴权后分流)
│ │ │ ├── keys.go # 密钥管理
│ │ │ ├── ratelimit.go # 登录失败限速
│ │ │ └── auth.go
│ │ ├── sse/manager.go
│ │ ├── static/static.go # go:embed 前端产物
│ │ └── middleware/
│ │ ├── auth.go # Agent 鉴权
│ │ └── user.go # 人类用户鉴权
│ └── go.mod / go.sum
├── plugins/
│ └── opencode-mail-bridge/ # 第一个接入平台
│ ├── package.json
│ └── index.js # 凭证 + HTTP + SSE + 四个工具 + event 钩子
├── client/electron/
│ ├── src/
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── sse.ts
│ │ ├── stores/
│ │ │ ├── mailStore.ts
│ │ │ ├── sessionStore.ts
│ │ │ ├── contactStore.ts
│ │ │ ├── uiStore.ts
│ │ │ └── authStore.ts
│ │ ├── components/
│ │ │ ├── icons.tsx # 纯 SVG,无 emoji
│ │ │ ├── Sidebar.tsx
│ │ │ ├── MailList.tsx
│ │ │ ├── ContactPanel.tsx
│ │ │ ├── MailView.tsx
│ │ │ ├── ComposePage.tsx # 右侧整页写信
│ │ │ ├── AddressInput.tsx # 三段式补全
│ │ │ ├── LoginPage.tsx
│ │ │ ├── SetupPage.tsx # 首启初始化向导
│ │ │ ├── AccountPage.tsx # 个人中心 + 连接密钥
│ │ │ ├── AdminUsersPage.tsx # 用户管理 / Agent 密钥
│ │ │ └── KeyPanel.tsx # 两处共用的密钥面板
│ │ ├── types/index.ts
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── test/markdown-xss.test.mjs # XSS 回归(npm test)
│ ├── index.html
│ ├── package.json
│ └── vite.config.ts
├── deploy/
│ ├── agentmail-gateway.service
│ ├── opencode-serve.service
│ └── install.sh # 构建 + 嵌入 + systemd 一键装
└── README.md