Archived
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
4.6 KiB
4.6 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、createFakeMailProvider; 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)推进,失败停在最后一封成功处 - 分发自动只投一次:先落
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 未变