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/superpowers/specs/2026-08-25-mail-design.md
T

592 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 邮件收发模块(`mail`)—— 后端设计
- 日期:2026-08-25
- 状态:设计已通过(用户 2026-08-25 裁决),待实施(实施计划另出)
- 范围:**只做后端**(apps/api + packages/contracts + db + 文档);web 一个文件不动,
前端按本轮产出的《前端对接说明-邮件收发》另做
- 依据(官方文档,核实日期 2026-08-25):
- 腾讯企业邮箱客户端协议设置(企业微信帮助中心):<https://open.work.weixin.qq.com/help2/pc/19886>
- 企业微信 · 邮件概述:<https://developer.work.weixin.qq.com/document/path/95486>
- 发送普通邮件 `compose_send`:<https://developer.work.weixin.qq.com/document/path/97445>
- 获取收件箱邮件列表 `get_mail_list`:<https://developer.work.weixin.qq.com/document/path/97369>
- 获取邮件内容 `read_mail`:<https://developer.work.weixin.qq.com/document/path/97979>
- 查询应用邮箱账号 `get_email_alias`:<https://developer.work.weixin.qq.com/document/path/97991>
- 邮箱回调通知(公共邮箱 `receive_email`):<https://developer.work.weixin.qq.com/document/path/100180>
- 创建公共邮箱(`create_auth_code` 拿客户端专用密码):<https://developer.work.weixin.qq.com/document/path/95511>
- 获取 access_token:<https://developer.work.weixin.qq.com/document/path/91039>
- 全局错误码 / 访问频率限制:<https://developer.work.weixin.qq.com/document/path/90313> / <https://developer.work.weixin.qq.com/document/path/90312>
- 老版企业邮箱开放接口(`api.exmail.qq.com`):<https://exmail.qq.com/qy_mng_logic/doc>
## 1. 背景与目标
现状:`channel` 模块(L2)已有整条「入站归一 → 事件(归属解析 / 触发器起单 / 任务 `listen` 监听 /
消息兜底)」与出站 `MailTransport` 端口,但两端都是**外界的替身**:出站 `createSimulatedMailTransport`
落库不真发,入站只能经 `POST /api/ingest/channel/:channelId/mail` 注入;`mail.send` 工具处理器回
`{queued:true}` 从不触达世界;全仓没有 IMAP/SMTP 代码,也没有周期作业调度器
(pg-boss 声明了零引用);channel 配置只读、无凭证列。
目标:新增后端模块 `mail`,把这条链的两端做实 ——
- **收**:轮询拉取指定邮箱(腾讯企业邮箱)的来信,解析、留痕、交给**注册了回调的订阅方**;
`channel` 是第一个订阅方,活动经现有事件链收到信(触发器起单 / `listen` 监听)。
- **发**:提供给系统内部调用的发信接口;活动经 `mail.send` 工具发信(写效应,放行时落地)。
- **配**:账号参数(含凭证)由前端配置(本轮只出接口与文档),多账号 + 默认发件账号。
非目标:前端页面、HTTP webhook 注册、IMAP IDLE 推送、INBOX 以外的文件夹、经 API 创建公共邮箱 /
邮件群组、日程 / 会议邮件、企业发信配额控制(只在文档里写腾讯的限额)。
## 2. 已定决策(用户 2026-08-25 裁决)
| 问题 | 决策 |
|---|---|
| 接入路 | **两条都做**:标准协议 IMAP/SMTP 与企业微信邮件 API(应用邮箱);一个 `MailProvider` 端口两个适配器,账号配置里选 `kind` |
| 新依赖 | **批准 `imapflow` + `nodemailer` + `postal-mime`**(§12.5 #3;三者 MIT / MIT-0,同一作者维护;postal-mime 零依赖,用 Node 自带 `TextDecoder` 解 GBK/GB18030,本机 Node 24 验过) |
| 「注册回调」的外延 | **进程内端口 `subscribe`**;不做 HTTP webhook |
| 账号数 | **多账号 + 默认发件账号** |
| 放置 | **新模块 `mail`(L1)+ `channel` 作首个订阅方**;channel 契约一字不动 |
## 3. 腾讯企业邮箱三条接入路(官方文档核实)与取法
| 路 | 收 | 发 | 前提 | 结论 |
|---|---|---|---|---|
| ① 标准协议 | IMAP `imap.exmail.qq.com:993`(SSL);**无推送**,只能轮询;IDLE 官方未提及 | SMTP `smtp.exmail.qq.com:465`(SSL;官方帮助只列 465,客服 FAQ 另列 587/25) | 账号=完整邮箱地址;密码=邮箱密码,开了安全登录 / 微信绑定则必须用**客户端专用密码**;管理员先在「协作 → 邮件 → 安全管理 → 客户端访问权限」放开,成员在「设置 → 收发信设置」开 IMAP/SMTP;公共邮箱也能生成专用密码(API 建箱时 `create_auth_code:1` 一次性返回) | **做**。任意指定邮箱都能接,不依赖企业微信应用授权 |
| ② 企业微信邮件 API | `get_mail_list(begin_time,end_time,cursor,limit≤1000)` → `mail_id`;`read_mail(mail_id)` → `mail_data`(原始 EML,仍要解 MIME);**只读应用邮箱自己的收件箱** | `compose_send`(to/cc/bcc 的 emails 或 userids,subject,content,content_type html/text,attachment_list base64;正文+附件 ≤50MB、附件 ≤200 个);发件人恒为应用邮箱;**不回 Message-ID** | 企业微信 ≥ 4.0.20;自建应用在管理端「协作 → 邮件」授邮件权限,系统自动分配应用邮箱(地址可经 `update_email_alias` 改);`gettoken(corpid, corpsecret)` → access_token 有效 7200s;频率限制每企业每接口 1 万次/分 | **做**。回调 `public_email_change/receive_email` 只对应用创建的公共邮箱、只推「新邮件数」不推内容,**不用** |
| ③ 老版开放接口 `api.exmail.qq.com` | 只有「新邮件提醒」(未读数;回调模式推新信通知),无读信 | 无 | — | **不可用** |
两条路共同点:**都是轮询拉 + MIME 解析**,腾讯没有带内容的推送。因此收信是工作者定时拉,
不是 webhook。
## 4. 架构与数据流
```
┌──────────────── 外界 ────────────────┐
│ 腾讯企业邮箱 IMAP/SMTP │ 企业微信邮件 API │
└──────────┬────────────┴───────┬────────┘
│ MailProvider(端口,两个适配器)
app/workers/mail-poller ─┤ 每 5s 一 tick,挑到期账号,一条 SQL 判+占租约
▼
modules/mail(L1) ── pull → MimeParser(postal-mime)→ 附件入 sys.upload
→ sys.mail_inbound(唯一键去重)→ 逐封推进游标
→ 分发给订阅方(sys.mail_inbound_delivery 逐订阅方留痕)
│ subscribe('channel', …) ← app 装配
▼
modules/channel(L2).ingestMail(channelId, InboundMail) ── 不动 ──▶ event → 触发器起单 / 任务 listen / 消息兜底
发:活动 mail.send 工具(write 效应 → 放行时 ToolGateway.execute)─┐
channel.sendMail → MailTransport(app 注入:转 mail.send)──────┼──▶ modules/mail.send
运营面试发路由 ───────────────────────────────────────────────┘ → sys.mail_outbound(先落 sending)
→ MailProvider.send → sent / failed(厂商原话)
```
层级:`mail` 是 **L1**(只依赖 `@contracts`、`@shared/db` 与端口);`channel`、`toolbox` 不 import 它,
全部经 app 装配(与 channel 依赖 event 的方式相同)。
## 5. `mail` 模块(`apps/api/src/modules/mail`)
### 5.1 `contract.ts`
```ts
import type {
MailAccountInput, MailAccountView, MailAccountKind, ProbeReport,
InboundSummary, InboundDetail, DeliveryView, OutboundView, SendInput, SendResult,
} from '@contracts'; // schema 定义在 packages/contracts/src/mail.ts(§7.1)
export class MailAccountNotFoundError extends Error { constructor(readonly accountId: string) }
export class MailAccountNameTakenError extends Error { constructor(readonly name: string) } // 409
export class MailAccountDisabledError extends Error { constructor(readonly accountId: string) } // 412
export class MailNoDefaultAccountError extends Error {} // 412
export class MailSecretMissingError extends Error { constructor(readonly accountId: string) } // 412
export class MailPollBusyError extends Error { constructor(readonly accountId: string) } // 409(另一进程占着租约)
export class MailInboundNotFoundError extends Error { constructor(readonly mailId: string) } // 404
export class MailAttachmentNotFoundError extends Error { constructor(readonly mailId: string, readonly index: number) }
export class MailSubscriberUnknownError extends Error { constructor(readonly subscriber: string) } // 404
export class MailSendFailedError extends Error { // 发信失败:已落 failed 行
constructor(readonly outboundId: string, readonly vendorMessage: string, readonly retryable: boolean)
}
/** 订阅方回调:返回 `{skipped}` 表示这封信与我无关(留痕为 skipped,不算失败) */
export type InboundHandler = (
mail: InboundDetail,
ctx: { readonly accountId: string; readonly channelId: string | undefined },
) => Promise<void | { readonly skipped: string }>;
export type PollOutcome = {
readonly accountId: string;
readonly pulled: number; // provider 交出的封数
readonly stored: number; // 真新入库的封数(去重后)
readonly dispatched: number; // 分发过的封数
readonly more: boolean; // 预算用尽还有余下,下一 tick 接着
readonly error?: string; // 厂商原话(已脱敏)
};
export type MailService = {
// 账号
listAccounts(): Promise<MailAccountView[]>;
getAccount(accountId: string): Promise<MailAccountView>;
createAccount(input: MailAccountInput): Promise<MailAccountView>;
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>; // secret 缺省 = 不改
deleteAccount(accountId: string): Promise<void>;
probe(target: { accountId: string } | { input: MailAccountInput }): Promise<ProbeReport>; // 已存 / 未存两种
// 收
pollDue(now: Date): Promise<PollOutcome[]>; // 工作者用:到期且未被占的账号各拉一轮
pollNow(accountId: string): Promise<PollOutcome>; // 路由用:租约被占 → MailPollBusyError
subscribe(name: string, handler: InboundHandler): () => void; // 同名重复注册 → 抛
listInbound(query: { accountId?: string; limit?: number }): Promise<InboundSummary[]>;
getInbound(mailId: string): Promise<InboundDetail>;
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; // 原件 .eml;超限没存 → MailAttachmentNotFoundError
redeliver(mailId: string, subscriber?: string): Promise<DeliveryView[]>; // 人按的钮,至少一次
// 发
send(input: SendInput): Promise<SendResult>;
listOutbound(query: { accountId?: string; limit?: number }): Promise<OutboundView[]>;
};
```
### 5.2 `port.ts`
```ts
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; // 与 llm/toolbox 同款,app 注入同一实例
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 库形与传输形(domain 定义,这里只列名):`MailCursor`(§5.3 两形)、`Address = {address, name}`、`DeliveryState = 'dispatching'|'done'|'failed'|'skipped'`、
* `InboundRecord` / `OutboundRecord` = §6 两张表的行(驼峰)、`OutboundMessage` = 交给 provider 的出信(from/to/cc/bcc/subject/text/html/messageId/inReplyTo/references/attachments 字节) */
/** 账号记录(库形):`MailAccountView` 的全部字段(不含 status / secretBound)+ 下面三列;settings 为按 kind 的非密配置;secretCipher 为密封的 JSON({password} | {corpSecret}) */
export type MailAccountRecord = MailAccountViewFields & { secretCipher: string | null; cursor: MailCursor | null; pollingUntil: Date | null; lastPolledAt: Date | null; lastPollError: string | null; lastInboundAt: Date | null };
export type MailAccountStore = {
list(): Promise<MailAccountRecord[]>;
get(id: string): Promise<MailAccountRecord | undefined>;
save(record: MailAccountRecord): Promise<void>; // upsert;name 唯一、默认至多一个由库约束
remove(id: string): Promise<void>;
listDue(now: Date): Promise<MailAccountRecord[]>; // enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空/过期
claimPolling(id: string, now: Date, leaseMs: number): Promise<boolean>; // 一条 SQL 判+占(§9.1)
releasePolling(id: string): Promise<void>;
savePoll(id: string, patch: { cursor?: MailCursor; polledAt?: Date; error?: string | null; lastInboundAt?: Date; address?: string }): Promise<void>;
};
export type InboundStore = {
insert(record: InboundRecord): Promise<{ inserted: boolean }>; // (account_id, message_id) / (account_id, provider_ref) 冲突 → false
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>; // wecom 拉列表后先滤,不重复 read_mail
list(query: { accountId?: string; limit: number }): Promise<InboundSummary[]>;
get(mailId: string): Promise<InboundDetail | undefined>;
setDelivery(d: { mailId: string; subscriber: string; state: DeliveryState; error?: string; startedAt: Date; finishedAt?: Date }): Promise<void>;
deliveries(mailId: string): Promise<DeliveryView[]>;
};
export type OutboundStore = {
insert(record: OutboundRecord): Promise<void>;
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
list(query: { accountId?: string; limit: number }): Promise<OutboundView[]>;
};
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
export type AttachmentStore = {
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string; text?: string }>;
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
};
/** 外界:一种账号类型一个适配器。凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
export type ProviderAccount = { readonly id: string; readonly kind: MailAccountKind; readonly address: string; readonly displayName: string; readonly settings: Record<string, unknown>; readonly secret: Record<string, string> };
export type RawMessage = { readonly providerRef: string; readonly eml: Uint8Array; readonly receivedAt: Date; readonly cursorAfter?: MailCursor };
export type MailProvider = {
readonly kind: MailAccountKind;
probe(account: ProviderAccount): Promise<ProbeReport>;
/** 无游标时由 provider 锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
anchor(account: ProviderAccount, backfillFrom: Date | undefined, now: Date): Promise<MailCursor>;
pull(account: ProviderAccount, cursor: MailCursor, opts: { limit: number; now: Date }): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string }>;
};
export type ParsedMail = { messageId?: string; inReplyTo?: string; references: string[]; from?: { address: string; name: string }; to: Address[]; cc: Address[]; subject: string; text?: string; html?: string; date?: Date; attachments: Array<{ name: string; contentType: string; bytes: Uint8Array }> };
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
/** 变更通知 —— app 接到 broadcaster(`{kind:'system', id:'mail'}`);模块不知道 SSE 的存在 */
export type ChangeNotifier = { changed(reason: string): void };
export type MailServiceDeps = {
readonly accounts: MailAccountStore; readonly inbound: InboundStore; readonly outbound: OutboundStore;
readonly attachments: AttachmentStore;
readonly providers: Readonly<Record<MailAccountKind, MailProvider>>;
readonly mime: MimeParser; readonly secrets: SecretBox; readonly clock: Clock; readonly ids: IdGenerator;
readonly notify: ChangeNotifier;
readonly limits?: { perTick?: number; tickBudgetMs?: number; attachmentBytes?: number; rawBytes?: number }; // 默认 50 封 / 20s / 8MB / 16MB
};
```
### 5.3 `internal/domain/`(纯函数,零 IO)
- `cursor.ts`:`MailCursor` 两形 `{kind:'imap', uidValidity, lastUid}` / `{kind:'wecom', lastTime}`;
`cursorSummary(cursor)` 人读摘要(「UID 1234 · validity 5」/「至 2026-08-25 10:00」);
`wecomWindow(cursor, backfillFrom, now)` → `{beginTime: lastTime − 600s, endTime: now}`(重叠 10 分钟治时钟偏,重复由唯一键挡)。
- `due.ts`:`isDue(record, now)`、`leaseMsFor(intervalSec) = max(5min, 3×interval)`。
- `inbound-record.ts`:`inboundRecordOf(parsed, raw, account, receivedAt, ids)`:`messageId` 缺失时用
`<providerRef@account.address>` 顶(不抛);`threadId = inReplyTo ?? references 末项`;
`bodyText = text ?? stripHtml(html) ?? ''`;附件按上限分「存」与「超限留注」。
- `redact.ts`:`redactSecrets(text, secrets)` —— 明文、base64、URL-encoded 三形都换成 `***`。
- `send-input.ts`:`validateSend(input)`(收件人非空、text/html 至少一个、附件单个 ≤8MB、总 ≤20MB;
wecom 额外 ≤50MB / ≤200 个)、`pickAccount(accounts, requested?)`(指定的 → 必须 enabled;不指定 → 唯一 `isDefault`;没有 → `MailNoDefaultAccountError`)、
`messageIdFor(id, address) = <id@域>`。
- `delivery.ts`:状态机 `dispatching → done | failed | skipped`,`redeliver` 只从 `done/failed/skipped` 出发。
- `account-view.ts`:`viewOf(record)`(去密文、`secretBound = cipher != null`、状态子对象、`polling = pollingUntil > now`)。
### 5.4 `internal/usecase/mail-service.ts` 要点
- **拉一轮**(`pollAccount`):`claimPolling` 抢不到 → busy;凭证空 → 记 `last_poll_error='未配置凭证'`、还租约;
无游标 → `provider.anchor`;循环 `pull`(封顶 50 封 / 20s):逐封 `mime.parse` → 附件 `attachments.put`
(超限 `ref:null` + 注)→ `inbound.insert`(冲突 = 已有,跳过)→ **带 `cursorAfter` 的逐封存游标** →
新入库的才 `dispatch`;整批完存 `cursor` + `polledAt` + `error:null`;任何异常:存 `error`(脱敏)、
游标停在最后一封成功处;`finally` 还租约;有变化就 `notify.changed('inbound')`。
- **分发**(`dispatch`):对每个已注册订阅方顺序执行,先写 `dispatching` 再调,`done / skipped / failed(原话)`;
一个订阅方失败不影响其它。**不自动重试**(§9.3)。
- **发**(`send`):`validateSend` → `pickAccount` → 若带 `idempotencyKey` 且已有 `sent` 行 → 直接回它
(放行重放不发两封)→ 落 `sending` 行 → `provider.send` → `sent`(providerRef)或 `failed` + 抛
`MailSendFailedError`;附件 `uploadRef` 经 `attachments.get` 取字节。
- **到期扫描**(`pollDue`):账号间 `Promise.allSettled` 并行,单账号内串行;一个账号的异常只记在它自己的 `last_poll_error`。
- **账号变更**:`kind` 或 `settings` 变了 → 游标清空(下一轮重锚)、`last_poll_error` 清空;删除账号时另一进程若正拉着,它的 `savePoll` 落在不存在的行上 = no-op,不抛。
- **探测**(`probe`):已存账号用库里凭证,未存用入参里的;wecom 探到应用邮箱地址后回填 `address`(已存账号)。
- **凭证**:`createAccount/updateAccount` 里 `secret` 有值才 `seal`,空 = 保留原密文(与 llm 同款);
视图只回 `secretBound`。
- **默认账号**:`isDefault:true` 保存时先把别的置 false(同一事务;库上再钉一个部分唯一索引兜底)。
### 5.5 `internal/adapter/`
- `mail-schema.ts`:四张 Drizzle 表(§6)。`mail-store.ts`:三个 store;`claimPolling` 用原生 SQL(§9.1)。
- `imap-smtp-provider.ts`(imapflow + nodemailer):
- `anchor`:连上、`mailboxOpen(folder, {readOnly:true})`,无 backfill → `{uidValidity, lastUid: uidNext−1}`;
有 backfill → `search({since})` 取最小 UID − 1。
- `pull`:`readOnly` 打开(**不改已读标记**,轮询不动人的信箱);`uidValidity` 变了 → 重锚不回填、记一条 error 说明;
`fetch(`${lastUid+1}:*`, {uid:true, source:true, internalDate:true})`,按 UID 升序,每封 `cursorAfter = {…, lastUid: uid}`;
`more = 取满 limit`。**陷阱**:IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义),
客户端必须再按 `uid > lastUid` 过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
- `send`:`nodemailer.createTransport({host, port, secure: smtpTls, auth:{user: login ?? address, pass}})`,
`sendMail({messageId, from: '"displayName" <address>', to, cc, bcc, subject, text, html, inReplyTo, references, attachments})`;
`probe`:IMAP 登录 + `transporter.verify()` 各报 ok / 厂商原话。
- 超时:连接 30s、单封拉取 60s。
- `wecom-provider.ts`(原生 `fetch`,可注入以便测试):
- token 缓存**在实例闭包里**按账号存(不是模块级,§12.5 #6),提前 5 分钟刷新;40014 / 42001 刷一次重试;
45009 → 本轮停、记 error;`-1` 重试 ≤3 次。
- `anchor`:`{lastTime: backfillFrom ?? now}`;`pull`:`get_mail_list(window, cursor, limit)` 翻页,
`knownProviderRefs` 先滤,新的才 `read_mail` → `mail_data`;**文档未说 `mail_data` 编码**,
实现时探针定:先按原文解,解不出 `From:`/`Subject:` 再按 base64 兜底,探明后写死一档并记注释。
- `send`:`compose_send` 按文档校验 50MB / 200 附件;发件人恒为应用邮箱;
**不回 Message-ID** → `providerRef` 空,线程级归属(①级)对 wecom 发出的信不成立,③级地址窗仍可用(写进文档)。
- `probe`:`gettoken` + `get_email_alias` → `receive.ok` 与地址;`send.checked=false` + 注「发信权限只能在真发时验证」——
界面不撒谎,不把「拿到 token」说成「能发」。
- `postal-mime-parser.ts`:`PostalMime.parse(eml)` → `ParsedMail`;地址、Message-ID、References 归一为裸串;
日期解析失败留空。
- `fake-provider.ts`:内存假 provider(可喂 EML 夹具、可让 send 失败)—— **邮件通道是外界,替身允许**;
它是实现端口的适配器,不是测试里的 mock。
## 6. 数据模型与迁移 `db/migrations/0033_mail.sql`
```sql
create table sys.mail_account (
id text primary key,
name text not null,
kind text not null check (kind in ('imap-smtp','wecom')),
address text not null default '', -- imap-smtp:登录邮箱;wecom:应用邮箱(探测后回填)
display_name text not null default '',
enabled boolean not null default true,
is_default boolean not null default false,
poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600),
channel_id text, -- 入站交给哪个通道;空 = channel 订阅方 skipped
backfill_from timestamptz,
settings jsonb not null default '{}', -- 按 kind 的非密配置(§7.1 schema)
secret_cipher text, -- AES-256-GCM 密封的 JSON;永不出接口
cursor jsonb,
last_polled_at timestamptz,
last_poll_error text,
last_inbound_at timestamptz,
polling_until timestamptz, -- 跨进程租约(与 sys.task.advancing_until 同款)
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index mail_account_name_key on sys.mail_account (name);
create unique index mail_account_one_default on sys.mail_account ((true)) where is_default; -- 默认账号至多一个
create index mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
create table sys.mail_inbound (
id text primary key,
account_id text not null, -- 不设外键:账号删了留痕还在
message_id text not null,
provider_ref text not null, -- imap:UID(带 validity 前缀);wecom:mail_id
in_reply_to text,
reference_ids jsonb not null default '[]',
from_addr text not null default '',
from_name text not null default '',
to_list jsonb not null default '[]', -- [{address,name}]
cc_list jsonb not null default '[]',
subject text not null default '',
text_body text,
html_body text,
sent_at timestamptz,
received_at timestamptz not null,
attachments jsonb not null default '[]', -- [{index,name,contentType,size,ref|null,note}],ref = sys.upload.id
raw bytea, -- 原件;> 16MB 留空
raw_note text not null default '',
size integer not null default 0,
created_at timestamptz not null default now()
);
create unique index mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
create unique index mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
create index mail_inbound_time_idx on sys.mail_inbound (received_at desc);
create table sys.mail_inbound_delivery (
mail_id text not null references sys.mail_inbound(id) on delete cascade,
subscriber text not null,
state text not null check (state in ('dispatching','done','failed','skipped')),
error text,
started_at timestamptz not null default now(),
finished_at timestamptz,
primary key (mail_id, subscriber)
);
create table sys.mail_outbound (
id text primary key,
account_id text not null,
message_id text not null, -- 自造 <uuid@域>;线程级归属的对表来源
idempotency_key text, -- 放行落地带的键;同键不发两封
to_list jsonb not null,
cc_list jsonb not null default '[]',
bcc_list jsonb not null default '[]',
subject text not null,
text_body text,
html_body text,
in_reply_to text,
reference_ids jsonb not null default '[]',
attachments jsonb not null default '[]', -- [{name,contentType,size,ref}]
state text not null check (state in ('sending','sent','failed')),
error text,
provider_ref text,
requested_by jsonb not null default '{}', -- {kind:'tool'|'channel'|'ops', taskId?, staffId?, tool?}
created_at timestamptz not null default now(),
sent_at timestamptz
);
create unique index mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
create index mail_outbound_time_idx on sys.mail_outbound (created_at desc);
```
`meta/_journal.json` 追加 `idx 33 / tag 0033_mail`。`reset-runs` 加 `sys.mail_inbound`、
`sys.mail_inbound_delivery`、`sys.mail_outbound`(清跑出来的,留 `sys.mail_account` 这份配置)。
## 7. 契约变更(`packages/contracts`)
### 7.1 新增 `packages/contracts/src/mail.ts`(schema,前后端共用)
```ts
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
export const imapSmtpSettingsSchema = z.object({
imapHost: z.string().min(1).default('imap.exmail.qq.com'), imapPort: z.number().int().min(1).max(65535).default(993), imapTls: z.boolean().default(true),
smtpHost: z.string().min(1).default('smtp.exmail.qq.com'), smtpPort: z.number().int().min(1).max(65535).default(465), smtpTls: z.boolean().default(true), // true=隐式 TLS(465);false=STARTTLS(587/25)
login: z.string().min(1).optional(), // 缺省 = address
folder: z.string().min(1).default('INBOX'),
});
export const wecomSettingsSchema = z.object({ corpId: z.string().min(1), apiBase: z.string().url().default('https://qyapi.weixin.qq.com') });
const accountCommon = { name: z.string().min(1).max(64), displayName: z.string().max(64).default(''), enabled: z.boolean().default(true), isDefault: z.boolean().default(false),
pollIntervalSec: z.number().int().min(10).max(3600).default(30), channelId: channelIdSchema.nullable().default(null), backfillFrom: z.iso.datetime().nullable().default(null) };
export const mailAccountInputSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('imap-smtp'), address: z.string().email(), ...accountCommon, settings: imapSmtpSettingsSchema, secret: z.object({ password: z.string().min(1) }).optional() }),
z.object({ kind: z.literal('wecom'), ...accountCommon, settings: wecomSettingsSchema, secret: z.object({ corpSecret: z.string().min(1) }).optional() }), // address 由探测回填
]);
export const mailAccountViewSchema = z.object({ id, kind, name, address, displayName, enabled, isDefault, pollIntervalSec, channelId, backfillFrom, settings /* 非密 */, secretBound: z.boolean(),
status: z.object({ lastPolledAt: iso|null, lastPollError: string|null, lastInboundAt: iso|null, cursor: string|null /* 人读摘要 */, polling: z.boolean() }), createdAt, updatedAt });
export const probeReportSchema = z.object({ kind, address: z.string().optional(),
receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().optional() }),
send: z.object({ checked: z.boolean(), ok: z.boolean().optional(), error: z.string().optional(), note: z.string().optional() }) });
export const deliveryViewSchema = z.object({ subscriber, state: z.enum(['dispatching','done','failed','skipped']), error: string|null, startedAt, finishedAt: iso|null });
export const inboundAttachmentSchema = z.object({ index: int, name, contentType, size: int, ref: string|null, note: string });
export const inboundSummarySchema = z.object({ id, accountId, messageId, from: address, to: address[], cc: address[], subject, sentAt: iso|null, receivedAt, textPreview: z.string() /* ≤200 字 */, hasHtml, attachments: inboundAttachmentSchema[], deliveries: deliveryViewSchema[] });
export const inboundDetailSchema = inboundSummarySchema.extend({ inReplyTo: string|null, references: string[], text: string|null, html: string|null, size: int, rawAvailable: z.boolean() });
export const sendInputSchema = z.object({ accountId: z.string().optional(), to: z.array(z.string().email()).min(1), cc: z.array(z.string().email()).default([]), bcc: z.array(z.string().email()).default([]),
subject: z.string().min(1), text: z.string().optional(), html: z.string().optional(), inReplyTo: z.string().optional(), references: z.array(z.string()).default([]),
attachments: z.array(z.object({ name: z.string().min(1), contentType: z.string().default('application/octet-stream'), dataBase64: z.string().optional(), uploadRef: z.string().optional() })).default([]),
idempotencyKey: z.string().optional(), requestedBy: z.object({ kind: z.enum(['tool','channel','ops']), taskId: z.string().optional(), staffId: z.string().optional(), tool: z.string().optional() }) })
.refine(v => v.text !== undefined || v.html !== undefined, '正文 text / html 至少一个');
export const sendResultSchema = z.object({ outboundId, accountId, messageId, state: z.enum(['sent','failed']), error: z.string().optional(), providerRef: z.string().optional() });
export const outboundViewSchema = z.object({ id, accountId, messageId, to: string[], cc, bcc, subject, state: z.enum(['sending','sent','failed']), error: string|null, providerRef: string|null, requestedBy, createdAt, sentAt: iso|null });
```
### 7.2 新增路由 `packages/contracts/src/api/mail.ts`(经 `api/index.ts` 转出;写路由由 `/api/gov/*` 中间件自动广播)
| 方法 | 路径 | access | 说明 |
|---|---|---|---|
| GET | `/api/gov/mail/accounts` | `['design','ops']` | `{accounts: MailAccountView[]}` |
| POST | `/api/gov/mail/accounts` | `ops` | body `MailAccountInput`;409 名字重复 |
| POST | `/api/gov/mail/accounts/probe` | `ops` | **未存**的配置探测:body `MailAccountInput`(带 secret)→ `ProbeReport` |
| GET | `/api/gov/mail/accounts/:accountId` | `['design','ops']` | 404 |
| PUT | `/api/gov/mail/accounts/:accountId` | `ops` | secret 缺省 = 不改 |
| DELETE | `/api/gov/mail/accounts/:accountId` | `ops` | `{ok:true}`;留痕不删 |
| POST | `/api/gov/mail/accounts/:accountId/probe` | `ops` | 已存账号探测(用库里凭证)→ `ProbeReport`;凭证空 412 |
| POST | `/api/gov/mail/accounts/:accountId/poll` | `ops` | 立即拉一轮 → `PollOutcome`;另一进程占着 409;停用 412 |
| POST | `/api/gov/mail/accounts/:accountId/send-test` | `ops` | body `{to, subject, text}` → `SendResult`(失败在 200 的 body 里如实说) |
| GET | `/api/gov/mail/inbound?accountId&limit` | `['design','ops']` | `{mails: InboundSummary[]}`,limit 默认 50、上限 200 |
| GET | `/api/gov/mail/inbound/:mailId` | `['design','ops']` | `InboundDetail` |
| GET | `/api/gov/mail/inbound/:mailId/attachments/:index` | `['design','ops']` | `{name, contentType, dataBase64}`;可带 `?ticket=` 下载(ADR-012 §6);未存(超限)404 |
| GET | `/api/gov/mail/inbound/:mailId/raw` | `['design','ops']` | 原件 `{name:'<messageId>.eml', contentType:'message/rfc822', dataBase64}`;可带 `?ticket=`;超限没存 404 |
| POST | `/api/gov/mail/inbound/:mailId/redeliver` | `ops` | body `{subscriber?}` → `{deliveries}`;订阅方名不认识 404 |
| GET | `/api/gov/mail/outbound?accountId&limit` | `['design','ops']` | `{mails: OutboundView[]}` |
错误语义按仓内口径:400 `validation-failed`(带 issues)· 404 `not-found` · 409 `conflict` · 412 `precondition-failed`;
探测 / 试发 / 拉取的**厂商失败不是接口错误**,在 200 的 body 里带原话(脱敏)。
`route-coverage.it.test.ts` 自动兜:少一条契约就红。
### 7.3 `mail.send` 工具契约升 2.0.0(`seed-mvp.ts` 的 `MAIL_TOOL`;安装恒新增,同版重装 no-op)
```ts
name: 'mail.send', version: '2.0.0', effect: 'write', idempotent: true, connectionKeys: [], // 凭证归 sys.mail_account,不再走工具连接配置
input: { type:'object', required:['to','subject'], properties: {
to: { type:'array', items:{type:'string'}, minItems:1 }, cc: {…}, bcc: {…}, subject: {type:'string'}, text: {type:'string'}, html: {type:'string'},
attachments: { type:'array', items:{ type:'object', required:['uploadRef'], properties:{ uploadRef:{type:'string'}, name:{type:'string'} } } },
inReplyTo: {type:'string'}, accountId: {type:'string'} } },
output: { type:'object', properties: { outboundId:{type:'string'}, messageId:{type:'string'}, state:{type:'string'} } },
errors: [{code:'send-failed', retryable:true, message:'发信失败'}, {code:'no-account', retryable:false, message:'没有可用的邮箱账号'}],
```
**破坏性**:`to` 由 string 变 array、`body` 改名 `text`。唯一消费方是已卸载的 quote-* 活动(2026-08-24),
无在途调用;按 §12.4 记入 CONTRACT.md 与本 spec,不走三步弃用。
### 7.4 路由归属总表
`docs/API文档-鉴权与人员.md` §6 总表补上 7.2 的 15 行(读标两端、写归 `ops`)。
## 8. app 层
- **装配**(`container.ts`):`mail` 在 `toolRuntime` 之前创建(它是 L1,只要 db / secrets / clock / ids);
`attachments` 注入件复用 `sys.upload` 的存取(把 `governance-ops.ts` 上传路由里那段 insert/select 抽成
`app/upload-store.ts` 两个函数,路由与注入件同用 —— 唯一一处顺手抽取,范围限定在那两段);
`notify` → `broadcaster.publish({kind:'system', id:'mail', reason})`。
`toolRuntime` 改为 `createToolRuntime(new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler)`
—— 处理器在 container 里闭包持 `mail`(`firstPartyToolHandlers` 这个模块级导出随之改成装配时拼出的局部量;仓内没有别处引用它),`first-party.ts` 里的旧 `mailSend` 删除;处理器把 `idempotencyKey`
与 `requestedBy:{kind:'tool', taskId?}` 带进 `mail.send`,失败映射为 `{ok:false, error:{code:'send-failed', retryable}}`。
channel 的 `mail:` 注入件改为 `{ send: (m) => mail.send({...m 映射, requestedBy:{kind:'channel', taskId}}) 再回 {messageId, threadId: m.threadId ?? messageId} }`
(按 `from` 找账号,找不到用默认);`createSimulatedMailTransport` 不再装配(代码与表暂留,记 debt)。
channel 创建后:`mail.subscribe('channel', …)`(§8 末)。
- **工作者**(`app/workers/mail-poller.ts`):`startMailPoller({container}, tickMs)`,与 `startDispatcher` 同形
(`setInterval` + `unref` + `stop()`),每 tick `container.mail.pollDue(now)`;`main.ts` 起、`close()` 停。
`config.ts` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000)`。
- **路由**(`app/routes/mail.ts`,`registerMailRoutes(app, container)`):按 7.2 逐条,错误映射照 5.1 的注释;
附件下载路由认 `?ticket=`(中间件已支持 GET 票据)。
- **channel 订阅方**(app 装配处的一个函数 `toInboundMail(detail)`,有测):
账号 `channelId` 空 → `{skipped:'账号未绑定通道'}`;否则
`channel.ingestMail(channelId, { messageId, threadId: inReplyTo ?? references.at(-1), from: from.address, to: to.map(a=>a.address), cc: …, subject, body: text ?? '', receivedAt: iso, attachments: 只带已存的 [{name, contentType, size, ref}] })`;
`ChannelNotFoundError / ChannelDisabledError` 原样抛 → 留痕 `failed` 带原话(通道停用不是信的错,人在运营面看得见)。
- **seed**:不种账号(凭证只能人填);`activities:install` 不涉及。
## 9. 可靠性与安全纪律
### 9.1 跨进程租约(两个后端连同一个库)
```sql
update sys.mail_account
set polling_until = now() + ($2 || ' milliseconds')::interval
where id = $1 and enabled
and (polling_until is null or polling_until < now())
returning id
```
判与占一条语句(分两句写就是经典竞态);过期 = `max(5min, 3×poll_interval)`,进程崩了不锁死;拉完置空。
`pollNow` 抢不到 → 409 `MailPollBusyError`(「另一台正在拉取」)。
### 9.2 去重与游标
`(account_id, message_id)` 与 `(account_id, provider_ref)` 两把唯一键:重复拉到(时钟偏、UIDVALIDITY 重锚、
wecom 十分钟重叠窗)一律 `inserted:false` 静默跳过、不分发。游标**逐封推进**(IMAP)/ 逐批推进(wecom),
中途失败停在最后一封成功处,下一 tick 从那儿接。首次无游标锚在当下,**不回填整箱**;
`backfillFrom` 才回填。
### 9.3 分发语义(诚实版「至少一次」)
自动路径**只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话)`。不自动重试 ——
channel → event 这条链按 `cause.ref` **不幂等**(`event.ingestInbound` 无去重),重投会起两单。
失败在运营面看得见,「重新投递」是人按的钮,那一刻起是至少一次(文档写明)。
进程在 `dispatching` 期间崩了:行停在 `dispatching`,列表按「投递状态未知(进程中断)」摆出来,同样交人。
根治(channel/event 按 `cause.ref` 幂等,自动重投才安全)记 `docs/debt.md`。
### 9.4 凭证与脱敏
- 密封:复用 `SecretBox`(AES-256-GCM,`JCAI_SECRET_KEY`,ADR-009);视图只回 `secretBound`;更新时空 = 不改。
- 明文只在 usecase 打开、交给 provider 那一段;不进 domain、不进留痕、不进日志。
- 所有写进 `last_poll_error / delivery.error / outbound.error` 的厂商原话先过 `redactSecrets`
(明文、base64、URL 编码三形);nodemailer 的 `response` 行可能带 AUTH 明文/密文,同样过。
- token(wecom)只在 provider 实例内存里,不落库。
### 9.5 §7.3「两缝」
`MailProvider` 是新 port,它不经 `ModelGateway` / `ToolInvoker`:**入站是外界事实,持久化即记录**
(回放读 `sys.mail_inbound`,永不重拉);**出站**活动侧经 `ToolInvoker`(可录可放),系统侧(channel / ops)
留痕 `sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」的解释,
随 ADR-013 一并定案。
### 9.6 界面不撒谎
探测分收 / 发两栏各说各的,wecom 的发信一栏 `checked:false` 明说「只能真发时验证」;
拉取错误摆厂商原话;wecom 的 `address` 探到之前是空串,前端摆「待探测」而不是编一个;
`polling` 只在租约未过期时为真。
## 10. 腾讯侧前置(管理员执行;写进《前端对接说明》)
**IMAP/SMTP 路**:① 管理员「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP;② 该邮箱在网页端
「设置 → 收发信设置」开启 IMAP/SMTP 服务;③ 开了安全登录 / 微信绑定的,「设置 → 邮箱绑定」生成
**客户端专用密码**(最多 20 个,短信下发),填进账号配置的密码格;公共邮箱可在企业微信管理端或经 API
`create_auth_code:1` 生成(只返回一次)。
**企业微信 API 路**:① 企业微信 ≥ 4.0.20;② 建自建应用,记下 corpid 与应用 secret;③ 管理端「协作 → 邮件」
给该应用授邮件权限,系统自动分配应用邮箱(前缀=应用名拼音,可改);④ 如企业开了 IP 白名单,把后端出口 IP 加进去
(否则 60020)。⑤ 发信配额归企业级共享,腾讯按账号数与信誉浮动,超了 SMTP/API 都会拒 —— 只记文档,不做控制。
## 11. 连带改动
| 处 | 改动 |
|---|---|
| `tools/module-tiers.json` + `docs/编码规范.md` §4 | tier 1 加 `mail`;表加一行「邮箱账号与收发:两条接入路适配、轮询拉取与留痕、订阅分发、出站留痕」 |
| `apps/api/package.json` | `imapflow` `nodemailer` `postal-mime`(+ `@types/nodemailer` dev) |
| `apps/api/src/app/tools/first-party.ts` | 删 `mailSend`;`FIRST_PARTY_TOOLS` 去掉 `mail.send` 那行(改由 container 装配) |
| `apps/api/src/app/seed-mvp.ts` | `MAIL_TOOL` 升 2.0.0(§7.3) |
| `apps/api/src/app/routes/governance-ops.ts` | 上传两段 SQL 抽到 `app/upload-store.ts`,行为不变 |
| `apps/api/src/app/reset-runs.ts` | 加三张留痕表 |
| `apps/api/src/modules/channel/CONTRACT.md` | 「邮件通道是外界」一条改为:真通道由 `mail` 模块提供(app 注入),模拟件只剩测试用途 |
| `docs/adr/ADR-013-mail-gateway.md` | 邮件收发:外界适配两条路、入站先持久后分发、出站留痕、凭证密封、跨进程租约、「两缝」解释 |
| `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md` | 本轮产出(§15) |
| `docs/API文档-鉴权与人员.md` §6 | 总表加 15 行 |
| `docs/debt.md` | 见 §13 |
| `CLAUDE.md` 现状快照 | 加一条 |
| `api-report/` | `pnpm api:check --update && git add api-report`(新模块契约面 + contracts) |
## 12. 测试策略
- **domain 零 mock**:游标两形与摘要、wecom 窗口、到期判定与租约时长、`inboundRecordOf`(缺 Message-ID / 只有 html /
附件超限)、`redactSecrets` 三形、`validateSend` 与 `pickAccount`(指定停用 / 无默认 / 多默认)、分发状态机、`viewOf` 不带密文。
- **usecase**(假 provider + 内存 store):拉 → 存 → 分发全链;重复拉不重分发;中途失败游标停对位;订阅方 skipped / failed 互不影响;
`redeliver`;`send` 的幂等键、失败落行并抛、附件取自 upload;`probe` 两种入参;`updateAccount` 空 secret 不改密文;默认账号唯一。
- **adapter**:`postal-mime-parser` 跑夹具(GBK 主题正文、多段、纯 html、附件、无 Message-ID);
`wecom-provider` 对假 `fetch`(token 缓存与过期刷新、45009 停、翻页、已知 mail_id 不再 read_mail、compose_send 载荷);
`imap-smtp-provider` 的映射逻辑(UID 区间、validity 变化、`cursorAfter`)单测;真信箱 IT 用 env 门控
`JCAI_MAIL_IT_IMAP_HOST/USER/PASS`(缺即 `describe.skipIf`)。
- **IT(真 PG)**:迁移;三个 store;`claimPolling` 两连接对抢 20 轮每轮恰一个赢、过期后接得上;唯一键冲突回 `inserted:false`。
- **app**:`toInboundMail` 映射;`mailSendTool` 错误映射;`route-coverage` 自动覆盖新路由归属。
- 门禁:`pnpm gates` 全绿 + api-report 更新。
## 13. 风险、取舍与欠账(进 `docs/debt.md`)
1. **channel/event 按 `cause.ref` 不幂等** → 分发只能自动一次;根治后可改自动重投。
2. **wecom 发信拿不到 Message-ID** → 线程级归属对 wecom 出信不成立(③级地址窗仍可用)。
3. **`mail_data` 编码文档未说** → 实现时探针定档;两档都留兜底直到探明。
4. **IMAP IDLE 未做** → 最快 10s 延迟(`pollIntervalSec` 下限)。
5. **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留** → 下一轮退役。
6. **附件走 `sys.upload` 的 8MB 上限** → 更大的附件只留注;要真存得改上传件的存放策略。
7. **订阅方不落库** → 新加订阅方收不到历史信(运营面可手动投)。
## 14. 实施边界
改:`apps/api/src/modules/mail/**`(新)· `packages/contracts/src/{mail.ts, api/mail.ts, api/index.ts, index.ts}` ·
`db/migrations/0033_mail.sql` + journal · `apps/api/src/app/{container.ts, main.ts, config.ts, reset-runs.ts, seed-mvp.ts, upload-store.ts(新), routes/mail.ts(新), workers/mail-poller.ts(新), tools/first-party.ts}` ·
`tools/module-tiers.json` · 文档(§11)。
不改:`apps/web/**`、`channel` 的 index/contract/port、任何门禁配置、`ingest` 路由(仍是注入门,e2e 继续用)。
## 15. 文档产出
- `docs/API文档-邮件收发.md`:通则 → 每条路由一节(`METHOD /path —— access`)→ 状态码与 body → schema 附录 → curl 实录。
- `docs/前端对接说明-邮件收发.md`:一分钟看懂 → 建议落点(运营中心 · 系统区「邮箱」)→ 账号表单按 `kind` 切换的字段清单与默认值 →
探测 / 立即拉取 / 试发 / 重新投递四个动作的交互与「界面不撒谎」口径 → 入站 / 出站列表与附件预览 → SSE 失效映射(`kind:'system'` → `['gov','mail',*]`)→
腾讯侧前置(§10)→ 接入完成的判据。