This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/plans/2026-08-25-mail.md
T
xcsandClaude Fable 5 58d6d778ea 计划勘误:cursor 测试的 wecom epoch 字面量错了整一年(1756116000 是 2025-08-25)
实现 Task 4 时逼出来的:那条断言期望 2026-08-25T10:00:00Z,而字面量落在 2025 —— 整套夹具都锚在
2026-08-25T10:00:00Z,改输入而不是改期望,才不会留下唯一一个锚在别的年份的夹具。
代码侧已在 83f65b2 修正,这里把计划文本对齐。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
2026-08-26 00:14:53 +08:00

320 KiB
Raw Blame History

邮件收发模块(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 <noreply@anthropic.com> 与 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: 加依赖

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" 数组改为:

    "1": [
      "event",
      "org",
      "llm",
      "ledger",
      "mail"
    ],

docs/编码规范.md §4 层级表,在 | L1 | \ledger` |` 那一行之后加:

| 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
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 <noreply@anthropic.com>\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: 写失败测试

// 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
// 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<typeof mailAccountKindSchema>;
export const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>> = {
  '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<typeof imapSmtpSettingsSchema>;

/** 企业微信邮件 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<typeof wecomSettingsSchema>;

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<typeof mailAccountInputSchema>;

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<typeof mailAccountViewSchema>;

/** 探测报告:收 / 发各说各的;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<typeof mailProbeReportSchema>;

export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const;
export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES);
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;

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<typeof mailDeliveryViewSchema>;

export const mailAddressSchema = z.object({ address: z.string(), name: z.string() });
export type MailAddress = z.infer<typeof mailAddressSchema>;

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<typeof mailInboundAttachmentSchema>;

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<typeof mailInboundSummarySchema>;

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<typeof mailInboundDetailSchema>;

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<typeof mailSendInputSchema>;

export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const;
export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES);
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;

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<typeof mailSendResultSchema>;

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<typeof mailOutboundViewSchema>;

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<typeof mailPollOutcomeSchema>;

在 packages/contracts/src/index.ts 的 export * from './org.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
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 <noreply@anthropic.com>\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: 写失败测试

// 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: 写路由契约
// 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 末尾加:

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
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 <noreply@anthropic.com>\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: 写失败测试(游标与到期)

// 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(1787652000))).toBe('至 2026-08-25T10:00:00.000Z');
  });
  it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => {
    expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3));
    expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5));
    expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull();
    expect(parseCursor(null)).toBeNull();
    expect(parseCursor('garbage')).toBeNull();
  });
});
// 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
// apps/api/src/modules/mail/package.json
{
  "name": "@modules/mail",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": { ".": "./index.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';
  }
}
// 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<string, unknown>;
  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;
}
// 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();
}
// 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<string, unknown>;
  /** 密封的 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<string, unknown>;
  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[];
};
// 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<void | { readonly skipped: string }>;

export type MailService = {
  /* ── 账号 ── */
  listAccounts(): Promise<MailAccountView[]>;
  getAccount(accountId: string): Promise<MailAccountView>;
  createAccount(input: MailAccountInput): Promise<MailAccountView>;
  /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
  updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
  /** 留痕不删(入站 / 出站表对账号不设外键) */
  deleteAccount(accountId: string): Promise<void>;
  /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
  probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
  /* ── 收 ── */
  /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
  pollDue(now: Date): Promise<MailPollOutcome[]>;
  /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
  pollNow(accountId: string): Promise<MailPollOutcome>;
  /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
  subscribe(name: string, handler: InboundHandler): () => void;
  listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
  getInbound(mailId: string): Promise<MailInboundDetail>;
  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<MailDeliveryView[]>;
  /* ── 发 ── */
  /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
  send(input: MailSendInput): Promise<MailSendResult>;
  listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
};
// 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<MailAccountRecord[]>;
  get(id: string): Promise<MailAccountRecord | undefined>;
  findByName(name: string): Promise<MailAccountRecord | undefined>;
  /** upsert;`isDefault:true` 时先把别的置 false(同一事务) */
  save(record: MailAccountRecord): Promise<void>;
  remove(id: string): Promise<void>;
  /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
  listDue(now: Date): Promise<MailAccountRecord[]>;
  /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
  claimPolling(id: string, leaseMs: number): Promise<boolean>;
  releasePolling(id: string): Promise<void>;
  /** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */
  savePoll(
    id: string,
    patch: {
      cursor?: MailCursor;
      polledAt?: Date;
      error?: string | null;
      lastInboundAt?: Date;
      address?: string;
    },
  ): Promise<void>;
};

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<Set<string>>;
  list(query: { accountId?: string | undefined; limit: number }): Promise<Array<{ record: InboundRecord; deliveries: DeliveryRecord[] }>>;
  get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>;
  /** upsert on (mail_id, subscriber) */
  setDelivery(delivery: DeliveryRecord): Promise<void>;
};

