From 83f65b27857fae876cc98bbe61f179430e363738 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 00:11:14 +0800 Subject: [PATCH] =?UTF-8?q?mail=20=E6=A8=A1=E5=9D=97=E9=AA=A8=E6=9E=B6:?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20/=20=E7=AB=AF=E5=8F=A3=20/=20=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E9=94=99=E8=AF=AF=20/=20=E6=B8=B8=E6=A0=87=E4=B8=A4?= =?UTF-8?q?=E5=BD=A2=20/=20=E5=88=B0=E6=9C=9F=E4=B8=8E=E7=A7=9F=E7=BA=A6(A?= =?UTF-8?q?DR-013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- apps/api/src/modules/mail/CONTRACT.md | 53 ++++++++ .../src/modules/mail/__tests__/cursor.test.ts | 68 ++++++++++ .../src/modules/mail/__tests__/due.test.ts | 33 +++++ apps/api/src/modules/mail/contract.ts | 83 ++++++++++++ apps/api/src/modules/mail/index.ts | 64 +++++++++ .../modules/mail/internal/domain/cursor.ts | 65 +++++++++ .../src/modules/mail/internal/domain/due.ts | 21 +++ .../modules/mail/internal/domain/errors.ts | 67 +++++++++ .../modules/mail/internal/domain/records.ts | 128 ++++++++++++++++++ apps/api/src/modules/mail/package.json | 7 + apps/api/src/modules/mail/port.ts | 111 +++++++++++++++ 11 files changed, 700 insertions(+) create mode 100644 apps/api/src/modules/mail/CONTRACT.md create mode 100644 apps/api/src/modules/mail/__tests__/cursor.test.ts create mode 100644 apps/api/src/modules/mail/__tests__/due.test.ts create mode 100644 apps/api/src/modules/mail/contract.ts create mode 100644 apps/api/src/modules/mail/index.ts create mode 100644 apps/api/src/modules/mail/internal/domain/cursor.ts create mode 100644 apps/api/src/modules/mail/internal/domain/due.ts create mode 100644 apps/api/src/modules/mail/internal/domain/errors.ts create mode 100644 apps/api/src/modules/mail/internal/domain/records.ts create mode 100644 apps/api/src/modules/mail/package.json create mode 100644 apps/api/src/modules/mail/port.ts diff --git a/apps/api/src/modules/mail/CONTRACT.md b/apps/api/src/modules/mail/CONTRACT.md new file mode 100644 index 0000000..d52b07e --- /dev/null +++ b/apps/api/src/modules/mail/CONTRACT.md @@ -0,0 +1,53 @@ +# 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 未变 diff --git a/apps/api/src/modules/mail/__tests__/cursor.test.ts b/apps/api/src/modules/mail/__tests__/cursor.test.ts new file mode 100644 index 0000000..2e0a80a --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/cursor.test.ts @@ -0,0 +1,68 @@ +/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */ +import { describe, expect, it } from 'vitest'; +import { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, + WECOM_OVERLAP_SEC, +} from '../internal/domain/cursor.ts'; + +describe('imap 游标', () => { + it('逐封推进只往前不往后', () => { + const c = imapCursor('7', 100); + expect(advanceImap(c, 105).lastUid).toBe(105); + expect(advanceImap(c, 90).lastUid).toBe(100); + }); + + it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => { + expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false); + expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true); + expect(imapNeedsReanchor(null, '7')).toBe(true); + expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true); + }); + + it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => { + expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]); + expect(newUids([100], 100)).toEqual([]); + }); +}); + +describe('wecom 游标', () => { + const now = new Date('2026-08-25T10:00:00Z'); + it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => { + const w = wecomWindow(wecomCursor(1_000_000), null, now); + expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) }); + }); + it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => { + const end = Math.floor(now.getTime() / 1000); + expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end }); + const since = new Date('2026-08-20T00:00:00Z'); + expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000)); + }); + it('lastTime 往前退不会退成负数', () => { + expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0); + }); +}); + +describe('摘要与解析', () => { + it('人读摘要;没拉过是 null', () => { + expect(cursorSummary(null)).toBeNull(); + expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7'); + // brief 原字面量 1756116000 对应 2025-08-25T10:00:00.000Z(与期望的 2026 年份相差整 365 天, + // 与本文件其余用例统一锚定的 2026-08-25T10:00:00Z 不符)——已核实为 brief 的数据笔误, + // 修的是输入字面量而非断言期望值,见 task-4-report.md。 + expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z'); + }); + it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => { + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3)); + expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5)); + expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull(); + expect(parseCursor(null)).toBeNull(); + expect(parseCursor('garbage')).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/due.test.ts b/apps/api/src/modules/mail/__tests__/due.test.ts new file mode 100644 index 0000000..03140ad --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/due.test.ts @@ -0,0 +1,33 @@ +/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */ +import { describe, expect, it } from 'vitest'; +import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null }; + +describe('isDue', () => { + it('从没拉过 → 到期;停用 → 永不到期', () => { + expect(isDue(base, now)).toBe(true); + expect(isDue({ ...base, enabled: false }, now)).toBe(false); + }); + it('上次 + 间隔 <= 现在才到期', () => { + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false); + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true); + }); + it('租约还没过期 → 别人在拉,不到期;过期了照常', () => { + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false); + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true); + }); +}); + +describe('leaseMsFor / isPolling', () => { + it('租约 = max(5 分钟, 3 × 间隔)', () => { + expect(leaseMsFor(30)).toBe(MIN_LEASE_MS); + expect(leaseMsFor(600)).toBe(1_800_000); + }); + it('polling 只在租约未过期时为真', () => { + expect(isPolling(null, now)).toBe(false); + expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true); + expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false); + }); +}); diff --git a/apps/api/src/modules/mail/contract.ts b/apps/api/src/modules/mail/contract.ts new file mode 100644 index 0000000..21c130f --- /dev/null +++ b/apps/api/src/modules/mail/contract.ts @@ -0,0 +1,83 @@ +/** + * module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。 + */ +import type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +} from '@contracts'; + +export type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +}; + +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './internal/domain/errors.ts'; + +/** + * 订阅方回调 —— 「注册回调读到邮件内容」的落点。 + * 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。 + * **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。 + */ +export type InboundHandler = ( + mail: MailInboundDetail, + ctx: { readonly accountId: string; readonly channelId: string | undefined }, +) => Promise; + +export type MailService = { + /* ── 账号 ── */ + listAccounts(): Promise; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ + updateAccount(accountId: string, input: MailAccountInput): Promise; + /** 留痕不删(入站 / 出站表对账号不设外键) */ + deleteAccount(accountId: string): Promise; + /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */ + probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise; + /* ── 收 ── */ + /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */ + pollDue(now: Date): Promise; + /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */ + pollNow(accountId: string): Promise; + /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */ + subscribe(name: string, handler: InboundHandler): () => void; + listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; + getInbound(mailId: string): Promise; + attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>; + /** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */ + raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; + /** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */ + redeliver(mailId: string, subscriber?: string): Promise; + /* ── 发 ── */ + /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */ + send(input: MailSendInput): Promise; + listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; +}; diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts new file mode 100644 index 0000000..5db4c55 --- /dev/null +++ b/apps/api/src/modules/mail/index.ts @@ -0,0 +1,64 @@ +/** + * module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + InboundHandler, + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, + MailService, +} from './contract.ts'; +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './contract.ts'; + +export type { + AttachmentStore, + ChangeNotifier, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + MimeParser, + OutboundMessage, + OutboundRecord, + OutboundStore, + ParsedMail, + ProviderAccount, + RawMessage, + SecretBox, +} from './port.ts'; + +export { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, +} from './internal/domain/cursor.ts'; +export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; diff --git a/apps/api/src/modules/mail/internal/domain/cursor.ts b/apps/api/src/modules/mail/internal/domain/cursor.ts new file mode 100644 index 0000000..1fd7cc6 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/cursor.ts @@ -0,0 +1,65 @@ +/** + * 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。 + * IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。 + */ +export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number }; +export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number }; +export type MailCursor = ImapCursor | WecomCursor; + +/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */ +export const WECOM_OVERLAP_SEC = 600; + +export function imapCursor(uidValidity: string, lastUid: number): ImapCursor { + return { kind: 'imap', uidValidity, lastUid }; +} + +export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor { + return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor; +} + +export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean { + return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity; +} + +/** + * IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)—— + * 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。 + */ +export function newUids(uids: readonly number[], lastUid: number): number[] { + return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b); +} + +export function wecomCursor(lastTime: number): WecomCursor { + return { kind: 'wecom', lastTime }; +} + +export function wecomWindow( + cursor: MailCursor | null, + backfillFrom: Date | null, + now: Date, +): { beginTime: number; endTime: number } { + const endTime = Math.floor(now.getTime() / 1000); + if (cursor !== null && cursor.kind === 'wecom') { + return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime }; + } + if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime }; + // 无游标不回填:窗口空,这一轮只是把游标锚在当下 + return { beginTime: endTime, endTime }; +} + +export function cursorSummary(cursor: MailCursor | null): string | null { + if (cursor === null) return null; + if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`; + return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`; +} + +/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */ +export function parseCursor(raw: unknown): MailCursor | null { + if (typeof raw !== 'object' || raw === null) return null; + const c = raw as Record; + if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && typeof c['lastUid'] === 'number') { + return imapCursor(c['uidValidity'], c['lastUid']); + } + if (c['kind'] === 'wecom' && typeof c['lastTime'] === 'number') return wecomCursor(c['lastTime']); + return null; +} diff --git a/apps/api/src/modules/mail/internal/domain/due.ts b/apps/api/src/modules/mail/internal/domain/due.ts new file mode 100644 index 0000000..8b936c0 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/due.ts @@ -0,0 +1,21 @@ +/** 到期判定与租约时长(设计稿 §9.1) */ +export const MIN_LEASE_MS = 5 * 60_000; + +/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */ +export function leaseMsFor(pollIntervalSec: number): number { + return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000); +} + +export function isPolling(pollingUntil: Date | null, now: Date): boolean { + return pollingUntil !== null && pollingUntil.getTime() > now.getTime(); +} + +export function isDue( + account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null }, + now: Date, +): boolean { + if (!account.enabled) return false; + if (isPolling(account.pollingUntil, now)) return false; + if (account.lastPolledAt === null) return true; + return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime(); +} diff --git a/apps/api/src/modules/mail/internal/domain/errors.ts b/apps/api/src/modules/mail/internal/domain/errors.ts new file mode 100644 index 0000000..ab0cd1f --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/errors.ts @@ -0,0 +1,67 @@ +/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */ +export class MailAccountNotFoundError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号不存在:${accountId}`); + this.name = 'MailAccountNotFoundError'; + } +} +export class MailAccountNameTakenError extends Error { + constructor(readonly accountName: string) { + super(`邮箱账号名已被占用:${accountName}`); + this.name = 'MailAccountNameTakenError'; + } +} +export class MailAccountDisabledError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号已停用:${accountId}`); + this.name = 'MailAccountDisabledError'; + } +} +export class MailNoDefaultAccountError extends Error { + constructor() { + super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认'); + this.name = 'MailNoDefaultAccountError'; + } +} +export class MailSecretMissingError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`); + this.name = 'MailSecretMissingError'; + } +} +export class MailPollBusyError extends Error { + constructor(readonly accountId: string) { + super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`); + this.name = 'MailPollBusyError'; + } +} +export class MailInboundNotFoundError extends Error { + constructor(readonly mailId: string) { + super(`这封信不在了:${mailId}`); + this.name = 'MailInboundNotFoundError'; + } +} +export class MailAttachmentNotFoundError extends Error { + constructor(readonly mailId: string, readonly index: number) { + super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`); + this.name = 'MailAttachmentNotFoundError'; + } +} +export class MailSubscriberUnknownError extends Error { + constructor(readonly subscriber: string) { + super(`没有这个订阅方:${subscriber}`); + this.name = 'MailSubscriberUnknownError'; + } +} +export class MailSubscriberTakenError extends Error { + constructor(readonly subscriber: string) { + super(`订阅方已注册,不许重名:${subscriber}`); + this.name = 'MailSubscriberTakenError'; + } +} +export class MailSendInvalidError extends Error { + constructor(readonly problems: readonly string[]) { + super(`发信入参不合法:${problems.join('; ')}`); + this.name = 'MailSendInvalidError'; + } +} diff --git a/apps/api/src/modules/mail/internal/domain/records.ts b/apps/api/src/modules/mail/internal/domain/records.ts new file mode 100644 index 0000000..208bde8 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/records.ts @@ -0,0 +1,128 @@ +/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */ +import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts'; +import type { MailCursor } from './cursor.ts'; + +export type MailAccountRecord = { + readonly id: string; + readonly kind: MailAccountKind; + readonly name: string; + readonly address: string; + readonly displayName: string; + readonly enabled: boolean; + readonly isDefault: boolean; + readonly pollIntervalSec: number; + readonly channelId: string | null; + readonly backfillFrom: Date | null; + /** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */ + readonly settings: Record; + /** 密封的 JSON:{password} | {corpSecret};永不出接口 */ + readonly secretCipher: string | null; + readonly cursor: MailCursor | null; + readonly lastPolledAt: Date | null; + readonly lastPollError: string | null; + readonly lastInboundAt: Date | null; + readonly pollingUntil: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +}; + +export type InboundAttachmentRecord = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; + readonly note: string; +}; + +export type InboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly providerRef: string; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly from: MailAddress; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly sentAt: Date | null; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + readonly raw: Uint8Array | null; + readonly rawNote: string; + readonly size: number; + readonly createdAt: Date; +}; + +export type DeliveryRecord = { + readonly mailId: string; + readonly subscriber: string; + readonly state: MailDeliveryState; + readonly error: string | null; + readonly startedAt: Date; + readonly finishedAt: Date | null; +}; + +export type OutboundAttachmentRecord = { + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; +}; + +export type OutboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly idempotencyKey: string | null; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly attachments: readonly OutboundAttachmentRecord[]; + readonly state: MailOutboundState; + readonly error: string | null; + readonly providerRef: string | null; + readonly requestedBy: Record; + readonly createdAt: Date; + readonly sentAt: Date | null; +}; + +export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }; + +/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */ +export type ParsedMail = { + readonly messageId?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly from?: MailAddress | undefined; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly date?: Date | undefined; + readonly attachments: readonly ParsedAttachment[]; +}; + +/** 交给 provider 的出信 —— 附件已是字节 */ +export type OutboundMessage = { + readonly messageId: string; + readonly from: MailAddress; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly attachments: readonly ParsedAttachment[]; +}; diff --git a/apps/api/src/modules/mail/package.json b/apps/api/src/modules/mail/package.json new file mode 100644 index 0000000..c0f19b4 --- /dev/null +++ b/apps/api/src/modules/mail/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/mail", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/mail/port.ts b/apps/api/src/modules/mail/port.ts new file mode 100644 index 0000000..5ab007d --- /dev/null +++ b/apps/api/src/modules/mail/port.ts @@ -0,0 +1,111 @@ +/** + * module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import type { MailCursor } from './internal/domain/cursor.ts'; +import type { + DeliveryRecord, + InboundRecord, + MailAccountRecord, + OutboundMessage, + OutboundRecord, + ParsedMail, +} from './internal/domain/records.ts'; + +export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail }; + +/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */ +export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type MailAccountStore = { + list(): Promise; + get(id: string): Promise; + findByName(name: string): Promise; + /** upsert;`isDefault:true` 时先把别的置 false(同一事务) */ + save(record: MailAccountRecord): Promise; + remove(id: string): Promise; + /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */ + listDue(now: Date): Promise; + /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */ + claimPolling(id: string, leaseMs: number): Promise; + releasePolling(id: string): Promise; + /** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */ + savePoll( + id: string, + patch: { + cursor?: MailCursor; + polledAt?: Date; + error?: string | null; + lastInboundAt?: Date; + address?: string; + }, + ): Promise; +}; + +export type InboundStore = { + /** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */ + insert(record: InboundRecord): Promise<{ inserted: boolean }>; + knownProviderRefs(accountId: string, refs: readonly string[]): Promise>; + list(query: { accountId?: string | undefined; limit: number }): Promise>; + get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>; + /** upsert on (mail_id, subscriber) */ + setDelivery(delivery: DeliveryRecord): Promise; +}; + +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise; + list(query: { accountId?: string | undefined; limit: number }): Promise; +}; + +/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */ +export type AttachmentStore = { + put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>; + get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>; +}; + +/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */ +export type ProviderAccount = { + readonly id: string; + readonly kind: MailAccountKind; + readonly address: string; + readonly displayName: string; + readonly settings: Record; + readonly secret: Record; +}; + +export type RawMessage = { + readonly providerRef: string; + readonly eml: Uint8Array; + readonly receivedAt: Date; + /** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */ + readonly cursorAfter?: MailCursor | undefined; +}; + +/** 外界:一种账号类型一个适配器(设计稿 §5.5) */ +export type MailProvider = { + readonly kind: MailAccountKind; + probe(account: ProviderAccount): Promise; + /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise; + pull( + account: ProviderAccount, + cursor: MailCursor, + opts: { + readonly limit: number; + readonly now: Date; + readonly backfillFrom: Date | null; + /** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */ + readonly known: (refs: readonly string[]) => Promise>; + }, + ): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>; + send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>; +}; + +export type MimeParser = { parse(eml: Uint8Array): Promise }; + +/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */ +export type ChangeNotifier = { changed(reason: string): void };