# 邮件收发模块(`mail`)实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 新增后端模块 `mail`(L1):腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)的收信轮询、订阅分发、发信留痕与账号配置接口;`channel` 成为首个订阅方,活动经 `mail.send` 工具发信。 **Architecture:** `mail` 只依赖 `@contracts` / `@shared/db` 与端口(`MailProvider` × 2 适配器、`MimeParser`、三个 store、`AttachmentStore`、`SecretBox`);app 层装配:轮询工作者(跨进程租约)、`channel` 订阅方、`mail.send` 工具处理器、15 条 `/api/gov/mail/*` 路由。入站先持久(两把唯一键去重)再分发(逐订阅方留痕,自动只投一次);出站先落 `sending` 行再经 provider 发。 **Tech Stack:** TypeScript / Hono / Drizzle + pg / zod v4 / vitest;新依赖 `imapflow@^1.7.6`、`nodemailer@^8.0.11` + `@types/nodemailer@^8.0.1`、`postal-mime@^3.0.0`。 **Spec:** `docs/superpowers/specs/2026-08-25-mail-design.md`(本计划与 spec 的三处差异见「与 spec 的差异」)。 ## Global Constraints - 五道门禁以退出码验:`pnpm gates`(= `gen:tiers → typecheck → lint → depcruise → api:check → test`);api-report 用 `pnpm api:check --update && git add api-report` 刷新。 - §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO 零 `Date.now`/`new Date()`/`Math.random`;不删测试;不动门禁配置;模块内 `.ts` 后缀显式;`apps/api/src/modules/**` 公开签名显式类型(`explicit-module-boundary-types`)。 - 模块只 import 更低层 `index.ts`;`mail` 是 L1(`tools/module-tiers.json` tier 1);`channel` / `toolbox` **不 import** `mail`,全经 app 注入。 - Drizzle / pg / imapflow / nodemailer / postal-mime 只准出现在 `internal/adapter/` 与 `apps/api/src/app/`。 - 凭证:只回 `secretBound: boolean`,密文永不出接口;错误文本先 `redactSecrets`。 - 界面不撒谎:探测 / 试发 / 拉取的厂商失败在 200 的 body 里带原话,不编状态码。 - 邮件通道是外界:`createFakeMailProvider` 是允许的替身;系统内部机制(store / usecase / 路由)真跑。 - 提交信息中文、末尾带 `Co-Authored-By: Claude Fable 5 ` 与 `Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr` 两行(仓内惯例)。 - **不改** `apps/web/**`、`channel` 的 index/contract/port、`ingest` 路由、任何门禁配置。 ## 与 spec 的差异(实施前核仓逼出的三处,以本计划为准) 1. **`mail.send` 工具契约保留 v1 字段**:toolbox 的 `checkEvolution` 不许删入参 / 出参字段 —— v2 保留 `body`(纯文本正文,不改名 `text`)与出参 `queued`;新增 `html` / `cc` / `bcc` / `attachments` / `inReplyTo` / `accountId` / `dryRun`。`to` 改为数组(演进检查不管类型;处理器仍容忍逗号分隔的字符串)。`connectionKeys: ['account']`(值 `default` = 用默认账号)—— 静态审对无连接键只 warn,但「从哪个信箱发」本来就是一个连接目标。 2. **`toolbox.register` 的试运行会真调处理器**:样例入参带 `dryRun: true`,处理器只校验不发。 3. **`send()` 不抛 `MailSendFailedError`**:厂商失败落 `failed` 行并以 `MailSendResult.state='failed'` + `error` 返回;只有入参 / 账号问题才抛(`MailSendInvalidError` 400、`MailNoDefaultAccountError` / `MailAccountDisabledError` / `MailSecretMissingError` 412、`MailAccountNotFoundError` 404)。channel 注入件与工具处理器各自把 `failed` 翻成自己的失败形态。 4. **nodemailer 用 8.x**:9.0.x 没有 DefinitelyTyped 类型包(`@types/nodemailer` 最新 8.0.1),8.0.11 + `@types/nodemailer@8.0.1` 类型对齐。 ## 文件结构 ``` packages/contracts/src/mail.ts # 账号 / 入站 / 出站 / 探测的 zod schema(§Task 2) packages/contracts/src/api/mail.ts # 15 条路由契约(§Task 3) packages/contracts/src/__tests__/mail.test.ts db/migrations/0033_mail.sql + meta/_journal.json # 四张表(§Task 8) apps/api/src/modules/mail/ package.json · index.ts · contract.ts · port.ts · CONTRACT.md internal/domain/records.ts # 库形 / 传输形类型(纯类型) internal/domain/cursor.ts # 游标两形、UID 过滤、wecom 窗口 internal/domain/due.ts # 到期判定、租约时长 internal/domain/inbound-record.ts # 解析结果 → 入站记录 / 视图、附件规划、正文提取 internal/domain/redact.ts # 脱敏、错误文本 internal/domain/send-input.ts # 发信校验、选账号、Message-ID internal/domain/delivery.ts # 分发状态机 internal/domain/account-view.ts # 账号记录 ↔ 输入 / 视图 internal/usecase/mail-service.ts # MailService internal/adapter/mail-schema.ts # Drizzle 表 internal/adapter/mail-store.ts # 三个 store + 租约 SQL internal/adapter/postal-mime-parser.ts internal/adapter/imap-smtp-provider.ts internal/adapter/wecom-provider.ts internal/adapter/fake-provider.ts # 外界替身 __tests__/fakes.ts # 内存 store / 时钟 / EML 夹具 __tests__/{cursor,due,inbound-record,redact,send-input,account-view,delivery}.test.ts __tests__/{mail-service-accounts,mail-service-poll,mail-service-send}.test.ts __tests__/postal-mime-parser.test.ts · imap-helpers.test.ts · imap-smtp-provider.it.test.ts · wecom-provider.test.ts __tests__/mail-store.it.test.ts apps/api/src/app/upload-store.ts # sys.upload 存取抽出(路由与 mail 注入件同用) apps/api/src/app/mail-wiring.ts # toInboundMail / channel 订阅方 / channel 传输件 / 工具处理器 apps/api/src/app/workers/mail-poller.ts apps/api/src/app/routes/mail.ts apps/api/src/app/__tests__/{mail-wiring,mail-routes,mail-poller,mail-routes-registry}.test.ts apps/api/src/app/{container,config,main,reset-runs,seed-mvp}.ts · tools/first-party.ts · routes/governance-ops.ts(上传两段抽出) tools/module-tiers.json · docs/编码规范.md §4 · apps/api/package.json docs/adr/ADR-013-mail-gateway.md · docs/API文档-邮件收发.md · docs/前端对接说明-邮件收发.md · docs/API文档-鉴权与人员.md §6 · docs/debt.md · CLAUDE.md · apps/api/src/modules/channel/CONTRACT.md ``` --- ### Task 1: 依赖与模块登记 **Files:** - Modify: `apps/api/package.json`(dependencies / devDependencies) - Modify: `tools/module-tiers.json`(tier 1 加 `mail`) - Modify: `docs/编码规范.md`(§4 层级表 L1 加一行) - Generated: `tools/depcruise-tiers.generated.cjs`(`pnpm gen:tiers` 产物,入库) **Interfaces:** - Produces: 包可 import `imapflow` / `nodemailer` / `postal-mime`;`apps/api/src/modules/mail/` 目录被门禁认作 L1。 - [ ] **Step 1: 加依赖** ```bash cd /Users/xcs/project2/tmp/BPOS-MVP pnpm --filter @app/api add imapflow@^1.7.6 nodemailer@^8.0.11 postal-mime@^3.0.0 pnpm --filter @app/api add -D @types/nodemailer@^8.0.1 node -e "const p=require('./apps/api/package.json');console.log(p.dependencies.imapflow,p.dependencies.nodemailer,p.dependencies['postal-mime'],p.devDependencies['@types/nodemailer'])" ``` Expected: 四个版本号各打印一行(`^1.7.6 ^8.0.11 ^3.0.0 ^8.0.1`)。 - [ ] **Step 2: 登记层级** `tools/module-tiers.json` 的 `"1"` 数组改为: ```json "1": [ "event", "org", "llm", "ledger", "mail" ], ``` `docs/编码规范.md` §4 层级表,在 `| L1 | \`ledger\` |` 那一行之后加: ```markdown | L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 | ``` - [ ] **Step 3: 重新生成 tier 规则并验门禁** Run: `pnpm gen:tiers && pnpm depcruise` Expected: 退出码 0;`git diff --stat tools/depcruise-tiers.generated.cjs` 显示该文件有变更(多了 `tier-order-mail` 规则)。 - [ ] **Step 4: Commit** ```bash git add apps/api/package.json pnpm-lock.yaml tools/module-tiers.json tools/depcruise-tiers.generated.cjs docs/编码规范.md git commit -m "$(printf 'mail 模块登记为 L1;批准 imapflow / nodemailer / postal-mime 三个依赖(设计稿 §2)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 2: 契约 schema `packages/contracts/src/mail.ts` **Files:** - Create: `packages/contracts/src/mail.ts` - Modify: `packages/contracts/src/index.ts`(加 `export * from './mail.ts';`) - Test: `packages/contracts/src/__tests__/mail.test.ts` **Interfaces:** - Produces(后续任务全靠这些名字):`MAIL_ACCOUNT_KINDS` `mailAccountKindSchema` `MailAccountKind` `MAIL_ACCOUNT_KIND_LABEL` `imapSmtpSettingsSchema` `ImapSmtpSettings` `wecomSettingsSchema` `WecomSettings` `mailAccountInputSchema` `MailAccountInput` `mailAccountStatusSchema` `mailAccountViewSchema` `MailAccountView` `mailProbeReportSchema` `MailProbeReport` `MAIL_DELIVERY_STATES` `mailDeliveryStateSchema` `MailDeliveryState` `mailDeliveryViewSchema` `MailDeliveryView` `mailAddressSchema` `MailAddress` `mailInboundAttachmentSchema` `MailInboundAttachment` `mailInboundSummarySchema` `MailInboundSummary` `mailInboundDetailSchema` `MailInboundDetail` `mailSendInputSchema` `MailSendInput` `mailSendResultSchema` `MailSendResult` `MAIL_OUTBOUND_STATES` `mailOutboundStateSchema` `MailOutboundState` `mailOutboundViewSchema` `MailOutboundView` `mailPollOutcomeSchema` `MailPollOutcome`。 - [ ] **Step 1: 写失败测试** ```ts // packages/contracts/src/__tests__/mail.test.ts /** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */ import { describe, expect, it } from 'vitest'; import { mailAccountInputSchema, mailAccountViewSchema, mailSendInputSchema, MAIL_ACCOUNT_KINDS, } from '../mail.ts'; describe('mailAccountInputSchema', () => { it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => { const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' }); expect(parsed.kind).toBe('imap-smtp'); if (parsed.kind !== 'imap-smtp') throw new Error('unreachable'); expect(parsed.settings).toEqual({ imapHost: 'imap.exmail.qq.com', imapPort: 993, imapTls: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpTls: true, folder: 'INBOX', }); expect(parsed.enabled).toBe(true); expect(parsed.isDefault).toBe(false); expect(parsed.pollIntervalSec).toBe(30); expect(parsed.channelId).toBeNull(); expect(parsed.backfillFrom).toBeNull(); expect(parsed.secret).toBeUndefined(); }); it('wecom:corpId 必填,地址不由人填(探测回填)', () => { const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } }); if (parsed.kind !== 'wecom') throw new Error('unreachable'); expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com'); expect('address' in parsed).toBe(false); expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false); }); it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => { expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false); expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false); expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false); expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']); }); }); describe('mailAccountViewSchema', () => { it('视图只有 secretBound,没有任何密文 / 密码字段', () => { const keys = Object.keys(mailAccountViewSchema.options[0]!.shape); expect(keys).toContain('secretBound'); expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false); }); }); describe('mailSendInputSchema', () => { it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => { const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }); expect(ok.success).toBe(true); if (ok.success) expect(ok.data.attachments).toEqual([]); expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false); expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts` Expected: FAIL(`Cannot find module '../mail.ts'`)。 - [ ] **Step 3: 写 schema** ```ts // packages/contracts/src/mail.ts /** * 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。 * * 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。 * 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱), * 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。 */ import { z } from 'zod'; import { channelIdSchema } from './id.ts'; export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const; export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS); export type MailAccountKind = z.infer; export const MAIL_ACCOUNT_KIND_LABEL: Readonly> = { 'imap-smtp': '标准协议(IMAP/SMTP)', wecom: '企业微信邮件 API(应用邮箱)', }; /** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ export const imapSmtpSettingsSchema = z.object({ imapHost: z.string().min(1).default('imap.exmail.qq.com'), imapPort: z.number().int().min(1).max(65535).default(993), /** true = 隐式 TLS(993);false = 明文(不建议) */ imapTls: z.boolean().default(true), smtpHost: z.string().min(1).default('smtp.exmail.qq.com'), smtpPort: z.number().int().min(1).max(65535).default(465), /** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */ smtpTls: z.boolean().default(true), /** 登录名;缺省 = address */ login: z.string().min(1).optional(), folder: z.string().min(1).default('INBOX'), }); export type ImapSmtpSettings = z.infer; /** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ export const wecomSettingsSchema = z.object({ corpId: z.string().min(1), apiBase: z.url().default('https://qyapi.weixin.qq.com'), }); export type WecomSettings = z.infer; const accountCommon = { name: z.string().min(1).max(64), displayName: z.string().max(64).default(''), enabled: z.boolean().default(true), isDefault: z.boolean().default(false), /** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */ pollIntervalSec: z.number().int().min(10).max(3600).default(30), /** 入站交给哪个通道;空 = channel 订阅方 skipped */ channelId: channelIdSchema.nullable().default(null), /** 从这一天起回填;空 = 首次拉取锚在当下,不回填整箱 */ backfillFrom: z.iso.datetime().nullable().default(null), }; export const mailAccountInputSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('imap-smtp'), address: z.email(), ...accountCommon, settings: imapSmtpSettingsSchema.prefault({}), /** 只写不读:缺省 = 不改(与 LLM 密钥同款) */ secret: z.object({ password: z.string().min(1) }).optional(), }), z.object({ kind: z.literal('wecom'), ...accountCommon, settings: wecomSettingsSchema, secret: z.object({ corpSecret: z.string().min(1) }).optional(), }), ]); export type MailAccountInput = z.infer; export const mailAccountStatusSchema = z.object({ lastPolledAt: z.iso.datetime().nullable(), /** 厂商原话(已脱敏);空 = 上一轮没出错 */ lastPollError: z.string().nullable(), lastInboundAt: z.iso.datetime().nullable(), /** 游标的人读摘要;还没拉过 = null */ cursor: z.string().nullable(), /** 此刻租约在手(某个进程正在拉) */ polling: z.boolean(), }); const viewCommon = { id: z.string(), name: z.string(), /** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */ address: z.string(), displayName: z.string(), enabled: z.boolean(), isDefault: z.boolean(), pollIntervalSec: z.number().int(), channelId: z.string().nullable(), backfillFrom: z.iso.datetime().nullable(), secretBound: z.boolean(), status: mailAccountStatusSchema, createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), }; export const mailAccountViewSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }), z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }), ]); export type MailAccountView = z.infer; /** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ export const mailProbeReportSchema = z.object({ kind: mailAccountKindSchema, address: z.string().optional(), receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }), send: z.object({ checked: z.boolean(), ok: z.boolean().optional(), error: z.string().optional(), note: z.string().optional(), }), }); export type MailProbeReport = z.infer; export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const; export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES); export type MailDeliveryState = z.infer; export const mailDeliveryViewSchema = z.object({ subscriber: z.string(), state: mailDeliveryStateSchema, error: z.string().nullable(), startedAt: z.iso.datetime(), finishedAt: z.iso.datetime().nullable(), }); export type MailDeliveryView = z.infer; export const mailAddressSchema = z.object({ address: z.string(), name: z.string() }); export type MailAddress = z.infer; export const mailInboundAttachmentSchema = z.object({ index: z.number().int(), name: z.string(), contentType: z.string(), size: z.number().int(), /** `sys.upload` 的 id;超限没存 = null,理由在 note */ ref: z.string().nullable(), note: z.string(), }); export type MailInboundAttachment = z.infer; export const mailInboundSummarySchema = z.object({ id: z.string(), accountId: z.string(), messageId: z.string(), from: mailAddressSchema, to: z.array(mailAddressSchema), cc: z.array(mailAddressSchema), subject: z.string(), sentAt: z.iso.datetime().nullable(), receivedAt: z.iso.datetime(), /** 正文前 200 字 */ textPreview: z.string(), hasHtml: z.boolean(), attachments: z.array(mailInboundAttachmentSchema), deliveries: z.array(mailDeliveryViewSchema), }); export type MailInboundSummary = z.infer; export const mailInboundDetailSchema = mailInboundSummarySchema.extend({ inReplyTo: z.string().nullable(), references: z.array(z.string()), text: z.string().nullable(), html: z.string().nullable(), size: z.number().int(), /** 原件 .eml 存下来了(≤16MB) */ rawAvailable: z.boolean(), }); export type MailInboundDetail = z.infer; export const mailSendInputSchema = z.object({ /** 指定账号;缺省用默认账号 */ accountId: z.string().optional(), /** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */ from: z.string().optional(), to: z.array(z.email()).min(1), cc: z.array(z.email()).default([]), bcc: z.array(z.email()).default([]), subject: z.string().min(1), text: z.string().optional(), html: z.string().optional(), inReplyTo: z.string().optional(), references: z.array(z.string()).default([]), attachments: z .array( z.object({ name: z.string().min(1), contentType: z.string().default('application/octet-stream'), /** 二选一:现给字节,或引用 sys.upload 的 id */ dataBase64: z.string().optional(), uploadRef: z.string().optional(), }), ) .default([]), /** 放行落地带的幂等键 —— 同键不发两封 */ idempotencyKey: z.string().optional(), requestedBy: z.object({ kind: z.enum(['tool', 'channel', 'ops']), taskId: z.string().optional(), staffId: z.string().optional(), tool: z.string().optional(), }), }); export type MailSendInput = z.infer; export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const; export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES); export type MailOutboundState = z.infer; export const mailSendResultSchema = z.object({ outboundId: z.string(), accountId: z.string(), messageId: z.string(), state: z.enum(['sent', 'failed']), error: z.string().optional(), providerRef: z.string().optional(), }); export type MailSendResult = z.infer; export const mailOutboundViewSchema = z.object({ id: z.string(), accountId: z.string(), messageId: z.string(), to: z.array(z.string()), cc: z.array(z.string()), bcc: z.array(z.string()), subject: z.string(), state: mailOutboundStateSchema, error: z.string().nullable(), providerRef: z.string().nullable(), requestedBy: z.record(z.string(), z.unknown()), createdAt: z.iso.datetime(), sentAt: z.iso.datetime().nullable(), }); export type MailOutboundView = z.infer; export const mailPollOutcomeSchema = z.object({ accountId: z.string(), /** provider 交出的封数 */ pulled: z.number().int(), /** 真新入库的封数(去重后) */ stored: z.number().int(), dispatched: z.number().int(), /** 预算用尽还有余下,下一 tick 接着 */ more: z.boolean(), error: z.string().optional(), }); export type MailPollOutcome = z.infer; ``` 在 `packages/contracts/src/index.ts` 的 `export * from './org.ts';` 之后加一行: ```ts export * from './mail.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts && pnpm typecheck` Expected: 4 tests PASS;typecheck 退出码 0。 - [ ] **Step 5: Commit** ```bash git add packages/contracts/src/mail.ts packages/contracts/src/index.ts packages/contracts/src/__tests__/mail.test.ts git commit -m "$(printf '邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 3: 路由契约 `packages/contracts/src/api/mail.ts` **Files:** - Create: `packages/contracts/src/api/mail.ts` - Modify: `packages/contracts/src/api/index.ts`(加 `export * from './mail.ts';`) - Test: `apps/api/src/app/__tests__/mail-routes-registry.test.ts` **Interfaces:** - Produces 15 条契约常量:`listMailAccountsRoute` `createMailAccountRoute` `probeMailDraftRoute` `getMailAccountRoute` `updateMailAccountRoute` `deleteMailAccountRoute` `probeMailAccountRoute` `pollMailAccountRoute` `sendTestMailRoute` `listMailInboundRoute` `getMailInboundRoute` `mailAttachmentRoute` `mailRawRoute` `redeliverMailInboundRoute` `listMailOutboundRoute`;`MAIL_ROUTES`(全部 15 条的数组,给文档 / 测试数)。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/app/__tests__/mail-routes-registry.test.ts /** * 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞), * 读标两端、写归运营中心(设计稿 §7.2)。 */ import { describe, expect, it } from 'vitest'; import * as contracts from '@contracts'; import { MAIL_ROUTES } from '@contracts'; import { buildRouteRegistry, routesOf } from '../route-registry.ts'; describe('邮件路由契约', () => { it('15 条全在契约命名空间里,注册表能建起来', () => { const all = routesOf(Object.values(contracts)); const registry = buildRouteRegistry(all); expect(MAIL_ROUTES).toHaveLength(15); for (const route of MAIL_ROUTES) { expect(registry.has(route.method, route.path)).toBe(true); } }); it('读标两端,写归 ops', () => { for (const route of MAIL_ROUTES) { if (route.method === 'GET') expect(route.access).toEqual(['design', 'ops']); else expect(route.access).toBe('ops'); } }); it('未存草稿探测与已存账号探测是两条不同的路', () => { const registry = buildRouteRegistry(routesOf(Object.values(contracts))); expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops'); expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops'); expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toEqual(['design', 'ops']); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts` Expected: FAIL(`MAIL_ROUTES` 不存在)。 - [ ] **Step 3: 写路由契约** ```ts // packages/contracts/src/api/mail.ts /** * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 * * 读标两端(设计中心要看通道对上了哪个信箱,运营中心管配置);写归运营中心。 * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 */ import { z } from 'zod'; import type { RouteContract } from './http.ts'; import { mailAccountInputSchema, mailAccountViewSchema, mailDeliveryViewSchema, mailInboundDetailSchema, mailInboundSummarySchema, mailOutboundViewSchema, mailPollOutcomeSchema, mailProbeReportSchema, mailSendResultSchema, } from '../mail.ts'; const accountParams = z.object({ accountId: z.string().min(1) }); const mailParams = z.object({ mailId: z.string().min(1) }); const listQuery = z.object({ accountId: z.string().optional(), /** 默认 50,上限 200 */ limit: z.coerce.number().int().min(1).max(200).optional(), }); export const listMailAccountsRoute = { method: 'GET', path: '/api/gov/mail/accounts', access: ['design', 'ops'], output: z.object({ accounts: z.array(mailAccountViewSchema) }), } as const satisfies RouteContract; export const createMailAccountRoute = { method: 'POST', path: '/api/gov/mail/accounts', access: 'ops', body: mailAccountInputSchema, output: mailAccountViewSchema, } as const satisfies RouteContract; /** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ export const probeMailDraftRoute = { method: 'POST', path: '/api/gov/mail/accounts/probe', access: 'ops', /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ query: z.object({ accountId: z.string().optional() }), body: mailAccountInputSchema, output: mailProbeReportSchema, } as const satisfies RouteContract; export const getMailAccountRoute = { method: 'GET', path: '/api/gov/mail/accounts/:accountId', access: ['design', 'ops'], params: accountParams, output: mailAccountViewSchema, } as const satisfies RouteContract; /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ export const updateMailAccountRoute = { method: 'PUT', path: '/api/gov/mail/accounts/:accountId', access: 'ops', params: accountParams, body: mailAccountInputSchema, output: mailAccountViewSchema, } as const satisfies RouteContract; export const deleteMailAccountRoute = { method: 'DELETE', path: '/api/gov/mail/accounts/:accountId', access: 'ops', params: accountParams, output: z.object({ ok: z.literal(true) }), } as const satisfies RouteContract; /** 已存账号探测(用库里凭证);凭证空 412 */ export const probeMailAccountRoute = { method: 'POST', path: '/api/gov/mail/accounts/:accountId/probe', access: 'ops', params: accountParams, output: mailProbeReportSchema, } as const satisfies RouteContract; /** 立即拉一轮;另一进程占着租约 409;停用 412 */ export const pollMailAccountRoute = { method: 'POST', path: '/api/gov/mail/accounts/:accountId/poll', access: 'ops', params: accountParams, output: mailPollOutcomeSchema, } as const satisfies RouteContract; export const sendTestMailRoute = { method: 'POST', path: '/api/gov/mail/accounts/:accountId/send-test', access: 'ops', params: accountParams, body: z.object({ to: z.email(), subject: z.string().min(1), text: z.string().min(1) }), output: mailSendResultSchema, } as const satisfies RouteContract; export const listMailInboundRoute = { method: 'GET', path: '/api/gov/mail/inbound', access: ['design', 'ops'], query: listQuery, output: z.object({ mails: z.array(mailInboundSummarySchema) }), } as const satisfies RouteContract; export const getMailInboundRoute = { method: 'GET', path: '/api/gov/mail/inbound/:mailId', access: ['design', 'ops'], params: mailParams, output: mailInboundDetailSchema, } as const satisfies RouteContract; /** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ export const mailAttachmentRoute = { method: 'GET', path: '/api/gov/mail/inbound/:mailId/attachments/:index', access: ['design', 'ops'], params: z.object({ mailId: z.string().min(1), index: z.coerce.number().int().min(0) }), output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), } as const satisfies RouteContract; /** 原件 .eml;超限没存 404 */ export const mailRawRoute = { method: 'GET', path: '/api/gov/mail/inbound/:mailId/raw', access: ['design', 'ops'], params: mailParams, output: z.object({ name: z.string(), contentType: z.literal('message/rfc822'), dataBase64: z.string() }), } as const satisfies RouteContract; /** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ export const redeliverMailInboundRoute = { method: 'POST', path: '/api/gov/mail/inbound/:mailId/redeliver', access: 'ops', params: mailParams, body: z.object({ subscriber: z.string().min(1).optional() }).default({}), output: z.object({ deliveries: z.array(mailDeliveryViewSchema) }), } as const satisfies RouteContract; export const listMailOutboundRoute = { method: 'GET', path: '/api/gov/mail/outbound', access: ['design', 'ops'], query: listQuery, output: z.object({ mails: z.array(mailOutboundViewSchema) }), } as const satisfies RouteContract; /** 全部 15 条 —— 文档总表与覆盖测试按它数 */ export const MAIL_ROUTES: readonly RouteContract[] = [ listMailAccountsRoute, createMailAccountRoute, probeMailDraftRoute, getMailAccountRoute, updateMailAccountRoute, deleteMailAccountRoute, probeMailAccountRoute, pollMailAccountRoute, sendTestMailRoute, listMailInboundRoute, getMailInboundRoute, mailAttachmentRoute, mailRawRoute, redeliverMailInboundRoute, listMailOutboundRoute, ]; ``` `packages/contracts/src/api/index.ts` 末尾加: ```ts export * from './mail.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts && pnpm typecheck` Expected: 3 tests PASS;typecheck 退出码 0。若 `buildRouteRegistry` 抛「路由撞车」,说明某条字面路径与参数模版同方法撞了 —— 按抛出的两条路径改 path,不改注册表。 - [ ] **Step 5: Commit** ```bash git add packages/contracts/src/api/mail.ts packages/contracts/src/api/index.ts apps/api/src/app/__tests__/mail-routes-registry.test.ts git commit -m "$(printf '邮件路由契约 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站列表 / 附件与原件 / 重新投递\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 4: 模块骨架 + domain:类型、错误、游标、到期 **Files:** - Create: `apps/api/src/modules/mail/package.json` · `index.ts` · `contract.ts` · `port.ts` · `CONTRACT.md` - Create: `apps/api/src/modules/mail/internal/domain/{records,errors,cursor,due}.ts` - Test: `apps/api/src/modules/mail/__tests__/{cursor,due}.test.ts` **Interfaces:** - Produces:`MailService` / `InboundHandler`(contract)· 全部端口类型(port)· 记录类型(records)· 11 个错误类 · `MailCursor` 与游标函数 · `leaseMsFor` `isDue` `isPolling`。后续任务只往 `index.ts` 追加导出行。 - [ ] **Step 1: 写失败测试(游标与到期)** ```ts // apps/api/src/modules/mail/__tests__/cursor.test.ts /** 游标两形 —— 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'); expect(cursorSummary(wecomCursor(1756116000))).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(); }); }); ``` ```ts // apps/api/src/modules/mail/__tests__/due.test.ts /** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 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); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/cursor.test.ts apps/api/src/modules/mail/__tests__/due.test.ts` Expected: FAIL(模块不存在)。 - [ ] **Step 3: 写骨架与 domain** ```json // apps/api/src/modules/mail/package.json { "name": "@modules/mail", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts" } } ``` ```ts // apps/api/src/modules/mail/internal/domain/errors.ts /** 领域错误(编码规范 §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'; } } ``` ```ts // apps/api/src/modules/mail/internal/domain/cursor.ts /** * 拉取游标 —— 两种接入路各一形(设计稿 §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; } ``` ```ts // apps/api/src/modules/mail/internal/domain/due.ts /** 到期判定与租约时长(设计稿 §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(); } ``` ```ts // apps/api/src/modules/mail/internal/domain/records.ts /** 库形与传输形 —— 纯类型,零逻辑(设计稿 §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[]; }; ``` ```ts // apps/api/src/modules/mail/contract.ts /** * 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; }; ``` ```ts // apps/api/src/modules/mail/port.ts /** * 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 }; ``` ```ts // apps/api/src/modules/mail/index.ts /** * 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'; ``` ```markdown # 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 未变 ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: cursor 7 + due 5 = 12 tests PASS;三道门禁退出码 0(`no-orphans` 对 index/contract/port 与 domain 文件:records / errors 被 contract.ts 与 port.ts 引用,cursor / due 被 index.ts 引用,不算孤儿)。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 5: domain:入站记录与脱敏 **Files:** - Create: `apps/api/src/modules/mail/internal/domain/inbound-record.ts` · `redact.ts` - Modify: `apps/api/src/modules/mail/index.ts`(追加导出) - Test: `apps/api/src/modules/mail/__tests__/{inbound-record,redact}.test.ts` **Interfaces:** - Produces:`ATTACHMENT_CAP` `RAW_CAP` `PREVIEW_CHARS` `fallbackMessageId(providerRef, address)` `threadIdOf(parsed)` `stripHtml(html)` `bodyTextOf(parsed)` `textPreviewOf(text, html)` `planAttachments(attachments, cap?)` → `AttachmentPlan[]` `rawOf(eml, cap?)` `buildInboundRecord(input)` `deliveryViewOf(d)` `summaryOf(record, deliveries)` `detailOf(record, deliveries)`;`secretVariants(secret)` `redactSecrets(text, secrets)` `errorText(error)`。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/modules/mail/__tests__/inbound-record.test.ts /** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */ import { describe, expect, it } from 'vitest'; import type { ParsedMail } from '../internal/domain/records.ts'; import { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, fallbackMessageId, planAttachments, rawOf, stripHtml, summaryOf, textPreviewOf, threadIdOf, } from '../internal/domain/inbound-record.ts'; const parsed: ParsedMail = { messageId: '', inReplyTo: '', references: ['', ''], from: { address: 'buyer@acme.com', name: '张三' }, to: [{ address: 'sales@we.com', name: '' }], cc: [], subject: '询价 RFQ-1', text: '你好,请报价', html: '

