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

7000 lines
320 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 邮件收发模块(`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: 加依赖**
```bash
cd /Users/xcs/project2/tmp/BPOS-MVP
pnpm --filter @app/api add imapflow@^1.7.6 nodemailer@^8.0.11 postal-mime@^3.0.0
pnpm --filter @app/api add -D @types/nodemailer@^8.0.1
node -e "const p=require('./apps/api/package.json');console.log(p.dependencies.imapflow,p.dependencies.nodemailer,p.dependencies['postal-mime'],p.devDependencies['@types/nodemailer'])"
```
Expected: 四个版本号各打印一行(`^1.7.6 ^8.0.11 ^3.0.0 ^8.0.1`)。
- [ ] **Step 2: 登记层级**
`tools/module-tiers.json` 的 `"1"` 数组改为:
```json
"1": [
"event",
"org",
"llm",
"ledger",
"mail"
],
```
`docs/编码规范.md` §4 层级表,在 `| L1 | \`ledger\` |` 那一行之后加:
```markdown
| L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 |
```
- [ ] **Step 3: 重新生成 tier 规则并验门禁**
Run: `pnpm gen:tiers && pnpm depcruise`
Expected: 退出码 0;`git diff --stat tools/depcruise-tiers.generated.cjs` 显示该文件有变更(多了 `tier-order-mail` 规则)。
- [ ] **Step 4: Commit**
```bash
git add apps/api/package.json pnpm-lock.yaml tools/module-tiers.json tools/depcruise-tiers.generated.cjs docs/编码规范.md
git commit -m "$(printf 'mail 模块登记为 L1;批准 imapflow / nodemailer / postal-mime 三个依赖(设计稿 §2)\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// packages/contracts/src/__tests__/mail.test.ts
/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */
import { describe, expect, it } from 'vitest';
import {
mailAccountInputSchema,
mailAccountViewSchema,
mailSendInputSchema,
MAIL_ACCOUNT_KINDS,
} from '../mail.ts';
describe('mailAccountInputSchema', () => {
it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => {
const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' });
expect(parsed.kind).toBe('imap-smtp');
if (parsed.kind !== 'imap-smtp') throw new Error('unreachable');
expect(parsed.settings).toEqual({
imapHost: 'imap.exmail.qq.com',
imapPort: 993,
imapTls: true,
smtpHost: 'smtp.exmail.qq.com',
smtpPort: 465,
smtpTls: true,
folder: 'INBOX',
});
expect(parsed.enabled).toBe(true);
expect(parsed.isDefault).toBe(false);
expect(parsed.pollIntervalSec).toBe(30);
expect(parsed.channelId).toBeNull();
expect(parsed.backfillFrom).toBeNull();
expect(parsed.secret).toBeUndefined();
});
it('wecom:corpId 必填,地址不由人填(探测回填)', () => {
const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } });
if (parsed.kind !== 'wecom') throw new Error('unreachable');
expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com');
expect('address' in parsed).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false);
});
it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => {
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false);
expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']);
});
});
describe('mailAccountViewSchema', () => {
it('视图只有 secretBound,没有任何密文 / 密码字段', () => {
const keys = Object.keys(mailAccountViewSchema.options[0]!.shape);
expect(keys).toContain('secretBound');
expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false);
});
});
describe('mailSendInputSchema', () => {
it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => {
const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } });
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.attachments).toEqual([]);
expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false);
expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false);
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts`
Expected: FAIL(`Cannot find module '../mail.ts'`)。
- [ ] **Step 3: 写 schema**
```ts
// packages/contracts/src/mail.ts
/**
* 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。
*
* 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。
* 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱),
* 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。
*/
import { z } from 'zod';
import { channelIdSchema } from './id.ts';
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS);
export type MailAccountKind = z.infer<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';` 之后加一行:
```ts
export * from './mail.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts && pnpm typecheck`
Expected: 4 tests PASS;typecheck 退出码 0。
- [ ] **Step 5: Commit**
```bash
git add packages/contracts/src/mail.ts packages/contracts/src/index.ts packages/contracts/src/__tests__/mail.test.ts
git commit -m "$(printf '邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/app/__tests__/mail-routes-registry.test.ts
/**
* 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞),
* 读标两端、写归运营中心(设计稿 §7.2)。
*/
import { describe, expect, it } from 'vitest';
import * as contracts from '@contracts';
import { MAIL_ROUTES } from '@contracts';
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
describe('邮件路由契约', () => {
it('15 条全在契约命名空间里,注册表能建起来', () => {
const all = routesOf(Object.values(contracts));
const registry = buildRouteRegistry(all);
expect(MAIL_ROUTES).toHaveLength(15);
for (const route of MAIL_ROUTES) {
expect(registry.has(route.method, route.path)).toBe(true);
}
});
it('读标两端,写归 ops', () => {
for (const route of MAIL_ROUTES) {
if (route.method === 'GET') expect(route.access).toEqual(['design', 'ops']);
else expect(route.access).toBe('ops');
}
});
it('未存草稿探测与已存账号探测是两条不同的路', () => {
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops');
expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops');
expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toEqual(['design', 'ops']);
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts`
Expected: FAIL(`MAIL_ROUTES` 不存在)。
- [ ] **Step 3: 写路由契约**
```ts
// packages/contracts/src/api/mail.ts
/**
* 邮件收发的路由契约(设计稿 §7.2;ADR-013)。
*
* 读标两端(设计中心要看通道对上了哪个信箱,运营中心管配置);写归运营中心。
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
* 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
import {
mailAccountInputSchema,
mailAccountViewSchema,
mailDeliveryViewSchema,
mailInboundDetailSchema,
mailInboundSummarySchema,
mailOutboundViewSchema,
mailPollOutcomeSchema,
mailProbeReportSchema,
mailSendResultSchema,
} from '../mail.ts';
const accountParams = z.object({ accountId: z.string().min(1) });
const mailParams = z.object({ mailId: z.string().min(1) });
const listQuery = z.object({
accountId: z.string().optional(),
/** 默认 50,上限 200 */
limit: z.coerce.number().int().min(1).max(200).optional(),
});
export const listMailAccountsRoute = {
method: 'GET',
path: '/api/gov/mail/accounts',
access: ['design', 'ops'],
output: z.object({ accounts: z.array(mailAccountViewSchema) }),
} as const satisfies RouteContract;
export const createMailAccountRoute = {
method: 'POST',
path: '/api/gov/mail/accounts',
access: 'ops',
body: mailAccountInputSchema,
output: mailAccountViewSchema,
} as const satisfies RouteContract;
/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */
export const probeMailDraftRoute = {
method: 'POST',
path: '/api/gov/mail/accounts/probe',
access: 'ops',
/** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */
query: z.object({ accountId: z.string().optional() }),
body: mailAccountInputSchema,
output: mailProbeReportSchema,
} as const satisfies RouteContract;
export const getMailAccountRoute = {
method: 'GET',
path: '/api/gov/mail/accounts/:accountId',
access: ['design', 'ops'],
params: accountParams,
output: mailAccountViewSchema,
} as const satisfies RouteContract;
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
export const updateMailAccountRoute = {
method: 'PUT',
path: '/api/gov/mail/accounts/:accountId',
access: 'ops',
params: accountParams,
body: mailAccountInputSchema,
output: mailAccountViewSchema,
} as const satisfies RouteContract;
export const deleteMailAccountRoute = {
method: 'DELETE',
path: '/api/gov/mail/accounts/:accountId',
access: 'ops',
params: accountParams,
output: z.object({ ok: z.literal(true) }),
} as const satisfies RouteContract;
/** 已存账号探测(用库里凭证);凭证空 412 */
export const probeMailAccountRoute = {
method: 'POST',
path: '/api/gov/mail/accounts/:accountId/probe',
access: 'ops',
params: accountParams,
output: mailProbeReportSchema,
} as const satisfies RouteContract;
/** 立即拉一轮;另一进程占着租约 409;停用 412 */
export const pollMailAccountRoute = {
method: 'POST',
path: '/api/gov/mail/accounts/:accountId/poll',
access: 'ops',
params: accountParams,
output: mailPollOutcomeSchema,
} as const satisfies RouteContract;
export const sendTestMailRoute = {
method: 'POST',
path: '/api/gov/mail/accounts/:accountId/send-test',
access: 'ops',
params: accountParams,
body: z.object({ to: z.email(), subject: z.string().min(1), text: z.string().min(1) }),
output: mailSendResultSchema,
} as const satisfies RouteContract;
export const listMailInboundRoute = {
method: 'GET',
path: '/api/gov/mail/inbound',
access: ['design', 'ops'],
query: listQuery,
output: z.object({ mails: z.array(mailInboundSummarySchema) }),
} as const satisfies RouteContract;
export const getMailInboundRoute = {
method: 'GET',
path: '/api/gov/mail/inbound/:mailId',
access: ['design', 'ops'],
params: mailParams,
output: mailInboundDetailSchema,
} as const satisfies RouteContract;
/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */
export const mailAttachmentRoute = {
method: 'GET',
path: '/api/gov/mail/inbound/:mailId/attachments/:index',
access: ['design', 'ops'],
params: z.object({ mailId: z.string().min(1), index: z.coerce.number().int().min(0) }),
output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }),
} as const satisfies RouteContract;
/** 原件 .eml;超限没存 404 */
export const mailRawRoute = {
method: 'GET',
path: '/api/gov/mail/inbound/:mailId/raw',
access: ['design', 'ops'],
params: mailParams,
output: z.object({ name: z.string(), contentType: z.literal('message/rfc822'), dataBase64: z.string() }),
} as const satisfies RouteContract;
/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */
export const redeliverMailInboundRoute = {
method: 'POST',
path: '/api/gov/mail/inbound/:mailId/redeliver',
access: 'ops',
params: mailParams,
body: z.object({ subscriber: z.string().min(1).optional() }).default({}),
output: z.object({ deliveries: z.array(mailDeliveryViewSchema) }),
} as const satisfies RouteContract;
export const listMailOutboundRoute = {
method: 'GET',
path: '/api/gov/mail/outbound',
access: ['design', 'ops'],
query: listQuery,
output: z.object({ mails: z.array(mailOutboundViewSchema) }),
} as const satisfies RouteContract;
/** 全部 15 条 —— 文档总表与覆盖测试按它数 */
export const MAIL_ROUTES: readonly RouteContract[] = [
listMailAccountsRoute,
createMailAccountRoute,
probeMailDraftRoute,
getMailAccountRoute,
updateMailAccountRoute,
deleteMailAccountRoute,
probeMailAccountRoute,
pollMailAccountRoute,
sendTestMailRoute,
listMailInboundRoute,
getMailInboundRoute,
mailAttachmentRoute,
mailRawRoute,
redeliverMailInboundRoute,
listMailOutboundRoute,
];
```
`packages/contracts/src/api/index.ts` 末尾加:
```ts
export * from './mail.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts && pnpm typecheck`
Expected: 3 tests PASS;typecheck 退出码 0。若 `buildRouteRegistry` 抛「路由撞车」,说明某条字面路径与参数模版同方法撞了 —— 按抛出的两条路径改 path,不改注册表。
- [ ] **Step 5: Commit**
```bash
git add packages/contracts/src/api/mail.ts packages/contracts/src/api/index.ts apps/api/src/app/__tests__/mail-routes-registry.test.ts
git commit -m "$(printf '邮件路由契约 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站列表 / 附件与原件 / 重新投递\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试(游标与到期)**
```ts
// apps/api/src/modules/mail/__tests__/cursor.test.ts
/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */
import { describe, expect, it } from 'vitest';
import {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
WECOM_OVERLAP_SEC,
} from '../internal/domain/cursor.ts';
describe('imap 游标', () => {
it('逐封推进只往前不往后', () => {
const c = imapCursor('7', 100);
expect(advanceImap(c, 105).lastUid).toBe(105);
expect(advanceImap(c, 90).lastUid).toBe(100);
});
it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => {
expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false);
expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true);
expect(imapNeedsReanchor(null, '7')).toBe(true);
expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true);
});
it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => {
expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]);
expect(newUids([100], 100)).toEqual([]);
});
});
describe('wecom 游标', () => {
const now = new Date('2026-08-25T10:00:00Z');
it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => {
const w = wecomWindow(wecomCursor(1_000_000), null, now);
expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) });
});
it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => {
const end = Math.floor(now.getTime() / 1000);
expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end });
const since = new Date('2026-08-20T00:00:00Z');
expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000));
});
it('lastTime 往前退不会退成负数', () => {
expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0);
});
});
describe('摘要与解析', () => {
it('人读摘要;没拉过是 null', () => {
expect(cursorSummary(null)).toBeNull();
expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7');
expect(cursorSummary(wecomCursor(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();
});
});
```
```ts
// apps/api/src/modules/mail/__tests__/due.test.ts
/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */
import { describe, expect, it } from 'vitest';
import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts';
const now = new Date('2026-08-25T10:00:00Z');
const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null };
describe('isDue', () => {
it('从没拉过 → 到期;停用 → 永不到期', () => {
expect(isDue(base, now)).toBe(true);
expect(isDue({ ...base, enabled: false }, now)).toBe(false);
});
it('上次 + 间隔 <= 现在才到期', () => {
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false);
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true);
});
it('租约还没过期 → 别人在拉,不到期;过期了照常', () => {
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false);
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true);
});
});
describe('leaseMsFor / isPolling', () => {
it('租约 = max(5 分钟, 3 × 间隔)', () => {
expect(leaseMsFor(30)).toBe(MIN_LEASE_MS);
expect(leaseMsFor(600)).toBe(1_800_000);
});
it('polling 只在租约未过期时为真', () => {
expect(isPolling(null, now)).toBe(false);
expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true);
expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false);
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/cursor.test.ts apps/api/src/modules/mail/__tests__/due.test.ts`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 写骨架与 domain**
```json
// apps/api/src/modules/mail/package.json
{
"name": "@modules/mail",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
```
```ts
// apps/api/src/modules/mail/internal/domain/errors.ts
/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */
export class MailAccountNotFoundError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号不存在:${accountId}`);
this.name = 'MailAccountNotFoundError';
}
}
export class MailAccountNameTakenError extends Error {
constructor(readonly accountName: string) {
super(`邮箱账号名已被占用:${accountName}`);
this.name = 'MailAccountNameTakenError';
}
}
export class MailAccountDisabledError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号已停用:${accountId}`);
this.name = 'MailAccountDisabledError';
}
}
export class MailNoDefaultAccountError extends Error {
constructor() {
super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认');
this.name = 'MailNoDefaultAccountError';
}
}
export class MailSecretMissingError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`);
this.name = 'MailSecretMissingError';
}
}
export class MailPollBusyError extends Error {
constructor(readonly accountId: string) {
super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`);
this.name = 'MailPollBusyError';
}
}
export class MailInboundNotFoundError extends Error {
constructor(readonly mailId: string) {
super(`这封信不在了:${mailId}`);
this.name = 'MailInboundNotFoundError';
}
}
export class MailAttachmentNotFoundError extends Error {
constructor(readonly mailId: string, readonly index: number) {
super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`);
this.name = 'MailAttachmentNotFoundError';
}
}
export class MailSubscriberUnknownError extends Error {
constructor(readonly subscriber: string) {
super(`没有这个订阅方:${subscriber}`);
this.name = 'MailSubscriberUnknownError';
}
}
export class MailSubscriberTakenError extends Error {
constructor(readonly subscriber: string) {
super(`订阅方已注册,不许重名:${subscriber}`);
this.name = 'MailSubscriberTakenError';
}
}
export class MailSendInvalidError extends Error {
constructor(readonly problems: readonly string[]) {
super(`发信入参不合法:${problems.join('; ')}`);
this.name = 'MailSendInvalidError';
}
}
```
```ts
// apps/api/src/modules/mail/internal/domain/cursor.ts
/**
* 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。
* IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。
*/
export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number };
export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number };
export type MailCursor = ImapCursor | WecomCursor;
/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */
export const WECOM_OVERLAP_SEC = 600;
export function imapCursor(uidValidity: string, lastUid: number): ImapCursor {
return { kind: 'imap', uidValidity, lastUid };
}
export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor {
return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor;
}
export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean {
return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity;
}
/**
* IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)——
* 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
*/
export function newUids(uids: readonly number[], lastUid: number): number[] {
return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b);
}
export function wecomCursor(lastTime: number): WecomCursor {
return { kind: 'wecom', lastTime };
}
export function wecomWindow(
cursor: MailCursor | null,
backfillFrom: Date | null,
now: Date,
): { beginTime: number; endTime: number } {
const endTime = Math.floor(now.getTime() / 1000);
if (cursor !== null && cursor.kind === 'wecom') {
return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime };
}
if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime };
// 无游标不回填:窗口空,这一轮只是把游标锚在当下
return { beginTime: endTime, endTime };
}
export function cursorSummary(cursor: MailCursor | null): string | null {
if (cursor === null) return null;
if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`;
return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`;
}
/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */
export function parseCursor(raw: unknown): MailCursor | null {
if (typeof raw !== 'object' || raw === null) return null;
const c = raw as Record<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;
}
```
```ts
// apps/api/src/modules/mail/internal/domain/due.ts
/** 到期判定与租约时长(设计稿 §9.1) */
export const MIN_LEASE_MS = 5 * 60_000;
/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */
export function leaseMsFor(pollIntervalSec: number): number {
return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000);
}
export function isPolling(pollingUntil: Date | null, now: Date): boolean {
return pollingUntil !== null && pollingUntil.getTime() > now.getTime();
}
export function isDue(
account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null },
now: Date,
): boolean {
if (!account.enabled) return false;
if (isPolling(account.pollingUntil, now)) return false;
if (account.lastPolledAt === null) return true;
return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime();
}
```
```ts
// apps/api/src/modules/mail/internal/domain/records.ts
/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */
import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts';
import type { MailCursor } from './cursor.ts';
export type MailAccountRecord = {
readonly id: string;
readonly kind: MailAccountKind;
readonly name: string;
readonly address: string;
readonly displayName: string;
readonly enabled: boolean;
readonly isDefault: boolean;
readonly pollIntervalSec: number;
readonly channelId: string | null;
readonly backfillFrom: Date | null;
/** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */
readonly settings: Record<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[];
};
```
```ts
// apps/api/src/modules/mail/contract.ts
/**
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
*/
import type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
} from '@contracts';
export type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
};
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './internal/domain/errors.ts';
/**
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
*/
export type InboundHandler = (
mail: MailInboundDetail,
ctx: { readonly accountId: string; readonly channelId: string | undefined },
) => Promise<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[]>;
};
```
```ts
// apps/api/src/modules/mail/port.ts
/**
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import type { MailCursor } from './internal/domain/cursor.ts';
import type {
DeliveryRecord,
InboundRecord,
MailAccountRecord,
OutboundMessage,
OutboundRecord,
ParsedMail,
} from './internal/domain/records.ts';
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type MailAccountStore = {
list(): Promise<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 };
```
```ts
// apps/api/src/modules/mail/index.ts
/**
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
InboundHandler,
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
MailService,
} from './contract.ts';
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './contract.ts';
export type {
AttachmentStore,
ChangeNotifier,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
MimeParser,
OutboundMessage,
OutboundRecord,
OutboundStore,
ParsedMail,
ProviderAccount,
RawMessage,
SecretBox,
} from './port.ts';
export {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
} from './internal/domain/cursor.ts';
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
```
```markdown
<!-- 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**
```bash
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: 写失败测试**
```ts
// apps/api/src/modules/mail/__tests__/inbound-record.test.ts
/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */
import { describe, expect, it } from 'vitest';
import type { ParsedMail } from '../internal/domain/records.ts';
import {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
fallbackMessageId,
planAttachments,
rawOf,
stripHtml,
summaryOf,
textPreviewOf,
threadIdOf,
} from '../internal/domain/inbound-record.ts';
const parsed: ParsedMail = {
messageId: '<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>']);
});
});
```
```ts
// apps/api/src/modules/mail/__tests__/redact.test.ts
/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */
import { describe, expect, it } from 'vitest';
import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts';
describe('redactSecrets', () => {
it('三形都换成 ***', () => {
const secret = 'p@ss w0rd';
const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`;
expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***');
});
it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => {
expect(secretVariants('')).toEqual([]);
expect(secretVariants('abc')).toEqual([]);
expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc');
});
it('没命中原样返回;正则元字符当字面', () => {
expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here');
expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***');
});
});
describe('errorText', () => {
it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => {
expect(errorText(new Error('boom'))).toBe('boom');
const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' });
expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed');
expect(errorText('raw')).toBe('raw');
expect(errorText(undefined)).toBe('未知错误');
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/inbound-record.test.ts apps/api/src/modules/mail/__tests__/redact.test.ts`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 写实现**
```ts
// apps/api/src/modules/mail/internal/domain/inbound-record.ts
/**
* 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做,
* 存完把 ref 交回来再 `buildInboundRecord`。
*/
import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts';
/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
export const RAW_CAP = 16 * 1024 * 1024;
export const PREVIEW_CHARS = 200;
function domainOf(address: string): string {
const at = address.lastIndexOf('@');
return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
}
/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */
export function fallbackMessageId(providerRef: string, address: string): string {
return `<${providerRef}@${domainOf(address)}>`;
}
/** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */
export function threadIdOf(parsed: Pick<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,
};
}
```
```ts
// apps/api/src/modules/mail/internal/domain/redact.ts
/**
* 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。
* 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。
*/
const MIN_SECRET_CHARS = 4;
export function secretVariants(secret: string): string[] {
if (secret.length < MIN_SECRET_CHARS) return [];
const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)];
return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function redactSecrets(text: string, secrets: readonly string[]): string {
let out = text;
for (const secret of secrets) {
for (const variant of secretVariants(secret)) {
out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***');
}
}
return out;
}
/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */
export function errorText(error: unknown): string {
if (error instanceof Error) {
const bag = error as Error & { response?: unknown; responseText?: unknown };
const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : '';
return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`;
}
if (error === undefined || error === null) return '未知错误';
return String(error);
}
```
`index.ts` 追加:
```ts
export {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
planAttachments,
RAW_CAP,
stripHtml,
summaryOf,
threadIdOf,
} from './internal/domain/inbound-record.ts';
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm lint`
Expected: 全部 PASS;lint 退出码 0(domain 里 `Buffer` 是全局对象不是 import,不触 `domain-is-pure-core`)。
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/modules/mail/__tests__/send-input.test.ts
/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */
import { describe, expect, it } from 'vitest';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts';
import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts';
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): 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>');
});
});
```
```ts
// apps/api/src/modules/mail/__tests__/delivery.test.ts
/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */
import { describe, expect, it } from 'vitest';
import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts';
describe('delivery', () => {
it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => {
expect(canRedeliver(undefined)).toBe(true);
expect(canRedeliver('done')).toBe(true);
expect(canRedeliver('failed')).toBe(true);
expect(canRedeliver('skipped')).toBe(true);
expect(canRedeliver('dispatching')).toBe(false);
});
it('回调的返回值定状态', () => {
expect(stateOfOutcome(undefined)).toBe('done');
expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped');
});
});
```
```ts
// apps/api/src/modules/mail/__tests__/account-view.test.ts
/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts';
const now = new Date('2026-08-25T10:00:00Z');
const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } });
describe('mergeAccount / secretOf', () => {
it('新建:id / 时间 / 密文来自 base,游标空', () => {
const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now });
expect(record.id).toBe('acc-1');
expect(record.address).toBe('sales@we.com');
expect(record.secretCipher).toBe('sealed');
expect(record.cursor).toBeNull();
expect(record.createdAt).toBe(now);
expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' }));
expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined();
});
it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => {
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe('');
const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now });
const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' };
expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work');
});
it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => {
const existing: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
cursor: imapCursor('7', 9),
lastPollError: '旧错误',
};
expect(cursorReset(existing, input)).toBe(false);
const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now });
expect(same.cursor).toEqual(imapCursor('7', 9));
const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } });
expect(cursorReset(existing, changed)).toBe(true);
const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now });
expect(reset.cursor).toBeNull();
expect(reset.lastPollError).toBeNull();
});
});
describe('viewOf', () => {
it('不带密文只带 secretBound;status 带游标摘要与 polling', () => {
const record: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }),
cursor: imapCursor('7', 9),
pollingUntil: new Date(now.getTime() + 1000),
};
const view = viewOf(record, now);
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('sealed');
expect(view.status.cursor).toBe('UID 9 · validity 7');
expect(view.status.polling).toBe(true);
expect(view.kind).toBe('imap-smtp');
if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com');
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/send-input.test.ts apps/api/src/modules/mail/__tests__/delivery.test.ts apps/api/src/modules/mail/__tests__/account-view.test.ts`
Expected: FAIL(模块不存在)。
- [ ] **Step 3: 写实现**
```ts
// apps/api/src/modules/mail/internal/domain/send-input.ts
/** 发信校验与选账号(设计稿 §5.3 / §5.4) */
import type { MailAccountKind, MailSendInput } from '@contracts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts';
import type { MailAccountRecord } from './records.ts';
export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024;
export const SEND_TOTAL_CAP = 20 * 1024 * 1024;
/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */
export const WECOM_TOTAL_CAP = 50 * 1024 * 1024;
export const WECOM_ATTACHMENT_COUNT_CAP = 200;
type SendShape = Pick<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}>`;
}
```
```ts
// apps/api/src/modules/mail/internal/domain/delivery.ts
/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */
import type { MailDeliveryState } from '@contracts';
export function canRedeliver(current: MailDeliveryState | undefined): boolean {
return current !== 'dispatching';
}
export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' {
return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done';
}
```
```ts
// apps/api/src/modules/mail/internal/domain/account-view.ts
/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */
import type { MailAccountInput, MailAccountView } from '@contracts';
import { mailAccountViewSchema } from '@contracts';
import { cursorSummary } from './cursor.ts';
import { isPolling } from './due.ts';
import type { MailAccountRecord } from './records.ts';
export function viewOf(record: MailAccountRecord, now: Date): MailAccountView {
return mailAccountViewSchema.parse({
id: record.id,
kind: record.kind,
name: record.name,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(),
settings: record.settings,
secretBound: record.secretCipher !== null && record.secretCipher !== '',
status: {
lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(),
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(),
cursor: cursorSummary(record.cursor),
polling: isPolling(record.pollingUntil, now),
},
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
});
}
/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */
export function secretOf(input: MailAccountInput): string | undefined {
return input.secret === undefined ? undefined : JSON.stringify(input.secret);
}
function sameSettings(a: Record<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` 追加:
```ts
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise`
Expected: 全部 PASS;三道门禁退出码 0。
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 <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**
```sql
-- db/migrations/0033_mail.sql
-- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。
--
-- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉:
-- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与
-- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。
-- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。
-- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。
create table if not exists sys.mail_account (
id text primary key,
name text not null,
kind text not null check (kind in ('imap-smtp', 'wecom')),
address text not null default '',
display_name text not null default '',
enabled boolean not null default true,
is_default boolean not null default false,
poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600),
channel_id text,
backfill_from timestamptz,
settings jsonb not null default '{}'::jsonb,
secret_cipher text,
cursor jsonb,
last_polled_at timestamptz,
last_poll_error text,
last_inbound_at timestamptz,
polling_until timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists mail_account_name_key on sys.mail_account (name);
-- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底
create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default;
create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
create table if not exists sys.mail_inbound (
id text primary key,
account_id text not null,
message_id text not null,
provider_ref text not null,
in_reply_to text,
reference_ids jsonb not null default '[]'::jsonb,
from_addr text not null default '',
from_name text not null default '',
to_list jsonb not null default '[]'::jsonb,
cc_list jsonb not null default '[]'::jsonb,
subject text not null default '',
text_body text,
html_body text,
sent_at timestamptz,
received_at timestamptz not null,
attachments jsonb not null default '[]'::jsonb,
raw bytea,
raw_note text not null default '',
size integer not null default 0,
created_at timestamptz not null default now()
);
create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc);
create table if not exists sys.mail_inbound_delivery (
mail_id text not null references sys.mail_inbound(id) on delete cascade,
subscriber text not null,
state text not null check (state in ('dispatching', 'done', 'failed', 'skipped')),
error text,
started_at timestamptz not null default now(),
finished_at timestamptz,
primary key (mail_id, subscriber)
);
create table if not exists sys.mail_outbound (
id text primary key,
account_id text not null,
message_id text not null,
idempotency_key text,
to_list jsonb not null,
cc_list jsonb not null default '[]'::jsonb,
bcc_list jsonb not null default '[]'::jsonb,
subject text not null,
text_body text,
html_body text,
in_reply_to text,
reference_ids jsonb not null default '[]'::jsonb,
attachments jsonb not null default '[]'::jsonb,
state text not null check (state in ('sending', 'sent', 'failed')),
error text,
provider_ref text,
requested_by jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
sent_at timestamptz
);
create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc);
```
`db/migrations/meta/_journal.json` 的 `entries` 末尾追加(注意上一条后面补逗号):
```json
{
"idx": 33,
"version": "7",
"when": 1785130120181,
"tag": "0033_mail",
"breakpoints": true
}
```
`apps/api/src/app/reset-runs.ts` 的 `RUN_TABLES` 在 `'sys.outbound_mail',` 之后加:
```ts
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
'sys.mail_inbound',
'sys.mail_inbound_delivery',
'sys.mail_outbound',
```
- [ ] **Step 2: 写失败测试(真 PG)**
```ts
// apps/api/src/modules/mail/__tests__/mail-store.it.test.ts
/**
* mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、
* 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。
*/
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { startItDatabase } from '@testing';
import type { ItDatabase } from '@testing';
import type { Db } from '@shared/db.ts';
import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts';
const database: ItDatabase | undefined = await startItDatabase();
let poolA: Pool | undefined;
let poolB: Pool | undefined;
let db: Db;
let dbB: Db;
const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url);
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): 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**
```ts
// apps/api/src/modules/mail/internal/adapter/mail-schema.ts
/**
* mail 的 sys schema 表(编码规范 §7 / ADR-002)。
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。
*/
import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import type { MailAddress } from '@contracts';
import { sys } from '@shared/db.ts';
/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
dataType() {
return 'bytea';
},
});
export const mailAccountTable = sys.table('mail_account', {
id: text('id').primaryKey(),
name: text('name').notNull(),
kind: text('kind').notNull(),
address: text('address').notNull().default(''),
displayName: text('display_name').notNull().default(''),
enabled: boolean('enabled').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false),
pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
channelId: text('channel_id'),
backfillFrom: timestamp('backfill_from', { withTimezone: true }),
settings: jsonb('settings').$type<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 }),
});
```
```ts
// apps/api/src/modules/mail/internal/adapter/mail-store.ts
/**
* mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
* 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type {
DeliveryRecord,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
OutboundRecord,
OutboundStore,
} from '../../port.ts';
import { parseCursor } from '../domain/cursor.ts';
import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts';
function rowsOf(result: unknown): unknown[] {
if (Array.isArray(result)) return result;
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
/* ── 账号 ─────────────────────────────────────────────────────────────── */
type AccountRow = typeof mailAccountTable.$inferSelect;
function toAccount(row: AccountRow): MailAccountRecord {
return {
id: row.id,
kind: mailAccountKindSchema.parse(row.kind),
name: row.name,
address: row.address,
displayName: row.displayName,
enabled: row.enabled,
isDefault: row.isDefault,
pollIntervalSec: row.pollIntervalSec,
channelId: row.channelId,
backfillFrom: row.backfillFrom,
settings: row.settings,
secretCipher: row.secretCipher,
cursor: parseCursor(row.cursor),
lastPolledAt: row.lastPolledAt,
lastPollError: row.lastPollError,
lastInboundAt: row.lastInboundAt,
pollingUntil: row.pollingUntil,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert {
return {
id: record.id,
name: record.name,
kind: record.kind,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom,
settings: record.settings,
secretCipher: record.secretCipher,
cursor: record.cursor,
lastPolledAt: record.lastPolledAt,
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt,
pollingUntil: record.pollingUntil,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
export function createMailAccountStore(db: Db): MailAccountStore {
return {
async list() {
const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async get(id) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async findByName(name) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async save(record) {
const values = accountValues(record);
await db.transaction(async (tx) => {
// 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底
if (record.isDefault) {
await tx
.update(mailAccountTable)
.set({ isDefault: false })
.where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`));
}
await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values });
});
},
async remove(id) {
await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id));
},
async listDue(now) {
const rows = await db
.select()
.from(mailAccountTable)
.where(
sql`${mailAccountTable.enabled}
and (${mailAccountTable.lastPolledAt} is null
or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now})
and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`,
)
.orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async claimPolling(id, leaseMs) {
const result = await db.execute(sql`
update sys.mail_account
set polling_until = now() + (${leaseMs} || ' milliseconds')::interval
where id = ${id} and enabled
and (polling_until is null or polling_until < now())
returning id`);
return rowsOf(result).length > 0;
},
async releasePolling(id) {
// 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉
await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined);
},
async savePoll(id, patch) {
const set: Partial<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` 追加:
```ts
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
```
- [ ] **Step 5: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
Expected: 7 tests PASS(套件没跳过 —— 输出里不能有「集成测试跳过」);三道门禁退出码 0。
另跑一次 `pnpm vitest run apps/api/src/app/__tests__/route-coverage.it.test.ts`:它对 `jcai_it_routes` 库跑全部迁移,0033 能应用即绿。
- [ ] **Step 6: Commit**
```bash
git add db/migrations/0033_mail.sql db/migrations/meta/_journal.json apps/api/src/modules/mail apps/api/src/app/reset-runs.ts
git commit -m "$(printf '迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)\n\nCo-Authored-By: Claude Fable 5 <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: 写夹具**
```ts
// apps/api/src/modules/mail/__tests__/fakes.ts
/**
* 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。
* 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import { isDue } from '../internal/domain/due.ts';
import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts';
import type {
AttachmentStore,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
OutboundMessage,
OutboundRecord,
OutboundStore,
RawMessage,
SecretBox,
} from '../port.ts';
const CRLF = '\r\n';
export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } {
let t = new Date(iso).getTime();
return {
now: () => new Date(t),
tick(ms) {
t += ms;
},
};
}
export function seqIds(prefix = 'id'): IdGenerator {
let n = 0;
return {
next: () => {
n += 1;
return `${prefix}-${n}`;
},
};
}
export const plainSecrets: SecretBox = {
seal: (s) => `sealed:${s}`,
open: (c) => {
if (!c.startsWith('sealed:')) throw new Error('密文格式非法');
return c.slice('sealed:'.length);
},
};
export function account(over: Partial<MailAccountRecord> = {}): 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: 写失败测试(真解析器跑夹具)**
```ts
// apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts
/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */
import { describe, expect, it } from 'vitest';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { eml } from './fakes.ts';
const parser = createPostalMimeParser();
const CRLF = '\r\n';
describe('createPostalMimeParser', () => {
it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => {
const parsed = await parser.parse(
eml({
from: '"张三" <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: 写解析器**
```ts
// apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts
/**
* MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。
* 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。
*/
import PostalMime from 'postal-mime';
import type { Address } from 'postal-mime';
import type { MailAddress } from '@contracts';
import type { MimeParser, ParsedMail } from '../../port.ts';
function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] {
const out: MailAddress[] = [];
for (const entry of list ?? []) {
if (entry.group !== undefined) {
for (const member of entry.group) out.push({ address: member.address, name: member.name });
} else if (entry.address !== undefined) {
out.push({ address: entry.address, name: entry.name });
}
}
return out;
}
/** References / In-Reply-To 是空白分隔的一串 <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` 追加:
```ts
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
```
- [ ] **Step 5: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts && pnpm typecheck && pnpm lint`
Expected: 5 tests PASS;门禁退出码 0。若 `Address` 类型的 `group` 判别在本版 d.ts 里报错,以 `node_modules/postal-mime/postal-mime.d.ts` 的 `Address` 定义为准改判别(本计划核对的是 3.0.0:`Mailbox = {name, address, group?: undefined}`,群组形 `{name, address?: undefined, group: Mailbox[]}`)。
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts
/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('acc'),
notify: { changed: (r) => void changes.push(r) },
});
return { service, accounts, imap, wecom, clock, changes };
}
const imapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } });
describe('账号', () => {
it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => {
const { service, accounts } = harness();
const view = await service.createAccount(imapInput);
expect(view.id).toBe('acc-1');
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('pw-1234');
expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`);
expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']);
});
it('名字唯一:重名 409;改名撞别人也 409', async () => {
const { service } = harness();
await service.createAccount(imapInput);
await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError);
const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' });
await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError);
});
it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => {
const { service, accounts } = harness();
const created = await service.createAccount(imapInput);
await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234');
await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9');
const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } });
const switched = await service.updateAccount(created.id, wecomInput);
expect(switched.secretBound).toBe(false);
expect(switched.address).toBe('');
});
it('默认账号只有一个;删了就没了(404)', async () => {
const { service } = harness();
const a = await service.createAccount(imapInput);
const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true });
expect((await service.getAccount(a.id)).isDefault).toBe(false);
expect((await service.getAccount(b.id)).isDefault).toBe(true);
await service.deleteAccount(a.id);
await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
});
it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => {
const { service, wecom, imap } = harness();
imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } });
const a = await service.createAccount(imapInput);
const report = await service.probe({ accountId: a.id });
expect(report.receive.error).toBe('LOGIN sales@we.com *** failed');
const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } }));
wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } });
await service.probe({ accountId: w.id });
expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work');
});
it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => {
const { service, accounts } = harness();
const report = await service.probe({ input: imapInput });
expect(report.receive.ok).toBe(true);
expect(accounts.rows.size).toBe(0);
await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError);
const a = await service.createAccount(imapInput);
const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id });
expect(borrowed.receive.ok).toBe(true);
});
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts`
Expected: FAIL(`mail-service.ts` 不存在)。
- [ ] **Step 3: 写用例(整份)**
先把 `contract.ts` 里 `probe` 的签名改成:
```ts
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
```
```ts
// apps/api/src/modules/mail/internal/usecase/mail-service.ts
/**
* mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。
*
* 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重)
* → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。
* 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。
*/
import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts';
import type { InboundHandler, MailService } from '../../contract.ts';
import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts';
import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts';
import { leaseMsFor } from '../domain/due.ts';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from '../domain/errors.ts';
import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts';
import { errorText, redactSecrets } from '../domain/redact.ts';
import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts';
import type {
AttachmentStore,
ChangeNotifier,
Clock,
IdGenerator,
InboundStore,
MailAccountStore,
MailProvider,
MimeParser,
OutboundStore,
ProviderAccount,
RawMessage,
SecretBox,
} from '../../port.ts';
export type MailServiceDeps = {
readonly accounts: MailAccountStore;
readonly inbound: InboundStore;
readonly outbound: OutboundStore;
readonly attachments: AttachmentStore;
readonly providers: Readonly<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` 追加:
```ts
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
export { createMailService } from './internal/usecase/mail-service.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
Expected: 6 tests PASS;门禁退出码 0。
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 <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: 写测试**
```ts
// apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts
/**
* 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、
* 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。
*/
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail } from '@contracts';
import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import {
account,
eml,
fakeProvider,
fixedClock,
memoryAccounts,
memoryAttachments,
memoryInbound,
memoryOutbound,
plainSecrets,
rawMessage,
seqIds,
} from './fakes.ts';
function harness(over: { perTick?: number } = {}) {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const inbound = memoryInbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound,
outbound: memoryOutbound(),
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('in'),
notify: { changed: (r) => void changes.push(r) },
limits: { perTick: over.perTick ?? 50 },
});
return { service, accounts, inbound, attachments, imap, wecom, clock, changes };
}
describe('拉取', () => {
it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<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**
```bash
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: 写测试**
```ts
// apps/api/src/modules/mail/__tests__/mail-service-send.test.ts
/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */
import { describe, expect, it } from 'vitest';
import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const outbound = memoryOutbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound,
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('out'),
notify: { changed: () => undefined },
});
return { service, accounts, outbound, attachments, imap, clock };
}
const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } };
describe('send', () => {
it('默认账号发出:留痕 sent、Message-ID 是 <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**
```bash
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: 写失败测试(纯映射)**
```ts
// apps/api/src/modules/mail/__tests__/imap-helpers.test.ts
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
import { describe, expect, it } from 'vitest';
import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } };
describe('imapSettingsOf', () => {
it('空 settings 取腾讯默认;登录名缺省 = 地址', () => {
const s = imapSettingsOf(account);
expect(s.settings.imapHost).toBe('imap.exmail.qq.com');
expect(s.settings.smtpPort).toBe(465);
expect(s.login).toBe('sales@we.com');
expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com');
});
it('缺密码当场说清', () => {
expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码');
});
});
describe('internalDateOf', () => {
const fallback = new Date('2026-08-25T10:00:00Z');
it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => {
const d = new Date('2026-08-25T09:00:00Z');
expect(internalDateOf(d, fallback)).toBe(d);
expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z');
expect(internalDateOf('garbage', fallback)).toBe(fallback);
expect(internalDateOf(undefined, fallback)).toBe(fallback);
});
});
describe('orderFetched', () => {
it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => {
const rows = orderFetched('7', [
{ uid: 12, source: Buffer.from('b'), internalDate: undefined },
{ uid: 10, source: Buffer.from('a'), internalDate: undefined },
{ uid: 11, source: undefined, internalDate: undefined },
], new Date('2026-08-25T10:00:00Z'));
expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']);
expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12));
expect(rows[0]?.eml).toBeInstanceOf(Uint8Array);
});
});
describe('mailOptionsOf', () => {
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
const options = mailOptionsOf({
messageId: '<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: 写适配器**
```ts
// apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts
/**
* 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。
*
* 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址;
* 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。
* 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。
* IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。
*/
import { ImapFlow } from 'imapflow';
import nodemailer from 'nodemailer';
import type Mail from 'nodemailer/lib/mailer';
import { imapSmtpSettingsSchema } from '@contracts';
import type { ImapSmtpSettings, MailProbeReport } from '@contracts';
import { imapCursor, newUids } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts';
const CONNECT_TIMEOUT_MS = 30_000;
const SOCKET_TIMEOUT_MS = 60_000;
export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } {
const settings = imapSmtpSettingsSchema.parse(account.settings);
const password = account.secret['password'];
if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)');
return { settings, login: settings.login ?? account.address, password };
}
export function internalDateOf(value: Date | string | undefined, fallback: Date): Date {
if (value instanceof Date) return value;
if (typeof value === 'string') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return fallback;
}
/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */
export function orderFetched(
uidValidity: string,
rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>,
now: Date,
): RawMessage[] {
return rows
.filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined)
.sort((a, b) => a.uid - b.uid)
.map((r) => ({
providerRef: `${uidValidity}:${r.uid}`,
eml: Uint8Array.from(r.source),
receivedAt: internalDateOf(r.internalDate, now),
cursorAfter: imapCursor(uidValidity, r.uid),
}));
}
export function mailOptionsOf(message: OutboundMessage): Mail.Options {
return {
messageId: message.messageId,
from: { name: message.from.name, address: message.from.address },
to: [...message.to],
cc: [...message.cc],
bcc: [...message.bcc],
subject: message.subject,
...(message.text === undefined ? {} : { text: message.text }),
...(message.html === undefined ? {} : { html: message.html }),
...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }),
references: [...message.references],
attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })),
};
}
function transporterOf(account: ProviderAccount) {
const { settings, login, password } = imapSettingsOf(account);
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpTls,
requireTLS: !settings.smtpTls,
auth: { user: login, pass: password },
connectionTimeout: CONNECT_TIMEOUT_MS,
greetingTimeout: CONNECT_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
}
async function withImap<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` 追加:
```ts
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
```
- [ ] **Step 4: 写 env 门控的真信箱集成测试**
```ts
// apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts
/**
* 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。
* JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com)
* JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事)
*/
import { describe, expect, it } from 'vitest';
import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts';
const user = process.env['JCAI_MAIL_IT_IMAP_USER'];
const pass = process.env['JCAI_MAIL_IT_IMAP_PASS'];
const enabled = user !== undefined && pass !== undefined;
if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n');
const account = {
id: 'it',
kind: 'imap-smtp' as const,
address: user ?? '',
displayName: 'JCAI IT',
settings: {
imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com',
smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com',
},
secret: { password: pass ?? '' },
};
const known = async (): Promise<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**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/modules/mail/__tests__/wecom-provider.test.ts
/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */
import { describe, expect, it } from 'vitest';
import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts';
import type { FetchLike } from '../internal/adapter/wecom-provider.ts';
import { wecomCursor } from '../internal/domain/cursor.ts';
type Call = { url: string; body: unknown };
function scripted(handlers: Record<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: 写适配器**
```ts
// apps/api/src/modules/mail/internal/adapter/wecom-provider.ts
/**
* 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25):
* gettoken(corpid, corpsecret) → access_token(7200s)
* exmail/app/get_email_alias → 应用邮箱地址
* exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱)
* exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64)
* exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立)
* token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。
*/
import { wecomSettingsSchema } from '@contracts';
import type { MailProbeReport } from '@contracts';
import { wecomCursor, wecomWindow } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts';
export type FetchLike = (
url: string,
init?: { method?: string; headers?: Record<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` 追加:
```ts
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise`
Expected: 全部 PASS(wecom 7 tests);门禁退出码 0。
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/app/__tests__/mail-wiring.test.ts
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
import type { InboundMail } from '@contracts';
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
const detail: MailInboundDetail = {
id: 'in-1',
accountId: 'acc-1',
messageId: '<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');
});
});
```
```ts
// apps/api/src/app/__tests__/mail-poller.test.ts
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MailPollOutcome } from '@contracts';
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
const calls: Date[] = [];
return {
calls,
mail: {
pollDue: async (now: Date) => {
calls.push(now);
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
return outcomes;
},
},
};
}
describe('pollMailOnce', () => {
it('回用例的结果;出错的账号写一行 stderr', async () => {
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
const out = await pollMailOnce({ container: { mail: mail as never } });
expect(out).toHaveLength(2);
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
write.mockRestore();
});
});
describe('startMailPoller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('按 tick 调;在跑时跳过;stop 后停', async () => {
const { mail, calls } = fakeMail([], 1000);
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
await vi.advanceTimersByTimeAsync(350);
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
await vi.advanceTimersByTimeAsync(1000);
expect(calls.length).toBeGreaterThanOrEqual(2);
poller.stop();
const before = calls.length;
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toHaveLength(before);
});
});
```
注:两处 `mail as never` 只是把测试桩缩到 `pollDue` 一个方法 —— `Container['mail']` 是整个 `MailService`,桩不必长成它;`as never` 不在 §12.5 的禁止清单里(禁的是 `as unknown as`)。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts`
Expected: FAIL(文件不存在)。
- [ ] **Step 3: 写 upload-store / mail-wiring / mail-poller**
```ts
// apps/api/src/app/upload-store.ts
/**
* sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。
* 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { extractText } from './upload-extract.ts';
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
export const UPLOAD_CAP = 8 * 1024 * 1024;
function rowsOf<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'`):
```ts
app.post(uploadFileRoute.path, async (c) => {
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const { name, contentType, dataBase64 } = parsed.data;
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
if (bytes.length === 0) {
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
return c.json(body, 400);
}
if (bytes.length > UPLOAD_CAP) {
const body: ApiError = {
code: 'validation-failed',
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
};
return c.json(body, 400);
}
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
});
app.get(downloadFileRoute.path, async (c) => {
const hit = await readUpload(container.db, c.req.param('id'));
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404);
}
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
});
```
```ts
// apps/api/src/app/mail-wiring.ts
/**
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
* 三段都是纯映射 + 一次调用,单测直接对函数。
*/
import type { InboundMail, MailSendInput } from '@contracts';
import type { MailTransport } from '@modules/channel';
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
import {
bodyTextOf,
MailAccountDisabledError,
MailAccountNotFoundError,
MailNoDefaultAccountError,
MailSecretMissingError,
MailSendInvalidError,
} from '@modules/mail';
import type { ToolHandler } from '@modules/toolbox';
/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */
export function toInboundMail(mail: MailInboundDetail): InboundMail {
const threadId = mail.inReplyTo ?? mail.references.at(-1);
return {
messageId: mail.messageId,
...(threadId === undefined ? {} : { threadId }),
from: mail.from.address,
to: mail.to.map((a) => a.address),
cc: mail.cc.map((a) => a.address),
subject: mail.subject,
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
receivedAt: mail.receivedAt,
attachments: mail.attachments.flatMap((a) =>
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
),
};
}
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<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;
}
};
}
```
```ts
// apps/api/src/app/workers/mail-poller.ts
/**
* 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里,
* 两个后端连同一个库时一个信箱同一时刻只有一个在拉。
* 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。
*/
import type { MailPollOutcome } from '@modules/mail';
import type { Container } from '../container.ts';
export type MailPollerDeps = { readonly container: Pick<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 其余三个);加
```ts
import type { MailService } from '@modules/mail';
import {
createImapSmtpProvider,
createInboundStore,
createMailAccountStore,
createMailService,
createOutboundStore,
createPostalMimeParser,
createWecomProvider,
} from '@modules/mail';
import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts';
import { readUpload, storeUpload } from './upload-store.ts';
```
2. `Container` 类型加 `/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService;`(放在 `channel` 之后)。
3. 删掉模块级 `export const firstPartyToolHandlers = …`(仓内无其它引用)。
4. 在 `const toolRegistry = createToolRegistry(db);` 之前加:
```ts
/*
* 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。
* 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。
* 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。
*/
const mail = createMailService({
accounts: createMailAccountStore(db),
inbound: createInboundStore(db),
outbound: createOutboundStore(db),
attachments: {
put: async (file) => ({
ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref,
}),
get: (ref) => readUpload(db, ref),
},
providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() },
mime: createPostalMimeParser(),
secrets,
clock: systemClock,
ids,
notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) },
});
```
5. `const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);` 改为
```ts
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
const toolRuntime = createToolRuntime(
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
httpToolHandler,
);
```
6. channel 装配里 `mail: createSimulatedMailTransport(db, ids),` 改为 `mail: channelTransport(mail),`;紧跟 `const channel = createChannelService({...});` 之后加:
```ts
// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面
mail.subscribe('channel', channelSubscriber(channel));
```
7. `return {...}` 里 `channel,` 之后加 `mail,`。
`apps/api/src/app/main.ts`:`import { startMailPoller } from './workers/mail-poller.ts';`;在 `const advancer = …` 之后加
```ts
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
```
`shutdown` 里 `advancer.stop();` 之后加 `mailPoller.stop();`。
- [ ] **Step 5: 跑测试确认通过**
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
Expected: 8 + 2 tests PASS;三道门禁退出码 0(`app-not-into-internal`:app 只 import `@modules/mail` 的出口;`no-orphans`:`upload-store.ts` / `mail-wiring.ts` 被 container 引用)。
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/app
git commit -m "$(printf 'app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用\n\nCo-Authored-By: Claude Fable 5 <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: 写失败测试**
```ts
// apps/api/src/app/__tests__/mail-routes.test.ts
/**
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
*/
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import { createMailService, createPostalMimeParser } from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { registerMailRoutes } from '../routes/mail.ts';
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const mail = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('x'),
notify: { changed: () => undefined },
});
const app = new Hono<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: 写路由并注册**
```ts
// apps/api/src/app/routes/mail.ts
/**
* 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判;
* 这里只做 zod 校验、调用用例、把领域错误翻成状态码。
* 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。
*/
import type { Context, Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
createMailAccountRoute,
deleteMailAccountRoute,
getMailAccountRoute,
getMailInboundRoute,
listMailAccountsRoute,
listMailInboundRoute,
listMailOutboundRoute,
mailAttachmentRoute,
mailRawRoute,
pollMailAccountRoute,
probeMailAccountRoute,
probeMailDraftRoute,
redeliverMailInboundRoute,
sendTestMailRoute,
updateMailAccountRoute,
} from '@contracts';
import type { MailService } from '@modules/mail';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberUnknownError,
} from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { staffOf } from '../http-env.ts';
export type MailRouteDeps = { readonly mail: MailService };
/** 领域错误 → 状态码;认不出的交回 onError(500) */
export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined {
if (
error instanceof MailAccountNotFoundError ||
error instanceof MailInboundNotFoundError ||
error instanceof MailAttachmentNotFoundError ||
error instanceof MailSubscriberUnknownError
) {
return { status: 404, body: { code: 'not-found', message: error.message } };
}
if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) {
return { status: 409, body: { code: 'conflict', message: error.message } };
}
if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) {
return { status: 412, body: { code: 'precondition-failed', message: error.message } };
}
if (error instanceof MailSendInvalidError) {
return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } };
}
return undefined;
}
function invalid(c: Context<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**
```bash
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` 整段换成:
```ts
/**
* 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。
*
* 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。
* 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
*/
const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
displayName: '发送邮件',
version: '2.0.0',
description:
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
input: {
type: 'object',
required: ['to', 'subject'],
properties: {
to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' },
cc: { type: 'array', items: { type: 'string' }, description: '抄送' },
bcc: { type: 'array', items: { type: 'string' }, description: '密送' },
subject: { type: 'string' },
body: { type: 'string', description: '纯文本正文(与 html 至少一个)' },
html: { type: 'string', description: 'HTML 正文' },
attachments: {
type: 'array',
items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } },
description: '附件:上传件的 ref',
},
inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' },
accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' },
dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' },
},
},
output: {
type: 'object',
properties: {
queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' },
outboundId: { type: 'string' },
messageId: { type: 'string' },
state: { type: 'string' },
dryRun: { type: 'boolean' },
},
},
effect: 'write' as const,
errors: [
{ code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' },
{ code: 'no-account', retryable: false, message: '没有可用的邮箱账号' },
{ code: 'invalid', retryable: false, message: '入参不合法' },
],
idempotent: true,
connectionKeys: ['account'],
};
```
注册循环里的样例改为(`to` 变数组、加 `dryRun`;连接值按键给):
```ts
input: {
pn: 'LM358DR',
to: ['x@y.com'],
subject: '询价',
body: '样例正文',
dryRun: true,
history_days: 30,
market_stable: true,
lifecycle: '在产',
},
```
```ts
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
...(contract.connectionKeys.length === 0
? {}
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
```
- [ ] **Step 2: 演进检查与试运行走一遍(单测)**
在 `apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts` 末尾追加一条(照该文件既有的 `checkEvolution` 用例写法):
```ts
describe('mail.send 2.0.0 的演进', () => {
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
const v1 = {
name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)',
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'],
};
const v2 = {
...v1, version: '2.0.0', connectionKeys: ['account'],
input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } },
output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } },
};
expect(checkEvolution(v1, v2)).toEqual([]);
});
});
```
Run: `pnpm vitest run apps/api/src/modules/toolbox`
Expected: PASS(含新用例)。
- [ ] **Step 3: 刷新契约快照并跑五道门禁**
```bash
pnpm api:check --update && git add api-report
pnpm gates
```
Expected: `api-report/apps/api/src/modules/mail/{index,contract,port}.d.ts` 与 `api-report/packages/contracts/...` 有新增;`pnpm gates` 退出码 0(gen:tiers / typecheck / lint / depcruise / api:check / test 全绿;IT 套件在有 PG 时跑、没有时跳过并告警 —— 本轮验收要求 PG 在)。
- [ ] **Step 4: 本机起一次真服务核一遍(有 PG + Keycloak 配置时)**
```bash
pnpm db:migrate && pnpm db:seed
pnpm dev:api
```
Expected:启动日志无 `[mail-poller]` 报错(没有账号时每 tick 空转);`pnpm db:seed` 日志里 `② 功能工具已注册并入白名单`,且 `mail.send` 未出现「未过两审」(dryRun 样例通过试运行)。
用服务账号 token(API 文档 §8 的取法)`curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/api/gov/mail/accounts` → `{"accounts":[]}`。
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/app/seed-mvp.ts apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts api-report
git commit -m "$(printf 'mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿\n\nCo-Authored-By: Claude Fable 5 <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**
```markdown
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
状态:已接受(2026-08-25)
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
(核实日期 2026-08-25,链接列在设计稿开头)。
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据)。
## 问题
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
## 决策
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下,
`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
订阅是代码级装配(不落库、不回放老信)。
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封,
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
厂商失败在 200 的 body 里说,不编状态码。
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
## 后果
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。
- 欠账见 `docs/debt.md`「邮件收发」段。
```
- [ ] **Step 2: channel 的 CONTRACT.md**
把 `apps/api/src/modules/channel/CONTRACT.md` 不变量里这一条:
```
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
系统内部机制一律真跑,不得 mock
```
改成:
```
- 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方,
把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认);
`createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用);
系统内部机制一律真跑,不得 mock
```
`依赖:` 那一行的 `MailTransport`(模拟通道)改为 `MailTransport`(app 注入,转 mail 模块)。
- [ ] **Step 3: API 文档 `docs/API文档-邮件收发.md`**
```markdown
# API 文档 —— 邮件收发(ADR-013)
> 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用);
> 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。
> 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`;
> 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。
## 0. 通则
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/mail/*`;读标两端 `design\|ops`,写归 `ops` |
| 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` |
| 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 |
| 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) |
| 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) |
状态码怎么读(通则之外本文特有的):
| 状态 | code | 何时 |
|---|---|---|
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 |
| 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 |
| 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** |
| 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) |
## 1. 账号
### `GET /api/gov/mail/accounts` —— access: `design|ops`
```json
{
"accounts": [
{
"id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
"enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null,
"settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" },
"secretBound": true,
"status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false },
"createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z"
},
{
"id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false,
"pollIntervalSec": 60, "channelId": null, "backfillFrom": null,
"settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" },
"secretBound": false,
"status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false },
"createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z"
}
]
}
```
- `address`:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。
- `status.cursor` 是人读摘要(还没拉过 = null);`status.polling` 为真表示此刻某台后端正拉着。
- `status.lastPollError` 是厂商原话(脱敏);为 null 表示上一轮没出错。
### `POST /api/gov/mail/accounts` —— access: `ops`
body 按 `kind` 分两形(缺省值见 §5 schema):
```json
{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
"isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
"settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
```
```json
{ "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } }
```
→ 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。`isDefault:true` 会把别的账号的默认位放下。
### `POST /api/gov/mail/accounts/probe?accountId=` —— access: `ops`
**未存**的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 `?accountId=` 借库里那份密文。
没 secret 又没 `accountId` → 412。
```json
{ "kind": "imap-smtp", "address": "sales@corp.com",
"receive": { "ok": true, "latencyMs": 412 },
"send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
```
```json
{ "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work",
"receive": { "ok": true, "latencyMs": 230 },
"send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } }
```
### `GET /api/gov/mail/accounts/:accountId` —— access: `design|ops`
→ 账号视图;404。
### `PUT /api/gov/mail/accounts/:accountId` —— access: `ops`
body 同建账号;`secret` 缺省 = 不改密文;换 `kind` 又没给 secret → 旧密文作废(`secretBound:false`);
`kind` 或 `settings` 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。
### `DELETE /api/gov/mail/accounts/:accountId` —— access: `ops`
→ `{ "ok": true }`。留痕(入站 / 出站)不删。
### `POST /api/gov/mail/accounts/:accountId/probe` —— access: `ops`
已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 `address`。412 凭证空。
### `POST /api/gov/mail/accounts/:accountId/poll` —— access: `ops`
立即拉一轮(不等轮询到点):
```json
{ "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false }
```
- `pulled` 服务器交出几封;`stored` 真新入库几封(去重后);`dispatched` 分发过几封;`more` 为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。
- 出错:`error` 带原话(同时写进账号的 `status.lastPollError`),仍是 200。
- 409:另一台后端正占着租约;412:账号停用。
### `POST /api/gov/mail/accounts/:accountId/send-test` —— access: `ops`
body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` →
```json
{ "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "<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)。
```json
{
"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`
```json
{
"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` 同名)
```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 实录
```bash
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
# 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码)
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \
-d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq .
# 探测(已存)/ 立即拉取 / 试发
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq .
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq .
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq .
# 来信、附件、重新投递
curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}'
curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq .
# 出站
curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}'
```
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
```
- [ ] **Step 4: 前端对接说明 `docs/前端对接说明-邮件收发.md`**
```markdown
# 邮件收发 —— 前端对接说明
> 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲
> **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与
> `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。
## 0. 一分钟看懂
- 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。
界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。
- 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。
表单按 `kind` 切换字段;两种都有「探测」。
- 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。
- 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。
- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。读接口两端都能调,设计中心可只读展示。
## 1. 建议的信息架构
```
运营中心 / 系统 / 邮箱
├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯
│ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除
│ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe)
├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递)
└─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间
```
## 2. 账号表单(字段清单与默认值)
公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)·
`pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)·
`backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。
`kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)·
`settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)·
`settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。
`kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。
`address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。
保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。
## 3. 四个动作的交互与「界面不撒谎」
| 动作 | 接口 | 怎么摆 |
|---|---|---|
| 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 |
| 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 |
| 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 |
| 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 |
列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。
## 4. 来信与投递状态
- 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。
- 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。
**`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。
- 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。
- 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形,
预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。
`ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。
- 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。
- 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。
## 5. 发信记录
列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。
`sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。
## 6. SSE 失效映射
`app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀
(账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。
`reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。
## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉)
**IMAP/SMTP 路**
1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框;
公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。
4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。
**企业微信邮件 API 路**
1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。
发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。
## 8. 错误处理
| 状态 | 何时 | 前端动作 |
|---|---|---|
| 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 |
| 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 |
| 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 |
| 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 |
## 9. 接入完成的判据
- [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true
- [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话
- [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址
- [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由
- [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note
- [ ] 重新投递前有提示,投递后状态刷新
- [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点)
- [ ] 试发失败时列表里看得到 `failed` 与原话
- [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话
## 10. 本文与真实契约的核对状态
路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过;字段名与 `packages/contracts/src/mail.ts` 同名。
示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。
```
- [ ] **Step 5: 总表、debt、CLAUDE.md**
`docs/API文档-鉴权与人员.md` §6 总表:在 `| \`ops\` | DELETE | \`/api/gov/access/roles/:role\` |` 之后加 8 行:
```markdown
| `ops` | POST | `/api/gov/mail/accounts` |
| `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) |
| `ops` | PUT | `/api/gov/mail/accounts/:accountId` |
| `ops` | DELETE | `/api/gov/mail/accounts/:accountId` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` |
| `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` |
```
在 `| \`design\|ops\` | GET | \`/api/gov/workflows/:id\` |` 之后加 7 行:
```markdown
| `design\|ops` | GET | `/api/gov/mail/accounts` |
| `design\|ops` | GET | `/api/gov/mail/accounts/:accountId` |
| `design\|ops` | GET | `/api/gov/mail/inbound` |
| `design\|ops` | GET | `/api/gov/mail/inbound/:mailId` |
| `design\|ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) |
| `design\|ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
| `design\|ops` | GET | `/api/gov/mail/outbound` |
```
计数那一行改为:`计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 27 + 两端 15 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。`
`docs/debt.md` 末尾追加:
```markdown
## 2026-08-25 · 邮件收发接上了,留下七笔账(ADR-013)
① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次;
根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。
② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。
③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。
④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。
⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。
⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。
⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。
要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。
另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。
```
`CLAUDE.md`:现状快照末尾加一条(照既有格式):
```markdown
- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与
企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 →
`sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。
分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。
发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、
运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。
15 条 `/api/gov/mail/*` 路由(读两端、写 ops);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。
欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由。
```
文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后):
```markdown
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
```
- [ ] **Step 6: 核对与提交**
Run: `pnpm gates`
Expected: 退出码 0(文档改动不影响门禁;api-report 已在 Task 16 刷新)。
核一遍:`grep -c "/api/gov/mail" docs/API文档-鉴权与人员.md` = 15;`grep -n "mail" tools/module-tiers.json` 有一行。
```bash
git add docs apps/api/src/modules/channel/CONTRACT.md CLAUDE.md
git commit -m "$(printf '文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账七笔、CLAUDE 快照\n\nCo-Authored-By: Claude Fable 5 <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 三处同名。