diff --git a/docs/superpowers/plans/2026-08-25-mail.md b/docs/superpowers/plans/2026-08-25-mail.md new file mode 100644 index 0000000..3fbd538 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-mail.md @@ -0,0 +1,6999 @@ +# 邮件收发模块(`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 三处同名。