复审的两条范围外观察,都落在这一轮刚动过的那两段里,一并收掉: ① 收侧判据看不见 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
7.1 KiB
module: mail
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
SSE(app 经 ChangeNotifier 端口接到 broadcaster)
公开出口: index.ts
- 类型:
MailServiceInboundHandler与 @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)MailSubscriberTakenErrorMailSendInvalidError(400) - 端口类型:
MailAccountStoreInboundStoreOutboundStoreAttachmentStoreMailProviderMimeParserSecretBoxClockIdGeneratorChangeNotifier;记录类型MailAccountRecordInboundRecordOutboundRecordDeliveryRecordParsedMailOutboundMessageProviderAccountRawMessageMailCursor - 用例工厂:
createMailService(deps: MailServiceDeps): MailService - 纯函数:游标(
imapCursoradvanceImapimapNeedsReanchornewUidswecomCursorwecomWindowcursorSummaryparseCursor)、到期(isDueisPollingleaseMsFor)、 入站(buildInboundRecordplanAttachmentsbodyTextOfstripHtmlthreadIdOfsummaryOfdetailOf)、 脱敏(redactSecretssecretVariantserrorText)、发信(validateSendpickAccountmessageIdFor)、 账号(viewOfmergeAccountsecretOfcursorReset)、分发(canRedeliverstateOfOutcome) - adapter:
createMailAccountStorecreateInboundStorecreateOutboundStore(Drizzle)、createPostalMimeParser、createImapSmtpProvider、createWecomProvider; Drizzle 表:mailAccountTablemailInboundTablemailInboundDeliveryTablemailOutboundTable
层级: 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 侧装配未变