Archived
文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账十五笔、CLAUDE 快照
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
# 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) |
|
||||
|
||||
状态码怎么读(通则之外本文特有的):
|
||||
|
||||
| 状态 | 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": "<c0ff…@corp.com>", "state": "sent", "providerRef": "250 OK: queued as …" }
|
||||
```
|
||||
|
||||
失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。
|
||||
|
||||
## 2. 入站
|
||||
|
||||
### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `ops`
|
||||
|
||||
按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。
|
||||
|
||||
```json
|
||||
{
|
||||
"mails": [
|
||||
{
|
||||
"id": "in-01J…", "accountId": "6e1c…", "messageId": "<CAF…@mail.acme.com>",
|
||||
"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=` 用 `<a href download>`(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": "<c0ff…@corp.com>", "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` 进信封,活动经上传件读文本。
|
||||
- **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。
|
||||
|
||||
## 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<ImapSmtpSettings>; 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<string, unknown>; 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}'
|
||||
```
|
||||
|
||||
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
|
||||
Reference in New Issue
Block a user