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/apps/api/src/modules/mail/CONTRACT.md
T
xcsandClaude Fable 5 f70de44338 终审残余两处:换 address 就是换信箱;错误话的出口也剥 NUL
复审的两条范围外观察,都落在这一轮刚动过的那两段里,一并收掉:

① 收侧判据看不见 address —— `imapSettingsOf` 用的是 `login ?? address`,而 login **常常空着**,
   这时候真正决定「登到哪个信箱」的就是 address。上一刀把发信三键排除掉之后,
   换 address 判据为假:旧游标的 UID 说的是原来那个箱子,而 UID 是按信箱编号的。
   判据改认**实际登录名**:login 填了就比 login(这时 address 只是发信落款,换它不动收信),
   空着就比 address。wecom 不参与 —— 它的 address 由探测回填,比它会在回填那一刻误清游标。

② 治毒信的那道门自己会被同一个字符毒死 —— 降级存档把解析失败的原因写进正文与
   `last_poll_error`,而那串原因常常来自那封信自己的字节。`errorText` 出口一律剥 NUL:
   所有落库的错误话都从这一个出口出去,分散到每个调用点去剥,总会漏掉下一个新调用点。

两条测试都验过 RED→GREEN(拿掉修复各自变红,旁边的既有用例不受影响)。

顺带:`mail/CONTRACT.md` 两处点名的 `createFakeMailProvider` 全仓不存在(未定义、未导出、无调用方)——
契约文档摆一个不存在的出口,照它接线的人会去 import 一个没有的东西;改成实情。
debt 补 ⑳-㉔(IMAP 每轮重登录与腾讯频率限制 / wecom -1 无退避 / pollDue 每 tick 刷日志 /
notes 不设上限 / claimForResend 推 created_at 使该列不再是创建时刻)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
2026-08-26 04:07:08 +08:00

7.1 KiB

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; 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)推进,失败停在最后一封成功处
  • 一封解析不动的信不许卡死整条路(2026-08-25 终审):落 text/jsonb 的字段一律剥 NUL(stripNul); 单封 storeOne 抛错时先落降级骨架(原件原样存、主题从原始字节扒、正文写明失败原因)—— 落成了就推进游标并把「第 N 封(providerRef)解析失败,已降级存档」写进 last_poll_error; 骨架也落不下才停在这一封,那时 last_poll_error 必须点名是哪一封。降级行不自动分发 (正文是失败原因不是客户的话),人可按「重新投递」
  • 线程取根:threadIdOf = references[0] ?? inReplyTo(ADR-013 决策 3),全仓唯一口径 —— app 的 mail-wiring.toInboundMail 调它,不另抄一份
  • provider 的 note 要上界面:pull 回的 note(当前唯一一种:UIDVALIDITY 变化已重锚、期间来信未拉取) 落进 last_poll_error;丢了一段信而账号页一片绿是界面撒谎
  • 读取分两档:list / getMeta 不取 raw 原件字节(一封上限 16MB),rawAvailable 由 raw is not null 投影;只有「下载原件」那一条路走全列的 get
  • 账号的配置列与运行列分家:save() 只写配置列;运行列(cursor / last_polled_at / last_poll_error / last_inbound_at / polling_until)只经 savePoll / claimPolling / releasePolling 改 —— 整行 upsert 会把另一台正持着的租约与刚推进的游标盖回读那一刻的快照。清游标是显式的 savePoll({cursor:null})
  • 重锚只看收侧:cursorReset 只比 kind 与收侧 settings 键(imap-smtp:imapHost/imapPort/imapTls/ login/folder;wecom:corpId/apiBase)—— 改 SMTP 端口不该把来信丢掉
  • 分发自动只投一次:先落 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' 返回(调用方各自翻译)
  • 复用旧行重发要先抢:OutboundStore.claimForResend 一条条件更新(failed、或停够 5 分钟的 sending 才抢得到;抢到即置 sending 并把 created_at 推到 now)—— 抢到才发,抢不到回「本次未重复发送」。 两个后端连同一个库时,这一句是「同键不发两封」唯一的墙
  • 凭证密封: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;测试替身是 __tests__/fakes.ts 里的内存件(不从 index 导出 —— 外界替身只进测试)

影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。 2026-08-25 终审修复轮改了 port.ts:InboundStore 新增 getMeta(list / getMeta 不再取 raw)、 OutboundStore 新增 claimForResend、MailAccountStore.savePoll 的 cursor 允许显式 null、 MailProvider.pull 回参可带 note —— 端口的实现方只有本模块的 adapter 与 __tests__/fakes.ts 的内存件(两边语义必须一致),app 侧装配未变