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
T
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

18 KiB
Raw Blame History

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

{
  "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):

{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
  "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
  "settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
{ "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。

{ "kind": "imap-smtp", "address": "sales@corp.com",
  "receive": { "ok": true, "latencyMs": 412 },
  "send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
{ "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

立即拉一轮(不等轮询到点):

{ "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": "正文" } →

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

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

{
  "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 同名)

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 实录

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}'

示例响应按契约构造;真实响应待账号配好后用上面的序列换。