Files
MailUI4Agents/docs/API.md
JianFeeeee 9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00

23 KiB
Raw Blame History

AgentMail WebAPI

WebUI 与第三方客户端调用的是同一套 HTTP API,没有任何「仅前端可用」的私有通道。 这份文档描述如何以纯 API 方式接入。

基地址:{host}/api/v1

一、认证

三类调用者,各有凭证,互不越界:

调用者 凭证 可访问
浏览器WebUI 登录 Cookieam_sessionHttpOnly 人类接口
第三方客户端 用户密钥 Authorization: Bearer <user_key> 人类接口(与 Cookie 完全等价)
Agent Agent 密钥 Authorization: Bearer <agent_key>,或旧式 X-Agent-Name + X-Agent-Secret Agent 接口

两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent Agent 密钥读不了人类邮箱。

取得用户密钥

在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调:

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)。

之后每个请求

curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"

两处例外:?access_token=

EventSourceSSE<a download> 由浏览器直接发起,无法设置请求头。 只有这两个端点额外接受 query 令牌:

  • GET /events/stream?access_token=<token>
  • GET /me/attachments/{id}?access_token=<token>

其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。

二、三维寻址

收件人地址形如 name@path.sessionsession 位三种语义:

地址 含义
pi@root 投递到 piroot默认会话(从未通信则建立)
pi@root.new 强制新建会话
pi@root.fix-leak 投递到别名 fix-leak已有会话;不存在则 404「无法送达」
jianf@.new 人类用户也是 name 位的一等公民path 可空)

path 内可含 /.,解析时按最后一个 . 切分 session 位。 会话别名负责寻址,因此全局唯一;new 是保留字。

三、人类接口

邮件

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  转发(引用原文 + 附件随行)

发信请求体:

{
  "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
}

reply_to 让回信落回原会话;session_aliasmax_rounds 仅在 to.new 结尾 (即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段, 每封新信都会悄悄改掉对方正在遵守的约定。

对话树

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 编码:回复指向来信,转发指向被转发的原件。 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。

{
  "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_preview240 字节,按 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
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?}

往返预算

配额的语义是「这件事值得多少个来回」—— 那是任务的属性,不是 Agent 的属性。 只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。 所以预算落在会话上:写信时用 max_rounds 给,之后在对话页里随时调。

# 派活时给 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" -->),服务端解析后 从入库正文里剥掉标记并记在该封邮件上。

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 按参数递进:无参返回可用 namename 返回该 Agent 的 pathname+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 就能在本站域下执行脚本)。

单个附件默认上限 25MBAGENTMAIL_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合理来回数差一个量级。

{ "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 条,按最近活跃排序后截断

字段要求见 插件适配指南(含 subagent 过滤、 slug 去重等规则)。

心跳还可带 models(平台当前看得见的模型目录):

{ "models": [
    { "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
] }

响应回传当前生效的模型范围,插件据此决定这一轮按什么顺序尝试:

{
  "status": "ok", "pending_mails": 0, "stats": {...},
  "platform_sessions_synced": 12, "models_synced": 9,
  "allowed_models": [{"provider":"llmsproxy","model":"AUTO"}],
  "models_unrestricted": false
}

models_unrestricted 为真表示管理员没划定范围,插件应回退到平台自己的默认模型 —— 与「一个都不许用」不同。

标记已读

# 标记指定几封(一次最多 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 之外的还没看过)。

  • 鉴权写在 UPDATEWHERE 里:不是发给自己(也没被抄送)的邮件根本改不动
  • 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败
  • 重复标记已读的邮件返回 marked: 0不是错误Agent 常把上一轮的 id 原样传回)
  • 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到, 标了只会让计数与用户看到的对不上

每个 Agent 可用的模型范围

GET /admin/agents/{name}/models    目录(带已选标记与 rank+ stale
PUT /admin/agents/{name}/models    保存选择,数组顺序即优先级
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 连交代都做不了
# 转发本轮总结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 只接受 permissionsummary(白名单,不是任意字符串)
  • 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

事件类型:connectednew_mailpermission_decisionsession_updatesession_archivedagent_online

new_mail 的 payload

{
  "mail_id": "...", "session_id": "...", "from_name": "admin",
  "subject": "...", "mail_type": "normal", "role": "to",
  "to_workspace": "/home/program/agentmail"
}

to_workspace收件方那个地址的 path 位(抄送方拿到的是自己那个地址的, 不是主收件人的)。插件应当用它作为会话的工作目录 —— 自己拼一个临时目录会让 平台按 cwd 分组时把所有邮件会话归进「未分组」。

permission_decision 的 payload 含 relay_key(上游权限询问的 idsession_id:前者让插件对上平台侧那条待决询问,后者是插件重启丢了内存映射时 的兜底 —— 那种情况下决策会被当作一封普通通知投进会话。

按收件人分流Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。 不能只报 X-Agent-Name 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。

// 浏览器Cookie 模式
new EventSource('/api/v1/events/stream', { withCredentials: true });

// 浏览器密钥模式EventSource 不能带头)
new EventSource(`/api/v1/events/stream?access_token=${token}`);
# 非浏览器客户端:用请求头
curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN"

六、错误约定

400 的信息指向具体字段

请求体解析失败时不再回一句笼统的 Invalid 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-DispositionContent-Length (前者是附件下载取文件名所必需)。

WebUI 内嵌在 Gateway 中时同源,不涉及 CORS独立部署的 Web 客户端需要配置此项。

八、前端如何指向不同后端

WebUI 的 src/api/ 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 src/api/config.ts

// 构建期
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 与密钥的差异。