Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
9.3 KiB
邮件收发 —— 前端对接说明
后端已按 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 …/redeliverbody{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 路
- 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
- 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
- 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成客户端专用密码(最多 20 个,短信下发),填进表单的密码框;
公共邮箱可在管理端生成(或经 API 建箱时
create_auth_code:1,只返回一次)。 - 服务器:
imap.exmail.qq.com:993/smtp.exmail.qq.com:465(表单默认值);信创环境是xcimap/xcsmtp。
企业微信邮件 API 路
- 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
- 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
- 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
- 只能收发应用邮箱自己的信,不能读成员邮箱;发出的信拿不到 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 序列换。