# 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 侧装配未变