Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
17 KiB
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[]subjectbody/htmlcc[]bcc[]attachments[{uploadRef}]inReplyToaccountId;写效应,放行时才真发;出参queued / outboundId / messageId / state;错误send-failed(可重试)/no-account/invalid。 放行落地会带idempotencyKey(同一次放行重放不发两封),重试分三种局面:上一封已sent→ 直接短路回那次的结果,不再发; 上一封failed,或停在sending超过 5 分钟(算作上次进程崩在半路)→ 复用那一行重发;停在sending不足 5 分钟 (算作正在飞)→ 不发,如实回state:'failed'加一句「同一幂等键的上一封仍在发送中,本次未重复发送」,过一会再放行会重试。
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}'
示例响应按契约构造;真实响应待账号配好后用上面的序列换。