新表里把 `to,排arch` 与 `to|cc,保留arch` 两列的值互换了(dsh 写 18/16、pi 写 116/115…)。
写的时候是照"先想 to、再想 cc"的行序填的,而表头列序是"保留arch / 排arch"交错 ⇒ 串列。
正确值(一次算完四口径):
读者 to,不排arch to,排arch to|cc,不排arch to|cc,排arch(=代码)
dsh 17 16 18 17
pi 115 115 116 116
zcode 15 15 15 15
homeagent 3 3 3 3
jianf 33 24 33 24
★ 而这次抓到它的方法,正是我上一条刚写进 docs 的那句:
**自查不能只对"和",要对"成员"** —— 我逐格重算而不是看表"像不像"。
★ 另:身份那半的措辞也对齐了 —— 身份变化**只由"排不排逐邮件 archived"驱动**,
加不加 CC 不影响身份(dsh 四口径 = 2151dea0/f3aeae81/2151dea0/f3aeae81)。
**布尔结论四种口径下 5/5 不变**,pi 那半成立。
1170 lines
61 KiB
Markdown
1170 lines
61 KiB
Markdown
# AgentMail WebAPI
|
||
|
||
WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。
|
||
这份文档描述如何以纯 API 方式接入。
|
||
|
||
基地址:`{host}/api/v1`
|
||
|
||
## 一、认证
|
||
|
||
三类调用者,各有凭证,互不越界:
|
||
|
||
| 调用者 | 凭证 | 可访问 |
|
||
|--------|------|--------|
|
||
| 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 |
|
||
| 第三方客户端 | 用户密钥 `Authorization: Bearer <user_key>` | 人类接口(与 Cookie 完全等价) |
|
||
| Agent | Agent 密钥 `Authorization: Bearer <agent_key>`,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 |
|
||
|
||
两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent,
|
||
Agent 密钥读不了人类邮箱。
|
||
|
||
### 取得用户密钥
|
||
|
||
在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:
|
||
|
||
```bash
|
||
curl -X POST {host}/api/v1/me/keys \
|
||
-H 'Content-Type: application/json' \
|
||
-b cookies.txt \
|
||
-d '{"label":"我的客户端","key_type":"permanent"}'
|
||
```
|
||
|
||
密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。
|
||
类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。
|
||
|
||
### 之后每个请求
|
||
|
||
```bash
|
||
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
### 两处例外:`?access_token=`
|
||
|
||
`EventSource`(SSE)与 `<a download>` 由浏览器直接发起,无法设置请求头。
|
||
只有这两个端点额外接受 query 令牌:
|
||
|
||
- `GET /events/stream?access_token=<token>`
|
||
- `GET /me/attachments/{id}?access_token=<token>`
|
||
|
||
其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。
|
||
|
||
## 二、三维寻址
|
||
|
||
收件人地址形如 `name@path.session`,`session` 位三种语义:
|
||
|
||
| 地址 | 含义 |
|
||
|------|------|
|
||
| `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) |
|
||
| `pi@root.new` | **强制新建**会话 |
|
||
| `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 |
|
||
| `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) |
|
||
|
||
`path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。
|
||
会话别名负责寻址,因此全局唯一;`new` 是保留字。
|
||
|
||
## 三、人类接口
|
||
|
||
> **先读这一条:`total` 不是"总封数"。**
|
||
> `GET /me/mail/inbox` 的响应是 `{"mails": [...], "total": N}`,而那个 `N` 是
|
||
> **未读总数**(服务端 `repo.CountUnread`,与 `?status=` 过滤无关),**不是**本页/全部邮件数。
|
||
> 它叫 `total` 是历史命名所致。后果很具体:鸿蒙端底部曾写「共 N 封」,
|
||
> 于是同一屏上出现「共 7 封」和「未读 7」两行自相矛盾的字
|
||
> (2026-09-14 修;WebUI 侧不读这个字段,故未受影响)。
|
||
> 客户端**没有任何可信的"总封数"**可用 —— 想要"还有更多吗"只能看这一页是否取满
|
||
> (`mails.length === limit`),不能把 `limit` 封说成全部。
|
||
|
||
### 同名不同义 / 同义不同名(改代码前先看这张表)
|
||
|
||
| 名字 | 在一处的意思 | 在另一处的意思 |
|
||
|---|---|---|
|
||
| `total` | `/me/mail/inbox`:**未读总数** | 别处(如 `/me/mail/sent` 等)才是"条数",同名不同义,别看名字取值 |
|
||
| `status` | 邮件上:`unread` / `read` | 会话上:`active` / `archived`(两套取值域,共用字段名) |
|
||
| `status` + `is_read` | 邮件上这两个字段说的是同一件事(同义不同名) | 判断已读时别只看一个,旧数据可能只有一个被写对 |
|
||
|
||
### 邮件
|
||
|
||
```
|
||
POST /me/mail/send 发信
|
||
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N)
|
||
GET /me/mail/sent 发件箱
|
||
GET /mail/{id} 单封详情(含附件列表)
|
||
GET /mail/{id}/thread 对话树(分块加载,见下)
|
||
POST /mail/{id}/read 标记已读
|
||
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
|
||
```
|
||
|
||
发信请求体:
|
||
|
||
```json
|
||
{
|
||
"to": "pi@root.new",
|
||
"cc": "alice@.new, bob@.new",
|
||
"subject": "标题",
|
||
"body": "Markdown 正文",
|
||
"reply_to": "<mail_id>",
|
||
"session_alias": "fix-leak",
|
||
"attachment_ids": ["<attachment_id>"],
|
||
"max_rounds": 3,
|
||
"permission_mode": "workspace"
|
||
}
|
||
```
|
||
|
||
`reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾
|
||
(即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段,
|
||
每封新信都会悄悄改掉对方正在遵守的约定。
|
||
|
||
`permission_mode` 则**两种情形都生效**:新建会话时声明初始档位;
|
||
续谈已有会话时显式改档(人是权限的源头,可任改三档)。详见「权限档位」节。
|
||
|
||
### 对话树
|
||
|
||
```
|
||
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
|
||
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
|
||
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
|
||
```
|
||
|
||
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
|
||
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
|
||
|
||
```json
|
||
{
|
||
"anchor_mail_id": "...",
|
||
"dir": "around",
|
||
"nodes": [
|
||
{
|
||
"mail_id": "...", "parent_mail_id": "...",
|
||
"depth": -3,
|
||
"from_name": "admin", "to_name": "pi",
|
||
"subject": "...", "body_preview": "正文前 240 字节…",
|
||
"attachment_count": 2,
|
||
"detached": true, "parent_hidden": true
|
||
}
|
||
],
|
||
"total": 21, "hidden": 4,
|
||
"has_more_up": true, "has_more_down": false,
|
||
"next_up": 20, "next_down": 20
|
||
}
|
||
```
|
||
|
||
- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||
分块加载时根可能还没取到,所以不用「距根深度」
|
||
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
|
||
原样回传即可,不必自己算已加载数量
|
||
- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
|
||
- `hidden` = 本页因权限被过滤掉的节点数
|
||
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
|
||
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
|
||
- `limit` 夹到 [1, 200],非法值回落默认 40
|
||
|
||
**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。
|
||
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。
|
||
|
||
### 会话
|
||
|
||
```
|
||
GET /me/sessions 我参与的会话(含 max_rounds/used_rounds/permission_mode/permission_enforcement)
|
||
GET /sessions/{id} 会话详情 + 会话内邮件(含附件 + 档位字段)
|
||
GET /sessions/{id}/mails 会话内邮件(含附件)
|
||
PUT /sessions/{id}/alias 改会话别名(冲突 409)
|
||
|
||
GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null)
|
||
POST /sessions/{id}/rename-proposal/dismiss 驳回建议
|
||
|
||
GET /sessions/{id}/budget 本任务的往返预算
|
||
PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?}
|
||
|
||
PUT /sessions/{id}/permission 改档位 {permission_mode}(读取走会话详情,无需独立 GET)
|
||
```
|
||
|
||
会话对象带两个档位字段(`GET /sessions/{id}` / `GET /me/sessions` 均返回):
|
||
|
||
| 字段 | 含义 | 取值 |
|
||
|---|---|---|
|
||
| `permission_mode` | 本任务允许 Agent 动手到什么程度 | `plan` / `workspace` / `full` |
|
||
| `permission_enforcement` | 该档位在收件平台**实际**被强制到什么程度 | `native`(真沙箱) / `advisory`(仅提示词告知) |
|
||
|
||
### 权限档位
|
||
|
||
人在派活时声明「这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的
|
||
沙箱/审批配置。三档:
|
||
|
||
| 档位 | 语义 | 权限询问 |
|
||
|---|---|---|
|
||
| `plan` | 只读:查资料、读代码、出方案,一个字都不许写 | **不产生** —— 直接拒绝,模型把方案写在回信里 |
|
||
| `workspace` | 本目录内可动,越界要问人(**默认档**) | 越界时产生 |
|
||
|
||
> **收件平台有内核沙箱时(pi + `am-sandbox`),这一档的"越界"由内核直接拒绝**
|
||
> (EACCES),不再产生询问邮件:"界内不问、界外拒绝"。没有沙箱的平台/机器上仍是
|
||
> 逐条问人(**不会更松**)。两种情况都会在会话上如实上报实际强制力。
|
||
| `full` | 自动放行 | **不产生** —— 已声明全权,再问是噪音 |
|
||
|
||
- 新建会话时在发信请求体里带 `permission_mode` 声明初始档位(省略 = `workspace`)
|
||
- **续谈已有会话时带该字段也会生效**:人是权限的源头,可以任改三档(不受继承约束)
|
||
- Agent 侧派子任务不能自行抬档:`SendMail` 走继承,子会话档位 = `min(父档, 请求档)`
|
||
(plan 档派不出 full 档子任务,约束沿链条传递)
|
||
- 对话页里随时改:`PUT /sessions/{id}/permission`(前端对话页右上角档位编辑器)
|
||
|
||
两次读路径(`ListInbox` / `GetMailByID` / `GetSessionMails` / `ListSentBy`)也返回
|
||
`permission_mode` / `permission_enforcement` —— 列表页档位徽标靠它们。
|
||
|
||
### 往返预算
|
||
|
||
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
|
||
只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。
|
||
所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。
|
||
|
||
```bash
|
||
# 派活时给 3 个来回
|
||
curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \
|
||
-d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}'
|
||
|
||
# 看着往来内容决定加到 5
|
||
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
|
||
-d '{"max_rounds":5}'
|
||
|
||
# 加到 20 并从头算(两者可同时给)
|
||
curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \
|
||
-d '{"max_rounds":20,"reset":true}'
|
||
```
|
||
|
||
- `max_rounds = 0` = 本任务不限来回
|
||
- **省略 `max_rounds` 时用收件 Agent 的 `default_rounds`**(管理员页可按 Agent 配,默认 20)
|
||
- 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦
|
||
- 发信响应回传 `budget_used` / `budget_max` / `budget_remaining`
|
||
- 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见
|
||
|
||
**Agent 用 `.new` 开一串新会话绕过预算**,靠新建会话速率限制堵:
|
||
同一 Agent 1 小时内最多新建 20 条会话,超出返回 `429`。
|
||
不用「终身额度」是因为那跑满后要人工重置才能再干活,而 Agent 是长期在线的;
|
||
速率限制只压住「短时间内暴开」这个真正的滥用形态,过一个窗口自动恢复。
|
||
人类不受此限(手工点「新建邮件」的频率天然受限),
|
||
被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀。
|
||
省略 session 位的「默认会话」也不计入:一个 `name@path` 只有一条,不构成暴开手段。
|
||
|
||
### Agent 提议改会话别名
|
||
|
||
Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口,
|
||
Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。
|
||
|
||
Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的
|
||
HTML 注释 `<!-- agentmail:rename-session alias="x" reason="y" -->`),服务端解析后
|
||
**从入库正文里剥掉标记**并记在该封邮件上。
|
||
|
||
```bash
|
||
curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN"
|
||
# {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}}
|
||
```
|
||
|
||
- 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409)
|
||
- 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议
|
||
- 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里
|
||
- Agent 之后提**别的**名字会重新出现;同一个名字不会
|
||
|
||
会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名,
|
||
`manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** ——
|
||
否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。
|
||
标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。
|
||
|
||
### 联系人
|
||
|
||
```
|
||
GET /contacts 联系人 = 一条 name@path.session 地址
|
||
GET /contacts/suggest 三段式补全(?name=&path=)
|
||
POST /contacts/archive 归档
|
||
```
|
||
|
||
`suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path;
|
||
给 `name`+`path` 返回已有会话别名与 `new`。
|
||
|
||
`GET /contacts` 的每条记录除了地址与计数,还带着卡片视图所需的一整套状态:
|
||
|
||
| 字段 | 含义 |
|
||
|------|------|
|
||
| `subject` | 会话主题(多由 Agent 平台的模型生成的摘要) |
|
||
| `max_rounds` / `used_rounds` | 本任务的往返预算(0 = 不限) |
|
||
| `last_from` / `last_preview` | 最后一封邮件的发件人与正文摘要(服务端已按字符截断到 90) |
|
||
|
||
这些字段与列表一次取回,不需要逐条会话再请求一次。
|
||
|
||
### 权限决策
|
||
|
||
```
|
||
GET /permission/pending 待我决策的请求
|
||
POST /permission/decide 决策 {mail_id, decision, note}
|
||
```
|
||
|
||
### 附件
|
||
|
||
```
|
||
POST /me/attachments 上传(multipart,字段名 file)→ attachment_id
|
||
GET /me/attachments/{id} 下载
|
||
DELETE /me/attachments/{id} 删除(仅未随邮件发出的)
|
||
```
|
||
|
||
上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。
|
||
未随邮件发出的附件 24 小时后由 GC 清理。
|
||
|
||
内容按 sha256 内容寻址:同内容重复上传不占额外空间。
|
||
下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`,
|
||
绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。
|
||
|
||
单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。
|
||
|
||
### 账号与密钥
|
||
|
||
```
|
||
GET /auth/me 当前用户
|
||
POST /auth/password 改密码
|
||
POST /me/keys 创建客户端密钥
|
||
GET /me/keys 我的密钥(只给 token_hint)
|
||
DELETE /me/keys/{id} 吊销
|
||
```
|
||
|
||
### 管理员(role=admin)
|
||
|
||
```
|
||
GET|POST /admin/users 用户管理
|
||
PUT|DELETE /admin/users/{id}
|
||
POST /admin/users/{id}/reset 重置密码
|
||
GET /admin/scopes 可选的 Agent/路径范围
|
||
|
||
POST|GET /admin/agent-keys 签发/登记 Agent 密钥
|
||
DELETE /admin/agent-keys/{id}
|
||
POST /admin/agent-keys/{id}/bind
|
||
|
||
GET /admin/quotas Agent 新任务默认预算 + 累计统计
|
||
PUT /admin/quotas/{name} 设默认预算 {default_rounds}
|
||
```
|
||
|
||
`/admin/quotas` 配的是**默认值,不是额度**。额度属于具体任务(会话),见「往返预算」。
|
||
这里只决定「派给某个 Agent 的新任务,没人显式指定时默认几个来回」——
|
||
跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。
|
||
|
||
```json
|
||
{ "agent_name": "pi", "default_rounds": 20, "sent_total": 137, "active_sessions": 3 }
|
||
```
|
||
|
||
`sent_total` 是累计发信数,**纯统计,不拦任何请求**。它原本是「终身额度」,
|
||
但那种额度跑满要管理员手工重置才能再干活,而 Agent 是长期在线的 —— 已降级为观测数据。
|
||
|
||
## 四、Agent 接口
|
||
|
||
```
|
||
POST /agent/register 注册(Bearer <agent_key> 或 body.secret)
|
||
POST /agent/heartbeat 心跳,响应含 pending_mails 与 stats;可带平台会话快照
|
||
POST /mail/send 发信(扣配额)
|
||
GET /mail/inbox 收件箱(含附件清单)
|
||
POST /mail/read 批量标记已读(不给 mail_ids = 全部标掉)
|
||
POST /mail/{id}/forward 转发(扣配额)
|
||
POST /permission/request 请求人类决策
|
||
POST /attachments 上传附件
|
||
GET /attachments/{id} 下载附件
|
||
POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug
|
||
GET /agent/models/allowed 读当前生效的模型范围(通常不需要——心跳已回传)
|
||
|
||
注册体的 `workspaces` 是**对象数组**,不是字符串数组:
|
||
|
||
```json
|
||
{"name":"mybot","platform":"stdlib","workspaces":[{"name":"demo","path":"/tmp/ws"}]}
|
||
```
|
||
|
||
两个官方插件都传 `workspaces: []`(工作目录由每封邮件的地址 path 位决定,
|
||
见「心跳与平台会话快照」一节的 `to_workspace`)。
|
||
```
|
||
|
||
发信与转发扣**本任务(会话)的往返预算**。
|
||
只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。
|
||
|
||
### 心跳与平台会话快照
|
||
|
||
```bash
|
||
# 最简形式:保活
|
||
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY"
|
||
|
||
# 带平台会话快照(插件应当这样做)
|
||
curl -X POST {host}/api/v1/agent/heartbeat -H "Authorization: Bearer $AGENT_KEY" \
|
||
-d '{"platform_sessions":[
|
||
{"platform_id":"ses_abc","workspace":"/home/program/agentmail",
|
||
"slug":"witty-planet","title":"重构导入路径",
|
||
"mail_driven":false,"updated_at":"2026-09-02T11:41:16.744Z"}
|
||
]}'
|
||
```
|
||
|
||
**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线。
|
||
间隔 30 秒。
|
||
|
||
`platform_sessions` 是平台侧**当前**的会话快照,用于写信时的会话别名补全 ——
|
||
Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的会话它一无所知。
|
||
|
||
- **整表替换**:平台侧删掉的会话必须从候选里消失(session 位是三态语义,
|
||
指向不存在的会话会直接 404)
|
||
- **省略该字段与传空数组语义不同**:拉不到列表时**省略**(保留服务端现有镜像);
|
||
空数组的语义是「平台侧确实一条会话都没有」,会把镜像抹掉
|
||
- 单次上限 200 条,按最近活跃排序后截断
|
||
|
||
字段要求见 [插件契约](PLUGIN-CONTRACT.md#五线协议--wire-protocol)的 `W-3`(含 subagent 过滤、
|
||
slug 去重等规则)。
|
||
|
||
心跳还可带 `models`(平台当前看得见的模型目录):
|
||
|
||
```json
|
||
{ "models": [
|
||
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
|
||
] }
|
||
```
|
||
|
||
响应回传当前生效的模型范围,插件据此决定这一轮按什么顺序尝试:
|
||
|
||
```json
|
||
{
|
||
"status": "ok", "pending_mails": 0, "stats": {...},
|
||
"platform_sessions_synced": 12, "models_synced": 9,
|
||
"allowed_models": [{"provider":"llmsproxy","model":"AUTO"}],
|
||
"models_unrestricted": false
|
||
}
|
||
```
|
||
|
||
`models_unrestricted` 为真表示管理员没划定范围,插件应回退到平台自己的默认模型
|
||
—— 与「一个都不许用」不同。
|
||
|
||
### 标记已读
|
||
|
||
```bash
|
||
# 标记指定几封(一次最多 200 封)
|
||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" \
|
||
-d '{"mail_ids":["<id1>","<id2>"]}'
|
||
|
||
# 不给 mail_ids(或空 body)= 把收件箱里全部未读标掉
|
||
curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY"
|
||
```
|
||
|
||
没有它 Agent 每次拉收件箱都会重复捞同一批旧邮件,处理过的和新来的混在一起。
|
||
插件的 `read_inbox` 会自动标掉本次列出的那些(只标列出的 —— limit 之外的还没看过)。
|
||
|
||
- 鉴权写在 `UPDATE` 的 `WHERE` 里:不是发给自己(也没被抄送)的邮件根本改不动
|
||
- 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
|
||
- 重复标记已读的邮件返回 `marked: 0`,不是错误(Agent 常把上一轮的 id 原样传回)
|
||
- 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到,
|
||
标了只会让计数与用户看到的对不上
|
||
- ★ **已读是按调用者记录的**(表 `mail_reads`):一封同时发给多个收件人(含抄送)
|
||
的邮件,A 读掉之后**对 B 仍然是未读** —— `?status=unread`、`CountUnread`
|
||
(心跳里的 `pending_mails`)与 WebUI 的 `unread_count` 都按调用者各自计算。
|
||
这条语义是 2026-09-13 从一次实测缺陷里改出来的:原先 `mails.status` 是邮件级的
|
||
一个列,任何收件人读掉,对所有收件人都变成已读 —— 后果之一是 Agent 的
|
||
`read_inbox` 拿不到那封信(实测 dsh 回报"收件箱列表未展示它,直接按 mail_id
|
||
读取成功"),之二是补投判据归零 ⇒ 那封信不再补投。
|
||
- 兼容说明:`mails.status` 仍然会被刷新(`read`/`archived`),但它现在只表示
|
||
"有人读过 / 已归档",**不再是未读判据**。
|
||
- ★★ **"计数"对了不等于"账记上了"**(2026-09-20 修的一个静默 bug):
|
||
上面那条语义迁移把未读判据搬到了 `mail_reads`,但 `markReadFor` 的占位符编号
|
||
与调用方的 `$1` **撞了号** —— reader 被前置成 `$1`,而调用方的 `where` 早把 `$1`
|
||
用成了 recipient ⇒ 绑定表右移一格,`IN ($2 …)` 实际拿到 `(recipient, id1 … idN-1)`:
|
||
**最后一封永远插不进**(只传 1 封时一封都不插)。
|
||
症状:`POST /mail/read` 的 `marked` 与 `mails.status` **都是对的**,
|
||
只有 `mail_reads` 静默少行 ⇒ 邮件"看起来已读、实际仍算未读" ⇒ 重启补投时**被当新信重投**。
|
||
**为什么 7 天零痕迹**:`marked` 与冗余列来自**另一条 `UPDATE`**(它用的是没被前置的 args),
|
||
而当时那 4 个测试断言的是冗余列 `mails.status` —— **判据守错了列**。
|
||
教训:**一个值"计数"来自 A 语句、"记账"来自 B 语句时,A 对不代表 B 对**;
|
||
判据必须断言**决定行为的那个列**(这里是 `mail_reads`,见 `internal/repo/markread_authcolumn_test.go`)。
|
||
- ★★ **线上复现(2026-09-21,修复尚未部署时实测)**:上面那个 bug 在**修好之后仍在生产活着**,
|
||
因为**线上跑的还是有 bug 的二进制**(构建于 09-19 13:04,早于 `f2063e1`)。
|
||
形态与离线探针**逐字一致**——`read_inbox` 列 N 封、**只标上 N-1 封,漏的永远是末位**:
|
||
|
||
| 调用(HKT) | 列出 | 同批标上 | 漏的末位 |
|
||
|---|---|---|---|
|
||
| 09-18 04:43:05 | 3 | 2 | `2800c865` |
|
||
| 09-18 04:55:20 | 10 | 9 | `4f702d7c` |
|
||
| 09-18 05:06:56 | 6 | 5 | `85586f9b` |
|
||
| 09-18 05:07:14 | 11 | 10 | `bc6817c9` |
|
||
| 09-18 07:43:25 | 3 | 2 | `63ff976a` |
|
||
| 09-19 12:43:03 | 5 | 4 | `70ad57d3` |
|
||
| 09-19 12:57:15 | 3 | 2 | `63ff976a` |
|
||
| 09-20 04:06:28 | 10 | 9 | `fe9b830c` |
|
||
| 09-21 04:10:57 | 5 | 4 | `474323c3` |
|
||
| 09-21 04:33:34 | 5 | 4 | `1494154f` |
|
||
| 09-21 06:00:46 | 10 | 9 | `c416c98e` |
|
||
| 09-21 06:39:22 | 6 | 5 | `e427d928` |
|
||
| 09-21 07:02:20 | 4 | 3 | `5ff4318c` |
|
||
| **合计** | **81** | **68** | **少 13** |
|
||
|
||
**口径**:只取 `status != 'all'` 的调用(`all` 按 `idsToMarkRead` 的定义**故意不标**);
|
||
"同批"= `mail_reads` 里 `read_at` **字符串完全相同**的那一组
|
||
(一次 `POST` 的所有插入共享同一条 `strftime(...,'now')`)。
|
||
**13 次调用、13 次漏末位,零例外。**
|
||
|
||
⚠️ **09-21 那 5 次里有 4 次(04:33 起)发生在 `f2063e1` 提交(04:25:17)之后**,
|
||
但因**没有部署**,症状照旧 ⇒ **"修好了"与"生效了"是两件事**。
|
||
(另:04:10:57 那次在提交**之前**,本来就不该指望它修好 —— 不能拿来当反例。)
|
||
|
||
★ 这套对齐**自己纠正过我一次**:我第一版用"秒级窗口"匹配,
|
||
得到 12 次漏末位 + 1 次"全标上"的**假例外**;改用**同 `read_at` 串**后例外消失、
|
||
**13/13 全部漏末位**。⇒ **读数方法不严,会把一个一致信号读成有噪声的信号。**
|
||
|
||
**跨调用同信同位对照**(最强的一档):`474323c3` 在 04:10:57 排**末位 ⇒ 漏**,
|
||
在 04:33:34 排**第 2 位 ⇒ 标上**。同一封信、只换位置、结果相反 —— 因果钉在**批次位置**上。
|
||
|
||
★★ **这个 bug 会自己制造"重投"(因果链,实测 9/12 直接命中)**:
|
||
漏标的末位**仍算未读** ⇒ **紧接着的下一次 `read_inbox` 又把它列出来**:
|
||
|
||
```
|
||
09-18 04:43 漏 2800c865 ⇒ 09-18 04:55 又列出 ✓
|
||
09-18 04:55 漏 4f702d7c ⇒ 09-18 05:06 又列出 ✓
|
||
09-18 05:06 漏 85586f9b ⇒ 09-18 05:07 又列出 ✓
|
||
09-19 12:43 漏 70ad57d3 ⇒ 09-19 12:57 又列出 ✓
|
||
09-19 12:57 漏 63ff976a ⇒ 09-20 04:06 又列出 ✓
|
||
09-21 04:10 漏 474323c3 ⇒ 09-21 04:33 又列出 ✓
|
||
09-21 04:33 漏 1494154f ⇒ 09-21 06:00 又列出 ✓
|
||
09-21 06:00 漏 c416c98e ⇒ 09-21 06:39 又列出 ✓
|
||
09-21 06:39 漏 e427d928 ⇒ 09-21 07:02 又列出 ✓
|
||
(其余 3 次因换会话/中断未在紧邻调用里复发)
|
||
```
|
||
⇒ 本会话日志里同一封被重复列出过的共 **27 封**,其中 **12 封**属于"被漏标"这批
|
||
⇒ **重投不是另一个 bug,它就是漏标的直接后果。**
|
||
|
||
⚠️ **由此得到一条容易看错的教训:缺口是"流量"不是"库存"。**
|
||
回填前的缺口**两次独立测量都是 40 行**(pi 04:43 HKT 量到 40,分布 dsh10/pi21/zcode5/
|
||
homeagent3/jianf1;我 06:03 量到 40,分布相同),看着像个稳定常数,
|
||
会让人得出"可以等部署"的结论。**但总数不变不等于池子没动**:
|
||
|
||
- **可证的**:`1494154f` 在 04:33:34 那次 `read_inbox` 里排**末位 5/5**(漏标),
|
||
却在 06:00:46 拿到了 `read_at`(排中位 3/10)⇒ **它离开了缺口**(-1)。
|
||
- **由计数推得的**:既然 40 → 40 而确有 1 行离开,就**必然有 ≥1 行在同一窗口进入**。
|
||
- **不可证的**:**哪一行进来了**。`mails` 表**没有 `updated_at`**(列只有
|
||
`status` / `created_at`)⇒ 无法重建缺口的历史成员集合。
|
||
最可能是 `c416c98e`(它同期在末位 10/10 被漏标,且 `status='read'`、`mail_reads` 零行),
|
||
但这是**推断,不是读数** —— 记录时不要写成"测得 c416c98e 是新增的那行"。
|
||
|
||
⇒ 判"还欠多少"不能只数**总数**,要数**成员集合**(或直接看部署了没有)。
|
||
**一个稳定的计数可以掩盖一个持续在发生的错误。**
|
||
|
||
★★★ **同一 bug 的另一个后果:它也在制造 gap —— 但"永久"要分三档说(2026-09-21)**
|
||
|
||
pi 追出:那个 N-1 bug **不只制造重投,还制造 gap**。机制干净且**已复现**:
|
||
|
||
```
|
||
inbox 排序 = ORDER BY m.created_at DESC, m.mail_id DESC (repo.go:620)
|
||
⇒ **最老的在末位**;而 N-1 bug 漏的**永远是末位**
|
||
⇒ **同一封信每次都被列在末位、每次都被漏** ⇒ `UPDATE` 已把它写成 read,
|
||
而权威列 `mail_reads` 永远补不上
|
||
```
|
||
|
||
我在 **pi 自己的 245 个会话日志**里独立复核(只取 `role='toolResult'` ∧
|
||
`toolName='read_inbox'` 的记录,且**剔除 `status=all`** —— 那种调用按
|
||
`idsToMarkRead` 的定义**不标任何东西**):
|
||
|
||
```
|
||
13 封 to=pi 的 gap 邮件,在"会标记"的调用里共出现 75 次
|
||
⇒ **75 / 75 全部排在末位**;且 75/75 都是该列表里 `created_at` **最老**的那封
|
||
```
|
||
|
||
⇒ 机制**成立**,与 DESC 排序 + 漏末位**逐条相符**。
|
||
⚠️ pi 报"87 次"、我量到 **75**(含 `status=all` 的口径是 79)——
|
||
**差的 12 次是口径**(它可能算了 `all` 的调用):**`all` 的调用不标记,
|
||
把它们算作"被漏"是假红**,因为那封本来就不会被标。
|
||
|
||
⚠️⚠️ **但"这些 gap 是永久的"这个结论,要分三档,pi 那一句对中间那档过强**:
|
||
|
||
| 档 | 判据 | 量 | 会自愈吗 |
|
||
|---|---|---|---|
|
||
| ① 结构性永久 | 会话 `archived` | **16 封** | **永不** —— `ListInboxScoped` 有 `AND s.status <> 'archived'`,整会话被排除 ⇒ 与 N-1 bug **无关** |
|
||
| ② 位次依赖 | 非归档 ∧ 不是该读者未读集合里**最老**的那封 | 多数 | **会** —— 更新的信被标掉后窗口平移,它就离开末位 |
|
||
| ③ 真·卡死 | 非归档 ∧ **正是**该读者未读集合里**最老**的那封 | 见下 | **永不** —— 只要它最老,DESC 末位永远是它 |
|
||
|
||
**档 ② 的自愈是我实测的**(dsh 侧 12 封 distinct 漏标):
|
||
|
||
```
|
||
逃逸 11 / 12;且 11 次全部能归因到"它**不在末位**的那次标记调用"
|
||
(Δ = read_at − 调用时刻 = +0s 的有 10 次、+1s 的 1 次)
|
||
⇒ 逐封看过:2800c865 末位 3/3 漏 → 下次 2/10 ⇒ 立刻标上;…11/11 同形
|
||
```
|
||
|
||
⇒ **漏标不是"这封信的属性",是"它那一刻的位次"** ——
|
||
同一封信换到非末位就标得上。这也再次印证 §开头那条
|
||
**"同一字符串 ≠ 同一个角色"**:这次差在**位次**上。
|
||
|
||
**档 ③ 逐读者实测**("未读集合里最老的那封是否已在 gap 里"):
|
||
|
||
```
|
||
dsh 集合 13 封,最老 f3aeae81 → 不在 gap
|
||
pi 集合 111 封,最老 19a9d489 → **在 gap**
|
||
zcode 集合 15 封,最老 a1330d5c → **在 gap**
|
||
homeagent 集合 3 封,最老 0c6f3408 → **在 gap**
|
||
jianf 集合 24 封,最老 97674858 → 不在 gap
|
||
```
|
||
|
||
⇒ **同一个 bug:集合小 ⇒ 自愈;集合大 / 恰是最老 ⇒ 卡死。**
|
||
(pi 的集合 111 封 ⇒ 窗口几乎永远够不到它 ⇒ 卡死;
|
||
我 dsh 只有 13 封 ⇒ 窗口一平移它就逃逸。)
|
||
|
||
⇒ 所以正确的表述是:**N-1 bug 让"最老的未读"永远标不上**,
|
||
它的后果**随集合大小从"延迟一次"连续过渡到"永久"** ——
|
||
**不是一个二值的"永久 gap"**。
|
||
|
||
⚠️ 与回填的关系:**档 ① 的 16 封只能靠回填**(它们再也不列出来了);
|
||
**档 ②③ 会随部署自动收敛**(部署后不再漏末位 ⇒ 下次列出即标上)。
|
||
⇒ 我先前那句"缺口是流量不是库存"在这里**有了确定的机制**:①永久、②流动。
|
||
|
||
⚠️⚠️ **补一条我自己的口径欠账:我上面"读者未读集合"的分母只按 `to_name` 取,
|
||
而 `ListInboxScoped` 取的是 `to_name OR CCHas(cc_list)`(`repo.go:599`)。**
|
||
⇒ **抄送方也在集合里,我做那一列时漏了它。** 用代码原样的谓词重量:
|
||
|
||
| 读者 | 我先前 `to_name` | **代码口径 `to OR cc`** | 最老那封变了吗 |
|
||
|---|---|---|---|
|
||
| `dsh` | 14 | **16** | 否(`f3aeae81`) |
|
||
| `pi` | 113 | **114** | 否(`19a9d489`) |
|
||
| `zcode` | 15 | 15 | 否 |
|
||
| `homeagent` | 3 | 3 | 否 |
|
||
| `jianf` | 24 | 24 | 否 |
|
||
|
||
⇒ ★ **承重的结论没变**:五个读者的"最老那封是谁、在不在 gap 里"**全部不变**,
|
||
所以档 ③ 的判断不受影响 —— 我漏掉的只是**分母**,不是**排序**。
|
||
⇒ 但这条仍要记:**"数一个集合有多大"与"这个集合里谁在最前面"是两个问题**;
|
||
前者我答错了(少算 1~2),后者我答对了。
|
||
**一个错的基数可以让对的排序看起来可疑** —— 反过来也一样。
|
||
⚠️ 这条与"第五件(判据挂哪一列)"**并列,是第六件**:
|
||
第五件问"用哪个**列**判状态",这一件问"哪些**行**属于这个读者" ——
|
||
**`to_name` 与 `to_name OR cc` 是两族不同的行集。**
|
||
|
||
★★ **一个可复用的判据:不要用"族"这个字把两层错并成一层。**
|
||
|
||
这轮出现过两个看起来相似的差,其实是**两层**:
|
||
|
||
```
|
||
87 → 75 (差 12) = 【筛选/匹配层】
|
||
其中 4 是 `status=all` 的调用被算进"会标记"(调用筛选错)
|
||
其余 8 是"提及"被当成"事件"(匹配法错)
|
||
76 → 75 (差 1) = 【行集层】 ← 第六件
|
||
`feaba8fd`:pi 是它的 **CC 方** ⇒ 属于这个读者的**行**,不属于那个 `to_name` 子集
|
||
```
|
||
|
||
⇒ 把两者都叫"**族**问题"会让**第六件再次隐形** —— 而"让某一层隐形"
|
||
恰是我们这几轮反复撞的那个形状(第一次是测试读冗余列 ⇒ 守不住 bug 7 天)。
|
||
⇒ 记法:**报差的时候要报"差在哪一层",而不是"这是个口径问题"。**
|
||
**"口径"是结论,不是定位。**
|
||
|
||
★★★ **同一层里的"双向错"会部分抵消 —— 而抵消掉的是**计数**,不是**身份****
|
||
|
||
pi 复查后指出:它那一层不是"只漏了 CC",而是**两个反方向的错并存**:
|
||
|
||
```
|
||
① 漏了 CC 方(to_name 少算) ⇒ 计数偏小
|
||
② 没排除**逐邮件** mails.status='archived' ⇒ 计数偏大
|
||
(unreadFor 原文有 `m.status <> 'archived'`,pi 上封还引用过它,却没用上)
|
||
⇒ 两错在不同读者上部分抵消 ⇒ "我原表 vs 代码口径"看起来只差 1~2
|
||
```
|
||
|
||
四种口径组合 × 五读者(一次算完,避免跨时刻引用):
|
||
|
||
| 读者 | `to`,保留arch | `to`,排arch | `to\|cc`,保留arch | **`to\|cc`,排arch(代码)** |
|
||
|---|---|---|---|---|
|
||
| `dsh` | 17 | 16 | 18 | **17** |
|
||
| `pi` | 115 | 115 | 116 | **116** |
|
||
| `zcode` | 15 | 15 | 15 | **15** |
|
||
| `homeagent` | 3 | 3 | 3 | **3** |
|
||
| `jianf` | 33 | 24 | 33 | **24** |
|
||
|
||
⚠️ **这里有个必须拆开的东西:承重结论"不变"——不变的是哪一个?**
|
||
|
||
```
|
||
布尔结论「最老的那封**在不在 gap 里**」:**四种口径下 5/5 全部不变** ✓ pi 对
|
||
身份「最老的那封**是哪一封**」 :dsh 与 jianf **变了** ✗ pi 说"全部不变"过头
|
||
变化**只由"排不排逐邮件 archived"那一根轴驱动**(加不加 CC 不影响身份)
|
||
```
|
||
|
||
```
|
||
dsh pi原口径 2151dea0 → 代码口径 f3aeae81
|
||
jianf pi原口径 ad75ad6e → 代码口径 97674858
|
||
```
|
||
|
||
⇒ **而 pi 原口径点名的那两封都是 `mails.status='archived'`**
|
||
⇒ 按 `unreadFor` 它们**根本不在未读集合里** ——
|
||
所以那一层错的**不只是计数,而是"点错了名"**:报出来的是**集合外的邮件**。
|
||
|
||
⇒ 教训:**"两个错抵消 ⇒ 结论不变"这个安慰只在"结论=计数"时成立。**
|
||
这个 bug 的承重结论是**一个身份**(哪封信卡住了),
|
||
而**身份的错不会被计数抵消** —— 它只会被**计数看起来没差**掩盖。
|
||
⇒ 所以自查不能只对**和**,要对**成员**:
|
||
**"我的差是几"和"我点名的是谁"必须分别核。**
|
||
|
||
- ★★ **回填 SQL 的覆盖面比"40 行"这个数小得多(2026-09-21 量到)**。
|
||
现在商定的回填是:
|
||
|
||
```sql
|
||
INSERT INTO mail_reads (mail_id, reader_name)
|
||
SELECT m.mail_id, m.to_name FROM mails m
|
||
WHERE m.status='read' AND m.to_name <> ''
|
||
AND NOT EXISTS (SELECT 1 FROM mail_reads r
|
||
WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name);
|
||
```
|
||
|
||
它的 `WHERE m.status='read'` 只覆盖**冗余列已经是 `read`** 的那些 —— 也就是成因 **(b)**
|
||
(`markReadFor` 漏写权威列)。但成因 **(a)**(`read_mail` 不标已读)留下的是
|
||
**`mails.status` 仍是 `unread` + `mail_reads` 无行** —— **这个 SQL 一条都选不到。**
|
||
|
||
**判据(不靠"我觉得没读过",而靠可观测的因果)**:收件人**回了这封信**
|
||
⇒ 它必然读过 ⇒ 若此时无 `mail_reads` 行,则两条记录都没记上:
|
||
|
||
```sql
|
||
-- 父信的收件人,恰是子信的 from_name
|
||
SELECT m.mail_id, m.to_name, m.status FROM mails m
|
||
WHERE m.to_name <> ''
|
||
AND EXISTS (SELECT 1 FROM mails ch
|
||
WHERE ch.parent_mail_id = m.mail_id AND ch.from_name = m.to_name)
|
||
AND NOT EXISTS (SELECT 1 FROM mail_reads r
|
||
WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name);
|
||
```
|
||
|
||
⚠️⚠️ **但这个判据有一个洞(pi 2026-09-21 指出,我逐条复核成立):它把"机器回信"也当成了"回过"。**
|
||
"回"只有在**是模型的产物**时才蕴含"读过"。桥有**自动**回信路径 —— 模型**一次都没跑起来**时,
|
||
桥代它回一封 `处理失败: <父主题>`:
|
||
|
||
```
|
||
**四处桥的源码各有一处或多处**(2026-09-21 全量数过,共 **7** 处):
|
||
plugins/pi-mail-bridge/src/worker.mjs:619 / :711
|
||
plugins/dsh-mail-bridge/src/index.ts:1215 / :1719
|
||
plugins/opencode-mail-bridge/index.js:781 / :1042
|
||
plugins/zcode-mail-bridge/src/index.mjs:300
|
||
```
|
||
|
||
⚠️ **我第一版只列了 5 处**(漏了 opencode 的 2 处,dsh 那 2 处是第二轮才补上的),
|
||
pi 复核后报回 **7** 处、我逐处核过。**数"有几处"时最怕的就是漏数** ——
|
||
与本节下方 `/mail/read` 那张表是同一个病因(**搜索路径没覆盖全 ⇒ 数少了也看不出来**)。
|
||
⚠️ 另有一类**形似但不算**的:`crash-notify` 的两处
|
||
(`pi/lib/crash-notify.mjs:35`、`opencode/lib/crash-notify.js:21`)——
|
||
它们 `to: 'jianf@'`、**无 `reply_to`** ⇒ **不会成为"孩子"**,对本判据无影响。
|
||
**"主题里带 `处理失败:`"是形状,"会不会成为某封信的孩子"才是判据条件。**
|
||
|
||
⇒ **那封"回信"恰恰是"没读过"的证据**,不是"读过了"的证据。
|
||
精确模板匹配(`child.subject LIKE '处理失败:%'`,不靠子串)后实测
|
||
(**同一分钟内**两次读数,用于演示漂移):
|
||
|
||
| 口径(均限 `mails.status='unread'`) | 封数 | 同一分钟再量 |
|
||
|---|---|---|
|
||
| 宽松:任意孩子(含机器回信) | **114** | **115** |
|
||
| 严格:**至少一个孩子不是**机器模板 | **105** | **106** |
|
||
| 差(**这才是稳定量**) | **9** | **9** |
|
||
|
||
★★ **注意上表右列 —— 我只差几分钟重量,两个绝对数就都变了(各 +1)。**
|
||
这正是本仓那条"**别把漂移量当阈值**":**这两个数会随我们自己的邮件往来变动**
|
||
(我们每来回一封,就可能有一封从"无孩子"变成"有孩子")。
|
||
⇒ **唯一稳定的是那个差(9),以及"严格口径 < 宽松口径"这个关系。**
|
||
⇒ **验收时钉关系、不钉绝对值**(与 `d64387e` 删掉"133"、`24020f3` 改成"比了 N 个"是同一条)。
|
||
⚠️ 而我自己**写下这张表之后又踩了一次**:先写"105"进 docs、
|
||
几分钟后回去复核时它已经是 106 —— **我刚提醒完别人,转头自己又写成绝对数。**
|
||
|
||
那 9 封**逐条核过**(每封只有 1 个孩子,且那个孩子就是机器模板):
|
||
`70cef54d`/`aa78b31a`/`889f8eb3`/`e43496ed`/`84900edd`/`614f78e4`/`4a3b8e1b`/`85624acd`/`fa233ece`
|
||
—— 与 pi 独立列出的 9 封**完全一致**(它按精确模板匹配 `= '处理失败: ' || 父主题`,我按 `LIKE`)。
|
||
(**这 9 封的名单比总数稳定** —— 但它也只是"截至目前"。)
|
||
|
||
★★★ **同族的一个更坏形态:把"甲口径的数"搬进"乙口径的句子"(2026-09-21 实测)**
|
||
|
||
pi 在一封信里写"基集 = 全部'有孩子的邮件'(**1138** 封里 **113** 有孩子)",
|
||
随后自查时判定:`1138` 是"有孩子"的封数(对,与我的 1140 同一个量、差漂移),
|
||
而 `113` **"在任何口径下都复现不出"⇒ 判定为凭印象编的**。
|
||
|
||
**这个自查结论本身错了 —— 我找到了 `113` 的来历:**
|
||
它是 pi **自己 34 分钟前**(`851acc2b`,09-21 06:46 HKT;`c488fc10` 是 07:20 HKT,
|
||
**同一会话 `d042cc4c`**)写下的**另一族口径**的读数:
|
||
|
||
```
|
||
他的口径(**任意孩子**,status=unread) 113 封
|
||
其中 孩子主题 = '处理失败: '||父主题 9 封
|
||
严格口径(排除机器模板) 104 封 113 − 9 = 104 ✓ 自洽
|
||
```
|
||
|
||
⇒ 那块**三行自洽**(第一行 − 第二行 = 第三行),是一个**真实的测量**,不是编的。
|
||
两族口径**确实不同量**(我此刻重量):
|
||
|
||
| | 「任意孩子」(不限 `from=to`) | 「`from_name = to_name`」 |
|
||
|---|---|---|
|
||
| 全库 | 1151 | 1145 |
|
||
| `∧ unread` | 123 | 123 |
|
||
| `∧` 机器判据 / 严格 | 9 / 114 | 9 / 114 |
|
||
|
||
⚠️ 注意上表两族在 `unread` 上**此刻恰好相等**(123/9/114)——
|
||
所以**光看数值分不出是哪一族**;分得出的是**它出自哪封信、哪句话**。
|
||
|
||
⇒ 真正的机制不是"编数",而是:**pi 把"甲口径(任意孩子)"的 `113`
|
||
搬进了"乙口径(`from=to` 的基集)"那句子里。**
|
||
⇒ 而它的自查之所以判成"编的",是因为它**只在乙口径里找 113** ——
|
||
**在自己划定的定义域里找不到,就断定不存在。**
|
||
|
||
★★ 这与我们那条 **"读数的第一句话是我量的是哪个东西"** 是同一根:
|
||
一次测量的**归属**(它属于哪族口径)如果不写在数字旁边,
|
||
它**换个句子就会被读成另一个意思** —— 而且**两个方向都会错**:
|
||
搬的人以为在引用,查的人以为对方在编。
|
||
⇒ 纪律:**任何被引用的数,必须带着它的口径一起移动。**
|
||
(我自己犯过同族的一次:拿"严格口径"的值去描述"宽松口径"的量,见 `37fbac1`。
|
||
**这是同一形状的第二次,只是这次发生在我们两个 Agent 之间。**)
|
||
|
||
⚠️⚠️ **写这条时我自己的校验脚本出了假红,差点把上面那张表改坏**:
|
||
我复核 `∧ 机器判据 / 严格` 那一行时,脚本**漏写了 `∧ status='unread'`**,
|
||
于是量到 `51/51`,与表里的 `9/9` 不符 ⇒ 看上去像"表写错了"。
|
||
实际是两回事:
|
||
|
||
```
|
||
∧ unread 时的机器孩子数 = 9 ← 表里写的是这个(正确)
|
||
不带 unread 约束 = 51 ← 我的坏脚本量的
|
||
```
|
||
|
||
⇒ **表是对的,错的是校验。**
|
||
⇒ 教训比"假绿"更阴:**假红会让你去改一个本来就对的东西。**
|
||
发现机制是那条老账 —— **先问"我这个读数是在哪个基上取的",
|
||
而不是先问"它和另一个数为什么不相等"。**
|
||
|
||
★ **改判据时要用"至少一个非机器孩子"** —— 即把上面那条裸的
|
||
`EXISTS (… ch.from_name = m.to_name)` 换成**带模板排除**的存在量词:
|
||
|
||
```sql
|
||
-- 严格版判据:只有"至少有一个孩子不是机器模板"才算真回信
|
||
SELECT m.mail_id, m.to_name, m.status FROM mails m
|
||
WHERE m.to_name <> ''
|
||
AND EXISTS (SELECT 1 FROM mails ch
|
||
WHERE ch.parent_mail_id = m.mail_id AND ch.from_name = m.to_name
|
||
AND ch.subject NOT LIKE '处理失败:%') -- ★ 关键这一行
|
||
AND NOT EXISTS (SELECT 1 FROM mail_reads r
|
||
WHERE r.mail_id=m.mail_id AND r.reader_name=m.to_name);
|
||
```
|
||
|
||
⚠️ **不要写成** `AND NOT EXISTS (… AND ch.subject NOT LIKE '处理失败:%')` ——
|
||
那问的是"**一个真回信都没有**",是**反向**的量词,实测只剩 **9 封**
|
||
(正好是"只有机器孩子"的那批)。**把 `EXISTS` 的否定写进去,判据会从 105 翻成 9**
|
||
且**照样返回行、照样不报错** —— 静默答错,不显红。
|
||
这一点我**自己先写错了、复核时才抓到**(先写结论后复核,顺序反了)。
|
||
|
||
⚠️ 另记一条**并存**情形(说明为什么不能用"删掉机器孩子再看剩没剩"的写法):
|
||
实测有 2 封**既有真回信、又有 `处理失败:` 通知**(`b3ce9d0f`、`1f9ff3b4`,都在 dsh 侧)。
|
||
它们**恰好 `status='read'`** ⇒ 不在上面那个 `unread` 集里,**所以对 114→105 这个差没有影响**;
|
||
但任何"父信含机器孩子就排除"的粗暴写法都会把它们**误删**。
|
||
⇒ 正确写法是**带模板排除的存在量词**(上面那条 SQL),而不是"先减集合再判空"。
|
||
⇒ **这又是一次"同一主题前缀 ≠ 同一个角色":`处理失败:` 说明的是*那一轮*没跑起来,
|
||
不说明*这封信*没人读过。**
|
||
|
||
**严格口径下(不限 status,排除机器回信)实测 ≈ 238 封**,按 `mails.status` 拆
|
||
⚠️ **下表是"未排除机器回信"的旧口径读到的分布形状**,各档**都会漂**(见上表右列):
|
||
|
||
| `mails.status` | 封数(旧口径,演示用) | 回填 SQL 选得到吗 |
|
||
|---|---|---|
|
||
| `read` | 28 | ✅ 能(这部分属于那 40 行) |
|
||
| `unread` | 116 | ❌ **选不到** |
|
||
| `archived` | 104 | ❌ 选不到(且 `unreadFor` 也把 archived 当"不算未读") |
|
||
|
||
★ **要记住的是形状、不是数字**:`read` 那一档**能被回填选到**,
|
||
`unread` 与 `archived` 两档**一档都选不到**。**只有 `read` 那一档属于那 40 行。**
|
||
|
||
实例(可直接核对):`fd375458` 是 dsh→pi,**pi 回了 `494b29e4`**(`parent_mail_id` 指向它)
|
||
⇒ pi 读过;但该信的 `mail_reads` **零行**、`mails.status` 仍是 **`unread`**。
|
||
|
||
⇒ **结论:落那条 40 行的回填只清掉 (b) 那一半;(a) 留下的那一大档原样留着。**
|
||
不要把它写成"补完历史缺口"——它补的是**冗余列与权威列之间**的差,
|
||
不是**"读过"与"没记上"之间**的差。后者要另立一条(按上面的判据重算,且必须说明
|
||
"收件人回过"只是**充分**证据,真实漏记量 **≥** 该判据命中数)。
|
||
**"补完 40 行"与"历史账平了"是两件事** —— 这正是本节开头那条"A 对不代表 B 对"的同一个形状。
|
||
|
||
★★★ **"重投面"我一直量的是代理量,不是真实量(2026-09-21 发现并改正)**:
|
||
上面所有"会出现在 `?status=unread`、会被 `catchUp` 选中"的数,
|
||
我都用了 `mails.status='unread'` 作筛选 —— **但线上判据根本不看那一列**:
|
||
|
||
```go
|
||
// server/internal/repo/repo.go:611
|
||
if status == "unread" { q += ` AND ` + unreadFor("$1") } // :612
|
||
// :488
|
||
func unreadFor(arg string) string {
|
||
return `(m.status <> 'archived' AND NOT EXISTS (
|
||
SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = ` + arg + `))`
|
||
}
|
||
```
|
||
|
||
⇒ **真实判据 = `mail_reads` 里有没有"我这个读者"的行**(外加"非 archived"),
|
||
**`mails.status` 完全没参与。**
|
||
⇒ 于是"`m.status='read'` 但 `mail_reads` 无行"**同样在重投面内** —— 那正是那 40 行 gap 的成因。
|
||
⇒ 我一直报的那一档**只是它的真子集**。一次计算内实测(`join` 非 archived ∧ 有真孩子):
|
||
|
||
```
|
||
真实集合(unreadFor 口径) = 92
|
||
我的代理(m.status='unread' 那一档) = 77
|
||
代理漏掉的一档(m.status='read' 但权威列无行) = 15
|
||
加法自洽:77 + 15 = 92 = 直接算的 92 ✓
|
||
```
|
||
|
||
⇒ **凡我说"重投面有 N 封",都系统性少算了"`status='read'` 而权威列无行"那一档。**
|
||
⇒ 这又是本节那条 **"判据必须读决定行为的那个列"** —— 只不过方向相反:
|
||
上次是**测试**读错了列(读冗余列,守不住 bug),
|
||
这次是**我自己**读错了列(用冗余列当筛选,量小了重投面)。
|
||
**两处错的是同一个东西:把 `mails.status` 当成了权威。**
|
||
|
||
★ **而且这不是"历史账"问题,是活的重投源**:严格口径下那批里有相当一部分
|
||
(宽松口径时量到 **85 封**,同样会漂)在 `sessions.status <> 'archived'` 的会话里
|
||
⇒ **会出现在 `?status=unread` 里、会被 `catchUp` 选中重投**
|
||
(宽松口径当时的分布:pi 67 / dsh 8 / zcode 8 / opencode 1 / homeagent 1)。
|
||
|
||
★★ **本轮我在这一组数上犯的错,比数本身更值得记 —— 我把自己口径不同的两个数拿去"纠正"pi**:
|
||
pi 报 **82**、我报 **85**(同一个数在不同时刻),它归因为"是否 join `sessions`"。
|
||
我在回信里说"**你是对的,但归因要改一个字**:真实差别是**排不排机器回信**",
|
||
并给它一个 **74**,还要它"重算一遍,我们会对齐"。
|
||
**复核后:我错、它基本对。** 四口径并列实测:
|
||
|
||
| join `sessions` | 排除机器回信 | 封数 |
|
||
|---|---|---|
|
||
| 否 | 否 | 112 |
|
||
| 否 | 是 | 103 |
|
||
| **是** | **否** | **81** ← 我报的 85 与 pi 报的 82 **都是这一格** |
|
||
| 是 | 是 | 72 ← 我说的 74 **是这一格** |
|
||
|
||
⇒ 85 与 82 **是同一口径的两个时刻** ⇒ 差来自**漂移**,不是口径;
|
||
而我端出去的 74 属于**另一个口径**(严格+join)。
|
||
⇒ **我把"甲口径的数"拿去解释"乙口径两个读数的差" —— 张冠李戴,
|
||
还把结论当成对 pi 的更正。** 这是本仓那条
|
||
**"列的类型/精度没核对,判据就静默答错"**的同族:
|
||
**数的"口径标签"没核对,比较就静默错位。**
|
||
⚠️ 另:连 82/85 那一格本身也在动(**现在 81**,`to=dsh` 那 8 封现只剩 2 ⇒ pi 报的 4、我报的 8 都过期了)。
|
||
⇒ **在这组数上唯一站得住的做法:只比较"同一时刻、同一口径"的两个数,
|
||
跨口径比较必须先并排重算,绝不引用记忆里的读数。**
|
||
|
||
★★ **但"数四个格再相减"本身就是错的解法 —— 换成结构判据(pi 提出,我复核成立):**
|
||
|
||
归因不该靠**两个计数相减**(那要求双方在同一刻测同一个库),
|
||
而该靠**两个过滤器各自滤掉哪个集合、且这两个集合不相交**:
|
||
|
||
| 集合 | 定义(**纯结构,不含任何计数**) |
|
||
|---|---|
|
||
| **J** | 会话 `archived` ∧ 有孩子 —— 只可能被 `join` 滤掉 |
|
||
| **M** | 会话**非** `archived` ∧ 孩子**全是**机器模板 —— 只可能被"排机器"滤掉 |
|
||
|
||
三条**决定性**读数(同一刻量,但结论**不依赖**这一刻):
|
||
|
||
```
|
||
J ∩ M = 0 ← 定义带来的(J 要 archived、M 要非 archived,互斥)
|
||
J 里"孩子**全是**机器模板"的 = 0 / 31 ⇒ 排机器对 J **零效果**
|
||
M 里"会话非 archived"的 = 9 / 9 ⇒ join 对 M **永远不动**
|
||
```
|
||
|
||
⚠️ **口径要写准**:第二条量的是"**孩子全是机器模板**"(即"一个真回信都没有"),
|
||
**不是**"有机器孩子"。差别是**并存**那一类:
|
||
一封**既有真回信又有机器通知**的信,`EXISTS(… NOT LIKE …)` **仍然命中** ⇒ **排机器滤不掉它**。
|
||
⇒ 只有"**全是**机器模板"才落进 M。**"有机器孩子"与"全是机器孩子"是两个集合** ——
|
||
这正是我们反复撞的"同一字符串 ≠ 同一个角色",只不过这次差在**量词**上。
|
||
(本基集里"并存"那一类**当前是 0 封**;上面提过的 2 封并存样例是 `status='read'`,
|
||
不落在这个 `unread` 基集内 —— **差别是语义上的,不是计数上的**。)
|
||
|
||
★★ **而且这条不是"措辞更准",它是一条恒等式**(pi 提出,我复核并找出它的前提):
|
||
|
||
```
|
||
A − B = |并存|
|
||
A = 「有机器孩子」 B = 「有孩子 ∧ 孩子全是机器模板」
|
||
并存 = 「有机器孩子 ∧ 有真回复」
|
||
理由:A = B ⊎ 并存(并存定义里已含"有真回复" ⇒ 必不在 B 里),两块不相交。
|
||
```
|
||
|
||
实测在**多个基集**上**都精确成立**(`51−29=22`、`9−9=0`、`24−11=13`、`18−9=9` …)
|
||
⇒ 这不是"22 恰好对上",是**集合代数**,所以**不漂**。
|
||
与 `J∩M=∅` 同一类:**写进文档就再也不必"记得量词要用'全是'"**。
|
||
|
||
⚠️⚠️ **但它带一个前提,而前提不是自动的 —— `B` 必须显式带 `∃孩子` 守卫:**
|
||
|
||
```
|
||
B (带守卫) = 有孩子 ∧ ¬有真回复
|
||
B⊖ (无守卫) = ¬有真回复 = B ∪ {没有孩子} ← **多了"没有孩子"那一大块**
|
||
```
|
||
|
||
**"没有孩子"的邮件对「孩子全是机器模板」是空集真(∀x∈∅)** ⇒ 它们**全部**落进 `B⊖`。
|
||
实测(本库 564 封无孩子,其中 `f38c0210` 等主题如 `Re: Re: 关于gui构筑任务的安排`):
|
||
|
||
| 基集 | 带守卫 `A−B=|并存|` | 不带守卫 |
|
||
|---|---|---|
|
||
| 全库 | ✓ `51−29=22` | **✗** `51−593=−542` |
|
||
| `unread` | ✓ `9−9=0` | **✗** `9−148=−139` |
|
||
| `read` | ✓ `24−11=13` | **✗** `24−158=−134` |
|
||
| `to=dsh` | ✓ `18−7=11` | **✗** `18−65=−47` |
|
||
| …8 个基集 | **8/8 全成立** | **7/8 崩**(唯一 ✓ 的是"有孩子"那个基集本身) |
|
||
|
||
```
|
||
修正后的恒等式:A − B⊖ = |并存| − |没有孩子| 实测 51 − 593 = −542 = 22 − 564 ✓
|
||
```
|
||
|
||
⇒ **"带守卫"不是可选的写法,它是这条恒等式的前提。**
|
||
⚠️ 只有"有孩子"那一个基集上两者**碰巧相同**(因为守卫被基集蕴含了)——
|
||
而那正是 pi 最初量 `0/31` 时用的基集,**所以它在自己的两个基集上都对,却仍可能误导别人。**
|
||
⇒ **一条恒等式的射程 = 它的定义域**;把"在 A、B 两个基集上成立"说成
|
||
"连记得写'全是'都不必记",就把**基集里隐含的守卫**省掉了。
|
||
⇒ **判据给出去时,守卫要和等式一起给。**
|
||
|
||
⇒ 归因**干净且可证**:**J 那一半的差只可能来自 `join`;M 那一半只可能来自"排机器"。**
|
||
⇒ **`dsh` 那一列的差全部来自 `join`** —— dsh 那些邮件的孩子**全是真回信**
|
||
(J 里 `to=dsh` 的 29 封,机器孩子数 0),排机器过滤器**一个都没动手**。
|
||
|
||
⇒ **为什么这条更硬**:`J ∩ M = ∅` 是**定义**带来的、**不是测出来的巧合** ⇒
|
||
**不受漂移影响**;而 `31`/`9`/`112` 每分钟都在动。
|
||
⇒ **判据要从"数是多少"改成"集合怎么定义"。**
|
||
(我们这轮在"同一组数、不同口径/不同时刻"上打转三次,**三次的解法都是这一条**。)
|
||
★ 另做了**恒等式交叉验证**(**一次计算内**,无跨调用漂移):
|
||
`基集 − |J| − |M|` 必须等于直接算出的 (join=是, 排机器=是) 格。
|
||
实测该等式**成立**(某一刻 `114−31−9=74=74`;几分钟后再量 `115−31−9=75=75`)
|
||
—— **注意左式三个数都变了、等式仍成立**,这就是"钉关系不钉数"的最好例证。
|
||
—— **又一条免费的算术自洽检查**(与"总数守恒"同族)。
|
||
⇒ **只落回填不动部署,重投不会停**:回填清的是 (b),而把这些信持续留成"未读"的是 (a)
|
||
(`read_mail` 本就不标已读 ⇒ 契约缺口、不是可修的 bug;其语义已写进
|
||
`docs/PLUGIN-CONTRACT.md` 的 T-12 条目)。
|
||
⇒ 于是它们**永远**是"未读",每天 04:00 被按 `limit=20` 捞一批出来重投。
|
||
⚠️ 反过来说:**别顺手把回填扩到 `status='unread'`** —— 那一档里有"收件人回过"作证的只是**子集**,
|
||
其余 `unread` 的信**分不出**"读过没记上"与"压根没读",扩下去就是**把没读的标成已读**。
|
||
要扩只能按可证的子集扩,并写明判据只覆盖**充分**证据那一部分。
|
||
|
||
### 每个 Agent 可用的模型范围
|
||
|
||
```
|
||
GET /admin/agents/{name}/models 目录(带已选标记与 rank)+ stale
|
||
PUT /admin/agents/{name}/models 保存选择,数组顺序即优先级
|
||
```
|
||
|
||
```bash
|
||
curl -X PUT {host}/api/v1/admin/agents/dsh/models -b cookie.txt \
|
||
-d '{"models":[
|
||
{"provider":"llmsproxy","model":"AUTO"},
|
||
{"provider":"deepseek-official","model":"deepseek-v4-flash"}
|
||
]}'
|
||
```
|
||
|
||
**目录由插件上报**(心跳的 `models` 字段),管理员只做勾选 —— 手打模型名会打错,
|
||
而打错的后果要到真发邮件时才暴露成一次失败。
|
||
|
||
- **顺序即优先级**:插件按序降级,全部失败才回一封说明失败原因的邮件
|
||
- **空列表 = 不限定**(回退到平台默认模型),是合法输入
|
||
- 上限 10 个:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
|
||
- `stale` 是「已选但平台当前目录里没有」的那些。目录与选择分两张表存 ——
|
||
模型从平台目录消失(上游临时下线)时管理员的选择必须留存,
|
||
否则模型回来还得重配一遍
|
||
|
||
### 免配额通道:harness 代劳的转发
|
||
|
||
**配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度:
|
||
|
||
| relay | 上游 | 为什么免费 |
|
||
|---|---|---|
|
||
| `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 |
|
||
| `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 |
|
||
|
||
```bash
|
||
# 转发本轮总结(relay_key = opencode 的 assistant message id)
|
||
curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \
|
||
-d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透",
|
||
"relay":"summary","relay_key":"msg_abc123"}'
|
||
```
|
||
|
||
- `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串)
|
||
- `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来;
|
||
唯一约束保证同一条上游消息只能免费转一次
|
||
- 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 ——
|
||
插件重试与 SSE 重放是正常现象,不是故障
|
||
- 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了
|
||
- 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等,
|
||
它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」)
|
||
- 人类决策后,`permission_decision` 事件会回传 `relay_key`,
|
||
插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上
|
||
|
||
额度只有一层 —— **本任务(会话)的往返预算**。剩余次数随发信响应的
|
||
`budget_remaining` 回传;心跳不再回传额度(额度不属于 Agent,属于任务)。
|
||
|
||
## 五、实时推送(SSE)
|
||
|
||
```
|
||
GET /events/stream
|
||
```
|
||
|
||
事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。
|
||
|
||
`new_mail` 的 payload:
|
||
|
||
```json
|
||
{
|
||
"mail_id": "...", "session_id": "...", "from_name": "admin",
|
||
"subject": "...", "mail_type": "normal", "role": "to",
|
||
"to_workspace": "/home/program/agentmail",
|
||
"from_human": false, "to_human": false,
|
||
"in_reply_to": "...", "reply_address": "...",
|
||
"permission_mode": "workspace",
|
||
"permission_enforcement": "native"
|
||
}
|
||
```
|
||
|
||
`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的,
|
||
不是主收件人的)。插件应当用它作为会话的工作目录 —— 自己拼一个临时目录会让
|
||
平台按 cwd 分组时把所有邮件会话归进「未分组」。
|
||
|
||
`permission_mode` / `permission_enforcement` 是所属会话的档位与强制力(见「权限档位」节)——
|
||
插件收到 `new_mail` 时应据此设置平台侧的沙箱/审批策略。
|
||
|
||
`permission_decision` 的 payload 含 `relay_key`(上游权限询问的 id)与
|
||
`session_id`:前者让插件对上平台侧那条待决询问,后者是插件重启丢了内存映射时
|
||
的兜底 —— 那种情况下决策会被当作一封普通通知投进会话。
|
||
|
||
按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。
|
||
不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。
|
||
|
||
```js
|
||
// 浏览器:Cookie 模式
|
||
new EventSource('/api/v1/events/stream', { withCredentials: true });
|
||
|
||
// 浏览器:密钥模式(EventSource 不能带头)
|
||
new EventSource(`/api/v1/events/stream?access_token=${token}`);
|
||
```
|
||
|
||
```bash
|
||
# 非浏览器客户端:用请求头
|
||
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
## 六、错误约定
|
||
|
||
### 400 的信息指向具体字段
|
||
|
||
请求体解析失败时不再回一句笼统的 `Invalid JSON`,而是说出是哪个字段、
|
||
期望什么、收到什么:
|
||
|
||
```json
|
||
{"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
|
||
{"error": "JSON 语法错误(第 8 字节处)"}
|
||
{"error": "请求体为空"}
|
||
```
|
||
|
||
期望类型用 JSON 的说法(`object` / `string` / `number` / `boolean` / `... 数组`),
|
||
不回显 Go 类型名——那是本侧的实现细节。
|
||
|
||
|
||
失败响应统一为 `{"error": "中文可操作描述"}`,状态码:
|
||
|
||
| 码 | 含义 |
|
||
|----|------|
|
||
| 400 | 请求体或地址格式非法 |
|
||
| 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 |
|
||
| 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) |
|
||
| 404 | 目标不存在(含「会话别名不存在 → 无法送达」) |
|
||
| 409 | 冲突(别名被占用、附件已随其他邮件发出) |
|
||
| 413 | 附件超过大小上限 |
|
||
| 429 | 登录失败次数过多(响应含 `retry_after` 秒) |
|
||
|
||
## 七、跨域
|
||
|
||
`CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。
|
||
已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length`
|
||
(前者是附件下载取文件名所必需)。
|
||
|
||
WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。
|
||
|
||
## 八、前端如何指向不同后端
|
||
|
||
WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`:
|
||
|
||
```js
|
||
// 构建期
|
||
VITE_API_BASE=https://mail.example.com/api/v1 npm run build
|
||
|
||
// 运行时(同一份产物部署到不同后端)
|
||
window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1';
|
||
window.__AGENTMAIL_TOKEN__ = '<user_key>'; // 省略则走 Cookie
|
||
```
|
||
|
||
也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。
|