mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 00:11:14 +08:00
co-authored by Claude Fable 5
parent 7c6e399b57
commit 83f65b2785
11 changed files with 700 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# module: mail
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
SSE(app 经 `ChangeNotifier` 端口接到 broadcaster)
公开出口: index.ts
- 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` /
`MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` /
`MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出
- 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412)
`MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409)
`MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404)
`MailSubscriberTakenError` `MailSendInvalidError`(400)
- 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser`
`SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord`
`OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor`
- 用例工厂:`createMailService(deps: MailServiceDeps): MailService`
- 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow`
`cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、
入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`;
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime
被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块:
channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`;
`mail.send` 工具处理器在 app 里闭包持本服务
不变量(ADR-013):
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
- **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
wecom 的 `address` 探到之前是空串
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变