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

4.6 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、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 未变