diff --git a/docs/superpowers/specs/2026-08-25-mail-design.md b/docs/superpowers/specs/2026-08-25-mail-design.md new file mode 100644 index 0000000..cd74042 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-mail-design.md @@ -0,0 +1,591 @@ +# 邮件收发模块(`mail`)—— 后端设计 + +- 日期:2026-08-25 +- 状态:设计已通过(用户 2026-08-25 裁决),待实施(实施计划另出) +- 范围:**只做后端**(apps/api + packages/contracts + db + 文档);web 一个文件不动, + 前端按本轮产出的《前端对接说明-邮件收发》另做 +- 依据(官方文档,核实日期 2026-08-25): + - 腾讯企业邮箱客户端协议设置(企业微信帮助中心): + - 企业微信 · 邮件概述: + - 发送普通邮件 `compose_send`: + - 获取收件箱邮件列表 `get_mail_list`: + - 获取邮件内容 `read_mail`: + - 查询应用邮箱账号 `get_email_alias`: + - 邮箱回调通知(公共邮箱 `receive_email`): + - 创建公共邮箱(`create_auth_code` 拿客户端专用密码): + - 获取 access_token: + - 全局错误码 / 访问频率限制: / + - 老版企业邮箱开放接口(`api.exmail.qq.com`): + +## 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; + +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; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + updateAccount(accountId: string, input: MailAccountInput): Promise; // secret 缺省 = 不改 + deleteAccount(accountId: string): Promise; + probe(target: { accountId: string } | { input: MailAccountInput }): Promise; // 已存 / 未存两种 + // 收 + pollDue(now: Date): Promise; // 工作者用:到期且未被占的账号各拉一轮 + pollNow(accountId: string): Promise; // 路由用:租约被占 → MailPollBusyError + subscribe(name: string, handler: InboundHandler): () => void; // 同名重复注册 → 抛 + listInbound(query: { accountId?: string; limit?: number }): Promise; + getInbound(mailId: string): Promise; + 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; // 人按的钮,至少一次 + // 发 + send(input: SendInput): Promise; + listOutbound(query: { accountId?: string; limit?: number }): Promise; +}; +``` + +### 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; + get(id: string): Promise; + save(record: MailAccountRecord): Promise; // upsert;name 唯一、默认至多一个由库约束 + remove(id: string): Promise; + listDue(now: Date): Promise; // enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空/过期 + claimPolling(id: string, now: Date, leaseMs: number): Promise; // 一条 SQL 判+占(§9.1) + releasePolling(id: string): Promise; + savePoll(id: string, patch: { cursor?: MailCursor; polledAt?: Date; error?: string | null; lastInboundAt?: Date; address?: string }): Promise; +}; + +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>; // wecom 拉列表后先滤,不重复 read_mail + list(query: { accountId?: string; limit: number }): Promise; + get(mailId: string): Promise; + setDelivery(d: { mailId: string; subscriber: string; state: DeliveryState; error?: string; startedAt: Date; finishedAt?: Date }): Promise; + deliveries(mailId: string): Promise; +}; + +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise; + list(query: { accountId?: string; limit: number }): Promise; +}; + +/** 附件存放 —— 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; readonly secret: Record }; +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; + /** 无游标时由 provider 锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | undefined, now: Date): Promise; + 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 }; + +/** 变更通知 —— 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>; + 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` 缺失时用 + `` 顶(不抛);`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) = `。 +- `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"
', 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, -- 自造 ;线程级归属的对表来源 + 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:'.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)→ 接入完成的判据。