This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/API文档-邮件收发.md
xcsandClaude Fable 5 816389a185 邮件收发终审修复轮:token 按凭证指纹、毒信有退路、只改发信设置不丢信、重发先抢、列表不搬原件、save 不盖运行列、重锚出声、脏游标不炸页、线程口径归一
全分支终审(跨任务视角)逮出的十条 P0 + 八条 P1,一次修完。每条都配了测试。

P0-1 企业微信 token 缓存改按**凭证指纹**(sha256(corpId,corpSecret),secret 不进 Map 的键):
  按 account.id 存有两条真实串号路径 —— 草稿探测共用 'draft'(探完 A 公司再探 B 公司会命中 A 的
  token,回的是 A 的应用邮箱地址,凭证填错的 B 也显示成功);已存账号换了凭证 id 不变,
  下一轮轮询继续拿旧 token 拉旧 corp 的收件箱,分发给新账号绑的通道(窗口 ≤ 7200s)。
P0-2 一封「毒信」不许卡死整条路:①domain 的 buildInboundRecord 把落 text/jsonb 的字段一律剥 NUL
  (PG 拒收它,MIME 解出二进制垃圾时真会出现);②单封失败先落**降级骨架**(原件原样存、
  主题从原始字节扒、正文写明失败原因)→ 落成了就推进游标并把「第 N 封(providerRef)解析失败,
  已降级存档」写进 last_poll_error;骨架也落不下才停在这一封,那时 error **点名是哪一封**。
  降级行不自动分发(正文是失败原因,不是客户的话)。
P0-3 cursorReset 只比**收侧**键(imapHost/imapPort/imapTls/login/folder;corpId/apiBase):
  从前比全部 settings,把 465 改成 587 也会清游标 → IMAP 重锚 → 上次游标到重锚之间的来信永远不拉。
P0-4 复用旧行重发**先抢**(OutboundStore.claimForResend,一条条件更新;真 store 与内存件同款):
  两个后端连同一个库,两台同时重放同一次放行都判「超时可复用」→ 两封真邮件,破掉「同键不发两封」。
P0-5 列表与只要元信息的读**不取 raw**(一封上限 16MB × 200 行);rawAvailable 由 `raw is not null`
  投影,不靠取回字节再判;只有「下载原件」走全列的 get()。InboundRecord 新增 rawAvailable。
P0-6 账号 save() 只写**配置列**:运行列(cursor/last_polled_at/last_poll_error/last_inbound_at/
  polling_until)只经 savePoll/claimPolling/releasePolling 改 —— 整行 upsert 会把另一台正持着的租约
  与刚推进的游标盖回读那一刻的快照(08-24 loop_state 整份覆盖同款指纹)。清游标改为显式单写那一列。
P0-7 UIDVALIDITY 变化不再静默:provider 回 note,用例落进 last_poll_error(「已重锚在当下;
  期间来信未拉取」)—— 丢了一段信而账号页一片绿是界面撒谎。
P0-8 parseCursor 连取值范围一起查(非负安全整数 + 界内:UID ≤ 0xFFFFFFFF、秒 ≤ Date 上限)——
  1e13 正是安全整数,乘 1000 出界,一行脏游标能让 listAccounts 整页 500。debt ⑨ 划线销账。
P0-9 线程口径归一:threadIdOf 改成**取根**(references[0] ?? inReplyTo,ADR-013 决策 3),
  mail-wiring 调它、不再内联一份。原测试改断言,不删。
P0-10 文档说老实话:mail.send 工具路发出的信**不登记 outbound_ref**(recordOutbound 的唯一调用方
  channel.sendMail 在 src 里零调用方)—— API 文档 §4 与 ADR-013 决策 6 各补一段,根治记 debt。

P1:imap anchor 的 Math.min(...uids) 改 reduce(十万级 UID 会栈溢出)· wecom 翻页加页数上限
(服务端一直回 has_more:1 会死循环)· MAIL_TOOL 导出并对**真契约**断言(原 v1/v2 是手抄夹具、
自证,且 v1 版本号写错)· reset-runs 清运行数据时把邮箱账号的游标与拉取状态一并清(留着游标而信没了,
wecom 十分钟重叠窗会把最近的信当新信再投一次)· due.test 的 leaseMsFor(30) 断言改写死 300_000(原为自证)·
cursor.test 删掉指向评审报告的注释 · 设计稿稿头标注三处已被 ADR-013 取代 · ADR-013 补记 wecom
compose_send 不带 inReplyTo/references(它发出的信在对方那里不串线程)。

契约变更(§12.4):port.ts 的 InboundStore 加 getMeta、OutboundStore 加 claimForResend、
savePoll 的 cursor 允许显式 null、MailProvider.pull 回参可带 note —— 实现方只有本模块 adapter 与
__tests__/fakes.ts 的内存件(两边语义一致,已同改),CONTRACT.md 已同步,api-report 已刷新。

测试:1922 → 1942(mail 模块 102 → 113,含真 PG 的 save 不盖运行列 / 不搬原件 / 两条真连接抢同一行)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
2026-08-26 03:47:48 +08:00

281 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": "<c0ff…@corp.com>", "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": "<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` 进信封,活动经上传件读文本。
线程身份取来信 `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<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}'
```
示例响应按契约构造;真实响应待账号配好后用上面的序列换。