# 邮件收发 —— 前端对接说明 > 后端已按 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 序列换。