export type OutboundStore = {
  insert(record: OutboundRecord): Promise<void>;
  byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
  setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
  list(query: { accountId?: string | undefined; limit: number }): Promise<OutboundRecord[]>;
};

/** 附件存放 —— 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<string, unknown>;
  readonly secret: Record<string, string>;
};

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<MailProbeReport>;
  /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
  anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
  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<Set<string>>;
    },
  ): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
  send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>;
};

export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };

/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
export type ChangeNotifier = { changed(reason: string): void };
// 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';
<!-- apps/api/src/modules/mail/CONTRACT.md -->
# 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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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: 写失败测试

// 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: '<m1@acme.com>',
  inReplyTo: '<q1@we.com>',
  references: ['<root@we.com>', '<q1@we.com>'],
  from: { address: 'buyer@acme.com', name: '张三' },
  to: [{ address: 'sales@we.com', name: '' }],
  cc: [],
  subject: '询价 RFQ-1',
  text: '你好,请报价',
  html: '<p>你好,请报价</p>',
  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('<q1@we.com>');
    expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<b>');
    expect(threadIdOf({ references: [] })).toBeUndefined();
  });
  it('缺 Message-ID 用 providerRef@域顶上,不抛', () => {
    expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe('<uid:7:12@we.com>');
    expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
  });
  it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
    expect(stripHtml('<style>p{}</style><p>你好&amp;<b>世界</b></p>\n<script>x()</script>  再见')).toBe('你好&世界 再见');
    expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
    expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
    expect(bodyTextOf({})).toBe('');
  });
  it('预览 200 字', () => {
    expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
    expect(textPreviewOf(null, '<p>来自 html</p>')).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('<m1@acme.com>');
    expect(record.inReplyTo).toBe('<q1@we.com>');
    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('<uid:7:13@we.com>');
    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(['<root@we.com>', '<q1@we.com>']);
  });
});
// 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: 写实现
// 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<ParsedMail, 'inReplyTo' | 'references'>): string | undefined {
  if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo;
  return parsed.references.at(-1);
}

const ENTITIES: Readonly<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };

export function stripHtml(html: string): string {
  return html
    .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
    .replace(/<br\s*\/?>|<\/(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<ParsedMail, 'text' | 'html'>): 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,
  };
}
// 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 追加:

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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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: 写失败测试

// 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>): 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('<id@域>;地址没有域用 local', () => {
    expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
    expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
  });
});
// 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');
  });
});
// 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: 写实现
// 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<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;

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}>`;
}
// 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';
}
// 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<string, unknown>, b: Record<string, unknown>): 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 追加:

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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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

-- 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 末尾追加(注意上一条后面补逗号):

    {
      "idx": 33,
      "version": "7",
      "when": 1785130120181,
      "tag": "0033_mail",
      "breakpoints": true
    }

apps/api/src/app/reset-runs.ts 的 RUN_TABLES 在 'sys.outbound_mail', 之后加:

  // 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
  'sys.mail_inbound',
  'sys.mail_inbound_delivery',
  'sys.mail_outbound',
  • Step 2: 写失败测试(真 PG)
// 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>): 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>): InboundRecord {
  return {
    id: 'in-1', accountId: 'acc-a', messageId: '<m1@x>', 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>): OutboundRecord {
  return {
    id: 'out-1', accountId: 'acc-a', messageId: '<o1@we.com>', 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: '<other@x>' }))).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: '<m2@x>', 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: '<o2@we.com>' }));
    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
// 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<Record<string, unknown>>().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<string[]>().notNull().default([]),
  fromAddr: text('from_addr').notNull().default(''),
  fromName: text('from_name').notNull().default(''),
  toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
  ccList: jsonb('cc_list').$type<MailAddress[]>().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<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
    .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<string[]>().notNull(),
  ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
  bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
  subject: text('subject').notNull(),
  textBody: text('text_body'),
  htmlBody: text('html_body'),
  inReplyTo: text('in_reply_to'),
  referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
  attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
  state: text('state').notNull(),
  error: text('error'),
  providerRef: text('provider_ref'),
  requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  sentAt: timestamp('sent_at', { withTimezone: true }),
});
// 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<typeof mailAccountTable.$inferInsert> = {};
      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<Map<string, DeliveryRecord[]>> {
    const out = new Map<string, DeliveryRecord[]>();
    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 追加:

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
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 <noreply@anthropic.com>\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: 写夹具

// 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> = {}): 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<string, MailAccountRecord> } {
  const rows = new Map<string, MailAccountRecord>();
  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<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
  const rows = new Map<string, InboundRecord>();
  const deliveries = new Map<string, DeliveryRecord>();
  // 与 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<string, OutboundRecord> } {
  const rows = new Map<string, OutboundRecord>();
  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<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
  const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
  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: 写失败测试(真解析器跑夹具)
// 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: '"张三" <buyer@acme.com>',
        to: 'sales@we.com, "Ops" <ops@we.com>',
        subject: '询价 RFQ-1',
        messageId: '<m1@acme.com>',
        inReplyTo: '<q1@we.com>',
        references: '<root@we.com> <q1@we.com>',
        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('<m1@acme.com>');
    expect(parsed.inReplyTo).toBe('<q1@we.com>');
    expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
    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" <c@x.com>;', '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', '', '<b>only html</b>', ''].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: 写解析器
// 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 是空白分隔的一串 <id>;拆开、去空 */
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<ParsedMail> {
      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 追加:

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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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<MailAccountKind, MailProvider>, mime, secrets, clock, ids, notify, limits? }。

  • probe 的第二形是 { input, accountId? }:改已存账号的表单上「测试」不必重打密码 —— 没给 secret 且给了 accountId 就用库里那份密文。

  • Step 1: 写失败测试