你好,请报价

', date: new Date('2026-08-25T09:00:00Z'), attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }], }; const now = new Date('2026-08-25T10:00:00Z'); describe('线程与正文', () => { it('线程 = In-Reply-To;没有就取 References 末项;都没有就是 undefined', () => { expect(threadIdOf(parsed)).toBe(''); expect(threadIdOf({ references: ['', ''] })).toBe(''); expect(threadIdOf({ references: [] })).toBeUndefined(); }); it('缺 Message-ID 用 providerRef@域顶上,不抛', () => { expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe(''); expect(fallbackMessageId('x', 'no-at')).toBe(''); }); it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => { expect(stripHtml('

你好&世界

\n 再见')).toBe('你好&世界 再见'); expect(bodyTextOf({ html: '
只有 html
' })).toBe('只有 html'); expect(bodyTextOf({ text: '有 text', html: '忽略' })).toBe('有 text'); expect(bodyTextOf({})).toBe(''); }); it('预览 200 字', () => { expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200); expect(textPreviewOf(null, '

来自 html

')).toBe('来自 html'); }); }); describe('附件与原件', () => { it('超上限的附件不存,留注;下标按原顺序', () => { const plan = planAttachments( [ { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) }, { name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) }, ], ATTACHMENT_CAP, ); expect(plan.map((p) => [p.index, p.store])).toEqual([ [0, true], [1, false], ]); expect(plan[1]!.note).toContain('8MB'); }); it('原件超上限留空 + 注', () => { expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' }); const big = rawOf(new Uint8Array(9), 8); expect(big.raw).toBeNull(); expect(big.note).not.toBe(''); }); }); describe('buildInboundRecord / 视图', () => { const record = buildInboundRecord({ id: 'in-1', accountId: 'acc-1', address: 'sales@we.com', providerRef: 'uid:7:12', parsed, eml: new Uint8Array([1, 2, 3]), receivedAt: now, attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }], now, }); it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => { expect(record.messageId).toBe(''); expect(record.inReplyTo).toBe(''); expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); expect(record.size).toBe(3); expect(record.raw).toEqual(new Uint8Array([1, 2, 3])); expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z'); }); it('缺 from / 缺 Message-ID 也能落记录', () => { const bare = buildInboundRecord({ id: 'in-2', accountId: 'acc-1', address: 'sales@we.com', providerRef: 'uid:7:13', parsed: { references: [], to: [], cc: [], subject: '', attachments: [] }, eml: new Uint8Array(), receivedAt: now, attachments: [], now, }); expect(bare.messageId).toBe(''); expect(bare.from).toEqual({ address: '', name: '' }); }); it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => { const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }]; const summary = summaryOf(record, deliveries); expect(summary.textPreview).toBe('你好,请报价'); expect(summary.hasHtml).toBe(true); expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() }); expect('text' in summary).toBe(false); const detail = detailOf(record, deliveries); expect(detail.text).toBe('你好,请报价'); expect(detail.rawAvailable).toBe(true); expect(detail.references).toEqual(['', '']); }); }); ``` ```ts // apps/api/src/modules/mail/__tests__/redact.test.ts /** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */ import { describe, expect, it } from 'vitest'; import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts'; describe('redactSecrets', () => { it('三形都换成 ***', () => { const secret = 'p@ss w0rd'; const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`; expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***'); }); it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => { expect(secretVariants('')).toEqual([]); expect(secretVariants('abc')).toEqual([]); expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc'); }); it('没命中原样返回;正则元字符当字面', () => { expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here'); expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***'); }); }); describe('errorText', () => { it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => { expect(errorText(new Error('boom'))).toBe('boom'); const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' }); expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed'); expect(errorText('raw')).toBe('raw'); expect(errorText(undefined)).toBe('未知错误'); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/inbound-record.test.ts apps/api/src/modules/mail/__tests__/redact.test.ts` Expected: FAIL(模块不存在)。 - [ ] **Step 3: 写实现** ```ts // apps/api/src/modules/mail/internal/domain/inbound-record.ts /** * 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做, * 存完把 ref 交回来再 `buildInboundRecord`。 */ import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts'; import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts'; /** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */ export const ATTACHMENT_CAP = 8 * 1024 * 1024; export const RAW_CAP = 16 * 1024 * 1024; export const PREVIEW_CHARS = 200; function domainOf(address: string): string { const at = address.lastIndexOf('@'); return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); } /** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */ export function fallbackMessageId(providerRef: string, address: string): string { return `<${providerRef}@${domainOf(address)}>`; } /** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */ export function threadIdOf(parsed: Pick): string | undefined { if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo; return parsed.references.at(-1); } const ENTITIES: Readonly> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; export function stripHtml(html: string): string { return html .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') .replace(/|<\/(p|div|tr|li|h[1-6])>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => { if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16)); if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1))); return ENTITIES[code.toLowerCase()] ?? whole; }) .replace(/[ \t\r\f\v]+/g, ' ') .replace(/\s*\n\s*/g, ' ') .trim(); } export function bodyTextOf(parsed: Pick): string { if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text; if (parsed.html !== undefined) return stripHtml(parsed.html); return ''; } export function textPreviewOf(text: string | null, html: string | null): string { const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) }); return body.slice(0, PREVIEW_CHARS); } export type AttachmentPlan = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly store: boolean; readonly note: string; }; export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] { return attachments.map((a, index) => { const size = a.bytes.byteLength; const store = size <= cap; return { index, name: a.name === '' ? `attachment-${index + 1}` : a.name, contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType, size, store, note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`, }; }); } export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } { if (eml.byteLength <= cap) return { raw: eml, note: '' }; return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` }; } export function buildInboundRecord(input: { readonly id: string; readonly accountId: string; readonly address: string; readonly providerRef: string; readonly parsed: ParsedMail; readonly eml: Uint8Array; readonly receivedAt: Date; readonly attachments: readonly InboundAttachmentRecord[]; readonly now: Date; readonly rawCap?: number | undefined; }): InboundRecord { const { parsed } = input; const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address); const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP); return { id: input.id, accountId: input.accountId, messageId, providerRef: input.providerRef, inReplyTo: parsed.inReplyTo ?? null, references: [...parsed.references], from: parsed.from ?? { address: '', name: '' }, to: [...parsed.to], cc: [...parsed.cc], subject: parsed.subject, text: parsed.text ?? null, html: parsed.html ?? null, sentAt: parsed.date ?? null, receivedAt: input.receivedAt, attachments: [...input.attachments], raw, rawNote: note, size: input.eml.byteLength, createdAt: input.now, }; } export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView { return { subscriber: d.subscriber, state: d.state, error: d.error, startedAt: d.startedAt.toISOString(), finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(), }; } export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary { return { id: record.id, accountId: record.accountId, messageId: record.messageId, from: record.from, to: [...record.to], cc: [...record.cc], subject: record.subject, sentAt: record.sentAt === null ? null : record.sentAt.toISOString(), receivedAt: record.receivedAt.toISOString(), textPreview: textPreviewOf(record.text, record.html), hasHtml: record.html !== null, attachments: record.attachments.map((a) => ({ ...a })), deliveries: deliveries.map(deliveryViewOf), }; } export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail { return { ...summaryOf(record, deliveries), inReplyTo: record.inReplyTo, references: [...record.references], text: record.text, html: record.html, size: record.size, rawAvailable: record.raw !== null, }; } ``` ```ts // apps/api/src/modules/mail/internal/domain/redact.ts /** * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 */ const MIN_SECRET_CHARS = 4; export function secretVariants(secret: string): string[] { if (secret.length < MIN_SECRET_CHARS) return []; const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)]; return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))]; } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function redactSecrets(text: string, secrets: readonly string[]): string { let out = text; for (const secret of secrets) { for (const variant of secretVariants(secret)) { out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***'); } } return out; } /** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */ export function errorText(error: unknown): string { if (error instanceof Error) { const bag = error as Error & { response?: unknown; responseText?: unknown }; const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : ''; return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`; } if (error === undefined || error === null) return '未知错误'; return String(error); } ``` `index.ts` 追加: ```ts export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts'; export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail && pnpm lint` Expected: 全部 PASS;lint 退出码 0(domain 里 `Buffer` 是全局对象不是 import,不触 `domain-is-pure-core`)。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 6: domain:发信校验、选账号、分发状态、账号视图 **Files:** - Create: `apps/api/src/modules/mail/internal/domain/{send-input,delivery,account-view}.ts` - Modify: `apps/api/src/modules/mail/index.ts`(追加导出) - Test: `apps/api/src/modules/mail/__tests__/{send-input,delivery,account-view}.test.ts` **Interfaces:** - Produces:`validateSend(input, kind, sizes)` → `string[]`;`pickAccount(accounts, {accountId?, from?})` → `MailAccountRecord`(抛 `MailAccountNotFoundError` / `MailAccountDisabledError` / `MailNoDefaultAccountError`);`messageIdFor(id, address)`;`canRedeliver(state)`;`stateOfOutcome(outcome)`;`viewOf(record, now)` → `MailAccountView`;`secretOf(input)` → `string | undefined`(JSON);`cursorReset(existing, input)`;`mergeAccount(input, base)` → `MailAccountRecord`。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/modules/mail/__tests__/send-input.test.ts /** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */ import { describe, expect, it } from 'vitest'; import type { MailAccountRecord } from '../internal/domain/records.ts'; import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts'; import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts'; const now = new Date('2026-08-25T10:00:00Z'); function account(over: Partial): MailAccountRecord { return { id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, ...over, }; } const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] }; describe('validateSend', () => { it('合法入参零问题', () => { expect(validateSend(base, 'imap-smtp', [])).toEqual([]); }); it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => { expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']); expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB'); expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB'); }); it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => { expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200'); expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]); }); }); describe('pickAccount', () => { const a = account({ id: 'a', isDefault: true }); const b = account({ id: 'b', address: 'b@we.com', enabled: false }); it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => { expect(pickAccount([a, b], {}).id).toBe('a'); expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError); }); it('指定 id:不存在 404、停用 412', () => { expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a'); expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError); expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError); }); it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => { expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a'); expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError); expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError); }); }); describe('messageIdFor', () => { it(';地址没有域用 local', () => { expect(messageIdFor('u-1', 'a@we.com')).toBe(''); expect(messageIdFor('u-1', '')).toBe(''); }); }); ``` ```ts // apps/api/src/modules/mail/__tests__/delivery.test.ts /** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */ import { describe, expect, it } from 'vitest'; import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts'; describe('delivery', () => { it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => { expect(canRedeliver(undefined)).toBe(true); expect(canRedeliver('done')).toBe(true); expect(canRedeliver('failed')).toBe(true); expect(canRedeliver('skipped')).toBe(true); expect(canRedeliver('dispatching')).toBe(false); }); it('回调的返回值定状态', () => { expect(stateOfOutcome(undefined)).toBe('done'); expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped'); }); }); ``` ```ts // apps/api/src/modules/mail/__tests__/account-view.test.ts /** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */ import { describe, expect, it } from 'vitest'; import { mailAccountInputSchema } from '@contracts'; import type { MailAccountRecord } from '../internal/domain/records.ts'; import { imapCursor } from '../internal/domain/cursor.ts'; import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts'; const now = new Date('2026-08-25T10:00:00Z'); const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } }); describe('mergeAccount / secretOf', () => { it('新建:id / 时间 / 密文来自 base,游标空', () => { const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }); expect(record.id).toBe('acc-1'); expect(record.address).toBe('sales@we.com'); expect(record.secretCipher).toBe('sealed'); expect(record.cursor).toBeNull(); expect(record.createdAt).toBe(now); expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' })); expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined(); }); it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => { const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe(''); const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }); const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' }; expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work'); }); it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => { const existing: MailAccountRecord = { ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), cursor: imapCursor('7', 9), lastPollError: '旧错误', }; expect(cursorReset(existing, input)).toBe(false); const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now }); expect(same.cursor).toEqual(imapCursor('7', 9)); const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } }); expect(cursorReset(existing, changed)).toBe(true); const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now }); expect(reset.cursor).toBeNull(); expect(reset.lastPollError).toBeNull(); }); }); describe('viewOf', () => { it('不带密文只带 secretBound;status 带游标摘要与 polling', () => { const record: MailAccountRecord = { ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }), cursor: imapCursor('7', 9), pollingUntil: new Date(now.getTime() + 1000), }; const view = viewOf(record, now); expect(view.secretBound).toBe(true); expect(JSON.stringify(view)).not.toContain('sealed'); expect(view.status.cursor).toBe('UID 9 · validity 7'); expect(view.status.polling).toBe(true); expect(view.kind).toBe('imap-smtp'); if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com'); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/send-input.test.ts apps/api/src/modules/mail/__tests__/delivery.test.ts apps/api/src/modules/mail/__tests__/account-view.test.ts` Expected: FAIL(模块不存在)。 - [ ] **Step 3: 写实现** ```ts // apps/api/src/modules/mail/internal/domain/send-input.ts /** 发信校验与选账号(设计稿 §5.3 / §5.4) */ import type { MailAccountKind, MailSendInput } from '@contracts'; import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts'; import type { MailAccountRecord } from './records.ts'; export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024; export const SEND_TOTAL_CAP = 20 * 1024 * 1024; /** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */ export const WECOM_TOTAL_CAP = 50 * 1024 * 1024; export const WECOM_ATTACHMENT_COUNT_CAP = 200; type SendShape = Pick; export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] { const problems: string[] = []; if (input.to.length === 0) problems.push('收件人至少一个'); if (input.subject.trim() === '') problems.push('主题不能为空'); if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) { problems.push('正文 text / html 至少一个'); } const total = attachmentSizes.reduce((sum, n) => sum + n, 0); if (kind === 'wecom') { if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`); if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB'); } else { const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP); if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`); if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB'); } return problems; } export function pickAccount( accounts: readonly MailAccountRecord[], requested: { readonly accountId?: string | undefined; readonly from?: string | undefined }, ): MailAccountRecord { if (requested.accountId !== undefined) { const hit = accounts.find((a) => a.id === requested.accountId); if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId); if (!hit.enabled) throw new MailAccountDisabledError(hit.id); return hit; } if (requested.from !== undefined) { const wanted = requested.from.trim().toLowerCase(); const hit = accounts.find((a) => a.address.toLowerCase() === wanted); // 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗 if (hit === undefined) throw new MailAccountNotFoundError(requested.from); if (!hit.enabled) throw new MailAccountDisabledError(hit.id); return hit; } const fallback = accounts.find((a) => a.isDefault && a.enabled); if (fallback === undefined) throw new MailNoDefaultAccountError(); return fallback; } export function messageIdFor(id: string, address: string): string { const at = address.lastIndexOf('@'); const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); return `<${id}@${domain}>`; } ``` ```ts // apps/api/src/modules/mail/internal/domain/delivery.ts /** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */ import type { MailDeliveryState } from '@contracts'; export function canRedeliver(current: MailDeliveryState | undefined): boolean { return current !== 'dispatching'; } export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' { return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done'; } ``` ```ts // apps/api/src/modules/mail/internal/domain/account-view.ts /** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */ import type { MailAccountInput, MailAccountView } from '@contracts'; import { mailAccountViewSchema } from '@contracts'; import { cursorSummary } from './cursor.ts'; import { isPolling } from './due.ts'; import type { MailAccountRecord } from './records.ts'; export function viewOf(record: MailAccountRecord, now: Date): MailAccountView { return mailAccountViewSchema.parse({ id: record.id, kind: record.kind, name: record.name, address: record.address, displayName: record.displayName, enabled: record.enabled, isDefault: record.isDefault, pollIntervalSec: record.pollIntervalSec, channelId: record.channelId, backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(), settings: record.settings, secretBound: record.secretCipher !== null && record.secretCipher !== '', status: { lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(), lastPollError: record.lastPollError, lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(), cursor: cursorSummary(record.cursor), polling: isPolling(record.pollingUntil, now), }, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), }); } /** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */ export function secretOf(input: MailAccountInput): string | undefined { return input.secret === undefined ? undefined : JSON.stringify(input.secret); } function sameSettings(a: Record, b: Record): boolean { const keys = new Set([...Object.keys(a), ...Object.keys(b)]); return [...keys].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); } /** kind 或 settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean { if (existing === undefined) return false; return existing.kind !== input.kind || !sameSettings(existing.settings, input.settings); } export function mergeAccount( input: MailAccountInput, base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date }, ): MailAccountRecord { const reset = cursorReset(base.existing, input); const existing = base.existing; return { id: base.id, kind: input.kind, name: input.name, // wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准 address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '', displayName: input.displayName, enabled: input.enabled, isDefault: input.isDefault, pollIntervalSec: input.pollIntervalSec, channelId: input.channelId, backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom), settings: { ...input.settings }, secretCipher: base.secretCipher, cursor: reset ? null : (existing?.cursor ?? null), lastPolledAt: existing?.lastPolledAt ?? null, lastPollError: reset ? null : (existing?.lastPollError ?? null), lastInboundAt: existing?.lastInboundAt ?? null, pollingUntil: existing?.pollingUntil ?? null, createdAt: existing?.createdAt ?? base.now, updatedAt: base.now, }; } ``` `index.ts` 追加: ```ts export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 全部 PASS;三道门禁退出码 0。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 7: 迁移 0033 + Drizzle 表 + 三个 store(真 PG 集成测试) **Files:** - Create: `db/migrations/0033_mail.sql`;Modify: `db/migrations/meta/_journal.json`(追加 idx 33) - Create: `apps/api/src/modules/mail/internal/adapter/mail-schema.ts` · `mail-store.ts` - Modify: `apps/api/src/modules/mail/index.ts`(追加 adapter 与表导出);`apps/api/src/app/reset-runs.ts`(三张留痕表) - Test: `apps/api/src/modules/mail/__tests__/mail-store.it.test.ts` **Interfaces:** - Produces:`createMailAccountStore(db)` `createInboundStore(db)` `createOutboundStore(db)`(各实现 port.ts 同名类型);表 `mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`。 - [ ] **Step 1: 写迁移与 journal** ```sql -- db/migrations/0033_mail.sql -- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。 -- -- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉: -- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与 -- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。 -- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。 -- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。 create table if not exists sys.mail_account ( id text primary key, name text not null, kind text not null check (kind in ('imap-smtp', 'wecom')), address text not null default '', display_name text not null default '', enabled boolean not null default true, is_default boolean not null default false, poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600), channel_id text, backfill_from timestamptz, settings jsonb not null default '{}'::jsonb, secret_cipher text, cursor jsonb, last_polled_at timestamptz, last_poll_error text, last_inbound_at timestamptz, polling_until timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists mail_account_name_key on sys.mail_account (name); -- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底 create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default; create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at); create table if not exists sys.mail_inbound ( id text primary key, account_id text not null, message_id text not null, provider_ref text not null, in_reply_to text, reference_ids jsonb not null default '[]'::jsonb, from_addr text not null default '', from_name text not null default '', to_list jsonb not null default '[]'::jsonb, cc_list jsonb not null default '[]'::jsonb, subject text not null default '', text_body text, html_body text, sent_at timestamptz, received_at timestamptz not null, attachments jsonb not null default '[]'::jsonb, raw bytea, raw_note text not null default '', size integer not null default 0, created_at timestamptz not null default now() ); create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id); create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref); create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc); create table if not exists sys.mail_inbound_delivery ( mail_id text not null references sys.mail_inbound(id) on delete cascade, subscriber text not null, state text not null check (state in ('dispatching', 'done', 'failed', 'skipped')), error text, started_at timestamptz not null default now(), finished_at timestamptz, primary key (mail_id, subscriber) ); create table if not exists sys.mail_outbound ( id text primary key, account_id text not null, message_id text not null, idempotency_key text, to_list jsonb not null, cc_list jsonb not null default '[]'::jsonb, bcc_list jsonb not null default '[]'::jsonb, subject text not null, text_body text, html_body text, in_reply_to text, reference_ids jsonb not null default '[]'::jsonb, attachments jsonb not null default '[]'::jsonb, state text not null check (state in ('sending', 'sent', 'failed')), error text, provider_ref text, requested_by jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), sent_at timestamptz ); create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null; create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc); ``` `db/migrations/meta/_journal.json` 的 `entries` 末尾追加(注意上一条后面补逗号): ```json { "idx": 33, "version": "7", "when": 1785130120181, "tag": "0033_mail", "breakpoints": true } ``` `apps/api/src/app/reset-runs.ts` 的 `RUN_TABLES` 在 `'sys.outbound_mail',` 之后加: ```ts // 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着 'sys.mail_inbound', 'sys.mail_inbound_delivery', 'sys.mail_outbound', ``` - [ ] **Step 2: 写失败测试(真 PG)** ```ts // apps/api/src/modules/mail/__tests__/mail-store.it.test.ts /** * mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、 * 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。 */ import { readFileSync } from 'node:fs'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { startItDatabase } from '@testing'; import type { ItDatabase } from '@testing'; import type { Db } from '@shared/db.ts'; import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts'; import { imapCursor } from '../internal/domain/cursor.ts'; import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts'; const database: ItDatabase | undefined = await startItDatabase(); let poolA: Pool | undefined; let poolB: Pool | undefined; let db: Db; let dbB: Db; const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url); const now = new Date('2026-08-25T10:00:00Z'); function account(over: Partial): MailAccountRecord { return { id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z', cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, ...over, }; } function inbound(over: Partial): InboundRecord { return { id: 'in-1', accountId: 'acc-a', messageId: '', providerRef: 'uid:7:1', inReplyTo: null, references: [], from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null, sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawNote: '', size: 3, createdAt: now, ...over, }; } function outbound(over: Partial): OutboundRecord { return { id: 'out-1', accountId: 'acc-a', messageId: '', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [], subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null, providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null, ...over, }; } beforeAll(async () => { if (database === undefined) return; poolA = new Pool({ connectionString: database.url, max: 2 }); poolB = new Pool({ connectionString: database.url, max: 2 }); await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade'); await poolA.query(readFileSync(MIGRATION, 'utf8')); db = drizzle(poolA); dbB = drizzle(poolB); }); afterAll(async () => { await poolA?.end(); await poolB?.end(); await database?.stop(); }); describe.skipIf(database === undefined)('mail store(真 PG)', () => { it('账号:save / get / findByName / list;isDefault 只留一个', async () => { const store = createMailAccountStore(db); await store.save(account({ id: 'acc-a', name: '甲', isDefault: true })); await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true })); expect((await store.get('acc-a'))?.isDefault).toBe(false); expect((await store.get('acc-b'))?.isDefault).toBe(true); expect((await store.findByName('甲'))?.id).toBe('acc-a'); expect((await store.list()).map((a) => a.id)).toEqual(['acc-a', 'acc-b']); }); it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => { const store = createMailAccountStore(db); await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null }); const got = await store.get('acc-a'); expect(got?.cursor).toEqual(imapCursor('7', 12)); expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString()); await store.savePoll('acc-a', { error: '连不上' }); expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12)); expect((await store.get('acc-a'))?.lastPollError).toBe('连不上'); await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined(); }); it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => { const store = createMailAccountStore(db); await store.save(account({ id: 'acc-a', name: '甲', lastPolledAt: new Date(now.getTime() - 60_000) })); await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', lastPolledAt: new Date(now.getTime() - 5_000) })); await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false })); await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) })); expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a']); }); it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => { const a = createMailAccountStore(db); const b = createMailAccountStore(dbB); await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' })); for (let round = 0; round < 20; round += 1) { const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]); expect(wins.filter(Boolean)).toHaveLength(1); await a.releasePolling('acc-lease'); } expect(await a.claimPolling('acc-lease', 60_000)).toBe(true); expect(await b.claimPolling('acc-lease', 60_000)).toBe(false); await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`); expect(await b.claimPolling('acc-lease', 60_000)).toBe(true); await b.releasePolling('acc-lease'); // 停用的账号抢不到 await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false })); expect(await a.claimPolling('acc-lease', 60_000)).toBe(false); }); it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => { const store = createInboundStore(db); expect(await store.insert(inbound({}))).toEqual({ inserted: true }); expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false }); expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '' }))).toEqual({ inserted: false }); expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1'])); expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set()); const got = await store.get('in-1'); expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3])); expect(got?.record.raw).toBeInstanceOf(Uint8Array); expect(Buffer.isBuffer(got?.record.raw)).toBe(false); }); it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => { const store = createInboundStore(db); await store.insert(inbound({ id: 'in-2', messageId: '', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) })); await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null }); await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }); const list = await store.list({ accountId: 'acc-a', limit: 10 }); expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']); expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]); expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]); expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']); }); it('出站:insert / byIdempotencyKey / setState / list', async () => { const store = createOutboundStore(db); await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' })); await store.insert(outbound({ id: 'out-2', messageId: '' })); expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1'); expect(await store.byIdempotencyKey('k-none')).toBeUndefined(); await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now }); await store.setState('out-2', 'failed', { error: '535 auth failed' }); const list = await store.list({ accountId: 'acc-a', limit: 10 }); expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' }); expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' }); // 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在 await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow(); }); }); ``` - [ ] **Step 3: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts` Expected: FAIL(`mail-store.ts` 不存在)。本机 `pnpm db:up` 起着 PG 时用 `localhost:54329`;起不了就走 Testcontainers;两者皆无 → 套件跳过并告警(那样本任务不算验过,先把库起来)。 - [ ] **Step 4: 写表与 store** ```ts // apps/api/src/modules/mail/internal/adapter/mail-schema.ts /** * mail 的 sys schema 表(编码规范 §7 / ADR-002)。 * 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。 */ import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; import type { MailAddress } from '@contracts'; import { sys } from '@shared/db.ts'; /** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */ const bytea = customType<{ data: Buffer; driverData: Buffer }>({ dataType() { return 'bytea'; }, }); export const mailAccountTable = sys.table('mail_account', { id: text('id').primaryKey(), name: text('name').notNull(), kind: text('kind').notNull(), address: text('address').notNull().default(''), displayName: text('display_name').notNull().default(''), enabled: boolean('enabled').notNull().default(true), isDefault: boolean('is_default').notNull().default(false), pollIntervalSec: integer('poll_interval_sec').notNull().default(30), channelId: text('channel_id'), backfillFrom: timestamp('backfill_from', { withTimezone: true }), settings: jsonb('settings').$type>().notNull().default({}), /** AES-256-GCM 密文;永不出接口 */ secretCipher: text('secret_cipher'), cursor: jsonb('cursor'), lastPolledAt: timestamp('last_polled_at', { withTimezone: true }), lastPollError: text('last_poll_error'), lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }), /** 跨进程租约 —— 与 sys.task.advancing_until 同款 */ pollingUntil: timestamp('polling_until', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export const mailInboundTable = sys.table('mail_inbound', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), messageId: text('message_id').notNull(), providerRef: text('provider_ref').notNull(), inReplyTo: text('in_reply_to'), referenceIds: jsonb('reference_ids').$type().notNull().default([]), fromAddr: text('from_addr').notNull().default(''), fromName: text('from_name').notNull().default(''), toList: jsonb('to_list').$type().notNull().default([]), ccList: jsonb('cc_list').$type().notNull().default([]), subject: text('subject').notNull().default(''), textBody: text('text_body'), htmlBody: text('html_body'), sentAt: timestamp('sent_at', { withTimezone: true }), receivedAt: timestamp('received_at', { withTimezone: true }).notNull(), attachments: jsonb('attachments') .$type>() .notNull() .default([]), raw: bytea('raw'), rawNote: text('raw_note').notNull().default(''), size: integer('size').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', { mailId: text('mail_id').notNull(), subscriber: text('subscriber').notNull(), state: text('state').notNull(), error: text('error'), startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), finishedAt: timestamp('finished_at', { withTimezone: true }), }); export const mailOutboundTable = sys.table('mail_outbound', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), messageId: text('message_id').notNull(), idempotencyKey: text('idempotency_key'), toList: jsonb('to_list').$type().notNull(), ccList: jsonb('cc_list').$type().notNull().default([]), bccList: jsonb('bcc_list').$type().notNull().default([]), subject: text('subject').notNull(), textBody: text('text_body'), htmlBody: text('html_body'), inReplyTo: text('in_reply_to'), referenceIds: jsonb('reference_ids').$type().notNull().default([]), attachments: jsonb('attachments').$type>().notNull().default([]), state: text('state').notNull(), error: text('error'), providerRef: text('provider_ref'), requestedBy: jsonb('requested_by').$type>().notNull().default({}), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), sentAt: timestamp('sent_at', { withTimezone: true }), }); ``` ```ts // apps/api/src/modules/mail/internal/adapter/mail-store.ts /** * mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 * 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。 */ import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; import type { DeliveryRecord, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, OutboundRecord, OutboundStore, } from '../../port.ts'; import { parseCursor } from '../domain/cursor.ts'; import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts'; function rowsOf(result: unknown): unknown[] { if (Array.isArray(result)) return result; const rows = (result as { rows?: unknown }).rows; return Array.isArray(rows) ? rows : []; } /* ── 账号 ─────────────────────────────────────────────────────────────── */ type AccountRow = typeof mailAccountTable.$inferSelect; function toAccount(row: AccountRow): MailAccountRecord { return { id: row.id, kind: mailAccountKindSchema.parse(row.kind), name: row.name, address: row.address, displayName: row.displayName, enabled: row.enabled, isDefault: row.isDefault, pollIntervalSec: row.pollIntervalSec, channelId: row.channelId, backfillFrom: row.backfillFrom, settings: row.settings, secretCipher: row.secretCipher, cursor: parseCursor(row.cursor), lastPolledAt: row.lastPolledAt, lastPollError: row.lastPollError, lastInboundAt: row.lastInboundAt, pollingUntil: row.pollingUntil, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert { return { id: record.id, name: record.name, kind: record.kind, address: record.address, displayName: record.displayName, enabled: record.enabled, isDefault: record.isDefault, pollIntervalSec: record.pollIntervalSec, channelId: record.channelId, backfillFrom: record.backfillFrom, settings: record.settings, secretCipher: record.secretCipher, cursor: record.cursor, lastPolledAt: record.lastPolledAt, lastPollError: record.lastPollError, lastInboundAt: record.lastInboundAt, pollingUntil: record.pollingUntil, createdAt: record.createdAt, updatedAt: record.updatedAt, }; } export function createMailAccountStore(db: Db): MailAccountStore { return { async list() { const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name); return rows.map(toAccount); }, async get(id) { const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1); return rows[0] === undefined ? undefined : toAccount(rows[0]); }, async findByName(name) { const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1); return rows[0] === undefined ? undefined : toAccount(rows[0]); }, async save(record) { const values = accountValues(record); await db.transaction(async (tx) => { // 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底 if (record.isDefault) { await tx .update(mailAccountTable) .set({ isDefault: false }) .where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`)); } await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values }); }); }, async remove(id) { await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id)); }, async listDue(now) { const rows = await db .select() .from(mailAccountTable) .where( sql`${mailAccountTable.enabled} and (${mailAccountTable.lastPolledAt} is null or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now}) and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`, ) .orderBy(mailAccountTable.name); return rows.map(toAccount); }, async claimPolling(id, leaseMs) { const result = await db.execute(sql` update sys.mail_account set polling_until = now() + (${leaseMs} || ' milliseconds')::interval where id = ${id} and enabled and (polling_until is null or polling_until < now()) returning id`); return rowsOf(result).length > 0; }, async releasePolling(id) { // 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉 await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined); }, async savePoll(id, patch) { const set: Partial = {}; if (patch.cursor !== undefined) set.cursor = patch.cursor; if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt; if (patch.error !== undefined) set.lastPollError = patch.error; if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt; if (patch.address !== undefined) set.address = patch.address; if (Object.keys(set).length === 0) return; await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id)); }, }; } /* ── 入站 ─────────────────────────────────────────────────────────────── */ type InboundRow = typeof mailInboundTable.$inferSelect; type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect; function toInbound(row: InboundRow): InboundRecord { return { id: row.id, accountId: row.accountId, messageId: row.messageId, providerRef: row.providerRef, inReplyTo: row.inReplyTo, references: row.referenceIds, from: { address: row.fromAddr, name: row.fromName }, to: row.toList, cc: row.ccList, subject: row.subject, text: row.textBody, html: row.htmlBody, sentAt: row.sentAt, receivedAt: row.receivedAt, attachments: row.attachments, /* * pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 —— * 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。 */ raw: row.raw === null ? null : Uint8Array.from(row.raw), rawNote: row.rawNote, size: row.size, createdAt: row.createdAt, }; } function toDelivery(row: DeliveryRow): DeliveryRecord { return { mailId: row.mailId, subscriber: row.subscriber, state: mailDeliveryStateSchema.parse(row.state), error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, }; } export function createInboundStore(db: Db): InboundStore { async function deliveriesOf(mailIds: readonly string[]): Promise> { const out = new Map(); if (mailIds.length === 0) return out; const rows = await db .select() .from(mailInboundDeliveryTable) .where(inArray(mailInboundDeliveryTable.mailId, [...mailIds])) .orderBy(mailInboundDeliveryTable.subscriber); for (const row of rows) { const list = out.get(row.mailId) ?? []; list.push(toDelivery(row)); out.set(row.mailId, list); } return out; } return { async insert(record) { const rows = await db .insert(mailInboundTable) .values({ id: record.id, accountId: record.accountId, messageId: record.messageId, providerRef: record.providerRef, inReplyTo: record.inReplyTo, referenceIds: [...record.references], fromAddr: record.from.address, fromName: record.from.name, toList: [...record.to], ccList: [...record.cc], subject: record.subject, textBody: record.text, htmlBody: record.html, sentAt: record.sentAt, receivedAt: record.receivedAt, attachments: [...record.attachments], raw: record.raw === null ? null : Buffer.from(record.raw), rawNote: record.rawNote, size: record.size, createdAt: record.createdAt, }) .onConflictDoNothing() .returning({ id: mailInboundTable.id }); return { inserted: rows.length > 0 }; }, async knownProviderRefs(accountId, refs) { if (refs.length === 0) return new Set(); const rows = await db .select({ providerRef: mailInboundTable.providerRef }) .from(mailInboundTable) .where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs]))); return new Set(rows.map((r) => r.providerRef)); }, async list(query) { const rows = await db .select() .from(mailInboundTable) .where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId)) .orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt)) .limit(query.limit); const deliveries = await deliveriesOf(rows.map((r) => r.id)); return rows.map((row) => ({ record: toInbound(row), deliveries: deliveries.get(row.id) ?? [] })); }, async get(mailId) { const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1); const row = rows[0]; if (row === undefined) return undefined; const deliveries = await deliveriesOf([mailId]); return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] }; }, async setDelivery(delivery) { const values = { mailId: delivery.mailId, subscriber: delivery.subscriber, state: delivery.state, error: delivery.error, startedAt: delivery.startedAt, finishedAt: delivery.finishedAt, }; await db .insert(mailInboundDeliveryTable) .values(values) .onConflictDoUpdate({ target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber], set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt }, }); }, }; } /* ── 出站 ─────────────────────────────────────────────────────────────── */ type OutboundRow = typeof mailOutboundTable.$inferSelect; function toOutbound(row: OutboundRow): OutboundRecord { return { id: row.id, accountId: row.accountId, messageId: row.messageId, idempotencyKey: row.idempotencyKey, to: row.toList, cc: row.ccList, bcc: row.bccList, subject: row.subject, text: row.textBody, html: row.htmlBody, inReplyTo: row.inReplyTo, references: row.referenceIds, attachments: row.attachments, state: mailOutboundStateSchema.parse(row.state), error: row.error, providerRef: row.providerRef, requestedBy: row.requestedBy, createdAt: row.createdAt, sentAt: row.sentAt, }; } export function createOutboundStore(db: Db): OutboundStore { return { async insert(record) { await db.insert(mailOutboundTable).values({ id: record.id, accountId: record.accountId, messageId: record.messageId, idempotencyKey: record.idempotencyKey, toList: [...record.to], ccList: [...record.cc], bccList: [...record.bcc], subject: record.subject, textBody: record.text, htmlBody: record.html, inReplyTo: record.inReplyTo, referenceIds: [...record.references], attachments: [...record.attachments], state: record.state, error: record.error, providerRef: record.providerRef, requestedBy: record.requestedBy, createdAt: record.createdAt, sentAt: record.sentAt, }); }, async byIdempotencyKey(key) { const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1); return rows[0] === undefined ? undefined : toOutbound(rows[0]); }, async setState(id, state, patch) { await db .update(mailOutboundTable) .set({ state, ...(patch.error === undefined ? {} : { error: patch.error }), ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), }) .where(eq(mailOutboundTable.id, id)); }, async list(query) { const rows = await db .select() .from(mailOutboundTable) .where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId)) .orderBy(desc(mailOutboundTable.createdAt)) .limit(query.limit); return rows.map(toOutbound); }, }; } ``` `index.ts` 追加: ```ts export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; ``` - [ ] **Step 5: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 7 tests PASS(套件没跳过 —— 输出里不能有「集成测试跳过」);三道门禁退出码 0。 另跑一次 `pnpm vitest run apps/api/src/app/__tests__/route-coverage.it.test.ts`:它对 `jcai_it_routes` 库跑全部迁移,0033 能应用即绿。 - [ ] **Step 6: Commit** ```bash git add db/migrations/0033_mail.sql db/migrations/meta/_journal.json apps/api/src/modules/mail apps/api/src/app/reset-runs.ts git commit -m "$(printf '迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 8: adapter:postal-mime 解析器 + 测试夹具 **Files:** - Create: `apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts` - Create: `apps/api/src/modules/mail/__tests__/fakes.ts`(EML 夹具 + 内存 store + 假 provider + 时钟;后续用例测试共用) - Modify: `apps/api/src/modules/mail/index.ts`(追加导出) - Test: `apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts` **Interfaces:** - Produces:`createPostalMimeParser(): MimeParser`;夹具 `eml(parts)` `fixedClock(iso?)` `seqIds(prefix?)` `plainSecrets` `memoryAccounts(clock)` `memoryInbound()` `memoryOutbound()` `memoryAttachments()` `fakeProvider(kind?)` → `FakeProvider`。 - postal-mime 3.0.0 的真实形状(本机核过):`PostalMime.parse(Uint8Array)` → `{ from?: {address,name}, to?: Address[], cc?: Address[], subject?, messageId?, inReplyTo?, references?: string(空格分隔), date?: ISO 串, text?, html?, attachments: [{filename, mimeType, disposition, content: ArrayBuffer}] }`;GBK 主题 / 正文解得出;群组地址形 `{name, group: Mailbox[]}`。 - [ ] **Step 1: 写夹具** ```ts // apps/api/src/modules/mail/__tests__/fakes.ts /** * 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。 * 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。 */ import type { MailAccountKind, MailProbeReport } from '@contracts'; import { isDue } from '../internal/domain/due.ts'; import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts'; import type { AttachmentStore, Clock, DeliveryRecord, IdGenerator, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, MailCursor, MailProvider, OutboundMessage, OutboundRecord, OutboundStore, RawMessage, SecretBox, } from '../port.ts'; const CRLF = '\r\n'; export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } { let t = new Date(iso).getTime(); return { now: () => new Date(t), tick(ms) { t += ms; }, }; } export function seqIds(prefix = 'id'): IdGenerator { let n = 0; return { next: () => { n += 1; return `${prefix}-${n}`; }, }; } export const plainSecrets: SecretBox = { seal: (s) => `sealed:${s}`, open: (c) => { if (!c.startsWith('sealed:')) throw new Error('密文格式非法'); return c.slice('sealed:'.length); }, }; export function account(over: Partial = {}): MailAccountRecord { const now = new Date('2026-08-25T09:00:00Z'); return { id: 'acc-1', kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com', displayName: '销售部', enabled: true, isDefault: true, pollIntervalSec: 30, channelId: 'ch-1', backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`, cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, ...over, }; } export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map } { const rows = new Map(); return { rows, async list() { return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); }, async get(id) { return rows.get(id); }, async findByName(name) { return [...rows.values()].find((r) => r.name === name); }, async save(record) { if (record.isDefault) { for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false }); } rows.set(record.id, record); }, async remove(id) { rows.delete(id); }, async listDue(now) { return [...rows.values()].filter((r) => isDue(r, now)); }, async claimPolling(id, leaseMs) { const r = rows.get(id); const now = clock.now(); if (r === undefined || !r.enabled) return false; if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false; rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) }); return true; }, async releasePolling(id) { const r = rows.get(id); if (r !== undefined) rows.set(id, { ...r, pollingUntil: null }); }, async savePoll(id, patch) { const r = rows.get(id); if (r === undefined) return; rows.set(id, { ...r, ...(patch.cursor === undefined ? {} : { cursor: patch.cursor }), ...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }), ...(patch.error === undefined ? {} : { lastPollError: patch.error }), ...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }), ...(patch.address === undefined ? {} : { address: patch.address }), }); }, }; } export function memoryInbound(): InboundStore & { rows: Map; deliveries: Map } { const rows = new Map(); const deliveries = new Map(); // 与 DB store 同序:按订阅方名排(测试里对顺序有断言) const of = (mailId: string): DeliveryRecord[] => [...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber)); return { rows, deliveries, async insert(record) { for (const r of rows.values()) { if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) { return { inserted: false }; } } rows.set(record.id, record); return { inserted: true }; }, async knownProviderRefs(accountId, refs) { return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef)); }, async list(query) { return [...rows.values()] .filter((r) => query.accountId === undefined || r.accountId === query.accountId) .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime()) .slice(0, query.limit) .map((record) => ({ record, deliveries: of(record.id) })); }, async get(mailId) { const record = rows.get(mailId); return record === undefined ? undefined : { record, deliveries: of(mailId) }; }, async setDelivery(delivery) { deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery); }, }; } export function memoryOutbound(): OutboundStore & { rows: Map } { const rows = new Map(); return { rows, async insert(record) { if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) { throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"'); } rows.set(record.id, record); }, async byIdempotencyKey(key) { return [...rows.values()].find((r) => r.idempotencyKey === key); }, async setState(id, state, patch) { const r = rows.get(id); if (r === undefined) return; rows.set(id, { ...r, state, ...(patch.error === undefined ? {} : { error: patch.error }), ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), }); }, async list(query) { return [...rows.values()] .filter((r) => query.accountId === undefined || r.accountId === query.accountId) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .slice(0, query.limit); }, }; } export function memoryAttachments(): AttachmentStore & { files: Map } { const files = new Map(); let n = 0; return { files, async put(file) { n += 1; const ref = `up-${n}`; files.set(ref, { ...file }); return { ref }; }, async get(ref) { const f = files.get(ref); return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes }; }, }; } export type FakeProvider = MailProvider & { readonly sent: OutboundMessage[]; readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; queue(...messages: RawMessage[]): void; failPullWith(error: Error | undefined): void; failSendWith(error: Error | undefined): void; probeWith(report: MailProbeReport | undefined): void; }; /** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */ export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider { const pending: RawMessage[] = []; const sent: OutboundMessage[] = []; const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; let pullError: Error | undefined; let sendError: Error | undefined; let probeReport: MailProbeReport | undefined; let uidSeq = 0; return { kind, sent, pullCalls, queue(...messages) { pending.push(...messages); }, failPullWith(error) { pullError = error; }, failSendWith(error) { sendError = error; }, probeWith(report) { probeReport = report; }, async probe(accountArg) { return ( probeReport ?? { kind, address: accountArg.address, receive: { ok: true, latencyMs: 1 }, send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true }, } ); }, async anchor(_accountArg, _backfillFrom, now) { return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000)); }, async pull(_accountArg, cursor, opts) { pullCalls.push({ cursor, limit: opts.limit }); if (pullError !== undefined) throw pullError; const batch = pending.splice(0, opts.limit).map((m) => { if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; uidSeq += 1; return { ...m, cursorAfter: imapCursor('v1', uidSeq) }; }); const last = batch.at(-1)?.cursorAfter; const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000)); return { messages: batch, cursor: next, more: pending.length > 0 }; }, async send(_accountArg, message) { if (sendError !== undefined) throw sendError; sent.push(message); return { providerRef: `fake:${sent.length}` }; }, }; } export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage { return { providerRef, eml, receivedAt }; } /** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */ export function eml(parts: { from?: string; to?: string; cc?: string; subject?: string; text?: string; html?: string; messageId?: string; inReplyTo?: string; references?: string; date?: string; attachment?: { name: string; contentType: string; content: string }; }): Uint8Array { const headers = [ `From: ${parts.from ?? 'buyer@acme.com'}`, `To: ${parts.to ?? 'sales@we.com'}`, ...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]), `Subject: ${parts.subject ?? '询价'}`, ...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]), ...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]), ...(parts.references === undefined ? [] : [`References: ${parts.references}`]), `Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`, 'MIME-Version: 1.0', ]; const text = parts.text ?? '你好,请报价'; if (parts.html === undefined && parts.attachment === undefined) { return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF)); } const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text]; if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html); if (parts.attachment !== undefined) { lines.push( '--B1', `Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`, `Content-Disposition: attachment; filename="${parts.attachment.name}"`, 'Content-Transfer-Encoding: base64', '', Buffer.from(parts.attachment.content, 'utf8').toString('base64'), ); } lines.push('--B1--', ''); return new TextEncoder().encode(lines.join(CRLF)); } ``` - [ ] **Step 2: 写失败测试(真解析器跑夹具)** ```ts // apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts /** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */ import { describe, expect, it } from 'vitest'; import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; import { eml } from './fakes.ts'; const parser = createPostalMimeParser(); const CRLF = '\r\n'; describe('createPostalMimeParser', () => { it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => { const parsed = await parser.parse( eml({ from: '"张三" ', to: 'sales@we.com, "Ops" ', subject: '询价 RFQ-1', messageId: '', inReplyTo: '', references: ' ', text: '你好,请报价', }), ); expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); expect(parsed.to).toEqual([ { address: 'sales@we.com', name: '' }, { address: 'ops@we.com', name: 'Ops' }, ]); expect(parsed.subject).toBe('询价 RFQ-1'); expect(parsed.messageId).toBe(''); expect(parsed.inReplyTo).toBe(''); expect(parsed.references).toEqual(['', '']); expect(parsed.text?.trim()).toBe('你好,请报价'); expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z'); }); it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => { const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价 const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好 const raw = [ 'From: buyer@acme.com', 'To: sales@we.com', `Subject: =?GBK?B?${subject}?=`, 'Content-Type: text/plain; charset=gbk', 'Content-Transfer-Encoding: base64', '', body, '', ].join(CRLF); const parsed = await parser.parse(new TextEncoder().encode(raw)); expect(parsed.subject).toBe('询价'); expect(parsed.text?.trim()).toBe('你好'); }); it('群组地址摊平;没有 Cc 就是空数组', async () => { const raw = ['From: a@x.com', 'To: team: b@x.com, "C" ;', 'Subject: s', '', 'body', ''].join(CRLF); const parsed = await parser.parse(new TextEncoder().encode(raw)); expect(parsed.to).toEqual([ { address: 'b@x.com', name: '' }, { address: 'c@x.com', name: 'C' }, ]); expect(parsed.cc).toEqual([]); }); it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => { const raw = ['Subject: x', 'Content-Type: text/html', '', 'only html', ''].join(CRLF); const parsed = await parser.parse(new TextEncoder().encode(raw)); expect(parsed.text).toBeUndefined(); expect(parsed.html).toContain('only html'); expect(parsed.messageId).toBeUndefined(); expect(parsed.from).toBeUndefined(); expect(parsed.date).toBeUndefined(); }); it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => { const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } })); expect(parsed.attachments).toHaveLength(1); expect(parsed.attachments[0]?.name).toBe('bom.xlsx'); expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel'); expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array); expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz'); }); }); ``` - [ ] **Step 3: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts` Expected: FAIL(解析器不存在)。 - [ ] **Step 4: 写解析器** ```ts // apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts /** * MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。 * 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。 */ import PostalMime from 'postal-mime'; import type { Address } from 'postal-mime'; import type { MailAddress } from '@contracts'; import type { MimeParser, ParsedMail } from '../../port.ts'; function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] { const out: MailAddress[] = []; for (const entry of list ?? []) { if (entry.group !== undefined) { for (const member of entry.group) out.push({ address: member.address, name: member.name }); } else if (entry.address !== undefined) { out.push({ address: entry.address, name: entry.name }); } } return out; } /** References / In-Reply-To 是空白分隔的一串 ;拆开、去空 */ function idsOf(value: string | undefined): string[] { if (value === undefined) return []; return value .split(/\s+/) .map((s) => s.trim()) .filter((s) => s !== ''); } function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array { if (typeof content === 'string') return new TextEncoder().encode(content); return content instanceof Uint8Array ? content : new Uint8Array(content); } export function createPostalMimeParser(): MimeParser { return { async parse(eml): Promise { const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' }); const from = email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined; const date = email.date === undefined ? undefined : new Date(email.date); const inReplyTo = idsOf(email.inReplyTo)[0]; return { ...(email.messageId === undefined ? {} : { messageId: email.messageId }), ...(inReplyTo === undefined ? {} : { inReplyTo }), references: idsOf(email.references), ...(from === undefined ? {} : { from }), to: mailboxesOf(email.to), cc: mailboxesOf(email.cc), subject: email.subject ?? '', ...(email.text === undefined ? {} : { text: email.text }), ...(email.html === undefined ? {} : { html: email.html }), ...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }), attachments: email.attachments.map((a) => ({ name: a.filename ?? '', contentType: a.mimeType, bytes: bytesOf(a.content), })), }; }, }; } ``` `index.ts` 追加: ```ts export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; ``` - [ ] **Step 5: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts && pnpm typecheck && pnpm lint` Expected: 5 tests PASS;门禁退出码 0。若 `Address` 类型的 `group` 判别在本版 d.ts 里报错,以 `node_modules/postal-mime/postal-mime.d.ts` 的 `Address` 定义为准改判别(本计划核对的是 3.0.0:`Mailbox = {name, address, group?: undefined}`,群组形 `{name, address?: undefined, group: Mailbox[]}`)。 - [ ] **Step 6: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 9: usecase `createMailService` —— 账号 CRUD / 探测 / 视图 **Files:** - Create: `apps/api/src/modules/mail/internal/usecase/mail-service.ts`(整份;收 / 发的用例也在这份里,Task 10 / 11 只加测试) - Modify: `apps/api/src/modules/mail/index.ts`(追加 `createMailService` 与 `MailServiceDeps`) - Test: `apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts` **Interfaces:** - Produces:`createMailService(deps: MailServiceDeps): MailService`;`MailServiceDeps = { accounts, inbound, outbound, attachments, providers: Record, mime, secrets, clock, ids, notify, limits? }`。 - `probe` 的第二形是 `{ input, accountId? }`:改已存账号的表单上「测试」不必重打密码 —— 没给 secret 且给了 accountId 就用库里那份密文。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts /** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */ import { describe, expect, it } from 'vitest'; import { mailAccountInputSchema } from '@contracts'; import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts'; import { createMailService } from '../internal/usecase/mail-service.ts'; import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; function harness() { const clock = fixedClock(); const accounts = memoryAccounts(clock); const imap = fakeProvider('imap-smtp'); const wecom = fakeProvider('wecom'); const changes: string[] = []; const service = createMailService({ accounts, inbound: memoryInbound(), outbound: memoryOutbound(), attachments: memoryAttachments(), providers: { 'imap-smtp': imap, wecom }, mime: createPostalMimeParser(), secrets: plainSecrets, clock, ids: seqIds('acc'), notify: { changed: (r) => void changes.push(r) }, }); return { service, accounts, imap, wecom, clock, changes }; } const imapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }); describe('账号', () => { it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => { const { service, accounts } = harness(); const view = await service.createAccount(imapInput); expect(view.id).toBe('acc-1'); expect(view.secretBound).toBe(true); expect(JSON.stringify(view)).not.toContain('pw-1234'); expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`); expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']); }); it('名字唯一:重名 409;改名撞别人也 409', async () => { const { service } = harness(); await service.createAccount(imapInput); await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError); const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' }); await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError); }); it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => { const { service, accounts } = harness(); const created = await service.createAccount(imapInput); await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' }); expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234'); await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } }); expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9'); const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } }); const switched = await service.updateAccount(created.id, wecomInput); expect(switched.secretBound).toBe(false); expect(switched.address).toBe(''); }); it('默认账号只有一个;删了就没了(404)', async () => { const { service } = harness(); const a = await service.createAccount(imapInput); const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true }); expect((await service.getAccount(a.id)).isDefault).toBe(false); expect((await service.getAccount(b.id)).isDefault).toBe(true); await service.deleteAccount(a.id); await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); }); it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => { const { service, wecom, imap } = harness(); imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } }); const a = await service.createAccount(imapInput); const report = await service.probe({ accountId: a.id }); expect(report.receive.error).toBe('LOGIN sales@we.com *** failed'); const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } })); wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } }); await service.probe({ accountId: w.id }); expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work'); }); it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => { const { service, accounts } = harness(); const report = await service.probe({ input: imapInput }); expect(report.receive.ok).toBe(true); expect(accounts.rows.size).toBe(0); await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError); const a = await service.createAccount(imapInput); const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id }); expect(borrowed.receive.ok).toBe(true); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts` Expected: FAIL(`mail-service.ts` 不存在)。 - [ ] **Step 3: 写用例(整份)** 先把 `contract.ts` 里 `probe` 的签名改成: ```ts probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise; ``` ```ts // apps/api/src/modules/mail/internal/usecase/mail-service.ts /** * mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。 * * 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重) * → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。 * 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。 */ import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts'; import type { InboundHandler, MailService } from '../../contract.ts'; import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts'; import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts'; import { leaseMsFor } from '../domain/due.ts'; import { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from '../domain/errors.ts'; import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts'; import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts'; import { errorText, redactSecrets } from '../domain/redact.ts'; import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts'; import type { AttachmentStore, ChangeNotifier, Clock, IdGenerator, InboundStore, MailAccountStore, MailProvider, MimeParser, OutboundStore, ProviderAccount, RawMessage, SecretBox, } from '../../port.ts'; export type MailServiceDeps = { readonly accounts: MailAccountStore; readonly inbound: InboundStore; readonly outbound: OutboundStore; readonly attachments: AttachmentStore; readonly providers: Readonly>; readonly mime: MimeParser; readonly secrets: SecretBox; readonly clock: Clock; readonly ids: IdGenerator; readonly notify: ChangeNotifier; readonly limits?: | { readonly perTick?: number | undefined; readonly tickBudgetMs?: number | undefined; readonly attachmentBytes?: number | undefined; readonly rawBytes?: number | undefined; } | undefined; }; const DEFAULT_LIST_LIMIT = 50; const MAX_LIST_LIMIT = 200; type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; function tallyToOutcome(t: PollTally): MailPollOutcome { return { accountId: t.accountId, pulled: t.pulled, stored: t.stored, dispatched: t.dispatched, more: t.more, ...(t.error === undefined ? {} : { error: t.error }), }; } function safeFileName(messageId: string): string { const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); return cleaned === '' ? 'mail' : cleaned; } function resultOf(record: OutboundRecord): MailSendResult { return { outboundId: record.id, accountId: record.accountId, messageId: record.messageId, state: record.state === 'sent' ? 'sent' : 'failed', ...(record.error === null ? {} : { error: record.error }), ...(record.providerRef === null ? {} : { providerRef: record.providerRef }), }; } export function createMailService(deps: MailServiceDeps): MailService { const limits = { perTick: deps.limits?.perTick ?? 50, tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000, attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP, rawBytes: deps.limits?.rawBytes ?? RAW_CAP, }; const subscribers = new Map(); async function requireAccount(accountId: string): Promise { const record = await deps.accounts.get(accountId); if (record === undefined) throw new MailAccountNotFoundError(accountId); return record; } /** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */ function openSecret(record: Pick): Record { if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id); const parsed: unknown = JSON.parse(deps.secrets.open(record.secretCipher)); if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id); return Object.fromEntries(Object.entries(parsed as Record).map(([k, v]) => [k, String(v)])); } function providerAccount(record: MailAccountRecord, secret: Record): ProviderAccount { return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret }; } function redactWith(secret: Record): (text: string) => string { const values = Object.values(secret); return (text) => redactSecrets(text, values); } function redactReport(report: MailProbeReport, secret: Record): MailProbeReport { const redact = redactWith(secret); return { ...report, receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) }, send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) }, }; } function limitOf(query: { limit?: number | undefined }): number { return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT)); } /* ── 分发 ────────────────────────────────────────────────────────── */ async function dispatch(record: InboundRecord, ctx: { accountId: string; channelId: string | null }, only?: string): Promise { const existing = (await deps.inbound.get(record.id))?.deliveries ?? []; const out: DeliveryRecord[] = []; for (const [name, handler] of subscribers) { if (only !== undefined && name !== only) continue; const current = existing.find((d) => d.subscriber === name); // 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事 if (!canRedeliver(current?.state)) { if (current !== undefined) out.push(current); continue; } const startedAt = deps.clock.now(); const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null }; await deps.inbound.setDelivery(dispatching); let settled: DeliveryRecord; try { const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined }); const state = stateOfOutcome(outcome); settled = { ...dispatching, state, // skipped 的理由也放 error 列:人在列表上看得见「为什么没投」 error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null, finishedAt: deps.clock.now(), }; } catch (error) { settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() }; } await deps.inbound.setDelivery(settled); out.push(settled); } return out; } /* ── 拉取 ────────────────────────────────────────────────────────── */ async function storeOne(record: MailAccountRecord, message: RawMessage): Promise { // 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍 const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]); if (known.has(message.providerRef)) return undefined; const parsed = await deps.mime.parse(message.eml); const attachments: InboundAttachmentRecord[] = []; for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) { let ref: string | null = null; if (plan.store) { const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array(); ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref; } attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note }); } const built = buildInboundRecord({ id: deps.ids.next(), accountId: record.id, address: record.address, providerRef: message.providerRef, parsed, eml: message.eml, receivedAt: message.receivedAt, attachments, now: deps.clock.now(), rawCap: limits.rawBytes, }); const { inserted } = await deps.inbound.insert(built); return inserted ? built : undefined; } async function pollAccount(record: MailAccountRecord): Promise { const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec)); if (!claimed) throw new MailPollBusyError(record.id); const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false }; let secret: Record = {}; try { secret = openSecret(record); const provider = deps.providers[record.kind]; const account = providerAccount(record, secret); const started = deps.clock.now(); let cursor = record.cursor; if (cursor === null) { cursor = await provider.anchor(account, record.backfillFrom, started); await deps.accounts.savePoll(record.id, { cursor }); } let budget = limits.perTick; let more = true; while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) { const batch = await provider.pull(account, cursor, { limit: budget, now: deps.clock.now(), backfillFrom: record.backfillFrom, known: (refs) => deps.inbound.knownProviderRefs(record.id, refs), }); tally.pulled += batch.messages.length; budget -= batch.messages.length; for (const message of batch.messages) { const stored = await storeOne(record, message); // 逐封推进:中途栽了,下一轮从这一封之后接着 if (message.cursorAfter !== undefined) { cursor = message.cursorAfter; await deps.accounts.savePoll(record.id, { cursor }); } if (stored !== undefined) { tally.stored += 1; await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt }); await dispatch(stored, { accountId: record.id, channelId: record.channelId }); tally.dispatched += 1; } } cursor = batch.cursor; await deps.accounts.savePoll(record.id, { cursor }); more = batch.more; if (batch.messages.length === 0) break; } tally.more = more; await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: null }); } catch (error) { const text = redactWith(secret)(errorText(error)); tally.error = text; await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text }); } finally { await deps.accounts.releasePolling(record.id); } if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound'); return tallyToOutcome(tally); } /* ── 服务 ────────────────────────────────────────────────────────── */ return { async listAccounts() { const now = deps.clock.now(); return (await deps.accounts.list()).map((r) => viewOf(r, now)); }, async getAccount(accountId) { return viewOf(await requireAccount(accountId), deps.clock.now()); }, async createAccount(input) { if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name); const secretJson = secretOf(input); const now = deps.clock.now(); const record = mergeAccount(input, { id: deps.ids.next(), existing: undefined, secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson), now, }); await deps.accounts.save(record); return viewOf(record, now); }, async updateAccount(accountId, input) { const existing = await requireAccount(accountId); const clash = await deps.accounts.findByName(input.name); if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name); const secretJson = secretOf(input); // 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废 const secretCipher = secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null; const now = deps.clock.now(); // kind / settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset) const record = mergeAccount(input, { id: accountId, existing, secretCipher, now }); await deps.accounts.save(record); return viewOf(record, now); }, async deleteAccount(accountId) { await requireAccount(accountId); await deps.accounts.remove(accountId); }, async probe(target) { if ('input' in target) { const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId); const secretJson = secretOf(target.input); const secret = secretJson !== undefined ? (JSON.parse(secretJson) as Record) : stored !== undefined && stored.kind === target.input.kind ? openSecret(stored) : (() => { throw new MailSecretMissingError(target.accountId ?? 'draft'); })(); const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() }); const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret)); return redactReport(report, secret); } const record = await requireAccount(target.accountId); const secret = openSecret(record); const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret); // wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改 if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) { await deps.accounts.savePoll(record.id, { address: report.address }); } return report; }, async pollDue(now) { const due = await deps.accounts.listDue(now); // 账号间并行,单账号内串行;一个账号的异常只记在它自己头上 const settled = await Promise.allSettled(due.map((record) => pollAccount(record))); return settled.map((r, i) => r.status === 'fulfilled' ? r.value : { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) }, ); }, async pollNow(accountId) { const record = await requireAccount(accountId); if (!record.enabled) throw new MailAccountDisabledError(accountId); return pollAccount(record); }, subscribe(name, handler) { if (subscribers.has(name)) throw new MailSubscriberTakenError(name); subscribers.set(name, handler); return () => { subscribers.delete(name); }; }, async listInbound(query) { const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) }); return rows.map((r) => summaryOf(r.record, r.deliveries)); }, async getInbound(mailId) { const hit = await deps.inbound.get(mailId); if (hit === undefined) throw new MailInboundNotFoundError(mailId); return detailOf(hit.record, hit.deliveries); }, async attachment(mailId, index) { const hit = await deps.inbound.get(mailId); if (hit === undefined) throw new MailInboundNotFoundError(mailId); const meta = hit.record.attachments.find((a) => a.index === index); if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index); const file = await deps.attachments.get(meta.ref); if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index); return { name: meta.name, contentType: meta.contentType, bytes: file.bytes }; }, async raw(mailId) { const hit = await deps.inbound.get(mailId); if (hit === undefined) throw new MailInboundNotFoundError(mailId); if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1); return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw }; }, async redeliver(mailId, subscriber) { const hit = await deps.inbound.get(mailId); if (hit === undefined) throw new MailInboundNotFoundError(mailId); if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber); const owner = await deps.accounts.get(hit.record.accountId); const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber); deps.notify.changed('inbound'); return deliveries.map(deliveryViewOf); }, async send(input) { const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from }); // 幂等键:放行重放不发两封;上一次失败 / 中断的那一行复用,重发 const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey); if (prior !== undefined && prior.state === 'sent') return resultOf(prior); const files: ParsedAttachment[] = []; for (const a of input.attachments) { if (a.dataBase64 !== undefined) { files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) }); } else if (a.uploadRef !== undefined) { const got = await deps.attachments.get(a.uploadRef); if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]); files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes }); } else { throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]); } } const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength)); if (problems.length > 0) throw new MailSendInvalidError(problems); const secret = openSecret(account); const now = deps.clock.now(); const id = prior?.id ?? deps.ids.next(); const messageId = prior?.messageId ?? messageIdFor(id, account.address); if (prior === undefined) { const record: OutboundRecord = { id, accountId: account.id, messageId, idempotencyKey: input.idempotencyKey ?? null, to: [...input.to], cc: [...input.cc], bcc: [...input.bcc], subject: input.subject, text: input.text ?? null, html: input.html ?? null, inReplyTo: input.inReplyTo ?? null, references: [...input.references], attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })), state: 'sending', error: null, providerRef: null, requestedBy: { ...input.requestedBy }, createdAt: now, sentAt: null, }; await deps.outbound.insert(record); } try { const sent = await deps.providers[account.kind].send(providerAccount(account, secret), { messageId, from: { address: account.address, name: account.displayName }, to: input.to, cc: input.cc, bcc: input.bcc, subject: input.subject, ...(input.text === undefined ? {} : { text: input.text }), ...(input.html === undefined ? {} : { html: input.html }), ...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }), references: input.references, attachments: files, }); await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }); deps.notify.changed('outbound'); return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }; } catch (error) { const text = redactWith(secret)(errorText(error)); await deps.outbound.setState(id, 'failed', { error: text }); deps.notify.changed('outbound'); return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text }; } }, async listOutbound(query) { const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) }); return rows.map((r) => ({ id: r.id, accountId: r.accountId, messageId: r.messageId, to: [...r.to], cc: [...r.cc], bcc: [...r.bcc], subject: r.subject, state: r.state, error: r.error, providerRef: r.providerRef, requestedBy: r.requestedBy, createdAt: r.createdAt.toISOString(), sentAt: r.sentAt === null ? null : r.sentAt.toISOString(), })); }, }; } ``` `index.ts` 追加: ```ts export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; export { createMailService } from './internal/usecase/mail-service.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 6 tests PASS;门禁退出码 0。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 10: usecase 测试 —— 拉取 / 去重 / 分发 / 重新投递 **Files:** - Test: `apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts` - Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts` **Interfaces:** - Consumes:Task 9 的 `createMailService` 与 Task 8 的夹具。 - [ ] **Step 1: 写测试** ```ts // apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts /** * 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、 * 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。 */ import { describe, expect, it } from 'vitest'; import type { MailInboundDetail } from '@contracts'; import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts'; import { createMailService } from '../internal/usecase/mail-service.ts'; import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; import { imapCursor } from '../internal/domain/cursor.ts'; import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds, } from './fakes.ts'; function harness(over: { perTick?: number } = {}) { const clock = fixedClock(); const accounts = memoryAccounts(clock); const inbound = memoryInbound(); const attachments = memoryAttachments(); const imap = fakeProvider('imap-smtp'); const wecom = fakeProvider('wecom'); const changes: string[] = []; const service = createMailService({ accounts, inbound, outbound: memoryOutbound(), attachments, providers: { 'imap-smtp': imap, wecom }, mime: createPostalMimeParser(), secrets: plainSecrets, clock, ids: seqIds('in'), notify: { changed: (r) => void changes.push(r) }, limits: { perTick: over.perTick ?? 50 }, }); return { service, accounts, inbound, attachments, imap, wecom, clock, changes }; } describe('拉取', () => { it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => { const h = harness(); await h.accounts.save(account()); h.imap.queue( rawMessage('uid:v1:1', eml({ messageId: '', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })), rawMessage('uid:v1:2', eml({ messageId: '', subject: '二' })), ); const outcome = await h.service.pollNow('acc-1'); expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false }); const saved = h.accounts.rows.get('acc-1'); expect(saved?.cursor).toEqual(imapCursor('v1', 2)); expect(saved?.lastPolledAt).toEqual(h.clock.now()); expect(saved?.lastInboundAt).not.toBeNull(); expect(saved?.lastPollError).toBeNull(); expect(saved?.pollingUntil).toBeNull(); const list = await h.service.listInbound({}); // 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序 expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']); const one = list.find((m) => m.subject === '一'); expect(one?.attachments[0]?.ref).toBe('up-1'); expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一'); expect(h.changes).toContain('inbound'); }); it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => { const h = harness(); await h.accounts.save(account()); const hits: string[] = []; h.service.subscribe('spy', async (m) => void hits.push(m.messageId)); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } }))); await h.service.pollNow('acc-1'); h.imap.queue( rawMessage('uid:v1:1', eml({ messageId: '' })), rawMessage('uid:v1:9', eml({ messageId: '' })), ); const again = await h.service.pollNow('acc-1'); expect(again.pulled).toBe(2); expect(again.stored).toBe(0); expect(hits).toEqual(['']); expect(h.attachments.files.size).toBe(1); }); it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => { const h = harness({ perTick: 2 }); await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } })); h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' }))); const first = await h.service.pollNow('w'); expect(first).toMatchObject({ pulled: 2, stored: 2, more: true }); expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom'); const second = await h.service.pollNow('w'); expect(second).toMatchObject({ pulled: 1, stored: 1, more: false }); }); it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => { const h = harness(); await h.accounts.save(account({ cursor: imapCursor('v1', 5) })); h.imap.failPullWith(new Error('LOGIN pw-1234 rejected')); const outcome = await h.service.pollNow('acc-1'); expect(outcome.error).toBe('LOGIN *** rejected'); const saved = h.accounts.rows.get('acc-1'); expect(saved?.lastPollError).toBe('LOGIN *** rejected'); expect(saved?.cursor).toEqual(imapCursor('v1', 5)); expect(saved?.pollingUntil).toBeNull(); h.imap.failPullWith(undefined); expect((await h.service.pollNow('acc-1')).error).toBeUndefined(); expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull(); }); it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => { const h = harness(); await h.accounts.save(account({ secretCipher: null })); const outcome = await h.service.pollNow('acc-1'); expect(outcome.error).toContain('未配置凭证'); expect(h.imap.pullCalls).toHaveLength(0); await h.accounts.save(account({ enabled: false })); await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError); await h.accounts.save(account({ pollingUntil: new Date(h.clock.now().getTime() + 60_000) })); await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError); }); it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => { const h = harness(); await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' })); await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null })); await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false })); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); const outcomes = await h.service.pollDue(h.clock.now()); expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']); expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1); expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证'); }); }); describe('分发', () => { it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => { const h = harness(); await h.accounts.save(account({ channelId: 'ch-9' })); const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = []; h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId })); h.service.subscribe('skip', async () => ({ skipped: '与我无关' })); const off = h.service.subscribe('boom', async () => { throw new Error('通道实例已停用:ch-9'); }); expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', subject: '主题' }))); await h.service.pollNow('acc-1'); expect(seen[0]?.mail.subject).toBe('主题'); expect(seen[0]?.channelId).toBe('ch-9'); const detail = await h.service.getInbound('in-1'); expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([ ['boom', 'failed', '通道实例已停用:ch-9'], ['ok', 'done', null], ['skip', 'skipped', '与我无关'], ]); off(); h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '' }))); await h.service.pollNow('acc-1'); expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']); }); it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => { const h = harness(); await h.accounts.save(account()); let fail = true; h.service.subscribe('flaky', async () => { if (fail) throw new Error('第一次不行'); }); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); await h.service.pollNow('acc-1'); expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed'); fail = false; const redone = await h.service.redeliver('in-1', 'flaky'); expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]); await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError); await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null }); expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching'); }); }); describe('附件与原件', () => { it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => { const h = harness(); await h.accounts.save(account()); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } }))); await h.service.pollNow('acc-1'); const file = await h.service.attachment('in-1', 0); expect(new TextDecoder().decode(file.bytes)).toBe('hello'); expect(file.name).toBe('a.txt'); await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来'); const raw = await h.service.raw('in-1'); expect(raw.name).toBe('m1_a.eml'); expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:'); }); }); ``` - [ ] **Step 2: 跑测试** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts` Expected: 9 tests PASS。红了就按测试名对回 Task 9 的用例修(常见:`listInbound` 排序断言 —— 两封 `receivedAt` 相同时按插入顺序,把断言里的顺序改成与夹具一致;`raw.name` 的 `safeFileName` 把 `` 收成 `m1_a`)。 - [ ] **Step 3: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail 用例测试:拉取 / 去重 / 逐封游标 / 失败停位 / 分发三态 / 重新投递 / 附件原件\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 11: usecase 测试 —— 发信 **Files:** - Test: `apps/api/src/modules/mail/__tests__/mail-service-send.test.ts` - Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts` - [ ] **Step 1: 写测试** ```ts // apps/api/src/modules/mail/__tests__/mail-service-send.test.ts /** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */ import { describe, expect, it } from 'vitest'; import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts'; import { createMailService } from '../internal/usecase/mail-service.ts'; import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; function harness() { const clock = fixedClock(); const accounts = memoryAccounts(clock); const outbound = memoryOutbound(); const attachments = memoryAttachments(); const imap = fakeProvider('imap-smtp'); const wecom = fakeProvider('wecom'); const service = createMailService({ accounts, inbound: memoryInbound(), outbound, attachments, providers: { 'imap-smtp': imap, wecom }, mime: createPostalMimeParser(), secrets: plainSecrets, clock, ids: seqIds('out'), notify: { changed: () => undefined }, }); return { service, accounts, outbound, attachments, imap, clock }; } const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } }; describe('send', () => { it('默认账号发出:留痕 sent、Message-ID 是 、发件人带显示名、返回 providerRef', async () => { const h = harness(); await h.accounts.save(account()); const result = await h.service.send(base); expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', providerRef: 'fake:1' }); expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' }); expect(h.imap.sent[0]?.messageId).toBe(''); const row = h.outbound.rows.get('out-1'); expect(row?.state).toBe('sent'); expect(row?.sentAt).toEqual(h.clock.now()); expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] }); }); it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => { const h = harness(); await h.accounts.save(account()); h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' })); const result = await h.service.send(base); expect(result.state).toBe('failed'); expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected'); expect(h.outbound.rows.get('out-1')?.state).toBe('failed'); }); it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => { const h = harness(); await h.accounts.save(account()); h.imap.failSendWith(new Error('暂时不行')); const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' }); expect(failed.state).toBe('failed'); h.imap.failSendWith(undefined); const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' }); expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); const again = await h.service.send({ ...base, idempotencyKey: 'k-1' }); expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); expect(h.imap.sent).toHaveLength(1); expect(h.outbound.rows.size).toBe(1); }); it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => { const h = harness(); await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError); await h.accounts.save(account()); await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError); await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError); await h.accounts.save(account({ secretCipher: null })); await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError); expect(h.outbound.rows.size).toBe(0); }); it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => { const h = harness(); await h.accounts.save(account()); const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' }); await h.service.send({ ...base, attachments: [ { name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref }, { name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') }, ], }); expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([ ['q.pdf', 'application/pdf', 2], ['n.txt', 'text/plain', 2], ]); await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError); await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError); }); it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => { const h = harness(); await h.accounts.save(account({ isDefault: false })); const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '', references: [''], requestedBy: { kind: 'channel', taskId: 't-1' } }); expect(result.accountId).toBe('acc-1'); expect(h.imap.sent[0]?.inReplyTo).toBe(''); expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); }); }); ``` - [ ] **Step 2: 跑测试** Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint` Expected: 全部 PASS;门禁退出码 0。 - [ ] **Step 3: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail 用例测试:发信留痕 / 厂商失败不抛 / 幂等键 / 附件来源 / from 对账号\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 12: adapter:IMAP/SMTP provider(imapflow + nodemailer) **Files:** - Create: `apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts` - Modify: `apps/api/src/modules/mail/index.ts`(追加导出) - Test: `apps/api/src/modules/mail/__tests__/imap-helpers.test.ts`(纯映射)· `apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts`(env 门控的真信箱) **Interfaces:** - Produces:`createImapSmtpProvider(): MailProvider`;导出的纯助手 `imapSettingsOf(account)` `internalDateOf(value, fallback)` `mailOptionsOf(message)` `orderFetched(rows)`。 - 真实签名(本机核过 imapflow 1.7.6 / @types/nodemailer 8.0.1):`new ImapFlow({host, port, secure, auth:{user, pass}, logger: false})`;`getMailboxLock(path, {readOnly: true})` → `{release()}`;`client.mailbox` → `{uidValidity: bigint, uidNext: number}`;`search({uid: 'n:*'} | {since: Date}, {uid: true})` → `number[] | false`;`fetch(number[], {uid, source, internalDate}, {uid: true})` → 异步迭代 `{uid, source?: Buffer, internalDate?: Date | string}`;`logout()` / `close()`。nodemailer:`createTransport({host, port, secure, requireTLS, auth:{user, pass}, connectionTimeout, greetingTimeout, socketTimeout})`,`verify(): Promise`,`sendMail({...}) → {messageId, response}`,`close()`。 - [ ] **Step 1: 写失败测试(纯映射)** ```ts // apps/api/src/modules/mail/__tests__/imap-helpers.test.ts /** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */ import { describe, expect, it } from 'vitest'; import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; import { imapCursor } from '../internal/domain/cursor.ts'; const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } }; describe('imapSettingsOf', () => { it('空 settings 取腾讯默认;登录名缺省 = 地址', () => { const s = imapSettingsOf(account); expect(s.settings.imapHost).toBe('imap.exmail.qq.com'); expect(s.settings.smtpPort).toBe(465); expect(s.login).toBe('sales@we.com'); expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com'); }); it('缺密码当场说清', () => { expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码'); }); }); describe('internalDateOf', () => { const fallback = new Date('2026-08-25T10:00:00Z'); it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => { const d = new Date('2026-08-25T09:00:00Z'); expect(internalDateOf(d, fallback)).toBe(d); expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z'); expect(internalDateOf('garbage', fallback)).toBe(fallback); expect(internalDateOf(undefined, fallback)).toBe(fallback); }); }); describe('orderFetched', () => { it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => { const rows = orderFetched('7', [ { uid: 12, source: Buffer.from('b'), internalDate: undefined }, { uid: 10, source: Buffer.from('a'), internalDate: undefined }, { uid: 11, source: undefined, internalDate: undefined }, ], new Date('2026-08-25T10:00:00Z')); expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']); expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12)); expect(rows[0]?.eml).toBeInstanceOf(Uint8Array); }); }); describe('mailOptionsOf', () => { it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => { const options = mailOptionsOf({ messageId: '', from: { address: 'sales@we.com', name: '销售部' }, to: ['b@acme.com'], cc: [], bcc: ['c@we.com'], subject: '报价', text: '正文', inReplyTo: '', references: [''], attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], }); expect(options.messageId).toBe(''); expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' }); expect(options.bcc).toEqual(['c@we.com']); expect(options.inReplyTo).toBe(''); expect(options.references).toEqual(['']); expect('html' in options).toBe(false); expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' }); expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts` Expected: FAIL(适配器不存在)。 - [ ] **Step 3: 写适配器** ```ts // apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts /** * 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。 * * 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址; * 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。 * 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。 * IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。 */ import { ImapFlow } from 'imapflow'; import nodemailer from 'nodemailer'; import type Mail from 'nodemailer/lib/mailer'; import { imapSmtpSettingsSchema } from '@contracts'; import type { ImapSmtpSettings, MailProbeReport } from '@contracts'; import { imapCursor, newUids } from '../domain/cursor.ts'; import type { MailCursor } from '../domain/cursor.ts'; import { errorText } from '../domain/redact.ts'; import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts'; const CONNECT_TIMEOUT_MS = 30_000; const SOCKET_TIMEOUT_MS = 60_000; export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } { const settings = imapSmtpSettingsSchema.parse(account.settings); const password = account.secret['password']; if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)'); return { settings, login: settings.login ?? account.address, password }; } export function internalDateOf(value: Date | string | undefined, fallback: Date): Date { if (value instanceof Date) return value; if (typeof value === 'string') { const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) return parsed; } return fallback; } /** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */ export function orderFetched( uidValidity: string, rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>, now: Date, ): RawMessage[] { return rows .filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined) .sort((a, b) => a.uid - b.uid) .map((r) => ({ providerRef: `${uidValidity}:${r.uid}`, eml: Uint8Array.from(r.source), receivedAt: internalDateOf(r.internalDate, now), cursorAfter: imapCursor(uidValidity, r.uid), })); } export function mailOptionsOf(message: OutboundMessage): Mail.Options { return { messageId: message.messageId, from: { name: message.from.name, address: message.from.address }, to: [...message.to], cc: [...message.cc], bcc: [...message.bcc], subject: message.subject, ...(message.text === undefined ? {} : { text: message.text }), ...(message.html === undefined ? {} : { html: message.html }), ...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }), references: [...message.references], attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })), }; } function transporterOf(account: ProviderAccount) { const { settings, login, password } = imapSettingsOf(account); return nodemailer.createTransport({ host: settings.smtpHost, port: settings.smtpPort, secure: settings.smtpTls, requireTLS: !settings.smtpTls, auth: { user: login, pass: password }, connectionTimeout: CONNECT_TIMEOUT_MS, greetingTimeout: CONNECT_TIMEOUT_MS, socketTimeout: SOCKET_TIMEOUT_MS, }); } async function withImap(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise): Promise { const { settings, login, password } = imapSettingsOf(account); const client = new ImapFlow({ host: settings.imapHost, port: settings.imapPort, secure: settings.imapTls, auth: { user: login, pass: password }, logger: false, connectionTimeout: CONNECT_TIMEOUT_MS, socketTimeout: SOCKET_TIMEOUT_MS, }); await client.connect(); try { return await fn(client, settings.folder); } finally { await client.logout().catch(() => client.close()); } } function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } { const box = client.mailbox; if (box === false) throw new Error('文件夹未打开'); return { uidValidity: String(box.uidValidity), uidNext: box.uidNext }; } async function timed(run: () => Promise): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> { const started = Date.now(); try { await run(); return { ok: true, latencyMs: Date.now() - started }; } catch (error) { return { ok: false, error: errorText(error) }; } } export function createImapSmtpProvider(): MailProvider { return { kind: 'imap-smtp', async probe(account): Promise { const receive = await timed(() => withImap(account, async (client, folder) => { const lock = await client.getMailboxLock(folder, { readOnly: true }); lock.release(); }), ); const send = await timed(async () => { const transporter = transporterOf(account); try { await transporter.verify(); } finally { transporter.close(); } }); return { kind: 'imap-smtp', address: account.address, receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error }, send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error }, }; }, async anchor(account, backfillFrom): Promise { return withImap(account, async (client, folder) => { const lock = await client.getMailboxLock(folder, { readOnly: true }); try { const box = mailboxOf(client); if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1); const uids = await client.search({ since: backfillFrom }, { uid: true }); const first = uids === false || uids.length === 0 ? box.uidNext : Math.min(...uids); return imapCursor(box.uidValidity, first - 1); } finally { lock.release(); } }); }, async pull(account, cursor, opts) { return withImap(account, async (client, folder) => { const lock = await client.getMailboxLock(folder, { readOnly: true }); try { const box = mailboxOf(client); if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) { // UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡 return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false }; } const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true }); const fresh = newUids(found === false ? [] : found, cursor.lastUid); const wanted = fresh.slice(0, opts.limit); const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = []; if (wanted.length > 0) { for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) { rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate }); } } const messages = orderFetched(box.uidValidity, rows, opts.now); return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length }; } finally { lock.release(); } }); }, async send(account, message) { const transporter = transporterOf(account); try { const info = await transporter.sendMail(mailOptionsOf(message)); return { providerRef: info.response }; } finally { transporter.close(); } }, }; } ``` `index.ts` 追加: ```ts export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; ``` - [ ] **Step 4: 写 env 门控的真信箱集成测试** ```ts // apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts /** * 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。 * JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com) * JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事) */ import { describe, expect, it } from 'vitest'; import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts'; const user = process.env['JCAI_MAIL_IT_IMAP_USER']; const pass = process.env['JCAI_MAIL_IT_IMAP_PASS']; const enabled = user !== undefined && pass !== undefined; if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n'); const account = { id: 'it', kind: 'imap-smtp' as const, address: user ?? '', displayName: 'JCAI IT', settings: { imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com', smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com', }, secret: { password: pass ?? '' }, }; const known = async (): Promise> => new Set(); describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => { const provider = createImapSmtpProvider(); it('探测:收发都 ok', async () => { const report = await provider.probe(account); expect(report.receive).toMatchObject({ ok: true }); expect(report.send).toMatchObject({ checked: true, ok: true }); }, 60_000); it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => { const now = new Date(); const cursor = await provider.anchor(account, null, now); expect(cursor.kind).toBe('imap'); const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known }); expect(empty.messages).toEqual([]); const since = new Date(now.getTime() - 7 * 86_400_000); const back = await provider.anchor(account, since, now); const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known }); expect(some.messages.length).toBeLessThanOrEqual(3); for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0); }, 120_000); it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => { const result = await provider.send(account, { messageId: ``, from: { address: account.address, name: 'JCAI IT' }, to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''], cc: [], bcc: [], subject: 'JCAI mail IT', text: '这是一封集成测试信', references: [], attachments: [], }); expect(result.providerRef ?? '').toContain('250'); }, 60_000); }); ``` - [ ] **Step 5: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 助手 6 tests PASS;IT 套件跳过并打印告警(没给真信箱);typecheck 退出码 0 —— 若 `ImapFlowOptions` 对 `connectionTimeout` / `socketTimeout` 报错,以 `node_modules/imapflow/lib/imap-flow.d.ts` 为准删掉不认识的键;`import type Mail from 'nodemailer/lib/mailer'` 报错则改成 `import type { Options as MailOptions } from 'nodemailer/lib/mailer';`。depcruise:imapflow / nodemailer 只在 adapter,过。 有真信箱时:`JCAI_MAIL_IT_IMAP_USER=xxx@corp.com JCAI_MAIL_IT_IMAP_PASS=客户端专用密码 pnpm vitest run apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts` → 2 PASS(第 3 条给 `JCAI_MAIL_IT_SEND_TO` 才跑)。 - [ ] **Step 6: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 13: adapter:企业微信邮件 API provider **Files:** - Create: `apps/api/src/modules/mail/internal/adapter/wecom-provider.ts` - Modify: `apps/api/src/modules/mail/index.ts`(追加导出) - Test: `apps/api/src/modules/mail/__tests__/wecom-provider.test.ts`(对假 fetch) **Interfaces:** - Produces:`createWecomProvider(deps?: { fetch?: FetchLike; now?: () => number }): MailProvider`;`FetchLike`;`WecomApiError(errcode, errmsg, api)`。 - 官方接口(核实 2026-08-25):`GET {apiBase}/cgi-bin/gettoken?corpid&corpsecret` → `{access_token, expires_in}`;`POST /cgi-bin/exmail/app/get_email_alias` → `{email, alias_list}`;`POST /cgi-bin/exmail/app/get_mail_list` body `{begin_time, end_time, cursor?, limit≤1000}` → `{next_cursor, has_more(0|1), mail_list:[{mail_id}]}`;`POST /cgi-bin/exmail/app/read_mail` body `{mail_id}` → `{mail_data}`(EML;编码文档未说);`POST /cgi-bin/exmail/app/compose_send` body `{to:{emails}, cc?, bcc?, subject, content, content_type:'html'|'text', attachment_list?:[{file_name, content(base64)}]}` → `{errcode, errmsg}`;错误码 `-1` 系统繁忙、`40014` token 非法、`42001` token 过期、`45009` 频率超限、`48002` 无权限、`60020` IP 不在白名单。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/modules/mail/__tests__/wecom-provider.test.ts /** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */ import { describe, expect, it } from 'vitest'; import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts'; import type { FetchLike } from '../internal/adapter/wecom-provider.ts'; import { wecomCursor } from '../internal/domain/cursor.ts'; type Call = { url: string; body: unknown }; function scripted(handlers: Record, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } { const calls: Call[] = []; const counts = new Map(); const fetch: FetchLike = async (url, init) => { const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record); calls.push({ url, body }); const key = Object.keys(handlers).find((k) => url.includes(k)); if (key === undefined) return { ok: false, status: 404, json: async () => ({}) }; const nth = (counts.get(key) ?? 0) + 1; counts.set(key, nth); return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) }; }; return { fetch, calls }; } const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } }; const known = async (): Promise> => new Set(); const now = new Date('2026-08-25T10:00:00Z'); const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n'; describe('token', () => { it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => { let clock = 0; let issued = 0; const { fetch, calls } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }), 'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }), }); const provider = createWecomProvider({ fetch, now: () => clock }); await provider.probe(account); await provider.probe(account); expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次 clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟 await provider.probe(account); expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3); expect(calls[0]?.url).toContain('corpid=ww1'); expect(calls[0]?.url).toContain('corpsecret=cs-1'); }); it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => { let busy = 2; const { fetch } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), 'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }), }); const report = await createWecomProvider({ fetch }).probe(account); expect(report.receive.ok).toBe(false); expect(report.receive.error).toContain('get_email_alias'); expect(report.receive.error).toContain('48002'); expect(report.send).toMatchObject({ checked: false }); await expect( createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }), ).rejects.toBeInstanceOf(WecomApiError); }); }); describe('probe / anchor', () => { it('探到应用邮箱地址;发信一栏 checked=false', async () => { const { fetch } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), 'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }), }); const report = await createWecomProvider({ fetch }).probe(account); expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } }); }); it('锚定 = 当下(或回填起点)的秒', async () => { const provider = createWecomProvider({ fetch: scripted({}).fetch }); expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000))); }); }); describe('pull', () => { it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => { const { fetch, calls } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), 'get_mail_list': (body) => body['cursor'] === undefined ? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] } : { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] }, 'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }), }); const provider = createWecomProvider({ fetch }); const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000); const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async () => new Set(['m2']) }); const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body; expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000), limit: 10 }); expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']); expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML); expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']); expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); expect(result.more).toBe(false); }); it('取满 limit 就停,more=true', async () => { const { fetch } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), 'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }), 'read_mail': () => ({ errcode: 0, mail_data: EML }), }); const result = await createWecomProvider({ fetch }).pull(account, wecomCursor(1), { limit: 2, now, backfillFrom: null, known }); expect(result.messages).toHaveLength(2); expect(result.more).toBe(true); }); }); describe('send', () => { it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => { const { fetch, calls } = scripted({ '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), 'compose_send': () => ({ errcode: 0, errmsg: 'ok' }), }); const result = await createWecomProvider({ fetch }).send(account, { messageId: '', from: { address: 'app@corp.wecom.work', name: '应用' }, to: ['a@x.com'], cc: ['b@x.com'], bcc: [], subject: '报价', text: '纯文本', html: '

html

', references: [], attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], }); expect(result).toEqual({}); const payload = calls.find((c) => c.url.includes('compose_send'))?.body; expect(payload).toEqual({ to: { emails: ['a@x.com'] }, cc: { emails: ['b@x.com'] }, subject: '报价', content: '

html

', content_type: 'html', attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }], }); }); }); ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/wecom-provider.test.ts` Expected: FAIL(适配器不存在)。 - [ ] **Step 3: 写适配器** ```ts // apps/api/src/modules/mail/internal/adapter/wecom-provider.ts /** * 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25): * gettoken(corpid, corpsecret) → access_token(7200s) * exmail/app/get_email_alias → 应用邮箱地址 * exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱) * exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64) * exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立) * token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。 */ import { wecomSettingsSchema } from '@contracts'; import type { MailProbeReport } from '@contracts'; import { wecomCursor, wecomWindow } from '../domain/cursor.ts'; import type { MailCursor } from '../domain/cursor.ts'; import { errorText } from '../domain/redact.ts'; import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts'; export type FetchLike = ( url: string, init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, ) => Promise<{ ok: boolean; status: number; json(): Promise }>; type WecomResponse = Record & { errcode?: number; errmsg?: string }; export class WecomApiError extends Error { constructor( readonly errcode: number, readonly errmsg: string, readonly api: string, ) { super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`); this.name = 'WecomApiError'; } } const TOKEN_EARLY_MS = 5 * 60_000; const BUSY_RETRIES = 3; const REQUEST_TIMEOUT_MS = 30_000; const LIST_PAGE = 100; /** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */ export function emlBytesOf(mailData: string): Uint8Array { const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048)); return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64')); } export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider { const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init)); const nowMs = deps.now ?? (() => Date.now()); const tokens = new Map(); async function call(api: string, url: string, init?: Parameters[1]): Promise { for (let attempt = 0; ; attempt += 1) { const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`); const body = (await res.json()) as WecomResponse; const code = typeof body.errcode === 'number' ? body.errcode : 0; if (code === 0) return body; if (code === -1 && attempt < BUSY_RETRIES) continue; throw new WecomApiError(code, String(body.errmsg ?? ''), api); } } async function tokenOf(account: ProviderAccount, force = false): Promise { const cached = tokens.get(account.id); if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token; const settings = wecomSettingsSchema.parse(account.settings); const secret = account.secret['corpSecret']; if (secret === undefined || secret === '') throw new Error('缺应用 secret'); const body = await call( 'gettoken', `${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`, { method: 'GET' }, ); const token = typeof body['access_token'] === 'string' ? body['access_token'] : ''; if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token'); const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200; tokens.set(account.id, { token, expiresAt: nowMs() + expiresIn * 1000 }); return token; } async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise { const settings = wecomSettingsSchema.parse(account.settings); const run = (token: string): Promise => call( api, `${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`, payload === undefined ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }, ); try { return await run(await tokenOf(account)); } catch (error) { if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) { return run(await tokenOf(account, true)); } throw error; } } return { kind: 'wecom', async probe(account): Promise { const started = nowMs(); try { const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias'); const address = typeof alias['email'] === 'string' ? alias['email'] : ''; return { kind: 'wecom', address, receive: { ok: true, latencyMs: nowMs() - started }, send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' }, }; } catch (error) { return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } }; } }, async anchor(_account, backfillFrom, now): Promise { return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000)); }, async pull(account, cursor, opts) { const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now); const ids: string[] = []; let pageCursor: string | undefined; let exhausted = false; // 取满 limit 就停,余下的下一轮接着(窗口重叠 + 唯一键保证不丢不重) while (ids.length < opts.limit) { const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', { begin_time: beginTime, end_time: endTime, ...(pageCursor === undefined ? {} : { cursor: pageCursor }), limit: Math.min(LIST_PAGE, opts.limit - ids.length), }); const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : []; for (const item of list) if (typeof item.mail_id === 'string') ids.push(item.mail_id); const hasMore = page['has_more'] === 1 || page['has_more'] === true; pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined; if (!hasMore || list.length === 0 || pageCursor === undefined) { exhausted = true; break; } } const known = await opts.known(ids); const messages: RawMessage[] = []; for (const mailId of ids.slice(0, opts.limit)) { if (known.has(mailId)) continue; const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId }); const data = mail['mail_data']; if (typeof data !== 'string' || data === '') continue; messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now }); } return { messages, cursor: wecomCursor(endTime), more: !exhausted }; }, async send(account, message) { await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', { to: { emails: [...message.to] }, ...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }), ...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }), subject: message.subject, content: message.html ?? message.text ?? '', content_type: message.html !== undefined ? 'html' : 'text', ...(message.attachments.length === 0 ? {} : { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }), }); return {}; }, }; } ``` `index.ts` 追加: ```ts export type { FetchLike } from './internal/adapter/wecom-provider.ts'; export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts'; ``` - [ ] **Step 4: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 全部 PASS(wecom 7 tests);门禁退出码 0。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/modules/mail git commit -m "$(printf 'mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 14: app 装配 —— 上传件存取抽出、mail 接线(channel 订阅方 / 传输件 / 工具处理器)、轮询工作者、config / main **Files:** - Create: `apps/api/src/app/upload-store.ts` · `apps/api/src/app/mail-wiring.ts` · `apps/api/src/app/workers/mail-poller.ts` - Modify: `apps/api/src/app/container.ts`(装配 mail;`mail.send` 处理器闭包;channel 传输件与订阅方;删模块级 `firstPartyToolHandlers`)· `apps/api/src/app/config.ts`(`mailPollTickMs`)· `apps/api/src/app/main.ts`(起停轮询器)· `apps/api/src/app/routes/governance-ops.ts`(上传两段改调 `upload-store.ts`)· `apps/api/src/app/tools/first-party.ts`(删 `mailSend`) - Test: `apps/api/src/app/__tests__/mail-wiring.test.ts` · `apps/api/src/app/__tests__/mail-poller.test.ts` **Interfaces:** - Produces:`storeUpload(db, {name, contentType, bytes, uploadedBy, note?})` → `StoredUpload`;`readUpload(db, ref)`;`toInboundMail(detail)` → `InboundMail`;`channelSubscriber(channel)` → `InboundHandler`;`channelTransport(mail)` → `MailTransport`;`mailSendTool(mail)` → `ToolHandler`;`pollMailOnce(deps)`;`startMailPoller(deps, tickMs)`;`Container.mail: MailService`;`AppConfig.mailPollTickMs`。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/app/__tests__/mail-wiring.test.ts /** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */ import { describe, expect, it } from 'vitest'; import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts'; import type { InboundMail } from '@contracts'; import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts'; const detail: MailInboundDetail = { id: 'in-1', accountId: 'acc-1', messageId: '', from: { address: 'buyer@acme.com', name: '张三' }, to: [{ address: 'sales@we.com', name: '' }], cc: [{ address: 'ops@we.com', name: 'Ops' }], subject: '询价 RFQ-1', sentAt: '2026-08-25T09:00:00.000Z', receivedAt: '2026-08-25T09:30:00.000Z', textPreview: '你好', hasHtml: true, attachments: [ { index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }, { index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' }, ], deliveries: [], inReplyTo: null, references: ['', ''], text: null, html: '

你好

', size: 100, rawAvailable: true, }; function fakeSend(result: Partial = {}): { send: (input: MailSendInput) => Promise; calls: MailSendInput[] } { const calls: MailSendInput[] = []; return { calls, send: async (input) => { calls.push(input); return { outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', ...result }; }, }; } describe('toInboundMail', () => { it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => { const mail: InboundMail = toInboundMail(detail); expect(mail.threadId).toBe(''); expect(mail.from).toBe('buyer@acme.com'); expect(mail.to).toEqual(['sales@we.com']); expect(mail.cc).toEqual(['ops@we.com']); expect(mail.body).toBe('你好'); expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]); expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z'); }); }); describe('channelSubscriber', () => { it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => { const calls: Array<[string, InboundMail]> = []; const handler = channelSubscriber({ ingestMail: async (id, mail) => { calls.push([id, mail]); return undefined; }, }); expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' }); expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined(); expect(calls[0]?.[0]).toBe('ch-1'); expect(calls[0]?.[1].messageId).toBe(''); }); }); describe('channelTransport', () => { it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => { const mail = fakeSend(); const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '', attachments: [{ name: 'q.pdf', ref: 'up-9' }] }); expect(sent).toEqual({ messageId: '', threadId: '' }); expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '', references: [''], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } }); const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] }); expect(fresh.threadId).toBe(''); }); it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => { await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed'); }); }); describe('mailSendTool', () => { const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' }; it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => { const mail = fakeSend(); const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '', state: 'sent' } }); expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '', references: [''], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } }); await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(mail.calls[1]?.accountId).toBeUndefined(); }); it('dryRun 只校验不发', async () => { const mail = fakeSend(); expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } }); expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false); expect(mail.calls).toHaveLength(0); }); it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => { const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } }); const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail'); const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(noAccount.error?.code).toBe('no-account'); const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } }); expect(invalid.error?.code).toBe('invalid'); }); }); ``` ```ts // apps/api/src/app/__tests__/mail-poller.test.ts /** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MailPollOutcome } from '@contracts'; import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts'; function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) { const calls: Date[] = []; return { calls, mail: { pollDue: async (now: Date) => { calls.push(now); if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs)); return outcomes; }, }, }; } describe('pollMailOnce', () => { it('回用例的结果;出错的账号写一行 stderr', async () => { const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]); const out = await pollMailOnce({ container: { mail: mail as never } }); expect(out).toHaveLength(2); expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上')); write.mockRestore(); }); }); describe('startMailPoller', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('按 tick 调;在跑时跳过;stop 后停', async () => { const { mail, calls } = fakeMail([], 1000); const poller = startMailPoller({ container: { mail: mail as never } }, 100); await vi.advanceTimersByTimeAsync(350); expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过 await vi.advanceTimersByTimeAsync(1000); expect(calls.length).toBeGreaterThanOrEqual(2); poller.stop(); const before = calls.length; await vi.advanceTimersByTimeAsync(2000); expect(calls).toHaveLength(before); }); }); ``` 注:两处 `mail as never` 只是把测试桩缩到 `pollDue` 一个方法 —— `Container['mail']` 是整个 `MailService`,桩不必长成它;`as never` 不在 §12.5 的禁止清单里(禁的是 `as unknown as`)。 - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts` Expected: FAIL(文件不存在)。 - [ ] **Step 3: 写 upload-store / mail-wiring / mail-poller** ```ts // apps/api/src/app/upload-store.ts /** * sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。 * 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。 */ import { sql } from 'drizzle-orm'; import type { Db } from '@shared/db.ts'; import { extractText } from './upload-extract.ts'; /** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ export const UPLOAD_CAP = 8 * 1024 * 1024; function rowsOf(result: unknown): T[] { const bag = result as { rows?: T[] } | T[]; return Array.isArray(bag) ? bag : (bag.rows ?? []); } export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string }; /** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */ export async function storeUpload( db: Db, file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined }, ): Promise { const got = extractText(file.name, file.contentType, file.bytes); const ref = crypto.randomUUID(); const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`; await db.execute(sql` insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)}, ${got.text ?? null}, ${note}, ${file.uploadedBy})`); return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note }; } export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> { const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>( sql`select name, content_type, bytes from sys.upload where id = ${ref}`, ); const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0]; if (hit === undefined) return undefined; // pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科) return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) }; } ``` `apps/api/src/app/routes/governance-ops.ts` 里上传两段改为(其余不动;`UPLOAD_CAP` 常量与 `extractText` import 删掉,改 import `storeUpload, readUpload, UPLOAD_CAP` from `'../upload-store.ts'`): ```ts app.post(uploadFileRoute.path, async (c) => { const parsed = uploadFileRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues }; return c.json(body, 400); } const { name, contentType, dataBase64 } = parsed.data; const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64')); if (bytes.length === 0) { const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` }; return c.json(body, 400); } if (bytes.length > UPLOAD_CAP) { const body: ApiError = { code: 'validation-failed', message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`, }; return c.json(body, 400); } // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null })); }); app.get(downloadFileRoute.path, async (c) => { const hit = await readUpload(container.db, c.req.param('id')); if (hit === undefined) { const body: ApiError = { code: 'not-found', message: '这个文件不在了' }; return c.json(body, 404); } return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') }); }); ``` ```ts // apps/api/src/app/mail-wiring.ts /** * mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013): * ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信 * ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认) * ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器) * 三段都是纯映射 + 一次调用,单测直接对函数。 */ import type { InboundMail, MailSendInput } from '@contracts'; import type { MailTransport } from '@modules/channel'; import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail'; import { bodyTextOf, MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError, } from '@modules/mail'; import type { ToolHandler } from '@modules/toolbox'; /** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */ export function toInboundMail(mail: MailInboundDetail): InboundMail { const threadId = mail.inReplyTo ?? mail.references.at(-1); return { messageId: mail.messageId, ...(threadId === undefined ? {} : { threadId }), from: mail.from.address, to: mail.to.map((a) => a.address), cc: mail.cc.map((a) => a.address), subject: mail.subject, body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }), receivedAt: mail.receivedAt, attachments: mail.attachments.flatMap((a) => a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }], ), }; } /** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */ export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise }): InboundHandler { return async (mail, ctx) => { if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' }; await channel.ingestMail(ctx.channelId, toInboundMail(mail)); }; } /** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */ export function channelTransport(mail: Pick): MailTransport { return { async send(input) { const result = await mail.send({ from: input.from, to: [...input.to], cc: [...input.cc], bcc: [], subject: input.subject, text: input.body, ...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }), references: input.threadId === undefined ? [] : [input.threadId], attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })), requestedBy: { kind: 'channel' }, }); if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`); return { messageId: result.messageId, threadId: input.threadId ?? result.messageId }; }, }; } function recipientsOf(value: unknown): string[] { if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== ''); if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== ''); return []; } function attachmentsOf(value: unknown): MailSendInput['attachments'] { if (!Array.isArray(value)) return []; return value.flatMap((v) => { if (typeof v !== 'object' || v === null) return []; const a = v as Record; const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined; if (uploadRef === undefined) return []; const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef; return [{ name, contentType: 'application/octet-stream', uploadRef }]; }); } export function mailSendTool(mail: Pick): ToolHandler { return async ({ input, connection, idempotencyKey }) => { const to = recipientsOf(input['to']); const cc = recipientsOf(input['cc']); const bcc = recipientsOf(input['bcc']); const subject = typeof input['subject'] === 'string' ? input['subject'] : ''; const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined; const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined; const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined; // 账号:入参指定 > 连接配置('default' = 默认账号) const configured = connection['account']; const accountId = typeof input['accountId'] === 'string' && input['accountId'] !== '' ? input['accountId'] : configured !== undefined && configured !== '' && configured !== 'default' ? configured : undefined; if (input['dryRun'] === true) { const problems = [ ...(to.length === 0 ? ['收件人至少一个'] : []), ...(subject === '' ? ['主题不能为空'] : []), ...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []), ]; return problems.length === 0 ? { ok: true, output: { queued: false, dryRun: true } } : { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } }; } try { const result = await mail.send({ ...(accountId === undefined ? {} : { accountId }), to, cc, bcc, subject, ...(body === undefined ? {} : { text: body }), ...(html === undefined ? {} : { html }), ...(inReplyTo === undefined ? {} : { inReplyTo }), references: inReplyTo === undefined ? [] : [inReplyTo], attachments: attachmentsOf(input['attachments']), ...(idempotencyKey === undefined ? {} : { idempotencyKey }), requestedBy: { kind: 'tool', tool: 'mail.send' }, }); if (result.state === 'failed') { return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } }; } return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } }; } catch (error) { if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } }; if ( error instanceof MailNoDefaultAccountError || error instanceof MailAccountNotFoundError || error instanceof MailAccountDisabledError || error instanceof MailSecretMissingError ) { return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } }; } throw error; } }; } ``` ```ts // apps/api/src/app/workers/mail-poller.ts /** * 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里, * 两个后端连同一个库时一个信箱同一时刻只有一个在拉。 * 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。 */ import type { MailPollOutcome } from '@modules/mail'; import type { Container } from '../container.ts'; export type MailPollerDeps = { readonly container: Pick; readonly now?: () => Date }; export async function pollMailOnce(deps: MailPollerDeps): Promise { const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))()); for (const outcome of outcomes) { if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`); } return outcomes; } export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } { let stopped = false; let running = false; const tick = async (): Promise => { if (stopped || running) return; running = true; try { await pollMailOnce(deps); } catch (error) { process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`); } finally { running = false; } }; const timer = setInterval(() => void tick(), tickMs); timer.unref(); return { stop() { stopped = true; clearInterval(timer); }, }; } ``` - [ ] **Step 4: 改 container / config / main / first-party** `apps/api/src/app/config.ts`:`AppConfig` 加 `/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */ readonly mailPollTickMs: number;`,`loadConfig` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),`。 `apps/api/src/app/tools/first-party.ts`:删掉 `const mailSend: ToolHandler = …` 整段(含注释)与 `FIRST_PARTY_TOOLS` 里的 `['mail.send', mailSend],` 一行。 `apps/api/src/app/container.ts`: 1. import:删 `createSimulatedMailTransport`(保留 channel 其余三个);加 ```ts import type { MailService } from '@modules/mail'; import { createImapSmtpProvider, createInboundStore, createMailAccountStore, createMailService, createOutboundStore, createPostalMimeParser, createWecomProvider, } from '@modules/mail'; import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts'; import { readUpload, storeUpload } from './upload-store.ts'; ``` 2. `Container` 类型加 `/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService;`(放在 `channel` 之后)。 3. 删掉模块级 `export const firstPartyToolHandlers = …`(仓内无其它引用)。 4. 在 `const toolRegistry = createToolRegistry(db);` 之前加: ```ts /* * 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。 * 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。 * 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。 */ const mail = createMailService({ accounts: createMailAccountStore(db), inbound: createInboundStore(db), outbound: createOutboundStore(db), attachments: { put: async (file) => ({ ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref, }), get: (ref) => readUpload(db, ref), }, providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() }, mime: createPostalMimeParser(), secrets, clock: systemClock, ids, notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) }, }); ``` 5. `const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);` 改为 ```ts /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ const toolRuntime = createToolRuntime( new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler, ); ``` 6. channel 装配里 `mail: createSimulatedMailTransport(db, ids),` 改为 `mail: channelTransport(mail),`;紧跟 `const channel = createChannelService({...});` 之后加: ```ts // channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面 mail.subscribe('channel', channelSubscriber(channel)); ``` 7. `return {...}` 里 `channel,` 之后加 `mail,`。 `apps/api/src/app/main.ts`:`import { startMailPoller } from './workers/mail-poller.ts';`;在 `const advancer = …` 之后加 ```ts // 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱 const mailPoller = startMailPoller({ container }, config.mailPollTickMs); ``` `shutdown` 里 `advancer.stop();` 之后加 `mailPoller.stop();`。 - [ ] **Step 5: 跑测试确认通过** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` Expected: 8 + 2 tests PASS;三道门禁退出码 0(`app-not-into-internal`:app 只 import `@modules/mail` 的出口;`no-orphans`:`upload-store.ts` / `mail-wiring.ts` 被 container 引用)。 - [ ] **Step 6: Commit** ```bash git add apps/api/src/app git commit -m "$(printf 'app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 15: 路由 `/api/gov/mail/*` **Files:** - Create: `apps/api/src/app/routes/mail.ts` - Modify: `apps/api/src/app/server.ts`(注册) - Test: `apps/api/src/app/__tests__/mail-routes.test.ts`(Hono `app.request`,真用例 + 内存 store,不起 PG) **Interfaces:** - Produces:`registerMailRoutes(app: Hono, deps: { mail: MailService }): void`;`mailErrorResponse(error)`。 - [ ] **Step 1: 写失败测试** ```ts // apps/api/src/app/__tests__/mail-routes.test.ts /** * 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。 * 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。 */ import { Hono } from 'hono'; import { describe, expect, it } from 'vitest'; import { createMailService, createPostalMimeParser } from '@modules/mail'; import type { AppEnv } from '../http-env.ts'; import { registerMailRoutes } from '../routes/mail.ts'; import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts'; function harness() { const clock = fixedClock(); const accounts = memoryAccounts(clock); const imap = fakeProvider('imap-smtp'); const mail = createMailService({ accounts, inbound: memoryInbound(), outbound: memoryOutbound(), attachments: memoryAttachments(), providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') }, mime: createPostalMimeParser(), secrets: plainSecrets, clock, ids: seqIds('x'), notify: { changed: () => undefined }, }); const app = new Hono(); registerMailRoutes(app, { mail }); const json = (method: string, path: string, body?: unknown) => app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); return { app, mail, accounts, imap, json }; } const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }; describe('账号路由', () => { it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => { const h = harness(); expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] }); expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400); const created = await h.json('POST', '/api/gov/mail/accounts', input); expect(created.status).toBe(200); const view = (await created.json()) as { id: string; secretBound: boolean }; expect(view.secretBound).toBe(true); expect(JSON.stringify(view)).not.toContain('pw-1234'); expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409); expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200); expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404); expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200); expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true }); expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404); }); it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => { const h = harness(); expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412); const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string }; expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200); h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } }); const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } }); }); it('立即拉取:200 outcome;停用 412;租约被占 409', async () => { const h = harness(); await h.accounts.save(account()); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 }); await h.accounts.save(account({ enabled: false })); expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412); await h.accounts.save(account({ pollingUntil: new Date('2027-01-01T00:00:00Z') })); expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409); }); it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => { const h = harness(); await h.accounts.save(account()); const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' }); expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '' }); h.imap.failSendWith(new Error('535 nope')); expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' }); expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400); }); }); describe('入站 / 出站路由', () => { it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => { const h = harness(); await h.accounts.save(account()); h.mail.subscribe('spy', async () => undefined); h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } }))); await h.json('POST', '/api/gov/mail/accounts/acc-1/poll'); const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> }; expect(list.mails).toHaveLength(1); expect(list.mails[0]?.deliveries).toHaveLength(1); const id = list.mails[0]!.id; expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200); expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404); const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string }; expect(att.name).toBe('a.txt'); expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi'); expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404); const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string }; expect(raw.contentType).toBe('message/rfc822'); expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] }); expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404); expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400); expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] }); }); }); /** 空 body 的 POST:redeliver 的 body 是可省的 */ function app_request_redeliver(h: ReturnType, id: string): Promise { return h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts` Expected: FAIL(`routes/mail.ts` 不存在)。 - [ ] **Step 3: 写路由并注册** ```ts // apps/api/src/app/routes/mail.ts /** * 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判; * 这里只做 zod 校验、调用用例、把领域错误翻成状态码。 * 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。 */ import type { Context, Hono } from 'hono'; import type { ApiError } from '@contracts'; import { createMailAccountRoute, deleteMailAccountRoute, getMailAccountRoute, getMailInboundRoute, listMailAccountsRoute, listMailInboundRoute, listMailOutboundRoute, mailAttachmentRoute, mailRawRoute, pollMailAccountRoute, probeMailAccountRoute, probeMailDraftRoute, redeliverMailInboundRoute, sendTestMailRoute, updateMailAccountRoute, } from '@contracts'; import type { MailService } from '@modules/mail'; import { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberUnknownError, } from '@modules/mail'; import type { AppEnv } from '../http-env.ts'; import { staffOf } from '../http-env.ts'; export type MailRouteDeps = { readonly mail: MailService }; /** 领域错误 → 状态码;认不出的交回 onError(500) */ export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined { if ( error instanceof MailAccountNotFoundError || error instanceof MailInboundNotFoundError || error instanceof MailAttachmentNotFoundError || error instanceof MailSubscriberUnknownError ) { return { status: 404, body: { code: 'not-found', message: error.message } }; } if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) { return { status: 409, body: { code: 'conflict', message: error.message } }; } if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) { return { status: 412, body: { code: 'precondition-failed', message: error.message } }; } if (error instanceof MailSendInvalidError) { return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } }; } return undefined; } function invalid(c: Context, message: string, detail: unknown): Response { const body: ApiError = { code: 'validation-failed', message, detail }; return c.json(body, 400); } async function jsonBody(c: Context): Promise { const text = await c.req.text(); if (text.trim() === '') return {}; try { return JSON.parse(text) as unknown; } catch { return undefined; } } export function registerMailRoutes(app: Hono, deps: MailRouteDeps): void { const { mail } = deps; const guarded = async (c: Context, run: () => Promise): Promise => { try { return await run(); } catch (error) { const mapped = mailErrorResponse(error); if (mapped === undefined) throw error; return c.json(mapped.body, mapped.status); } }; /* ── 账号 ── */ app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() })); app.post(createMailAccountRoute.path, async (c) => { const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); return guarded(c, async () => c.json(await mail.createAccount(parsed.data))); }); app.post(probeMailDraftRoute.path, async (c) => { const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); const accountId = c.req.query('accountId'); return guarded(c, async () => c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })), ); }); app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId'))))); app.put(updateMailAccountRoute.path, async (c) => { const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data))); }); app.delete(deleteMailAccountRoute.path, async (c) => guarded(c, async () => { await mail.deleteAccount(c.req.param('accountId')); return c.json({ ok: true as const }); }), ); app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') })))); app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId'))))); app.post(sendTestMailRoute.path, async (c) => { const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues); const me = staffOf(c); return guarded(c, async () => c.json( await mail.send({ accountId: c.req.param('accountId'), to: [parsed.data.to], cc: [], bcc: [], subject: parsed.data.subject, text: parsed.data.text, references: [], attachments: [], requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) }, }), ), ); }); /* ── 入站 ── */ app.get(listMailInboundRoute.path, async (c) => { const query = listMailInboundRoute.query.safeParse(c.req.query()); if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) }); }); app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId'))))); app.get(mailAttachmentRoute.path, async (c) => { const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') }); if (!params.success) return invalid(c, '附件序号不合法', params.error.issues); return guarded(c, async () => { const file = await mail.attachment(params.data.mailId, params.data.index); return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') }); }); }); app.get(mailRawRoute.path, async (c) => guarded(c, async () => { const file = await mail.raw(c.req.param('mailId')); return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') }); }), ); app.post(redeliverMailInboundRoute.path, async (c) => { const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues); return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) })); }); /* ── 出站 ── */ app.get(listMailOutboundRoute.path, async (c) => { const query = listMailOutboundRoute.query.safeParse(c.req.query()); if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) }); }); } ``` `apps/api/src/app/server.ts`:`import { registerMailRoutes } from './routes/mail.ts';`,在 `registerGovernanceOpsRoutes(app, container);` 之后加 `registerMailRoutes(app, container);`。 - [ ] **Step 4: 跑测试确认通过(含路由覆盖 IT)** Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint` Expected: 路由 5 tests PASS;路由覆盖 IT PASS(15 条新路由都在注册表里 —— 它是 fail-closed 的,红了就是有路由没契约);门禁退出码 0。若 `c.json(mapped.body, mapped.status)` 因状态码联合类型报错,把 `mailErrorResponse` 的 `status` 类型改成 hono 导出的 `ContentfulStatusCode`(`import type { ContentfulStatusCode } from 'hono/utils/http-status'`)。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/app git commit -m "$(printf '邮件路由 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站 / 附件原件 / 重新投递;领域错误翻状态码,厂商失败在 body 里说\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 16: `mail.send` 工具契约 2.0.0、api-report、五道门禁 **Files:** - Modify: `apps/api/src/app/seed-mvp.ts`(`MAIL_TOOL` 与注册循环的样例 / 连接值) - Modify: `api-report/**`(`pnpm api:check --update`) - Test: 既有 `apps/api/src/modules/toolbox/__tests__/*`(演进检查)+ 全量 `pnpm gates` - [ ] **Step 1: 改 seed 的工具契约** 把 `seed-mvp.ts` 里的 `MAIL_TOOL` 整段换成: ```ts /** * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。 * * 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。 * 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。 * `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。 * `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。 */ const MAIL_TOOL = { ...HISTORY_TOOL, name: 'mail.send', displayName: '发送邮件', version: '2.0.0', description: '从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' + 'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。', input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' }, cc: { type: 'array', items: { type: 'string' }, description: '抄送' }, bcc: { type: 'array', items: { type: 'string' }, description: '密送' }, subject: { type: 'string' }, body: { type: 'string', description: '纯文本正文(与 html 至少一个)' }, html: { type: 'string', description: 'HTML 正文' }, attachments: { type: 'array', items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } }, description: '附件:上传件的 ref', }, inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' }, accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' }, dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' }, }, }, output: { type: 'object', properties: { queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' }, outboundId: { type: 'string' }, messageId: { type: 'string' }, state: { type: 'string' }, dryRun: { type: 'boolean' }, }, }, effect: 'write' as const, errors: [ { code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' }, { code: 'no-account', retryable: false, message: '没有可用的邮箱账号' }, { code: 'invalid', retryable: false, message: '入参不合法' }, ], idempotent: true, connectionKeys: ['account'], }; ``` 注册循环里的样例改为(`to` 变数组、加 `dryRun`;连接值按键给): ```ts input: { pn: 'LM358DR', to: ['x@y.com'], subject: '询价', body: '样例正文', dryRun: true, history_days: 30, market_stable: true, lifecycle: '在产', }, ``` ```ts // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」 ...(contract.connectionKeys.length === 0 ? {} : { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }), ``` - [ ] **Step 2: 演进检查与试运行走一遍(单测)** 在 `apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts` 末尾追加一条(照该文件既有的 `checkEvolution` 用例写法): ```ts describe('mail.send 2.0.0 的演进', () => { it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => { const v1 = { name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)', input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } }, output: { type: 'object', properties: { queued: { type: 'boolean' } } }, effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'], }; const v2 = { ...v1, version: '2.0.0', connectionKeys: ['account'], input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } }, output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } }, }; expect(checkEvolution(v1, v2)).toEqual([]); }); }); ``` Run: `pnpm vitest run apps/api/src/modules/toolbox` Expected: PASS(含新用例)。 - [ ] **Step 3: 刷新契约快照并跑五道门禁** ```bash pnpm api:check --update && git add api-report pnpm gates ``` Expected: `api-report/apps/api/src/modules/mail/{index,contract,port}.d.ts` 与 `api-report/packages/contracts/...` 有新增;`pnpm gates` 退出码 0(gen:tiers / typecheck / lint / depcruise / api:check / test 全绿;IT 套件在有 PG 时跑、没有时跳过并告警 —— 本轮验收要求 PG 在)。 - [ ] **Step 4: 本机起一次真服务核一遍(有 PG + Keycloak 配置时)** ```bash pnpm db:migrate && pnpm db:seed pnpm dev:api ``` Expected:启动日志无 `[mail-poller]` 报错(没有账号时每 tick 空转);`pnpm db:seed` 日志里 `② 功能工具已注册并入白名单`,且 `mail.send` 未出现「未过两审」(dryRun 样例通过试运行)。 用服务账号 token(API 文档 §8 的取法)`curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/api/gov/mail/accounts` → `{"accounts":[]}`。 - [ ] **Step 5: Commit** ```bash git add apps/api/src/app/seed-mvp.ts apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts api-report git commit -m "$(printf 'mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ### Task 17: 文档 —— ADR-013、CONTRACT.md、API 文档、前端对接说明、总表、debt、CLAUDE.md **Files:** - Create: `docs/adr/ADR-013-mail-gateway.md` · `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md` - Modify: `apps/api/src/modules/channel/CONTRACT.md`(「邮件通道是外界」一条)· `docs/API文档-鉴权与人员.md`(§6 总表 + 计数)· `docs/debt.md`(新段)· `CLAUDE.md`(现状快照一条 + 文档权威地图两行) - [ ] **Step 1: ADR-013** ```markdown # ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕 状态:已接受(2026-08-25) 背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档 (核实日期 2026-08-25,链接列在设计稿开头)。 关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据)。 ## 问题 `channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口, 但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。 系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。 腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。 ## 决策 1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、 轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的 `MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。 2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与 `wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。 批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。 3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`, 重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下, `backfillFrom` 才回填);IMAP 只读打开,不改已读标记。 4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 —— channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。 订阅是代码级装配(不落库、不回放老信)。 5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款), 过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。 6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封, 失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成 `send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应, 放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。 7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret 缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。 8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发, `compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的 厂商失败在 200 的 body 里说,不编状态码。 9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录 (回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕 `sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。 ## 后果 - 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。 - wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。 - 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。 - `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串), 唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。 - 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 —— `channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。 - 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。 - 欠账见 `docs/debt.md`「邮件收发」段。 ``` - [ ] **Step 2: channel 的 CONTRACT.md** 把 `apps/api/src/modules/channel/CONTRACT.md` 不变量里这一条: ``` - 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入); 系统内部机制一律真跑,不得 mock ``` 改成: ``` - 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方, 把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认); `createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用); 系统内部机制一律真跑,不得 mock ``` `依赖:` 那一行的 `MailTransport`(模拟通道)改为 `MailTransport`(app 注入,转 mail 模块)。 - [ ] **Step 3: API 文档 `docs/API文档-邮件收发.md`** ```markdown # API 文档 —— 邮件收发(ADR-013) > 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用); > 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。 > 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`; > 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。 ## 0. 通则 | 项 | 值 | |---|---| | 前缀 | `/api/gov/mail/*`;读标两端 `design\|ops`,写归 `ops` | | 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` | | 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 | | 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) | | 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) | 状态码怎么读(通则之外本文特有的): | 状态 | code | 何时 | |---|---|---| | 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 | | 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 | | 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** | | 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) | ## 1. 账号 ### `GET /api/gov/mail/accounts` —— access: `design|ops` ```json { "accounts": [ { "id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部", "enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null, "settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" }, "secretBound": true, "status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false }, "createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z" }, { "id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false, "pollIntervalSec": 60, "channelId": null, "backfillFrom": null, "settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" }, "secretBound": false, "status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false }, "createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z" } ] } ``` - `address`:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。 - `status.cursor` 是人读摘要(还没拉过 = null);`status.polling` 为真表示此刻某台后端正拉着。 - `status.lastPollError` 是厂商原话(脱敏);为 null 表示上一轮没出错。 ### `POST /api/gov/mail/accounts` —— access: `ops` body 按 `kind` 分两形(缺省值见 §5 schema): ```json { "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部", "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } } ``` ```json { "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } } ``` → 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。`isDefault:true` 会把别的账号的默认位放下。 ### `POST /api/gov/mail/accounts/probe?accountId=` —— access: `ops` **未存**的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 `?accountId=` 借库里那份密文。 没 secret 又没 `accountId` → 412。 ```json { "kind": "imap-smtp", "address": "sales@corp.com", "receive": { "ok": true, "latencyMs": 412 }, "send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } } ``` ```json { "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work", "receive": { "ok": true, "latencyMs": 230 }, "send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } } ``` ### `GET /api/gov/mail/accounts/:accountId` —— access: `design|ops` → 账号视图;404。 ### `PUT /api/gov/mail/accounts/:accountId` —— access: `ops` body 同建账号;`secret` 缺省 = 不改密文;换 `kind` 又没给 secret → 旧密文作废(`secretBound:false`); `kind` 或 `settings` 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。 ### `DELETE /api/gov/mail/accounts/:accountId` —— access: `ops` → `{ "ok": true }`。留痕(入站 / 出站)不删。 ### `POST /api/gov/mail/accounts/:accountId/probe` —— access: `ops` 已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 `address`。412 凭证空。 ### `POST /api/gov/mail/accounts/:accountId/poll` —— access: `ops` 立即拉一轮(不等轮询到点): ```json { "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false } ``` - `pulled` 服务器交出几封;`stored` 真新入库几封(去重后);`dispatched` 分发过几封;`more` 为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。 - 出错:`error` 带原话(同时写进账号的 `status.lastPollError`),仍是 200。 - 409:另一台后端正占着租约;412:账号停用。 ### `POST /api/gov/mail/accounts/:accountId/send-test` —— access: `ops` body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` → ```json { "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "", "state": "sent", "providerRef": "250 OK: queued as …" } ``` 失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。 ## 2. 入站 ### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `design|ops` 按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。 ```json { "mails": [ { "id": "in-01J…", "accountId": "6e1c…", "messageId": "", "from": { "address": "buyer@acme.com", "name": "张三" }, "to": [{ "address": "sales@corp.com", "name": "" }], "cc": [], "subject": "询价 RFQ-2026-081", "sentAt": "2026-08-25T09:57:40.000Z", "receivedAt": "2026-08-25T09:58:11.000Z", "textPreview": "你好,请报价以下料号……", "hasHtml": true, "attachments": [ { "index": 0, "name": "BOM.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "size": 18234, "ref": "3f9e…", "note": "" }, { "index": 1, "name": "datasheets.zip", "contentType": "application/zip", "size": 9437184, "ref": null, "note": "附件 9.0MB,超过 8MB 上限,未存原件" } ], "deliveries": [ { "subscriber": "channel", "state": "done", "error": null, "startedAt": "2026-08-25T09:58:11.200Z", "finishedAt": "2026-08-25T09:58:11.480Z" } ] } ] } ``` `deliveries[].state`:`dispatching`(正在投;超过 10 分钟还是它 = 进程中断,交人重投)/ `done` / `skipped`(`error` 里是理由,如「账号未绑定通道」)/ `failed`(`error` 是原话,如「通道实例已停用:ch-rfq-mail」)。 ### `GET /api/gov/mail/inbound/:mailId` —— access: `design|ops` 列表那一项 + `inReplyTo` `references[]` `text` `html` `size` `rawAvailable`。404。 ### `GET /api/gov/mail/inbound/:mailId/attachments/:index` —— access: `design|ops` → `{ "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形)。可带 `?ticket=` 用 `
`(ADR-012 §6)。 超限没存的(`ref: null`)→ 404。附件同时也是一个上传件(`ref` = `sys.upload` 的 id),活动那一侧读得到抽出来的文本。 ### `GET /api/gov/mail/inbound/:mailId/raw` —— access: `design|ops` → `{ "name": "CAF…_mail.acme.com.eml", "contentType": "message/rfc822", "dataBase64": "…" }`;超 16MB 没存 → 404。可带 `?ticket=`。 ### `POST /api/gov/mail/inbound/:mailId/redeliver` —— access: `ops` body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不带)→ `{ "deliveries": [ … ] }`。 正在 `dispatching` 的那条跳过(原样回);不认识的订阅方 404。**从这一刻起对该订阅方是至少一次**:channel 那条链会再起一单。 ## 3. 出站 ### `GET /api/gov/mail/outbound?accountId=&limit=` —— access: `design|ops` ```json { "mails": [ { "id": "c0ff…", "accountId": "6e1c…", "messageId": "", "to": ["buyer@acme.com"], "cc": [], "bcc": [], "subject": "回复:询价 RFQ-2026-081", "state": "sent", "error": null, "providerRef": "250 OK: queued as …", "requestedBy": { "kind": "tool", "tool": "mail.send" }, "createdAt": "2026-08-25T11:02:00.000Z", "sentAt": "2026-08-25T11:02:01.300Z" } ] } ``` `requestedBy.kind`:`tool`(活动放行时经 `mail.send` 落地)/ `channel`(channel.sendMail)/ `ops`(运营面试发,带 `staffId`)。`state`:`sending`(在发;久停不动 = 进程中断)/ `sent` / `failed`。 ## 4. 活动怎么用(不是 HTTP) - **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。 - **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。 ## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名) ```ts type MailAccountKind = 'imap-smtp' | 'wecom'; type ImapSmtpSettings = { imapHost: string /* imap.exmail.qq.com */; imapPort: number /* 993 */; imapTls: boolean /* true */; smtpHost: string /* smtp.exmail.qq.com */; smtpPort: number /* 465 */; smtpTls: boolean /* true = 隐式 TLS;false = STARTTLS */; login?: string /* 缺省 = address */; folder: string /* INBOX */ }; type WecomSettings = { corpId: string; apiBase: string /* https://qyapi.weixin.qq.com */ }; // POST / PUT 的 body(缺省值:displayName '' · enabled true · isDefault false · pollIntervalSec 30(10–3600)· channelId null · backfillFrom null) type MailAccountInput = | { kind: 'imap-smtp'; address: string; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number; channelId?: string | null; backfillFrom?: string | null; settings?: Partial; secret?: { password: string } } | { kind: 'wecom'; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number; channelId?: string | null; backfillFrom?: string | null; settings: { corpId: string; apiBase?: string }; secret?: { corpSecret: string } }; type MailAccountStatus = { lastPolledAt: string | null; lastPollError: string | null; lastInboundAt: string | null; cursor: string | null; polling: boolean }; type MailAccountView = ( | { kind: 'imap-smtp'; settings: ImapSmtpSettings } | { kind: 'wecom'; settings: WecomSettings } ) & { id: string; name: string; address: string; displayName: string; enabled: boolean; isDefault: boolean; pollIntervalSec: number; channelId: string | null; backfillFrom: string | null; secretBound: boolean; status: MailAccountStatus; createdAt: string; updatedAt: string }; type MailProbeReport = { kind: MailAccountKind; address?: string; receive: { ok: boolean; error?: string; latencyMs?: number }; send: { checked: boolean; ok?: boolean; error?: string; note?: string } }; type MailPollOutcome = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; type MailAddress = { address: string; name: string }; type MailDeliveryView = { subscriber: string; state: 'dispatching' | 'done' | 'failed' | 'skipped'; error: string | null; startedAt: string; finishedAt: string | null }; type MailInboundAttachment = { index: number; name: string; contentType: string; size: number; ref: string | null; note: string }; type MailInboundSummary = { id: string; accountId: string; messageId: string; from: MailAddress; to: MailAddress[]; cc: MailAddress[]; subject: string; sentAt: string | null; receivedAt: string; textPreview: string; hasHtml: boolean; attachments: MailInboundAttachment[]; deliveries: MailDeliveryView[] }; type MailInboundDetail = MailInboundSummary & { inReplyTo: string | null; references: string[]; text: string | null; html: string | null; size: number; rawAvailable: boolean }; type MailSendResult = { outboundId: string; accountId: string; messageId: string; state: 'sent' | 'failed'; error?: string; providerRef?: string }; type MailOutboundView = { id: string; accountId: string; messageId: string; to: string[]; cc: string[]; bcc: string[]; subject: string; state: 'sending' | 'sent' | 'failed'; error: string | null; providerRef: string | null; requestedBy: Record; createdAt: string; sentAt: string | null }; ``` ## 6. curl 实录 ```bash TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \ -d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json') # 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码) curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \ -d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq . # 探测(已存)/ 立即拉取 / 试发 curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq . curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq . curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq . # 来信、附件、重新投递 curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}' curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq . # 出站 curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}' ``` 示例响应按契约构造;真实响应待账号配好后用上面的序列换。 ``` - [ ] **Step 4: 前端对接说明 `docs/前端对接说明-邮件收发.md`** ```markdown # 邮件收发 —— 前端对接说明 > 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲 > **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与 > `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。 ## 0. 一分钟看懂 - 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。 界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。 - 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。 表单按 `kind` 切换字段;两种都有「探测」。 - 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。 - 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。 - 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。读接口两端都能调,设计中心可只读展示。 ## 1. 建议的信息架构 ``` 运营中心 / 系统 / 邮箱 ├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯 │ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除 │ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe) ├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递) └─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间 ``` ## 2. 账号表单(字段清单与默认值) 公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)· `pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)· `backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。 `kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)· `settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)· `settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。 `kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。 `address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。 保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。 ## 3. 四个动作的交互与「界面不撒谎」 | 动作 | 接口 | 怎么摆 | |---|---|---| | 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 | | 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 | | 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 | | 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 | 列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。 ## 4. 来信与投递状态 - 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。 - 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。 **`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。 - 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。 - 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形, 预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。 `ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。 - 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。 - 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。 ## 5. 发信记录 列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。 `sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。 ## 6. SSE 失效映射 `app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀 (账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。 `reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。 ## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉) **IMAP/SMTP 路** 1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。 2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。 3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框; 公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。 4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。 **企业微信邮件 API 路** 1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。 2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。 3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。 4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。 发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。 ## 8. 错误处理 | 状态 | 何时 | 前端动作 | |---|---|---| | 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 | | 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 | | 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 | | 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 | ## 9. 接入完成的判据 - [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true - [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话 - [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址 - [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由 - [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note - [ ] 重新投递前有提示,投递后状态刷新 - [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点) - [ ] 试发失败时列表里看得到 `failed` 与原话 - [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话 ## 10. 本文与真实契约的核对状态 路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过;字段名与 `packages/contracts/src/mail.ts` 同名。 示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。 ``` - [ ] **Step 5: 总表、debt、CLAUDE.md** `docs/API文档-鉴权与人员.md` §6 总表:在 `| \`ops\` | DELETE | \`/api/gov/access/roles/:role\` |` 之后加 8 行: ```markdown | `ops` | POST | `/api/gov/mail/accounts` | | `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) | | `ops` | PUT | `/api/gov/mail/accounts/:accountId` | | `ops` | DELETE | `/api/gov/mail/accounts/:accountId` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` | | `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` | | `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` | ``` 在 `| \`design\|ops\` | GET | \`/api/gov/workflows/:id\` |` 之后加 7 行: ```markdown | `design\|ops` | GET | `/api/gov/mail/accounts` | | `design\|ops` | GET | `/api/gov/mail/accounts/:accountId` | | `design\|ops` | GET | `/api/gov/mail/inbound` | | `design\|ops` | GET | `/api/gov/mail/inbound/:mailId` | | `design\|ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) | | `design\|ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) | | `design\|ops` | GET | `/api/gov/mail/outbound` | ``` 计数那一行改为:`计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 27 + 两端 15 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。` `docs/debt.md` 末尾追加: ```markdown ## 2026-08-25 · 邮件收发接上了,留下七笔账(ADR-013) ① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次; 根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。 ② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。 ③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。 ④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。 ⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。 ⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。 ⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。 要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。 另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。 ``` `CLAUDE.md`:现状快照末尾加一条(照既有格式): ```markdown - **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与 企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 → `sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。 分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。 发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、 运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。 15 条 `/api/gov/mail/*` 路由(读两端、写 ops);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。 欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由。 ``` 文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后): ```markdown | | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 | | | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) | ``` - [ ] **Step 6: 核对与提交** Run: `pnpm gates` Expected: 退出码 0(文档改动不影响门禁;api-report 已在 Task 16 刷新)。 核一遍:`grep -c "/api/gov/mail" docs/API文档-鉴权与人员.md` = 15;`grep -n "mail" tools/module-tiers.json` 有一行。 ```bash git add docs apps/api/src/modules/channel/CONTRACT.md CLAUDE.md git commit -m "$(printf '文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账七笔、CLAUDE 快照\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" ``` --- ## 自审(spec 覆盖 / 占位 / 类型一致) | spec 节 | 落在 | |---|---| | §2 决策(两路 / 三依赖 / subscribe / 多账号 / 方案 A) | Task 1(依赖 + L1)· Task 4(subscribe 契约)· Task 12 / 13(两路) | | §5.1 contract / §5.2 port | Task 4(+ Task 9 的 `probe` 第二形 `{input, accountId?}`) | | §5.3 domain 八件 | Task 4(cursor / due / records / errors)· Task 5(inbound-record / redact)· Task 6(send-input / delivery / account-view) | | §5.4 usecase 要点(拉 / 分发 / 发 / 探测 / 凭证 / 默认账号 / 到期扫描 / 账号变更) | Task 9 实现,Task 10 / 11 测试 | | §5.5 adapter 六件 | Task 7(schema / store)· Task 8(postal-mime)· Task 12(imap-smtp)· Task 13(wecom)· Task 8 的 fakes(假 provider:住在 `__tests__/fakes.ts` 而不是 adapter —— 它只服务测试,不进生产装配) | | §6 四张表 + journal + reset-runs | Task 7 | | §7.1 / §7.2 / §7.3 / §7.4 契约、15 条路由、mail.send 2.0.0、总表 | Task 2 / 3 / 16 / 17 | | §8 app 装配 / 工作者 / 路由 / channel 订阅方 / 上传件抽出 | Task 14 / 15 | | §9 租约 / 去重 / 分发语义 / 脱敏 / 两缝 / 界面不撒谎 | Task 7(租约 IT)· Task 10(去重 / 分发)· Task 5(脱敏)· Task 17(ADR 写明两缝) | | §10 腾讯侧前置 | Task 17 前端对接说明 §7 | | §11 连带改动 | Task 1(tiers / 编码规范)· Task 14(first-party / 上传)· Task 16(seed / api-report)· Task 17(CONTRACT.md / ADR / 文档 / debt / CLAUDE) | | §12 测试策略 | 各任务 Step 1;IT:Task 7(store + 租约)、Task 12(真信箱 env 门控)、Task 15(路由覆盖) | | §13 欠账 | Task 17 debt 七笔(spec 的七条 + channel 无 upsert 路由这一条是实施核仓时补的) | 与 spec 的差异已在开头「与 spec 的差异」列明(工具契约保留 v1 字段 / dryRun / send 不抛 / nodemailer 8)。 类型名在各任务间核过:`MailAccountRecord` `InboundRecord` `DeliveryRecord` `OutboundRecord` `ParsedMail` `OutboundMessage` `RawMessage` `ProviderAccount` `MailCursor` 定义于 Task 4,Task 7–15 一律按此名引用;`known` 回调在 Task 4 的 port、Task 9 的用例、Task 12 / 13 的 provider 三处同名。