# API 文档 —— 邮件收发(ADR-013) > 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用); > 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。 > 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`; > 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。 ## 0. 通则 | 项 | 值 | |---|---| | 前缀 | `/api/gov/mail/*`;**15 条全部 `access: 'ops'`**(读写都归运营中心)—— 邮箱那一屏落在运营中心,设计端没有消费方(测试台读的是通道配置,不是邮箱账号);入站正文装的是客户来往内容,ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽;`route-access.test.ts` 那份「两端共用读路由」名单也因此没有再长(见 ADR-013 决策 10) | | 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` | | 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 | | 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) | | 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) | | 游标推进 | 两条路不是同一种:IMAP 随每一封成功入库**逐封前进**;企业微信是**整窗全有或全无**——一轮吃不完这一窗的新信,游标就原样不动、下一轮整窗重来(已入库的会被滤掉,不会重复处理),被截断的信不会跳过 | 状态码怎么读(通则之外本文特有的): | 状态 | code | 何时 | |---|---|---| | 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 | | 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 | | 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** | | 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) | ## 1. 账号 ### `GET /api/gov/mail/accounts` —— access: `ops` ```json { "accounts": [ { "id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部", "enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null, "settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" }, "secretBound": true, "status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false }, "createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z" }, { "id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false, "pollIntervalSec": 60, "channelId": null, "backfillFrom": null, "settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" }, "secretBound": false, "status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false }, "createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z" } ] } ``` - `address`:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。 - `status.cursor` 是人读摘要(还没拉过 = null);`status.polling` 为真表示此刻某台后端正拉着。 - `status.lastPollError` 是厂商原话(脱敏);为 null 表示上一轮没出错。 ### `POST /api/gov/mail/accounts` —— access: `ops` body 按 `kind` 分两形(缺省值见 §5 schema): ```json { "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部", "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } } ``` ```json { "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } } ``` → 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。`isDefault:true` 会把别的账号的默认位放下。 ### `POST /api/gov/mail/accounts/probe?accountId=` —— access: `ops` **未存**的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 `?accountId=` 借库里那份密文。 没 secret 又没 `accountId` → 412。 ```json { "kind": "imap-smtp", "address": "sales@corp.com", "receive": { "ok": true, "latencyMs": 412 }, "send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } } ``` ```json { "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work", "receive": { "ok": true, "latencyMs": 230 }, "send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } } ``` ### `GET /api/gov/mail/accounts/:accountId` —— access: `ops` → 账号视图;404。 ### `PUT /api/gov/mail/accounts/:accountId` —— access: `ops` body 同建账号;`secret` 缺省 = 不改密文;换 `kind` 又没给 secret → 旧密文作废(`secretBound:false`); `kind` 或 `settings` 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。 ### `DELETE /api/gov/mail/accounts/:accountId` —— access: `ops` → `{ "ok": true }`。留痕(入站 / 出站)不删。 ### `POST /api/gov/mail/accounts/:accountId/probe` —— access: `ops` 已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 `address`。412 凭证空。 ### `POST /api/gov/mail/accounts/:accountId/poll` —— access: `ops` 立即拉一轮(不等轮询到点): ```json { "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false } ``` - `pulled` 服务器交出几封;`stored` 真新入库几封(去重后);`dispatched` 分发过几封;`more` 为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。 - 出错:`error` 带原话(同时写进账号的 `status.lastPollError`),仍是 200。 - 409:另一台后端正占着租约;412:账号停用。 ### `POST /api/gov/mail/accounts/:accountId/send-test` —— access: `ops` body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` → ```json { "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "", "state": "sent", "providerRef": "250 OK: queued as …" } ``` 失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。 这条路**不带 `idempotencyKey`**,不受 §4「发」那三支幂等逻辑保护 —— 每次调用都是一封新信,重试会真的重发一遍。 ## 2. 入站 ### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `ops` 按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。 ```json { "mails": [ { "id": "in-01J…", "accountId": "6e1c…", "messageId": "", "from": { "address": "buyer@acme.com", "name": "张三" }, "to": [{ "address": "sales@corp.com", "name": "" }], "cc": [], "subject": "询价 RFQ-2026-081", "sentAt": "2026-08-25T09:57:40.000Z", "receivedAt": "2026-08-25T09:58:11.000Z", "textPreview": "你好,请报价以下料号……", "hasHtml": true, "attachments": [ { "index": 0, "name": "BOM.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "size": 18234, "ref": "3f9e…", "note": "" }, { "index": 1, "name": "datasheets.zip", "contentType": "application/zip", "size": 9437184, "ref": null, "note": "附件 9.0MB,超过 8MB 上限,未存原件" } ], "deliveries": [ { "subscriber": "channel", "state": "done", "error": null, "startedAt": "2026-08-25T09:58:11.200Z", "finishedAt": "2026-08-25T09:58:11.480Z" } ] } ] } ``` `deliveries[].state`:`dispatching`(正在投;超过 10 分钟还是它 = 进程中断,交人重投)/ `done` / `skipped`(`error` 里是理由,如「账号未绑定通道」)/ `failed`(`error` 是原话,如「通道实例已停用:ch-rfq-mail」)。 ### `GET /api/gov/mail/inbound/:mailId` —— access: `ops` 列表那一项 + `inReplyTo` `references[]` `text` `html` `size` `rawAvailable`。404。 ### `GET /api/gov/mail/inbound/:mailId/attachments/:index` —— access: `ops` → `{ "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形)。可带 `?ticket=` 用 ``(ADR-012 §6)。 超限没存的(`ref: null`)→ 404。附件同时也是一个上传件(`ref` = `sys.upload` 的 id),活动那一侧读得到抽出来的文本。 ### `GET /api/gov/mail/inbound/:mailId/raw` —— access: `ops` → `{ "name": "CAF…_mail.acme.com.eml", "contentType": "message/rfc822", "dataBase64": "…" }`;超 16MB 没存 → 404。可带 `?ticket=`。 ### `POST /api/gov/mail/inbound/:mailId/redeliver` —— access: `ops` body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不带)→ `{ "deliveries": [ … ] }`。 正在 `dispatching` 的那条跳过(原样回);不认识的订阅方 404。**从这一刻起对该订阅方是至少一次**:channel 那条链会再起一单。 ## 3. 出站 ### `GET /api/gov/mail/outbound?accountId=&limit=` —— access: `ops` ```json { "mails": [ { "id": "c0ff…", "accountId": "6e1c…", "messageId": "", "to": ["buyer@acme.com"], "cc": [], "bcc": [], "subject": "回复:询价 RFQ-2026-081", "state": "sent", "error": null, "providerRef": "250 OK: queued as …", "requestedBy": { "kind": "tool", "tool": "mail.send" }, "createdAt": "2026-08-25T11:02:00.000Z", "sentAt": "2026-08-25T11:02:01.300Z" } ] } ``` `requestedBy.kind`:`tool`(活动放行时经 `mail.send` 落地)/ `channel`(channel.sendMail)/ `ops`(运营面试发,带 `staffId`)。`state`:`sending`(在发;久停不动 = 进程中断)/ `sent` / `failed`。 ## 4. 活动怎么用(不是 HTTP) - **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。 线程身份取来信 `References` 的**首项**(即这一串的根),没有 `References` 时退回 `In-Reply-To`,全新来信两者皆无则不带线程 —— 归属解析①级靠它与我方出站登记的线程号(同样是首封的 messageId)对表;若认最近一跳,回信来回一趟就会对不上,退化成③级地址 + 时间窗。 - **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。 放行落地会带 `idempotencyKey`(同一次放行重放不发两封),重试分三种局面:上一封已 `sent` → 直接短路回那次的结果,不再发; 上一封 `failed`,或停在 `sending` **超过 5 分钟**(算作上次进程崩在半路)→ 复用那一行重发;停在 `sending` **不足 5 分钟** (算作正在飞)→ **不发**,如实回 `state:'failed'` 加一句「同一幂等键的上一封仍在发送中,本次未重复发送」,过一会再放行会重试。 复用那一行之前还要**抢**(一条条件更新):两个后端连同一个库时两台会同时判出「可以复用」,抢不到的那一路回 「同一幂等键的上一封正被另一路重发,本次未重复发送」,一封真信只出一次。 - **发出去的信不登记 `outbound_ref`**(实情,2026-08-25 终审核实):`event.recordOutbound` 的唯一调用方是 `channel.sendMail`,而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走的是 `deps.tools.execute` → `mail.send` 工具这条路, 它不经 channel,也就不写 `sys.outbound_ref`。所以:**活动经 `mail.send` 发出的信,回信归属只靠②字段提取与触发器** (以及③地址 + 时间窗);①级(`outbound_ref` 对表)目前只对 channel 路成立,而 channel 路暂无调用方。 下面 §5 里 `sys.mail_outbound.message_id`「线程级归属的对表来源」那句话,对生产唯一的发信路径**尚不成立**。 根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(跨模块契约变更,单独一件事),已记 `docs/debt.md`。 ## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名) ```ts type MailAccountKind = 'imap-smtp' | 'wecom'; type ImapSmtpSettings = { imapHost: string /* imap.exmail.qq.com */; imapPort: number /* 993 */; imapTls: boolean /* true */; smtpHost: string /* smtp.exmail.qq.com */; smtpPort: number /* 465 */; smtpTls: boolean /* true = 隐式 TLS;false = STARTTLS */; login?: string /* 缺省 = address */; folder: string /* INBOX */ }; type WecomSettings = { corpId: string; apiBase: string /* https://qyapi.weixin.qq.com */ }; // POST / PUT 的 body(缺省值:displayName '' · enabled true · isDefault false · pollIntervalSec 30(10–3600)· channelId null · backfillFrom null) type MailAccountInput = | { kind: 'imap-smtp'; address: string; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number; channelId?: string | null; backfillFrom?: string | null; settings?: Partial; secret?: { password: string } } | { kind: 'wecom'; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number; channelId?: string | null; backfillFrom?: string | null; settings: { corpId: string; apiBase?: string }; secret?: { corpSecret: string } }; type MailAccountStatus = { lastPolledAt: string | null; lastPollError: string | null; lastInboundAt: string | null; cursor: string | null; polling: boolean }; type MailAccountView = ( | { kind: 'imap-smtp'; settings: ImapSmtpSettings } | { kind: 'wecom'; settings: WecomSettings } ) & { id: string; name: string; address: string; displayName: string; enabled: boolean; isDefault: boolean; pollIntervalSec: number; channelId: string | null; backfillFrom: string | null; secretBound: boolean; status: MailAccountStatus; createdAt: string; updatedAt: string }; type MailProbeReport = { kind: MailAccountKind; address?: string; receive: { ok: boolean; error?: string; latencyMs?: number }; send: { checked: boolean; ok?: boolean; error?: string; note?: string } }; type MailPollOutcome = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; type MailAddress = { address: string; name: string }; type MailDeliveryView = { subscriber: string; state: 'dispatching' | 'done' | 'failed' | 'skipped'; error: string | null; startedAt: string; finishedAt: string | null }; type MailInboundAttachment = { index: number; name: string; contentType: string; size: number; ref: string | null; note: string }; type MailInboundSummary = { id: string; accountId: string; messageId: string; from: MailAddress; to: MailAddress[]; cc: MailAddress[]; subject: string; sentAt: string | null; receivedAt: string; textPreview: string; hasHtml: boolean; attachments: MailInboundAttachment[]; deliveries: MailDeliveryView[] }; type MailInboundDetail = MailInboundSummary & { inReplyTo: string | null; references: string[]; text: string | null; html: string | null; size: number; rawAvailable: boolean }; type MailSendResult = { outboundId: string; accountId: string; messageId: string; state: 'sent' | 'failed'; error?: string; providerRef?: string }; type MailOutboundView = { id: string; accountId: string; messageId: string; to: string[]; cc: string[]; bcc: string[]; subject: string; state: 'sending' | 'sent' | 'failed'; error: string | null; providerRef: string | null; requestedBy: Record; createdAt: string; sentAt: string | null }; ``` ## 6. curl 实录 ```bash TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \ -d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json') # 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码) curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \ -d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq . # 探测(已存)/ 立即拉取 / 试发 curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq . curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq . curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq . # 来信、附件、重新投递 curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}' curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq . # 出站 curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}' ``` 示例响应按契约构造;真实响应待账号配好后用上面的序列换。