// 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 的签名改成:

  probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
// 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<Record<MailAccountKind, MailProvider>>;
  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<string, InboundHandler>();

  async function requireAccount(accountId: string): Promise<MailAccountRecord> {
    const record = await deps.accounts.get(accountId);
    if (record === undefined) throw new MailAccountNotFoundError(accountId);
    return record;
  }

  /** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
  function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
    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<string, unknown>).map(([k, v]) => [k, String(v)]));
  }

  function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
    return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
  }

  function redactWith(secret: Record<string, string>): (text: string) => string {
    const values = Object.values(secret);
    return (text) => redactSecrets(text, values);
  }

  function redactReport(report: MailProbeReport, secret: Record<string, string>): 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<DeliveryRecord[]> {
    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<InboundRecord | undefined> {
    // 先按 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<MailPollOutcome> {
    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<string, string> = {};
    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<string, string>)
            : 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 追加:

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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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: 写测试

// 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: '<m1@acme.com>', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })),
      rawMessage('uid:v1:2', eml({ messageId: '<m2@acme.com>', 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: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } })));
    await h.service.pollNow('acc-1');
    h.imap.queue(
      rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>' })),
      rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
    );
    const again = await h.service.pollNow('acc-1');
    expect(again.pulled).toBe(2);
    expect(again.stored).toBe(0);
    expect(hits).toEqual(['<m1@acme.com>']);
    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: '<x@a>' })));
    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: '<m1@a>', 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: '<m2@a>' })));
    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: '<m1@a>' })));
    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: '<m1@a>', 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> 收成 m1_a)。

  • Step 3: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例测试:拉取 / 去重 / 逐封游标 / 失败停位 / 分发三态 / 重新投递 / 附件原件\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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: 写测试

// 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 是 <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: '<out-1@we.com>', state: 'sent', providerRef: 'fake:1' });
    expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' });
    expect(h.imap.sent[0]?.messageId).toBe('<out-1@we.com>');
    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: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
    expect(result.accountId).toBe('acc-1');
    expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
    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
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例测试:发信留痕 / 厂商失败不抛 / 幂等键 / 附件来源 / from 对账号\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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<true>,sendMail({...}) → {messageId, response},close()。

  • Step 1: 写失败测试(纯映射)

// 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: '<o1@we.com>',
      from: { address: 'sales@we.com', name: '销售部' },
      to: ['b@acme.com'],
      cc: [],
      bcc: ['c@we.com'],
      subject: '报价',
      text: '正文',
      inReplyTo: '<m1@acme.com>',
      references: ['<m1@acme.com>'],
      attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
    });
    expect(options.messageId).toBe('<o1@we.com>');
    expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
    expect(options.bcc).toEqual(['c@we.com']);
    expect(options.inReplyTo).toBe('<m1@acme.com>');
    expect(options.references).toEqual(['<m1@acme.com>']);
    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: 写适配器
// 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<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
  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<void>): 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<MailProbeReport> {
      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<MailCursor> {
      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 追加:

export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
  • Step 4: 写 env 门控的真信箱集成测试
// 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<Set<string>> => new Set<string>();

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: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
      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
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 <noreply@anthropic.com>\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: 写失败测试

// 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<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
  const calls: Call[] = [];
  const counts = new Map<string, number>();
  const fetch: FetchLike = async (url, init) => {
    const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
    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<Set<string>> => new Set<string>();
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: '<x@y>',
      from: { address: 'app@corp.wecom.work', name: '应用' },
      to: ['a@x.com'],
      cc: ['b@x.com'],
      bcc: [],
      subject: '报价',
      text: '纯文本',
      html: '<p>html</p>',
      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: '<p>html</p>',
      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: 写适配器
// 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<string, string>; body?: string; signal?: AbortSignal },
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;

type WecomResponse = Record<string, unknown> & { 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<string, { token: string; expiresAt: number }>();

  async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
    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<string> {
    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<WecomResponse> {
    const settings = wecomSettingsSchema.parse(account.settings);
    const run = (token: string): Promise<WecomResponse> =>
      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<MailProbeReport> {
      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<MailCursor> {
      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 追加:

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
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 <noreply@anthropic.com>\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: 写失败测试

// 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: '<m1@acme.com>',
  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: ['<root@we.com>', '<q1@we.com>'],
  text: null,
  html: '<p>你好</p>',
  size: 100,
  rawAvailable: true,
};

function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
  const calls: MailSendInput[] = [];
  return {
    calls,
    send: async (input) => {
      calls.push(input);
      return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
    },
  };
}

describe('toInboundMail', () => {
  it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => {
    const mail: InboundMail = toInboundMail(detail);
    expect(mail.threadId).toBe('<q1@we.com>');
    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('<m1@acme.com>');
  });
});

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: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
    expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
    expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], 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('<out-1@we.com>');
  });
  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: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
    expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
    expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], 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');
  });
});
// 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
// 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<T>(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<StoredUpload> {
  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'):

  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') });
  });
// 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<unknown> }): 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<MailService, 'send'>): 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<string, unknown>;
    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<MailService, 'send'>): 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;
    }
  };
}
// 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<Container, 'mail'>; readonly now?: () => Date };

export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
  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<void> => {
    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 其余三个);加
    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); 之前加:
    /*
     * 邮件收发(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); 改为
    /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
    const toolRuntime = createToolRuntime(
      new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
      httpToolHandler,
    );
    
  6. channel 装配里 mail: createSimulatedMailTransport(db, ids), 改为 mail: channelTransport(mail),;紧跟 const channel = createChannelService({...}); 之后加:
    // 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 = … 之后加

  // 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
  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
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 <noreply@anthropic.com>\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<AppEnv>, deps: { mail: MailService }): void;mailErrorResponse(error)。

  • Step 1: 写失败测试

// 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<AppEnv>();
  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: '<m1@a>' })));
    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: '<x-1@we.com>' });
    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: '<m1@a>', 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<typeof harness>, id: string): Promise<Response> {
  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: 写路由并注册
// 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<AppEnv>, message: string, detail: unknown): Response {
  const body: ApiError = { code: 'validation-failed', message, detail };
  return c.json(body, 400);
}

async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
  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<AppEnv>, deps: MailRouteDeps): void {
  const { mail } = deps;

  const guarded = async (c: Context<AppEnv>, run: () => Promise<Response>): Promise<Response> => {
    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
git add apps/api/src/app
git commit -m "$(printf '邮件路由 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站 / 附件原件 / 重新投递;领域错误翻状态码,厂商失败在 body 里说\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\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 整段换成:

/**
 * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 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;连接值按键给):

          input: {
            pn: 'LM358DR',
            to: ['x@y.com'],
            subject: '询价',
            body: '样例正文',
            dryRun: true,
            history_days: 30,
            market_stable: true,
            lifecycle: '在产',
          },
      // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;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 用例写法):

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: 刷新契约快照并跑五道门禁
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 配置时)
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
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 <noreply@anthropic.com>\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

# 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
# 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):

{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
  "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
  "settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
{ "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。

{ "kind": "imap-smtp", "address": "sales@corp.com",
  "receive": { "ok": true, "latencyMs": 412 },
  "send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
{ "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

立即拉一轮(不等轮询到点):

{ "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": "正文" } →

{ "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "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)。

{
  "mails": [
    {
      "id": "in-01J…", "accountId": "6e1c…", "messageId": "<CAF…@mail.acme.com>",
      "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= 用 <a href download>(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

{
  "mails": [
    { "id": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "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 同名)

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<ImapSmtpSettings>; 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<string, unknown>; createdAt: string; sentAt: string | null };

6. curl 实录

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 行:

| `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 行:

| `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 末尾追加:

## 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:现状快照末尾加一条(照既有格式):

- **邮件收发模块接上腾讯企业邮箱,两条路**(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 路由。

文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后):

| | `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 有一行。

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 <noreply@anthropic.com>\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 三处同名。