文档: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:
xcs
2026-08-26 02:38:12 +08:00
co-authored by Claude Fable 5
parent e0fc16db33
commit e862a5279b
7 changed files with 511 additions and 3 deletions
+265
View File
@@ -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}'
```
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
+16 -1
View File
@@ -287,6 +287,21 @@ body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role
| `ops` | GET | `/api/gov/access/roles` |
| `ops` | PUT | `/api/gov/access/roles/:role` |
| `ops` | DELETE | `/api/gov/access/roles/:role` |
| `ops` | POST | `/api/gov/mail/accounts` |
| `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) |
| `ops` | PUT | `/api/gov/mail/accounts/:accountId` |
| `ops` | DELETE | `/api/gov/mail/accounts/:accountId` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` |
| `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` |
| `ops` | GET | `/api/gov/mail/accounts` |
| `ops` | GET | `/api/gov/mail/accounts/:accountId` |
| `ops` | GET | `/api/gov/mail/inbound` |
| `ops` | GET | `/api/gov/mail/inbound/:mailId` |
| `ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) |
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
| `ops` | GET | `/api/gov/mail/outbound` |
| `design\|ops` | GET | `/api/gov/staff`(测试台选负责人) |
| `design\|ops` | GET | `/api/gov/process` |
| `design\|ops` | GET | `/api/gov/triggers` |
@@ -296,7 +311,7 @@ body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role
| `design\|ops` | GET | `/api/gov/data/:domain/:object` |
| `design\|ops` | GET | `/api/gov/workflows/:id` |
计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 19 + 两端 8 = 契约 95 条。
计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 34 + 两端 8 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。
## 7. Schema 附录(与 `packages/contracts/src/org.ts` 同名)
+61
View File
@@ -0,0 +1,61 @@
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
状态:已接受(2026-08-25)
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
(核实日期 2026-08-25,链接列在设计稿开头)。
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据 / 权限只到端)。
## 问题
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
## 决策
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下,
`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
订阅是代码级装配(不落库、不回放老信)。
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封,
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
厂商失败在 200 的 body 里说,不编状态码。
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
10. **15 条路由读写都归运营中心 `ops`,不设计端两端共读**:`route-access.test.ts`(编码规范 / ADR-012)钉着
一份有意枚举、随权限模型一起审过的「两端共用读路由」名单(原 8 条)。设计稿原把 7 条 GET 标成
`design|ops`,会让这份名单长到 14 条。落地时改成路由、不改名单 —— ①邮箱那一屏按本模块自己的前端
对接说明落在运营中心,设计端没有消费方(测试台读的是通道配置,不是邮箱账号);②入站正文装的是
客户来往内容,ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽;
③要往那份名单里加,得有真实消费方撑着,现在没有。故 15 条(账号 CRUD / 探测 / 拉取 / 试发 /
入站 / 出站 / 附件 / 原件 / 重投)一律 `access: 'ops'`。
## 后果
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。
- 欠账见 `docs/debt.md`「邮件收发」段。
+34
View File
@@ -1114,3 +1114,37 @@ SIGTERM 直接 `process.exit(0)` —— 在途请求与**所有 SSE 长连接**
⑥ **每版一份 node_modules**(`pnpm install --prod` 在发布目录里跑,实测 214M)。
pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人。留最近 5 版。
## 2026-08-25 · 邮件收发接上了,留下十五笔账(ADR-013)
① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次;
根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。
② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。
③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。
④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。
⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。
⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。
⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。
要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。
另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。
⑧ **`setState(id,'sent',…)` 的「不留错误话」是展开顺序撑出来的,不是真不变量**(`mail-store.ts`):
`{ ...(state==='sent'?{error:null}:{}), ...(patch.error===undefined?{}:{error:patch.error}) }` 里 `patch.error` 排在后面,
`setState(id,'sent',{error:'x'})` 在类型上合法且会让它赢,静默破掉「sent 的行没有错误话」这条 store 侧不变量。
当下没有调用点传得到这个组合,只是没人踩雷;把 `patch.error` 那条挪到 `state==='sent'` 判断前面即可让它成为真不变量。
⑨ **`parseCursor` 只查类型不查取值范围**(`cursor.ts`):`{kind:'wecom', lastTime: 1e13}` 这类脏游标能让 `cursorSummary`
的 `new Date(lastTime*1000).toISOString()` 抛 `RangeError`,而它渲的正是账号视图 `status.cursor` 那一格 ——
一行脏游标能让 `listAccounts` 整个 500。补一句 `Number.isSafeInteger` 校验即可封死。
⑩ **Message-ID 撞车(providerRef 是新的)留孤儿附件**:`storeOne` 先按 `provider_ref` 查重、再解析存附件、最后才 `insert`;
若真正撞车的是 `(account_id, message_id)` 那把键(provider_ref 没撞),附件已经落进 `sys.upload` 而 `insert` 报 `inserted:false`,
没人再引用这批附件。根治要给 `InboundStore` 加一个按 `messageId` 查重的口,插入前先挡。
⑪ **批内逐封持久化游标那条代码路径没有测试钉死**:要在中途注入一次 provider 失败才验得到「游标停在最后一封成功处」,
现有测试夹具还没有故障注入的口子。只影响批失败时的**返工量**,不影响正确性(两把唯一键仍挡得住重复入库)。
⑫ **两份 IT 测试的 `provisionDatabase()` 近乎逐字重复,且换出来的库从不清理**:`mail-store.it.test.ts` 与
`route-coverage.it.test.ts` 各自实现了一遍「从 base URL 换一个新库名」,`jcai_it_mail` 与先例 `jcai_it_routes` 一样
永不 DROP DATABASE —— 长跑的开发机会慢慢攒下若干 IT 库,该抽成一个共用 helper 并在跑完后清理。
⑬ **`mailSendTool` 的 `dryRun` 自己另写了一份校验**,与模块的 `validateSend` 是两份规则,会漂;
且 dryRun 完全不查附件的 `uploadRef` 是否真存在 —— 「dryRun 绿」不能保证「真发能过」(附件缺失要等真发才现形)。
⑭ **`attachmentsOf` 对形状不对的附件项仍是静默丢**(`mail-wiring.ts`):数组里混进非对象项会被 `flatMap` 悄悄滤掉,
与已经修过的「缺 `uploadRef` 当场拒」是同一族问题的更窄一种,还没堵上。
⑮ **`JCAI_MAIL_POLL_TICK_MS` 给垃圾值会得 `NaN`**:`Number(env[...] ?? 5000)` 不校验,`setInterval(fn, NaN)` 按最小间隔跑;
与既有的 `dispatchIntervalMs` 是同一款约定(未做输入校验),但这一条每拍要真打一次库,代价更大。
+120
View File
@@ -0,0 +1,120 @@
# 邮件收发 —— 前端对接说明
> 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲
> **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与
> `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。
## 0. 一分钟看懂
- 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。
界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。
- 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。
表单按 `kind` 切换字段;两种都有「探测」。
- 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。
- 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。
- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。**15 条接口全部 `access: 'ops'`**——
账号 / 来信 / 发信记录都只有运营中心能读能写,设计中心没有这一页、也调不通(邮箱那一屏没有设计端消费方,
入站正文又是客户来往内容,见 ADR-013 决策 10)。
## 1. 建议的信息架构
```
运营中心 / 系统 / 邮箱
├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯
│ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除
│ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe)
├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递)
└─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间
```
## 2. 账号表单(字段清单与默认值)
公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)·
`pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)·
`backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。
`kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)·
`settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)·
`settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。
`kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。
`address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。
保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。
## 3. 四个动作的交互与「界面不撒谎」
| 动作 | 接口 | 怎么摆 |
|---|---|---|
| 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 |
| 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 |
| 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 |
| 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 |
列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。
## 4. 来信与投递状态
- 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。
- 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。
**`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。
- 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。
- 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形,
预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。
`ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。
- 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。
- 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。
## 5. 发信记录
列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。
`sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。
## 6. SSE 失效映射
`app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀
(账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。
`reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。
## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉)
**IMAP/SMTP 路**
1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框;
公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。
4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。
**企业微信邮件 API 路**
1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。
发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。
## 8. 错误处理
| 状态 | 何时 | 前端动作 |
|---|---|---|
| 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 |
| 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 |
| 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 |
| 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 |
## 9. 接入完成的判据
- [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true
- [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话
- [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址
- [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由
- [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note
- [ ] 重新投递前有提示,投递后状态刷新
- [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点)
- [ ] 试发失败时列表里看得到 `failed` 与原话
- [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话
## 10. 本文与真实契约的核对状态
路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过(全部 `access: 'ops'`);字段名与 `packages/contracts/src/mail.ts` 同名。
示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。