全仓测试 route-access.test.ts 钉着一份有意枚举、随 Keycloak spec 审过的名单:哪些读路由被两个端共用。 计划原把邮件的 7 条 GET 标成 design|ops,名单就从八条长到十四条。裁决改路由、不改名单: ①邮箱那一屏按本计划自己的前端说明落在运营中心,设计端没有消费方(测试台读的是通道,不是邮箱账号); ②入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽; ③要往那份名单里加,得有真实消费方撑着。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
320 KiB
邮件收发模块(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.jsontier 1);channel/toolbox不 importmail,全经 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 的差异(实施前核仓逼出的三处,以本计划为准)
mail.send工具契约保留 v1 字段:toolbox 的checkEvolution不许删入参 / 出参字段 —— v2 保留body(纯文本正文,不改名text)与出参queued;新增html/cc/bcc/attachments/inReplyTo/accountId/dryRun。to改为数组(演进检查不管类型;处理器仍容忍逗号分隔的字符串)。connectionKeys: ['account'](值default= 用默认账号)—— 静态审对无连接键只 warn,但「从哪个信箱发」本来就是一个连接目标。toolbox.register的试运行会真调处理器:样例入参带dryRun: true,处理器只校验不发。send()不抛MailSendFailedError:厂商失败落failed行并以MailSendResult.state='failed'+error返回;只有入参 / 账号问题才抛(MailSendInvalidError400、MailNoDefaultAccountError/MailAccountDisabledError/MailSecretMissingError412、MailAccountNotFoundError404)。channel 注入件与工具处理器各自把failed翻成自己的失败形态。- nodemailer 用 8.x:9.0.x 没有 DefinitelyTyped 类型包(
@types/nodemailer最新 8.0.1),8.0.11 +@types/nodemailer@8.0.1类型对齐。
文件结构
packages/contracts/src/mail.ts # 账号 / 入站 / 出站 / 探测的 zod schema(§Task 2)
packages/contracts/src/api/mail.ts # 15 条路由契约(§Task 3)
packages/contracts/src/__tests__/mail.test.ts
db/migrations/0033_mail.sql + meta/_journal.json # 四张表(§Task 8)
apps/api/src/modules/mail/
package.json · index.ts · contract.ts · port.ts · CONTRACT.md
internal/domain/records.ts # 库形 / 传输形类型(纯类型)
internal/domain/cursor.ts # 游标两形、UID 过滤、wecom 窗口
internal/domain/due.ts # 到期判定、租约时长
internal/domain/inbound-record.ts # 解析结果 → 入站记录 / 视图、附件规划、正文提取
internal/domain/redact.ts # 脱敏、错误文本
internal/domain/send-input.ts # 发信校验、选账号、Message-ID
internal/domain/delivery.ts # 分发状态机
internal/domain/account-view.ts # 账号记录 ↔ 输入 / 视图
internal/usecase/mail-service.ts # MailService
internal/adapter/mail-schema.ts # Drizzle 表
internal/adapter/mail-store.ts # 三个 store + 租约 SQL
internal/adapter/postal-mime-parser.ts
internal/adapter/imap-smtp-provider.ts
internal/adapter/wecom-provider.ts
internal/adapter/fake-provider.ts # 外界替身
__tests__/fakes.ts # 内存 store / 时钟 / EML 夹具
__tests__/{cursor,due,inbound-record,redact,send-input,account-view,delivery}.test.ts
__tests__/{mail-service-accounts,mail-service-poll,mail-service-send}.test.ts
__tests__/postal-mime-parser.test.ts · imap-helpers.test.ts · imap-smtp-provider.it.test.ts · wecom-provider.test.ts
__tests__/mail-store.it.test.ts
apps/api/src/app/upload-store.ts # sys.upload 存取抽出(路由与 mail 注入件同用)
apps/api/src/app/mail-wiring.ts # toInboundMail / channel 订阅方 / channel 传输件 / 工具处理器
apps/api/src/app/workers/mail-poller.ts
apps/api/src/app/routes/mail.ts
apps/api/src/app/__tests__/{mail-wiring,mail-routes,mail-poller,mail-routes-registry}.test.ts
apps/api/src/app/{container,config,main,reset-runs,seed-mvp}.ts · tools/first-party.ts · routes/governance-ops.ts(上传两段抽出)
tools/module-tiers.json · docs/编码规范.md §4 · apps/api/package.json
docs/adr/ADR-013-mail-gateway.md · docs/API文档-邮件收发.md · docs/前端对接说明-邮件收发.md · docs/API文档-鉴权与人员.md §6 · docs/debt.md · CLAUDE.md · apps/api/src/modules/channel/CONTRACT.md
Task 1: 依赖与模块登记
Files:
- Modify:
apps/api/package.json(dependencies / devDependencies) - Modify:
tools/module-tiers.json(tier 1 加mail) - Modify:
docs/编码规范.md(§4 层级表 L1 加一行) - Generated:
tools/depcruise-tiers.generated.cjs(pnpm gen:tiers产物,入库)
Interfaces:
-
Produces: 包可 import
imapflow/nodemailer/postal-mime;apps/api/src/modules/mail/目录被门禁认作 L1。 -
Step 1: 加依赖
cd /Users/xcs/project2/tmp/BPOS-MVP
pnpm --filter @app/api add imapflow@^1.7.6 nodemailer@^8.0.11 postal-mime@^3.0.0
pnpm --filter @app/api add -D @types/nodemailer@^8.0.1
node -e "const p=require('./apps/api/package.json');console.log(p.dependencies.imapflow,p.dependencies.nodemailer,p.dependencies['postal-mime'],p.devDependencies['@types/nodemailer'])"
Expected: 四个版本号各打印一行(^1.7.6 ^8.0.11 ^3.0.0 ^8.0.1)。
- Step 2: 登记层级
tools/module-tiers.json 的 "1" 数组改为:
"1": [
"event",
"org",
"llm",
"ledger",
"mail"
],
docs/编码规范.md §4 层级表,在 | L1 | \ledger` |` 那一行之后加:
| L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 |
- Step 3: 重新生成 tier 规则并验门禁
Run: pnpm gen:tiers && pnpm depcruise
Expected: 退出码 0;git diff --stat tools/depcruise-tiers.generated.cjs 显示该文件有变更(多了 tier-order-mail 规则)。
- Step 4: Commit
git add apps/api/package.json pnpm-lock.yaml tools/module-tiers.json tools/depcruise-tiers.generated.cjs docs/编码规范.md
git commit -m "$(printf 'mail 模块登记为 L1;批准 imapflow / nodemailer / postal-mime 三个依赖(设计稿 §2)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 2: 契约 schema packages/contracts/src/mail.ts
Files:
- Create:
packages/contracts/src/mail.ts - Modify:
packages/contracts/src/index.ts(加export * from './mail.ts';) - Test:
packages/contracts/src/__tests__/mail.test.ts
Interfaces:
-
Produces(后续任务全靠这些名字):
MAIL_ACCOUNT_KINDSmailAccountKindSchemaMailAccountKindMAIL_ACCOUNT_KIND_LABELimapSmtpSettingsSchemaImapSmtpSettingswecomSettingsSchemaWecomSettingsmailAccountInputSchemaMailAccountInputmailAccountStatusSchemamailAccountViewSchemaMailAccountViewmailProbeReportSchemaMailProbeReportMAIL_DELIVERY_STATESmailDeliveryStateSchemaMailDeliveryStatemailDeliveryViewSchemaMailDeliveryViewmailAddressSchemaMailAddressmailInboundAttachmentSchemaMailInboundAttachmentmailInboundSummarySchemaMailInboundSummarymailInboundDetailSchemaMailInboundDetailmailSendInputSchemaMailSendInputmailSendResultSchemaMailSendResultMAIL_OUTBOUND_STATESmailOutboundStateSchemaMailOutboundStatemailOutboundViewSchemaMailOutboundViewmailPollOutcomeSchemaMailPollOutcome。 -
Step 1: 写失败测试
// packages/contracts/src/__tests__/mail.test.ts
/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */
import { describe, expect, it } from 'vitest';
import {
mailAccountInputSchema,
mailAccountViewSchema,
mailSendInputSchema,
MAIL_ACCOUNT_KINDS,
} from '../mail.ts';
describe('mailAccountInputSchema', () => {
it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => {
const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' });
expect(parsed.kind).toBe('imap-smtp');
if (parsed.kind !== 'imap-smtp') throw new Error('unreachable');
expect(parsed.settings).toEqual({
imapHost: 'imap.exmail.qq.com',
imapPort: 993,
imapTls: true,
smtpHost: 'smtp.exmail.qq.com',
smtpPort: 465,
smtpTls: true,
folder: 'INBOX',
});
expect(parsed.enabled).toBe(true);
expect(parsed.isDefault).toBe(false);
expect(parsed.pollIntervalSec).toBe(30);
expect(parsed.channelId).toBeNull();
expect(parsed.backfillFrom).toBeNull();
expect(parsed.secret).toBeUndefined();
});
it('wecom:corpId 必填,地址不由人填(探测回填)', () => {
const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } });
if (parsed.kind !== 'wecom') throw new Error('unreachable');
expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com');
expect('address' in parsed).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false);
});
it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => {
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false);
expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false);
expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']);
});
});
describe('mailAccountViewSchema', () => {
it('视图只有 secretBound,没有任何密文 / 密码字段', () => {
const keys = Object.keys(mailAccountViewSchema.options[0]!.shape);
expect(keys).toContain('secretBound');
expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false);
});
});
describe('mailSendInputSchema', () => {
it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => {
const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } });
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.attachments).toEqual([]);
expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false);
expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false);
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run packages/contracts/src/__tests__/mail.test.ts
Expected: FAIL(Cannot find module '../mail.ts')。
- Step 3: 写 schema
// packages/contracts/src/mail.ts
/**
* 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。
*
* 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。
* 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱),
* 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。
*/
import { z } from 'zod';
import { channelIdSchema } from './id.ts';
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS);
export type MailAccountKind = z.infer<typeof mailAccountKindSchema>;
export const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>> = {
'imap-smtp': '标准协议(IMAP/SMTP)',
wecom: '企业微信邮件 API(应用邮箱)',
};
/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */
export const imapSmtpSettingsSchema = z.object({
imapHost: z.string().min(1).default('imap.exmail.qq.com'),
imapPort: z.number().int().min(1).max(65535).default(993),
/** true = 隐式 TLS(993);false = 明文(不建议) */
imapTls: z.boolean().default(true),
smtpHost: z.string().min(1).default('smtp.exmail.qq.com'),
smtpPort: z.number().int().min(1).max(65535).default(465),
/** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */
smtpTls: z.boolean().default(true),
/** 登录名;缺省 = address */
login: z.string().min(1).optional(),
folder: z.string().min(1).default('INBOX'),
});
export type ImapSmtpSettings = z.infer<typeof imapSmtpSettingsSchema>;
/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */
export const wecomSettingsSchema = z.object({
corpId: z.string().min(1),
apiBase: z.url().default('https://qyapi.weixin.qq.com'),
});
export type WecomSettings = z.infer<typeof wecomSettingsSchema>;
const accountCommon = {
name: z.string().min(1).max(64),
displayName: z.string().max(64).default(''),
enabled: z.boolean().default(true),
isDefault: z.boolean().default(false),
/** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */
pollIntervalSec: z.number().int().min(10).max(3600).default(30),
/** 入站交给哪个通道;空 = channel 订阅方 skipped */
channelId: channelIdSchema.nullable().default(null),
/** 从这一天起回填;空 = 首次拉取锚在当下,不回填整箱 */
backfillFrom: z.iso.datetime().nullable().default(null),
};
export const mailAccountInputSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('imap-smtp'),
address: z.email(),
...accountCommon,
settings: imapSmtpSettingsSchema.prefault({}),
/** 只写不读:缺省 = 不改(与 LLM 密钥同款) */
secret: z.object({ password: z.string().min(1) }).optional(),
}),
z.object({
kind: z.literal('wecom'),
...accountCommon,
settings: wecomSettingsSchema,
secret: z.object({ corpSecret: z.string().min(1) }).optional(),
}),
]);
export type MailAccountInput = z.infer<typeof mailAccountInputSchema>;
export const mailAccountStatusSchema = z.object({
lastPolledAt: z.iso.datetime().nullable(),
/** 厂商原话(已脱敏);空 = 上一轮没出错 */
lastPollError: z.string().nullable(),
lastInboundAt: z.iso.datetime().nullable(),
/** 游标的人读摘要;还没拉过 = null */
cursor: z.string().nullable(),
/** 此刻租约在手(某个进程正在拉) */
polling: z.boolean(),
});
const viewCommon = {
id: z.string(),
name: z.string(),
/** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */
address: z.string(),
displayName: z.string(),
enabled: z.boolean(),
isDefault: z.boolean(),
pollIntervalSec: z.number().int(),
channelId: z.string().nullable(),
backfillFrom: z.iso.datetime().nullable(),
secretBound: z.boolean(),
status: mailAccountStatusSchema,
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
};
export const mailAccountViewSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }),
z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }),
]);
export type MailAccountView = z.infer<typeof mailAccountViewSchema>;
/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */
export const mailProbeReportSchema = z.object({
kind: mailAccountKindSchema,
address: z.string().optional(),
receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }),
send: z.object({
checked: z.boolean(),
ok: z.boolean().optional(),
error: z.string().optional(),
note: z.string().optional(),
}),
});
export type MailProbeReport = z.infer<typeof mailProbeReportSchema>;
export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const;
export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES);
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;
export const mailDeliveryViewSchema = z.object({
subscriber: z.string(),
state: mailDeliveryStateSchema,
error: z.string().nullable(),
startedAt: z.iso.datetime(),
finishedAt: z.iso.datetime().nullable(),
});
export type MailDeliveryView = z.infer<typeof mailDeliveryViewSchema>;
export const mailAddressSchema = z.object({ address: z.string(), name: z.string() });
export type MailAddress = z.infer<typeof mailAddressSchema>;
export const mailInboundAttachmentSchema = z.object({
index: z.number().int(),
name: z.string(),
contentType: z.string(),
size: z.number().int(),
/** `sys.upload` 的 id;超限没存 = null,理由在 note */
ref: z.string().nullable(),
note: z.string(),
});
export type MailInboundAttachment = z.infer<typeof mailInboundAttachmentSchema>;
export const mailInboundSummarySchema = z.object({
id: z.string(),
accountId: z.string(),
messageId: z.string(),
from: mailAddressSchema,
to: z.array(mailAddressSchema),
cc: z.array(mailAddressSchema),
subject: z.string(),
sentAt: z.iso.datetime().nullable(),
receivedAt: z.iso.datetime(),
/** 正文前 200 字 */
textPreview: z.string(),
hasHtml: z.boolean(),
attachments: z.array(mailInboundAttachmentSchema),
deliveries: z.array(mailDeliveryViewSchema),
});
export type MailInboundSummary = z.infer<typeof mailInboundSummarySchema>;
export const mailInboundDetailSchema = mailInboundSummarySchema.extend({
inReplyTo: z.string().nullable(),
references: z.array(z.string()),
text: z.string().nullable(),
html: z.string().nullable(),
size: z.number().int(),
/** 原件 .eml 存下来了(≤16MB) */
rawAvailable: z.boolean(),
});
export type MailInboundDetail = z.infer<typeof mailInboundDetailSchema>;
export const mailSendInputSchema = z.object({
/** 指定账号;缺省用默认账号 */
accountId: z.string().optional(),
/** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */
from: z.string().optional(),
to: z.array(z.email()).min(1),
cc: z.array(z.email()).default([]),
bcc: z.array(z.email()).default([]),
subject: z.string().min(1),
text: z.string().optional(),
html: z.string().optional(),
inReplyTo: z.string().optional(),
references: z.array(z.string()).default([]),
attachments: z
.array(
z.object({
name: z.string().min(1),
contentType: z.string().default('application/octet-stream'),
/** 二选一:现给字节,或引用 sys.upload 的 id */
dataBase64: z.string().optional(),
uploadRef: z.string().optional(),
}),
)
.default([]),
/** 放行落地带的幂等键 —— 同键不发两封 */
idempotencyKey: z.string().optional(),
requestedBy: z.object({
kind: z.enum(['tool', 'channel', 'ops']),
taskId: z.string().optional(),
staffId: z.string().optional(),
tool: z.string().optional(),
}),
});
export type MailSendInput = z.infer<typeof mailSendInputSchema>;
export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const;
export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES);
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;
export const mailSendResultSchema = z.object({
outboundId: z.string(),
accountId: z.string(),
messageId: z.string(),
state: z.enum(['sent', 'failed']),
error: z.string().optional(),
providerRef: z.string().optional(),
});
export type MailSendResult = z.infer<typeof mailSendResultSchema>;
export const mailOutboundViewSchema = z.object({
id: z.string(),
accountId: z.string(),
messageId: z.string(),
to: z.array(z.string()),
cc: z.array(z.string()),
bcc: z.array(z.string()),
subject: z.string(),
state: mailOutboundStateSchema,
error: z.string().nullable(),
providerRef: z.string().nullable(),
requestedBy: z.record(z.string(), z.unknown()),
createdAt: z.iso.datetime(),
sentAt: z.iso.datetime().nullable(),
});
export type MailOutboundView = z.infer<typeof mailOutboundViewSchema>;
export const mailPollOutcomeSchema = z.object({
accountId: z.string(),
/** provider 交出的封数 */
pulled: z.number().int(),
/** 真新入库的封数(去重后) */
stored: z.number().int(),
dispatched: z.number().int(),
/** 预算用尽还有余下,下一 tick 接着 */
more: z.boolean(),
error: z.string().optional(),
});
export type MailPollOutcome = z.infer<typeof mailPollOutcomeSchema>;
在 packages/contracts/src/index.ts 的 export * from './org.ts'; 之后加一行:
export * from './mail.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run packages/contracts/src/__tests__/mail.test.ts && pnpm typecheck
Expected: 4 tests PASS;typecheck 退出码 0。
- Step 5: Commit
git add packages/contracts/src/mail.ts packages/contracts/src/index.ts packages/contracts/src/__tests__/mail.test.ts
git commit -m "$(printf '邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 3: 路由契约 packages/contracts/src/api/mail.ts
Files:
- Create:
packages/contracts/src/api/mail.ts - Modify:
packages/contracts/src/api/index.ts(加export * from './mail.ts';) - Test:
apps/api/src/app/__tests__/mail-routes-registry.test.ts
Interfaces:
-
Produces 15 条契约常量:
listMailAccountsRoutecreateMailAccountRouteprobeMailDraftRoutegetMailAccountRouteupdateMailAccountRoutedeleteMailAccountRouteprobeMailAccountRoutepollMailAccountRoutesendTestMailRoutelistMailInboundRoutegetMailInboundRoutemailAttachmentRoutemailRawRouteredeliverMailInboundRoutelistMailOutboundRoute;MAIL_ROUTES(全部 15 条的数组,给文档 / 测试数)。 -
Step 1: 写失败测试
// apps/api/src/app/__tests__/mail-routes-registry.test.ts
/**
* 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞),
* 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方;
* 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。
*/
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) {
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')).toBe('ops');
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts
Expected: FAIL(MAIL_ROUTES 不存在)。
- Step 3: 写路由契约
// packages/contracts/src/api/mail.ts
/**
* 邮件收发的路由契约(设计稿 §7.2;ADR-013)。
*
* 读写都归运营中心 —— 邮箱那一屏落在运营中心,设计端没有消费方;入站正文装的是客户来往内容,不无故加宽可读面。
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
* 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
import {
mailAccountInputSchema,
mailAccountViewSchema,
mailDeliveryViewSchema,
mailInboundDetailSchema,
mailInboundSummarySchema,
mailOutboundViewSchema,
mailPollOutcomeSchema,
mailProbeReportSchema,
mailSendResultSchema,
} from '../mail.ts';
const accountParams = z.object({ accountId: z.string().min(1) });
const mailParams = z.object({ mailId: z.string().min(1) });
const listQuery = z.object({
accountId: z.string().optional(),
/** 默认 50,上限 200 */
limit: z.coerce.number().int().min(1).max(200).optional(),
});
export const listMailAccountsRoute = {
method: 'GET',
path: '/api/gov/mail/accounts',
access: '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: '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: '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: '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: '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: '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: 'ops',
query: listQuery,
output: z.object({ mails: z.array(mailOutboundViewSchema) }),
} as const satisfies RouteContract;
/** 全部 15 条 —— 文档总表与覆盖测试按它数 */
export const MAIL_ROUTES: readonly RouteContract[] = [
listMailAccountsRoute,
createMailAccountRoute,
probeMailDraftRoute,
getMailAccountRoute,
updateMailAccountRoute,
deleteMailAccountRoute,
probeMailAccountRoute,
pollMailAccountRoute,
sendTestMailRoute,
listMailInboundRoute,
getMailInboundRoute,
mailAttachmentRoute,
mailRawRoute,
redeliverMailInboundRoute,
listMailOutboundRoute,
];
packages/contracts/src/api/index.ts 末尾加:
export * from './mail.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts && pnpm typecheck
Expected: 3 tests PASS;typecheck 退出码 0。若 buildRouteRegistry 抛「路由撞车」,说明某条字面路径与参数模版同方法撞了 —— 按抛出的两条路径改 path,不改注册表。
- Step 5: Commit
git add packages/contracts/src/api/mail.ts packages/contracts/src/api/index.ts apps/api/src/app/__tests__/mail-routes-registry.test.ts
git commit -m "$(printf '邮件路由契约 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站列表 / 附件与原件 / 重新投递\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 4: 模块骨架 + domain:类型、错误、游标、到期
Files:
- Create:
apps/api/src/modules/mail/package.json·index.ts·contract.ts·port.ts·CONTRACT.md - Create:
apps/api/src/modules/mail/internal/domain/{records,errors,cursor,due}.ts - Test:
apps/api/src/modules/mail/__tests__/{cursor,due}.test.ts
Interfaces:
-
Produces:
MailService/InboundHandler(contract)· 全部端口类型(port)· 记录类型(records)· 11 个错误类 ·MailCursor与游标函数 ·leaseMsForisDueisPolling。后续任务只往index.ts追加导出行。 -
Step 1: 写失败测试(游标与到期)
// apps/api/src/modules/mail/__tests__/cursor.test.ts
/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */
import { describe, expect, it } from 'vitest';
import {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
WECOM_OVERLAP_SEC,
} from '../internal/domain/cursor.ts';
describe('imap 游标', () => {
it('逐封推进只往前不往后', () => {
const c = imapCursor('7', 100);
expect(advanceImap(c, 105).lastUid).toBe(105);
expect(advanceImap(c, 90).lastUid).toBe(100);
});
it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => {
expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false);
expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true);
expect(imapNeedsReanchor(null, '7')).toBe(true);
expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true);
});
it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => {
expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]);
expect(newUids([100], 100)).toEqual([]);
});
});
describe('wecom 游标', () => {
const now = new Date('2026-08-25T10:00:00Z');
it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => {
const w = wecomWindow(wecomCursor(1_000_000), null, now);
expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) });
});
it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => {
const end = Math.floor(now.getTime() / 1000);
expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end });
const since = new Date('2026-08-20T00:00:00Z');
expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000));
});
it('lastTime 往前退不会退成负数', () => {
expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0);
});
});
describe('摘要与解析', () => {
it('人读摘要;没拉过是 null', () => {
expect(cursorSummary(null)).toBeNull();
expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7');
expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z');
});
it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => {
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3));
expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5));
expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull();
expect(parseCursor(null)).toBeNull();
expect(parseCursor('garbage')).toBeNull();
});
});
// apps/api/src/modules/mail/__tests__/due.test.ts
/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */
import { describe, expect, it } from 'vitest';
import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts';
const now = new Date('2026-08-25T10:00:00Z');
const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null };
describe('isDue', () => {
it('从没拉过 → 到期;停用 → 永不到期', () => {
expect(isDue(base, now)).toBe(true);
expect(isDue({ ...base, enabled: false }, now)).toBe(false);
});
it('上次 + 间隔 <= 现在才到期', () => {
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false);
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true);
});
it('租约还没过期 → 别人在拉,不到期;过期了照常', () => {
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false);
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true);
});
});
describe('leaseMsFor / isPolling', () => {
it('租约 = max(5 分钟, 3 × 间隔)', () => {
expect(leaseMsFor(30)).toBe(MIN_LEASE_MS);
expect(leaseMsFor(600)).toBe(1_800_000);
});
it('polling 只在租约未过期时为真', () => {
expect(isPolling(null, now)).toBe(false);
expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true);
expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false);
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/cursor.test.ts apps/api/src/modules/mail/__tests__/due.test.ts
Expected: FAIL(模块不存在)。
- Step 3: 写骨架与 domain
// apps/api/src/modules/mail/package.json
{
"name": "@modules/mail",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
// apps/api/src/modules/mail/internal/domain/errors.ts
/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */
export class MailAccountNotFoundError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号不存在:${accountId}`);
this.name = 'MailAccountNotFoundError';
}
}
export class MailAccountNameTakenError extends Error {
constructor(readonly accountName: string) {
super(`邮箱账号名已被占用:${accountName}`);
this.name = 'MailAccountNameTakenError';
}
}
export class MailAccountDisabledError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号已停用:${accountId}`);
this.name = 'MailAccountDisabledError';
}
}
export class MailNoDefaultAccountError extends Error {
constructor() {
super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认');
this.name = 'MailNoDefaultAccountError';
}
}
export class MailSecretMissingError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`);
this.name = 'MailSecretMissingError';
}
}
export class MailPollBusyError extends Error {
constructor(readonly accountId: string) {
super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`);
this.name = 'MailPollBusyError';
}
}
export class MailInboundNotFoundError extends Error {
constructor(readonly mailId: string) {
super(`这封信不在了:${mailId}`);
this.name = 'MailInboundNotFoundError';
}
}
export class MailAttachmentNotFoundError extends Error {
constructor(readonly mailId: string, readonly index: number) {
super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`);
this.name = 'MailAttachmentNotFoundError';
}
}
export class MailSubscriberUnknownError extends Error {
constructor(readonly subscriber: string) {
super(`没有这个订阅方:${subscriber}`);
this.name = 'MailSubscriberUnknownError';
}
}
export class MailSubscriberTakenError extends Error {
constructor(readonly subscriber: string) {
super(`订阅方已注册,不许重名:${subscriber}`);
this.name = 'MailSubscriberTakenError';
}
}
export class MailSendInvalidError extends Error {
constructor(readonly problems: readonly string[]) {
super(`发信入参不合法:${problems.join('; ')}`);
this.name = 'MailSendInvalidError';
}
}
// apps/api/src/modules/mail/internal/domain/cursor.ts
/**
* 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。
* IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。
*/
export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number };
export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number };
export type MailCursor = ImapCursor | WecomCursor;
/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */
export const WECOM_OVERLAP_SEC = 600;
export function imapCursor(uidValidity: string, lastUid: number): ImapCursor {
return { kind: 'imap', uidValidity, lastUid };
}
export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor {
return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor;
}
export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean {
return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity;
}
/**
* IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)——
* 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
*/
export function newUids(uids: readonly number[], lastUid: number): number[] {
return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b);
}
export function wecomCursor(lastTime: number): WecomCursor {
return { kind: 'wecom', lastTime };
}
export function wecomWindow(
cursor: MailCursor | null,
backfillFrom: Date | null,
now: Date,
): { beginTime: number; endTime: number } {
const endTime = Math.floor(now.getTime() / 1000);
if (cursor !== null && cursor.kind === 'wecom') {
return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime };
}
if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime };
// 无游标不回填:窗口空,这一轮只是把游标锚在当下
return { beginTime: endTime, endTime };
}
export function cursorSummary(cursor: MailCursor | null): string | null {
if (cursor === null) return null;
if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`;
return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`;
}
/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */
export function parseCursor(raw: unknown): MailCursor | null {
if (typeof raw !== 'object' || raw === null) return null;
const c = raw as Record<string, unknown>;
if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && typeof c['lastUid'] === 'number') {
return imapCursor(c['uidValidity'], c['lastUid']);
}
if (c['kind'] === 'wecom' && typeof c['lastTime'] === 'number') return wecomCursor(c['lastTime']);
return null;
}
// apps/api/src/modules/mail/internal/domain/due.ts
/** 到期判定与租约时长(设计稿 §9.1) */
export const MIN_LEASE_MS = 5 * 60_000;
/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */
export function leaseMsFor(pollIntervalSec: number): number {
return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000);
}
export function isPolling(pollingUntil: Date | null, now: Date): boolean {
return pollingUntil !== null && pollingUntil.getTime() > now.getTime();
}
export function isDue(
account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null },
now: Date,
): boolean {
if (!account.enabled) return false;
if (isPolling(account.pollingUntil, now)) return false;
if (account.lastPolledAt === null) return true;
return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime();
}
// apps/api/src/modules/mail/internal/domain/records.ts
/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */
import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts';
import type { MailCursor } from './cursor.ts';
export type MailAccountRecord = {
readonly id: string;
readonly kind: MailAccountKind;
readonly name: string;
readonly address: string;
readonly displayName: string;
readonly enabled: boolean;
readonly isDefault: boolean;
readonly pollIntervalSec: number;
readonly channelId: string | null;
readonly backfillFrom: Date | null;
/** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */
readonly settings: Record<string, unknown>;
/** 密封的 JSON:{password} | {corpSecret};永不出接口 */
readonly secretCipher: string | null;
readonly cursor: MailCursor | null;
readonly lastPolledAt: Date | null;
readonly lastPollError: string | null;
readonly lastInboundAt: Date | null;
readonly pollingUntil: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
};
export type InboundAttachmentRecord = {
readonly index: number;
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
readonly note: string;
};
export type InboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly providerRef: string;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly from: MailAddress;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly sentAt: Date | null;
readonly receivedAt: Date;
readonly attachments: readonly InboundAttachmentRecord[];
readonly raw: Uint8Array | null;
readonly rawNote: string;
readonly size: number;
readonly createdAt: Date;
};
export type DeliveryRecord = {
readonly mailId: string;
readonly subscriber: string;
readonly state: MailDeliveryState;
readonly error: string | null;
readonly startedAt: Date;
readonly finishedAt: Date | null;
};
export type OutboundAttachmentRecord = {
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
};
export type OutboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly idempotencyKey: string | null;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly attachments: readonly OutboundAttachmentRecord[];
readonly state: MailOutboundState;
readonly error: string | null;
readonly providerRef: string | null;
readonly requestedBy: Record<string, unknown>;
readonly createdAt: Date;
readonly sentAt: Date | null;
};
export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array };
/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */
export type ParsedMail = {
readonly messageId?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly from?: MailAddress | undefined;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly date?: Date | undefined;
readonly attachments: readonly ParsedAttachment[];
};
/** 交给 provider 的出信 —— 附件已是字节 */
export type OutboundMessage = {
readonly messageId: string;
readonly from: MailAddress;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly attachments: readonly ParsedAttachment[];
};
// apps/api/src/modules/mail/contract.ts
/**
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
*/
import type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
} from '@contracts';
export type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
};
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './internal/domain/errors.ts';
/**
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
*/
export type InboundHandler = (
mail: MailInboundDetail,
ctx: { readonly accountId: string; readonly channelId: string | undefined },
) => Promise<void | { readonly skipped: string }>;
export type MailService = {
/* ── 账号 ── */
listAccounts(): Promise<MailAccountView[]>;
getAccount(accountId: string): Promise<MailAccountView>;
createAccount(input: MailAccountInput): Promise<MailAccountView>;
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
/** 留痕不删(入站 / 出站表对账号不设外键) */
deleteAccount(accountId: string): Promise<void>;
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
/* ── 收 ── */
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
pollDue(now: Date): Promise<MailPollOutcome[]>;
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
pollNow(accountId: string): Promise<MailPollOutcome>;
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
subscribe(name: string, handler: InboundHandler): () => void;
listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
getInbound(mailId: string): Promise<MailInboundDetail>;
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>;
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
/* ── 发 ── */
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
send(input: MailSendInput): Promise<MailSendResult>;
listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
};
// apps/api/src/modules/mail/port.ts
/**
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import type { MailCursor } from './internal/domain/cursor.ts';
import type {
DeliveryRecord,
InboundRecord,
MailAccountRecord,
OutboundMessage,
OutboundRecord,
ParsedMail,
} from './internal/domain/records.ts';
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type MailAccountStore = {
list(): Promise<MailAccountRecord[]>;
get(id: string): Promise<MailAccountRecord | undefined>;
findByName(name: string): Promise<MailAccountRecord | undefined>;
/** upsert;`isDefault:true` 时先把别的置 false(同一事务) */
save(record: MailAccountRecord): Promise<void>;
remove(id: string): Promise<void>;
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
listDue(now: Date): Promise<MailAccountRecord[]>;
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
claimPolling(id: string, leaseMs: number): Promise<boolean>;
releasePolling(id: string): Promise<void>;
/** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */
savePoll(
id: string,
patch: {
cursor?: MailCursor;
polledAt?: Date;
error?: string | null;
lastInboundAt?: Date;
address?: string;
},
): Promise<void>;
};
export type InboundStore = {
/** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */
insert(record: InboundRecord): Promise<{ inserted: boolean }>;
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
list(query: { accountId?: string | undefined; limit: number }): Promise<Array<{ record: InboundRecord; deliveries: DeliveryRecord[] }>>;
get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>;
/** upsert on (mail_id, subscriber) */
setDelivery(delivery: DeliveryRecord): Promise<void>;
};
export type OutboundStore = {
insert(record: OutboundRecord): Promise<void>;
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
list(query: { accountId?: string | undefined; limit: number }): Promise<OutboundRecord[]>;
};
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
export type AttachmentStore = {
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>;
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
};
/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
export type ProviderAccount = {
readonly id: string;
readonly kind: MailAccountKind;
readonly address: string;
readonly displayName: string;
readonly settings: Record<string, unknown>;
readonly secret: Record<string, string>;
};
export type RawMessage = {
readonly providerRef: string;
readonly eml: Uint8Array;
readonly receivedAt: Date;
/** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */
readonly cursorAfter?: MailCursor | undefined;
};
/** 外界:一种账号类型一个适配器(设计稿 §5.5) */
export type MailProvider = {
readonly kind: MailAccountKind;
probe(account: ProviderAccount): Promise<MailProbeReport>;
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
pull(
account: ProviderAccount,
cursor: MailCursor,
opts: {
readonly limit: number;
readonly now: Date;
readonly backfillFrom: Date | null;
/** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */
readonly known: (refs: readonly string[]) => Promise<Set<string>>;
},
): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>;
};
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
export type ChangeNotifier = { changed(reason: string): void };
// apps/api/src/modules/mail/index.ts
/**
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
InboundHandler,
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
MailService,
} from './contract.ts';
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './contract.ts';
export type {
AttachmentStore,
ChangeNotifier,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
MimeParser,
OutboundMessage,
OutboundRecord,
OutboundStore,
ParsedMail,
ProviderAccount,
RawMessage,
SecretBox,
} from './port.ts';
export {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
} from './internal/domain/cursor.ts';
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
<!-- apps/api/src/modules/mail/CONTRACT.md -->
# module: mail
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
SSE(app 经 `ChangeNotifier` 端口接到 broadcaster)
公开出口: index.ts
- 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` /
`MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` /
`MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出
- 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412)
`MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409)
`MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404)
`MailSubscriberTakenError` `MailSendInvalidError`(400)
- 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser`
`SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord`
`OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor`
- 用例工厂:`createMailService(deps: MailServiceDeps): MailService`
- 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow`
`cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、
入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`;
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime
被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块:
channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`;
`mail.send` 工具处理器在 app 里闭包持本服务
不变量(ADR-013):
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
- **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
wecom 的 `address` 探到之前是空串
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: cursor 7 + due 5 = 12 tests PASS;三道门禁退出码 0(no-orphans 对 index/contract/port 与 domain 文件:records / errors 被 contract.ts 与 port.ts 引用,cursor / due 被 index.ts 引用,不算孤儿)。
- Step 5: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 5: domain:入站记录与脱敏
Files:
- Create:
apps/api/src/modules/mail/internal/domain/inbound-record.ts·redact.ts - Modify:
apps/api/src/modules/mail/index.ts(追加导出) - Test:
apps/api/src/modules/mail/__tests__/{inbound-record,redact}.test.ts
Interfaces:
-
Produces:
ATTACHMENT_CAPRAW_CAPPREVIEW_CHARSfallbackMessageId(providerRef, address)threadIdOf(parsed)stripHtml(html)bodyTextOf(parsed)textPreviewOf(text, html)planAttachments(attachments, cap?)→AttachmentPlan[]rawOf(eml, cap?)buildInboundRecord(input)deliveryViewOf(d)summaryOf(record, deliveries)detailOf(record, deliveries);secretVariants(secret)redactSecrets(text, secrets)errorText(error)。 -
Step 1: 写失败测试
// apps/api/src/modules/mail/__tests__/inbound-record.test.ts
/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */
import { describe, expect, it } from 'vitest';
import type { ParsedMail } from '../internal/domain/records.ts';
import {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
fallbackMessageId,
planAttachments,
rawOf,
stripHtml,
summaryOf,
textPreviewOf,
threadIdOf,
} from '../internal/domain/inbound-record.ts';
const parsed: ParsedMail = {
messageId: '<m1@acme.com>',
inReplyTo: '<q1@we.com>',
references: ['<root@we.com>', '<q1@we.com>'],
from: { address: 'buyer@acme.com', name: '张三' },
to: [{ address: 'sales@we.com', name: '' }],
cc: [],
subject: '询价 RFQ-1',
text: '你好,请报价',
html: '<p>你好,请报价</p>',
date: new Date('2026-08-25T09:00:00Z'),
attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }],
};
const now = new Date('2026-08-25T10:00:00Z');
describe('线程与正文', () => {
it('线程 = In-Reply-To;没有就取 References 末项;都没有就是 undefined', () => {
expect(threadIdOf(parsed)).toBe('<q1@we.com>');
expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<b>');
expect(threadIdOf({ references: [] })).toBeUndefined();
});
it('缺 Message-ID 用 providerRef@域顶上,不抛', () => {
expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe('<uid:7:12@we.com>');
expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
});
it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
expect(stripHtml('<style>p{}</style><p>你好&<b>世界</b></p>\n<script>x()</script> 再见')).toBe('你好&世界 再见');
expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
expect(bodyTextOf({})).toBe('');
});
it('预览 200 字', () => {
expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
expect(textPreviewOf(null, '<p>来自 html</p>')).toBe('来自 html');
});
});
describe('附件与原件', () => {
it('超上限的附件不存,留注;下标按原顺序', () => {
const plan = planAttachments(
[
{ name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) },
{ name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) },
],
ATTACHMENT_CAP,
);
expect(plan.map((p) => [p.index, p.store])).toEqual([
[0, true],
[1, false],
]);
expect(plan[1]!.note).toContain('8MB');
});
it('原件超上限留空 + 注', () => {
expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' });
const big = rawOf(new Uint8Array(9), 8);
expect(big.raw).toBeNull();
expect(big.note).not.toBe('');
});
});
describe('buildInboundRecord / 视图', () => {
const record = buildInboundRecord({
id: 'in-1',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid:7:12',
parsed,
eml: new Uint8Array([1, 2, 3]),
receivedAt: now,
attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }],
now,
});
it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => {
expect(record.messageId).toBe('<m1@acme.com>');
expect(record.inReplyTo).toBe('<q1@we.com>');
expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(record.size).toBe(3);
expect(record.raw).toEqual(new Uint8Array([1, 2, 3]));
expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z');
});
it('缺 from / 缺 Message-ID 也能落记录', () => {
const bare = buildInboundRecord({
id: 'in-2',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid:7:13',
parsed: { references: [], to: [], cc: [], subject: '', attachments: [] },
eml: new Uint8Array(),
receivedAt: now,
attachments: [],
now,
});
expect(bare.messageId).toBe('<uid:7:13@we.com>');
expect(bare.from).toEqual({ address: '', name: '' });
});
it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => {
const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }];
const summary = summaryOf(record, deliveries);
expect(summary.textPreview).toBe('你好,请报价');
expect(summary.hasHtml).toBe(true);
expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() });
expect('text' in summary).toBe(false);
const detail = detailOf(record, deliveries);
expect(detail.text).toBe('你好,请报价');
expect(detail.rawAvailable).toBe(true);
expect(detail.references).toEqual(['<root@we.com>', '<q1@we.com>']);
});
});
// apps/api/src/modules/mail/__tests__/redact.test.ts
/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */
import { describe, expect, it } from 'vitest';
import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts';
describe('redactSecrets', () => {
it('三形都换成 ***', () => {
const secret = 'p@ss w0rd';
const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`;
expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***');
});
it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => {
expect(secretVariants('')).toEqual([]);
expect(secretVariants('abc')).toEqual([]);
expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc');
});
it('没命中原样返回;正则元字符当字面', () => {
expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here');
expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***');
});
});
describe('errorText', () => {
it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => {
expect(errorText(new Error('boom'))).toBe('boom');
const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' });
expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed');
expect(errorText('raw')).toBe('raw');
expect(errorText(undefined)).toBe('未知错误');
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/inbound-record.test.ts apps/api/src/modules/mail/__tests__/redact.test.ts
Expected: FAIL(模块不存在)。
- Step 3: 写实现
// apps/api/src/modules/mail/internal/domain/inbound-record.ts
/**
* 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做,
* 存完把 ref 交回来再 `buildInboundRecord`。
*/
import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts';
/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
export const RAW_CAP = 16 * 1024 * 1024;
export const PREVIEW_CHARS = 200;
function domainOf(address: string): string {
const at = address.lastIndexOf('@');
return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
}
/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */
export function fallbackMessageId(providerRef: string, address: string): string {
return `<${providerRef}@${domainOf(address)}>`;
}
/** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */
export function threadIdOf(parsed: Pick<ParsedMail, 'inReplyTo' | 'references'>): string | undefined {
if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo;
return parsed.references.at(-1);
}
const ENTITIES: Readonly<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
export function stripHtml(html: string): string {
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<br\s*\/?>|<\/(p|div|tr|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {
if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1)));
return ENTITIES[code.toLowerCase()] ?? whole;
})
.replace(/[ \t\r\f\v]+/g, ' ')
.replace(/\s*\n\s*/g, ' ')
.trim();
}
export function bodyTextOf(parsed: Pick<ParsedMail, 'text' | 'html'>): string {
if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text;
if (parsed.html !== undefined) return stripHtml(parsed.html);
return '';
}
export function textPreviewOf(text: string | null, html: string | null): string {
const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) });
return body.slice(0, PREVIEW_CHARS);
}
export type AttachmentPlan = {
readonly index: number;
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly store: boolean;
readonly note: string;
};
export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] {
return attachments.map((a, index) => {
const size = a.bytes.byteLength;
const store = size <= cap;
return {
index,
name: a.name === '' ? `attachment-${index + 1}` : a.name,
contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType,
size,
store,
note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`,
};
});
}
export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } {
if (eml.byteLength <= cap) return { raw: eml, note: '' };
return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` };
}
export function buildInboundRecord(input: {
readonly id: string;
readonly accountId: string;
readonly address: string;
readonly providerRef: string;
readonly parsed: ParsedMail;
readonly eml: Uint8Array;
readonly receivedAt: Date;
readonly attachments: readonly InboundAttachmentRecord[];
readonly now: Date;
readonly rawCap?: number | undefined;
}): InboundRecord {
const { parsed } = input;
const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address);
const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP);
return {
id: input.id,
accountId: input.accountId,
messageId,
providerRef: input.providerRef,
inReplyTo: parsed.inReplyTo ?? null,
references: [...parsed.references],
from: parsed.from ?? { address: '', name: '' },
to: [...parsed.to],
cc: [...parsed.cc],
subject: parsed.subject,
text: parsed.text ?? null,
html: parsed.html ?? null,
sentAt: parsed.date ?? null,
receivedAt: input.receivedAt,
attachments: [...input.attachments],
raw,
rawNote: note,
size: input.eml.byteLength,
createdAt: input.now,
};
}
export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView {
return {
subscriber: d.subscriber,
state: d.state,
error: d.error,
startedAt: d.startedAt.toISOString(),
finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(),
};
}
export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary {
return {
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
from: record.from,
to: [...record.to],
cc: [...record.cc],
subject: record.subject,
sentAt: record.sentAt === null ? null : record.sentAt.toISOString(),
receivedAt: record.receivedAt.toISOString(),
textPreview: textPreviewOf(record.text, record.html),
hasHtml: record.html !== null,
attachments: record.attachments.map((a) => ({ ...a })),
deliveries: deliveries.map(deliveryViewOf),
};
}
export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail {
return {
...summaryOf(record, deliveries),
inReplyTo: record.inReplyTo,
references: [...record.references],
text: record.text,
html: record.html,
size: record.size,
rawAvailable: record.raw !== null,
};
}
// apps/api/src/modules/mail/internal/domain/redact.ts
/**
* 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。
* 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。
*/
const MIN_SECRET_CHARS = 4;
export function secretVariants(secret: string): string[] {
if (secret.length < MIN_SECRET_CHARS) return [];
const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)];
return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function redactSecrets(text: string, secrets: readonly string[]): string {
let out = text;
for (const secret of secrets) {
for (const variant of secretVariants(secret)) {
out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***');
}
}
return out;
}
/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */
export function errorText(error: unknown): string {
if (error instanceof Error) {
const bag = error as Error & { response?: unknown; responseText?: unknown };
const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : '';
return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`;
}
if (error === undefined || error === null) return '未知错误';
return String(error);
}
index.ts 追加:
export {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
planAttachments,
RAW_CAP,
stripHtml,
summaryOf,
threadIdOf,
} from './internal/domain/inbound-record.ts';
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail && pnpm lint
Expected: 全部 PASS;lint 退出码 0(domain 里 Buffer 是全局对象不是 import,不触 domain-is-pure-core)。
- Step 5: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 6: domain:发信校验、选账号、分发状态、账号视图
Files:
- Create:
apps/api/src/modules/mail/internal/domain/{send-input,delivery,account-view}.ts - Modify:
apps/api/src/modules/mail/index.ts(追加导出) - Test:
apps/api/src/modules/mail/__tests__/{send-input,delivery,account-view}.test.ts
Interfaces:
-
Produces:
validateSend(input, kind, sizes)→string[];pickAccount(accounts, {accountId?, from?})→MailAccountRecord(抛MailAccountNotFoundError/MailAccountDisabledError/MailNoDefaultAccountError);messageIdFor(id, address);canRedeliver(state);stateOfOutcome(outcome);viewOf(record, now)→MailAccountView;secretOf(input)→string | undefined(JSON);cursorReset(existing, input);mergeAccount(input, base)→MailAccountRecord。 -
Step 1: 写失败测试
// apps/api/src/modules/mail/__tests__/send-input.test.ts
/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */
import { describe, expect, it } from 'vitest';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts';
import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts';
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
return {
id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null,
lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
...over,
};
}
const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] };
describe('validateSend', () => {
it('合法入参零问题', () => {
expect(validateSend(base, 'imap-smtp', [])).toEqual([]);
});
it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => {
expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']);
expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB');
expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB');
});
it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => {
expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200');
expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]);
});
});
describe('pickAccount', () => {
const a = account({ id: 'a', isDefault: true });
const b = account({ id: 'b', address: 'b@we.com', enabled: false });
it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => {
expect(pickAccount([a, b], {}).id).toBe('a');
expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError);
});
it('指定 id:不存在 404、停用 412', () => {
expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a');
expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError);
expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError);
});
it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => {
expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a');
expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError);
expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError);
});
});
describe('messageIdFor', () => {
it('<id@域>;地址没有域用 local', () => {
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
});
});
// apps/api/src/modules/mail/__tests__/delivery.test.ts
/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */
import { describe, expect, it } from 'vitest';
import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts';
describe('delivery', () => {
it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => {
expect(canRedeliver(undefined)).toBe(true);
expect(canRedeliver('done')).toBe(true);
expect(canRedeliver('failed')).toBe(true);
expect(canRedeliver('skipped')).toBe(true);
expect(canRedeliver('dispatching')).toBe(false);
});
it('回调的返回值定状态', () => {
expect(stateOfOutcome(undefined)).toBe('done');
expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped');
});
});
// apps/api/src/modules/mail/__tests__/account-view.test.ts
/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts';
const now = new Date('2026-08-25T10:00:00Z');
const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } });
describe('mergeAccount / secretOf', () => {
it('新建:id / 时间 / 密文来自 base,游标空', () => {
const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now });
expect(record.id).toBe('acc-1');
expect(record.address).toBe('sales@we.com');
expect(record.secretCipher).toBe('sealed');
expect(record.cursor).toBeNull();
expect(record.createdAt).toBe(now);
expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' }));
expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined();
});
it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => {
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe('');
const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now });
const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' };
expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work');
});
it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => {
const existing: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
cursor: imapCursor('7', 9),
lastPollError: '旧错误',
};
expect(cursorReset(existing, input)).toBe(false);
const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now });
expect(same.cursor).toEqual(imapCursor('7', 9));
const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } });
expect(cursorReset(existing, changed)).toBe(true);
const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now });
expect(reset.cursor).toBeNull();
expect(reset.lastPollError).toBeNull();
});
});
describe('viewOf', () => {
it('不带密文只带 secretBound;status 带游标摘要与 polling', () => {
const record: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }),
cursor: imapCursor('7', 9),
pollingUntil: new Date(now.getTime() + 1000),
};
const view = viewOf(record, now);
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('sealed');
expect(view.status.cursor).toBe('UID 9 · validity 7');
expect(view.status.polling).toBe(true);
expect(view.kind).toBe('imap-smtp');
if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com');
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/send-input.test.ts apps/api/src/modules/mail/__tests__/delivery.test.ts apps/api/src/modules/mail/__tests__/account-view.test.ts
Expected: FAIL(模块不存在)。
- Step 3: 写实现
// apps/api/src/modules/mail/internal/domain/send-input.ts
/** 发信校验与选账号(设计稿 §5.3 / §5.4) */
import type { MailAccountKind, MailSendInput } from '@contracts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts';
import type { MailAccountRecord } from './records.ts';
export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024;
export const SEND_TOTAL_CAP = 20 * 1024 * 1024;
/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */
export const WECOM_TOTAL_CAP = 50 * 1024 * 1024;
export const WECOM_ATTACHMENT_COUNT_CAP = 200;
type SendShape = Pick<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;
export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] {
const problems: string[] = [];
if (input.to.length === 0) problems.push('收件人至少一个');
if (input.subject.trim() === '') problems.push('主题不能为空');
if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) {
problems.push('正文 text / html 至少一个');
}
const total = attachmentSizes.reduce((sum, n) => sum + n, 0);
if (kind === 'wecom') {
if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`);
if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB');
} else {
const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP);
if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`);
if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB');
}
return problems;
}
export function pickAccount(
accounts: readonly MailAccountRecord[],
requested: { readonly accountId?: string | undefined; readonly from?: string | undefined },
): MailAccountRecord {
if (requested.accountId !== undefined) {
const hit = accounts.find((a) => a.id === requested.accountId);
if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId);
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
return hit;
}
if (requested.from !== undefined) {
const wanted = requested.from.trim().toLowerCase();
const hit = accounts.find((a) => a.address.toLowerCase() === wanted);
// 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗
if (hit === undefined) throw new MailAccountNotFoundError(requested.from);
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
return hit;
}
const fallback = accounts.find((a) => a.isDefault && a.enabled);
if (fallback === undefined) throw new MailNoDefaultAccountError();
return fallback;
}
export function messageIdFor(id: string, address: string): string {
const at = address.lastIndexOf('@');
const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
return `<${id}@${domain}>`;
}
// apps/api/src/modules/mail/internal/domain/delivery.ts
/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */
import type { MailDeliveryState } from '@contracts';
export function canRedeliver(current: MailDeliveryState | undefined): boolean {
return current !== 'dispatching';
}
export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' {
return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done';
}
// apps/api/src/modules/mail/internal/domain/account-view.ts
/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */
import type { MailAccountInput, MailAccountView } from '@contracts';
import { mailAccountViewSchema } from '@contracts';
import { cursorSummary } from './cursor.ts';
import { isPolling } from './due.ts';
import type { MailAccountRecord } from './records.ts';
export function viewOf(record: MailAccountRecord, now: Date): MailAccountView {
return mailAccountViewSchema.parse({
id: record.id,
kind: record.kind,
name: record.name,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(),
settings: record.settings,
secretBound: record.secretCipher !== null && record.secretCipher !== '',
status: {
lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(),
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(),
cursor: cursorSummary(record.cursor),
polling: isPolling(record.pollingUntil, now),
},
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
});
}
/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */
export function secretOf(input: MailAccountInput): string | undefined {
return input.secret === undefined ? undefined : JSON.stringify(input.secret);
}
function sameSettings(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
return [...keys].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null));
}
/** kind 或 settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */
export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean {
if (existing === undefined) return false;
return existing.kind !== input.kind || !sameSettings(existing.settings, input.settings);
}
export function mergeAccount(
input: MailAccountInput,
base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date },
): MailAccountRecord {
const reset = cursorReset(base.existing, input);
const existing = base.existing;
return {
id: base.id,
kind: input.kind,
name: input.name,
// wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准
address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '',
displayName: input.displayName,
enabled: input.enabled,
isDefault: input.isDefault,
pollIntervalSec: input.pollIntervalSec,
channelId: input.channelId,
backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom),
settings: { ...input.settings },
secretCipher: base.secretCipher,
cursor: reset ? null : (existing?.cursor ?? null),
lastPolledAt: existing?.lastPolledAt ?? null,
lastPollError: reset ? null : (existing?.lastPollError ?? null),
lastInboundAt: existing?.lastInboundAt ?? null,
pollingUntil: existing?.pollingUntil ?? null,
createdAt: existing?.createdAt ?? base.now,
updatedAt: base.now,
};
}
index.ts 追加:
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 全部 PASS;三道门禁退出码 0。
- Step 5: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 7: 迁移 0033 + Drizzle 表 + 三个 store(真 PG 集成测试)
Files:
- Create:
db/migrations/0033_mail.sql;Modify:db/migrations/meta/_journal.json(追加 idx 33) - Create:
apps/api/src/modules/mail/internal/adapter/mail-schema.ts·mail-store.ts - Modify:
apps/api/src/modules/mail/index.ts(追加 adapter 与表导出);apps/api/src/app/reset-runs.ts(三张留痕表) - Test:
apps/api/src/modules/mail/__tests__/mail-store.it.test.ts
Interfaces:
-
Produces:
createMailAccountStore(db)createInboundStore(db)createOutboundStore(db)(各实现 port.ts 同名类型);表mailAccountTablemailInboundTablemailInboundDeliveryTablemailOutboundTable。 -
Step 1: 写迁移与 journal
-- db/migrations/0033_mail.sql
-- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。
--
-- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉:
-- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与
-- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。
-- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。
-- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。
create table if not exists sys.mail_account (
id text primary key,
name text not null,
kind text not null check (kind in ('imap-smtp', 'wecom')),
address text not null default '',
display_name text not null default '',
enabled boolean not null default true,
is_default boolean not null default false,
poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600),
channel_id text,
backfill_from timestamptz,
settings jsonb not null default '{}'::jsonb,
secret_cipher text,
cursor jsonb,
last_polled_at timestamptz,
last_poll_error text,
last_inbound_at timestamptz,
polling_until timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists mail_account_name_key on sys.mail_account (name);
-- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底
create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default;
create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
create table if not exists sys.mail_inbound (
id text primary key,
account_id text not null,
message_id text not null,
provider_ref text not null,
in_reply_to text,
reference_ids jsonb not null default '[]'::jsonb,
from_addr text not null default '',
from_name text not null default '',
to_list jsonb not null default '[]'::jsonb,
cc_list jsonb not null default '[]'::jsonb,
subject text not null default '',
text_body text,
html_body text,
sent_at timestamptz,
received_at timestamptz not null,
attachments jsonb not null default '[]'::jsonb,
raw bytea,
raw_note text not null default '',
size integer not null default 0,
created_at timestamptz not null default now()
);
create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc);
create table if not exists sys.mail_inbound_delivery (
mail_id text not null references sys.mail_inbound(id) on delete cascade,
subscriber text not null,
state text not null check (state in ('dispatching', 'done', 'failed', 'skipped')),
error text,
started_at timestamptz not null default now(),
finished_at timestamptz,
primary key (mail_id, subscriber)
);
create table if not exists sys.mail_outbound (
id text primary key,
account_id text not null,
message_id text not null,
idempotency_key text,
to_list jsonb not null,
cc_list jsonb not null default '[]'::jsonb,
bcc_list jsonb not null default '[]'::jsonb,
subject text not null,
text_body text,
html_body text,
in_reply_to text,
reference_ids jsonb not null default '[]'::jsonb,
attachments jsonb not null default '[]'::jsonb,
state text not null check (state in ('sending', 'sent', 'failed')),
error text,
provider_ref text,
requested_by jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
sent_at timestamptz
);
create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc);
db/migrations/meta/_journal.json 的 entries 末尾追加(注意上一条后面补逗号):
{
"idx": 33,
"version": "7",
"when": 1785130120181,
"tag": "0033_mail",
"breakpoints": true
}
apps/api/src/app/reset-runs.ts 的 RUN_TABLES 在 'sys.outbound_mail', 之后加:
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
'sys.mail_inbound',
'sys.mail_inbound_delivery',
'sys.mail_outbound',
- Step 2: 写失败测试(真 PG)
// apps/api/src/modules/mail/__tests__/mail-store.it.test.ts
/**
* mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、
* 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。
*/
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { startItDatabase } from '@testing';
import type { ItDatabase } from '@testing';
import type { Db } from '@shared/db.ts';
import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts';
const database: ItDatabase | undefined = await startItDatabase();
let poolA: Pool | undefined;
let poolB: Pool | undefined;
let db: Db;
let dbB: Db;
const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url);
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
return {
id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z',
cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
...over,
};
}
function inbound(over: Partial<InboundRecord>): InboundRecord {
return {
id: 'in-1', accountId: 'acc-a', messageId: '<m1@x>', providerRef: 'uid:7:1', inReplyTo: null, references: [],
from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null,
sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawNote: '', size: 3, createdAt: now,
...over,
};
}
function outbound(over: Partial<OutboundRecord>): OutboundRecord {
return {
id: 'out-1', accountId: 'acc-a', messageId: '<o1@we.com>', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [],
subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null,
providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null,
...over,
};
}
beforeAll(async () => {
if (database === undefined) return;
poolA = new Pool({ connectionString: database.url, max: 2 });
poolB = new Pool({ connectionString: database.url, max: 2 });
await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade');
await poolA.query(readFileSync(MIGRATION, 'utf8'));
db = drizzle(poolA);
dbB = drizzle(poolB);
});
afterAll(async () => {
await poolA?.end();
await poolB?.end();
await database?.stop();
});
describe.skipIf(database === undefined)('mail store(真 PG)', () => {
it('账号:save / get / findByName / list;isDefault 只留一个', async () => {
const store = createMailAccountStore(db);
await store.save(account({ id: 'acc-a', name: '甲', isDefault: true }));
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true }));
expect((await store.get('acc-a'))?.isDefault).toBe(false);
expect((await store.get('acc-b'))?.isDefault).toBe(true);
expect((await store.findByName('甲'))?.id).toBe('acc-a');
expect((await store.list()).map((a) => a.id)).toEqual(['acc-a', 'acc-b']);
});
it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => {
const store = createMailAccountStore(db);
await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null });
const got = await store.get('acc-a');
expect(got?.cursor).toEqual(imapCursor('7', 12));
expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString());
await store.savePoll('acc-a', { error: '连不上' });
expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12));
expect((await store.get('acc-a'))?.lastPollError).toBe('连不上');
await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined();
});
it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => {
const store = createMailAccountStore(db);
await store.save(account({ id: 'acc-a', name: '甲', lastPolledAt: new Date(now.getTime() - 60_000) }));
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', lastPolledAt: new Date(now.getTime() - 5_000) }));
await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false }));
await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) }));
expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a']);
});
it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => {
const a = createMailAccountStore(db);
const b = createMailAccountStore(dbB);
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' }));
for (let round = 0; round < 20; round += 1) {
const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]);
expect(wins.filter(Boolean)).toHaveLength(1);
await a.releasePolling('acc-lease');
}
expect(await a.claimPolling('acc-lease', 60_000)).toBe(true);
expect(await b.claimPolling('acc-lease', 60_000)).toBe(false);
await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`);
expect(await b.claimPolling('acc-lease', 60_000)).toBe(true);
await b.releasePolling('acc-lease');
// 停用的账号抢不到
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false }));
expect(await a.claimPolling('acc-lease', 60_000)).toBe(false);
});
it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => {
const store = createInboundStore(db);
expect(await store.insert(inbound({}))).toEqual({ inserted: true });
expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false });
expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '<other@x>' }))).toEqual({ inserted: false });
expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1']));
expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set());
const got = await store.get('in-1');
expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3]));
expect(got?.record.raw).toBeInstanceOf(Uint8Array);
expect(Buffer.isBuffer(got?.record.raw)).toBe(false);
});
it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => {
const store = createInboundStore(db);
await store.insert(inbound({ id: 'in-2', messageId: '<m2@x>', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) }));
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null });
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now });
const list = await store.list({ accountId: 'acc-a', limit: 10 });
expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']);
expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]);
expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]);
expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']);
});
it('出站:insert / byIdempotencyKey / setState / list', async () => {
const store = createOutboundStore(db);
await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' }));
await store.insert(outbound({ id: 'out-2', messageId: '<o2@we.com>' }));
expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1');
expect(await store.byIdempotencyKey('k-none')).toBeUndefined();
await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now });
await store.setState('out-2', 'failed', { error: '535 auth failed' });
const list = await store.list({ accountId: 'acc-a', limit: 10 });
expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' });
expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' });
// 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在
await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow();
});
});
- Step 3: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts
Expected: FAIL(mail-store.ts 不存在)。本机 pnpm db:up 起着 PG 时用 localhost:54329;起不了就走 Testcontainers;两者皆无 → 套件跳过并告警(那样本任务不算验过,先把库起来)。
- Step 4: 写表与 store
// apps/api/src/modules/mail/internal/adapter/mail-schema.ts
/**
* mail 的 sys schema 表(编码规范 §7 / ADR-002)。
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。
*/
import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import type { MailAddress } from '@contracts';
import { sys } from '@shared/db.ts';
/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
dataType() {
return 'bytea';
},
});
export const mailAccountTable = sys.table('mail_account', {
id: text('id').primaryKey(),
name: text('name').notNull(),
kind: text('kind').notNull(),
address: text('address').notNull().default(''),
displayName: text('display_name').notNull().default(''),
enabled: boolean('enabled').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false),
pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
channelId: text('channel_id'),
backfillFrom: timestamp('backfill_from', { withTimezone: true }),
settings: jsonb('settings').$type<Record<string, unknown>>().notNull().default({}),
/** AES-256-GCM 密文;永不出接口 */
secretCipher: text('secret_cipher'),
cursor: jsonb('cursor'),
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
lastPollError: text('last_poll_error'),
lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }),
/** 跨进程租约 —— 与 sys.task.advancing_until 同款 */
pollingUntil: timestamp('polling_until', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundTable = sys.table('mail_inbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
providerRef: text('provider_ref').notNull(),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
fromAddr: text('from_addr').notNull().default(''),
fromName: text('from_name').notNull().default(''),
toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
ccList: jsonb('cc_list').$type<MailAddress[]>().notNull().default([]),
subject: text('subject').notNull().default(''),
textBody: text('text_body'),
htmlBody: text('html_body'),
sentAt: timestamp('sent_at', { withTimezone: true }),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
attachments: jsonb('attachments')
.$type<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
.notNull()
.default([]),
raw: bytea('raw'),
rawNote: text('raw_note').notNull().default(''),
size: integer('size').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', {
mailId: text('mail_id').notNull(),
subscriber: text('subscriber').notNull(),
state: text('state').notNull(),
error: text('error'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
});
export const mailOutboundTable = sys.table('mail_outbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
idempotencyKey: text('idempotency_key'),
toList: jsonb('to_list').$type<string[]>().notNull(),
ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
subject: text('subject').notNull(),
textBody: text('text_body'),
htmlBody: text('html_body'),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
state: text('state').notNull(),
error: text('error'),
providerRef: text('provider_ref'),
requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
sentAt: timestamp('sent_at', { withTimezone: true }),
});
// apps/api/src/modules/mail/internal/adapter/mail-store.ts
/**
* mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
* 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type {
DeliveryRecord,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
OutboundRecord,
OutboundStore,
} from '../../port.ts';
import { parseCursor } from '../domain/cursor.ts';
import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts';
function rowsOf(result: unknown): unknown[] {
if (Array.isArray(result)) return result;
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
/* ── 账号 ─────────────────────────────────────────────────────────────── */
type AccountRow = typeof mailAccountTable.$inferSelect;
function toAccount(row: AccountRow): MailAccountRecord {
return {
id: row.id,
kind: mailAccountKindSchema.parse(row.kind),
name: row.name,
address: row.address,
displayName: row.displayName,
enabled: row.enabled,
isDefault: row.isDefault,
pollIntervalSec: row.pollIntervalSec,
channelId: row.channelId,
backfillFrom: row.backfillFrom,
settings: row.settings,
secretCipher: row.secretCipher,
cursor: parseCursor(row.cursor),
lastPolledAt: row.lastPolledAt,
lastPollError: row.lastPollError,
lastInboundAt: row.lastInboundAt,
pollingUntil: row.pollingUntil,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert {
return {
id: record.id,
name: record.name,
kind: record.kind,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom,
settings: record.settings,
secretCipher: record.secretCipher,
cursor: record.cursor,
lastPolledAt: record.lastPolledAt,
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt,
pollingUntil: record.pollingUntil,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
export function createMailAccountStore(db: Db): MailAccountStore {
return {
async list() {
const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async get(id) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async findByName(name) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async save(record) {
const values = accountValues(record);
await db.transaction(async (tx) => {
// 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底
if (record.isDefault) {
await tx
.update(mailAccountTable)
.set({ isDefault: false })
.where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`));
}
await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values });
});
},
async remove(id) {
await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id));
},
async listDue(now) {
const rows = await db
.select()
.from(mailAccountTable)
.where(
sql`${mailAccountTable.enabled}
and (${mailAccountTable.lastPolledAt} is null
or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now})
and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`,
)
.orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async claimPolling(id, leaseMs) {
const result = await db.execute(sql`
update sys.mail_account
set polling_until = now() + (${leaseMs} || ' milliseconds')::interval
where id = ${id} and enabled
and (polling_until is null or polling_until < now())
returning id`);
return rowsOf(result).length > 0;
},
async releasePolling(id) {
// 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉
await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined);
},
async savePoll(id, patch) {
const set: Partial<typeof mailAccountTable.$inferInsert> = {};
if (patch.cursor !== undefined) set.cursor = patch.cursor;
if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt;
if (patch.error !== undefined) set.lastPollError = patch.error;
if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt;
if (patch.address !== undefined) set.address = patch.address;
if (Object.keys(set).length === 0) return;
await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id));
},
};
}
/* ── 入站 ─────────────────────────────────────────────────────────────── */
type InboundRow = typeof mailInboundTable.$inferSelect;
type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect;
function toInbound(row: InboundRow): InboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
providerRef: row.providerRef,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
from: { address: row.fromAddr, name: row.fromName },
to: row.toList,
cc: row.ccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
attachments: row.attachments,
/*
* pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 ——
* 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。
*/
raw: row.raw === null ? null : Uint8Array.from(row.raw),
rawNote: row.rawNote,
size: row.size,
createdAt: row.createdAt,
};
}
function toDelivery(row: DeliveryRow): DeliveryRecord {
return {
mailId: row.mailId,
subscriber: row.subscriber,
state: mailDeliveryStateSchema.parse(row.state),
error: row.error,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
};
}
export function createInboundStore(db: Db): InboundStore {
async function deliveriesOf(mailIds: readonly string[]): Promise<Map<string, DeliveryRecord[]>> {
const out = new Map<string, DeliveryRecord[]>();
if (mailIds.length === 0) return out;
const rows = await db
.select()
.from(mailInboundDeliveryTable)
.where(inArray(mailInboundDeliveryTable.mailId, [...mailIds]))
.orderBy(mailInboundDeliveryTable.subscriber);
for (const row of rows) {
const list = out.get(row.mailId) ?? [];
list.push(toDelivery(row));
out.set(row.mailId, list);
}
return out;
}
return {
async insert(record) {
const rows = await db
.insert(mailInboundTable)
.values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
providerRef: record.providerRef,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
fromAddr: record.from.address,
fromName: record.from.name,
toList: [...record.to],
ccList: [...record.cc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
sentAt: record.sentAt,
receivedAt: record.receivedAt,
attachments: [...record.attachments],
raw: record.raw === null ? null : Buffer.from(record.raw),
rawNote: record.rawNote,
size: record.size,
createdAt: record.createdAt,
})
.onConflictDoNothing()
.returning({ id: mailInboundTable.id });
return { inserted: rows.length > 0 };
},
async knownProviderRefs(accountId, refs) {
if (refs.length === 0) return new Set();
const rows = await db
.select({ providerRef: mailInboundTable.providerRef })
.from(mailInboundTable)
.where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs])));
return new Set(rows.map((r) => r.providerRef));
},
async list(query) {
const rows = await db
.select()
.from(mailInboundTable)
.where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId))
.orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt))
.limit(query.limit);
const deliveries = await deliveriesOf(rows.map((r) => r.id));
return rows.map((row) => ({ record: toInbound(row), deliveries: deliveries.get(row.id) ?? [] }));
},
async get(mailId) {
const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
const deliveries = await deliveriesOf([mailId]);
return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] };
},
async setDelivery(delivery) {
const values = {
mailId: delivery.mailId,
subscriber: delivery.subscriber,
state: delivery.state,
error: delivery.error,
startedAt: delivery.startedAt,
finishedAt: delivery.finishedAt,
};
await db
.insert(mailInboundDeliveryTable)
.values(values)
.onConflictDoUpdate({
target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber],
set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt },
});
},
};
}
/* ── 出站 ─────────────────────────────────────────────────────────────── */
type OutboundRow = typeof mailOutboundTable.$inferSelect;
function toOutbound(row: OutboundRow): OutboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
idempotencyKey: row.idempotencyKey,
to: row.toList,
cc: row.ccList,
bcc: row.bccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
attachments: row.attachments,
state: mailOutboundStateSchema.parse(row.state),
error: row.error,
providerRef: row.providerRef,
requestedBy: row.requestedBy,
createdAt: row.createdAt,
sentAt: row.sentAt,
};
}
export function createOutboundStore(db: Db): OutboundStore {
return {
async insert(record) {
await db.insert(mailOutboundTable).values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
idempotencyKey: record.idempotencyKey,
toList: [...record.to],
ccList: [...record.cc],
bccList: [...record.bcc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
attachments: [...record.attachments],
state: record.state,
error: record.error,
providerRef: record.providerRef,
requestedBy: record.requestedBy,
createdAt: record.createdAt,
sentAt: record.sentAt,
});
},
async byIdempotencyKey(key) {
const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1);
return rows[0] === undefined ? undefined : toOutbound(rows[0]);
},
async setState(id, state, patch) {
await db
.update(mailOutboundTable)
.set({
state,
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
})
.where(eq(mailOutboundTable.id, id));
},
async list(query) {
const rows = await db
.select()
.from(mailOutboundTable)
.where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId))
.orderBy(desc(mailOutboundTable.createdAt))
.limit(query.limit);
return rows.map(toOutbound);
},
};
}
index.ts 追加:
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
- Step 5: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 7 tests PASS(套件没跳过 —— 输出里不能有「集成测试跳过」);三道门禁退出码 0。
另跑一次 pnpm vitest run apps/api/src/app/__tests__/route-coverage.it.test.ts:它对 jcai_it_routes 库跑全部迁移,0033 能应用即绿。
- Step 6: Commit
git add db/migrations/0033_mail.sql db/migrations/meta/_journal.json apps/api/src/modules/mail apps/api/src/app/reset-runs.ts
git commit -m "$(printf '迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 8: adapter:postal-mime 解析器 + 测试夹具
Files:
- Create:
apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts - Create:
apps/api/src/modules/mail/__tests__/fakes.ts(EML 夹具 + 内存 store + 假 provider + 时钟;后续用例测试共用) - Modify:
apps/api/src/modules/mail/index.ts(追加导出) - Test:
apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts
Interfaces:
-
Produces:
createPostalMimeParser(): MimeParser;夹具eml(parts)fixedClock(iso?)seqIds(prefix?)plainSecretsmemoryAccounts(clock)memoryInbound()memoryOutbound()memoryAttachments()fakeProvider(kind?)→FakeProvider。 -
postal-mime 3.0.0 的真实形状(本机核过):
PostalMime.parse(Uint8Array)→{ from?: {address,name}, to?: Address[], cc?: Address[], subject?, messageId?, inReplyTo?, references?: string(空格分隔), date?: ISO 串, text?, html?, attachments: [{filename, mimeType, disposition, content: ArrayBuffer}] };GBK 主题 / 正文解得出;群组地址形{name, group: Mailbox[]}。 -
Step 1: 写夹具
// apps/api/src/modules/mail/__tests__/fakes.ts
/**
* 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。
* 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import { isDue } from '../internal/domain/due.ts';
import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts';
import type {
AttachmentStore,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
OutboundMessage,
OutboundRecord,
OutboundStore,
RawMessage,
SecretBox,
} from '../port.ts';
const CRLF = '\r\n';
export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } {
let t = new Date(iso).getTime();
return {
now: () => new Date(t),
tick(ms) {
t += ms;
},
};
}
export function seqIds(prefix = 'id'): IdGenerator {
let n = 0;
return {
next: () => {
n += 1;
return `${prefix}-${n}`;
},
};
}
export const plainSecrets: SecretBox = {
seal: (s) => `sealed:${s}`,
open: (c) => {
if (!c.startsWith('sealed:')) throw new Error('密文格式非法');
return c.slice('sealed:'.length);
},
};
export function account(over: Partial<MailAccountRecord> = {}): MailAccountRecord {
const now = new Date('2026-08-25T09:00:00Z');
return {
id: 'acc-1',
kind: 'imap-smtp',
name: '销售信箱',
address: 'sales@we.com',
displayName: '销售部',
enabled: true,
isDefault: true,
pollIntervalSec: 30,
channelId: 'ch-1',
backfillFrom: null,
settings: { folder: 'INBOX' },
secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`,
cursor: null,
lastPolledAt: null,
lastPollError: null,
lastInboundAt: null,
pollingUntil: null,
createdAt: now,
updatedAt: now,
...over,
};
}
export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<string, MailAccountRecord> } {
const rows = new Map<string, MailAccountRecord>();
return {
rows,
async list() {
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
},
async get(id) {
return rows.get(id);
},
async findByName(name) {
return [...rows.values()].find((r) => r.name === name);
},
async save(record) {
if (record.isDefault) {
for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false });
}
rows.set(record.id, record);
},
async remove(id) {
rows.delete(id);
},
async listDue(now) {
return [...rows.values()].filter((r) => isDue(r, now));
},
async claimPolling(id, leaseMs) {
const r = rows.get(id);
const now = clock.now();
if (r === undefined || !r.enabled) return false;
if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false;
rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) });
return true;
},
async releasePolling(id) {
const r = rows.get(id);
if (r !== undefined) rows.set(id, { ...r, pollingUntil: null });
},
async savePoll(id, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
...(patch.cursor === undefined ? {} : { cursor: patch.cursor }),
...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }),
...(patch.error === undefined ? {} : { lastPollError: patch.error }),
...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }),
...(patch.address === undefined ? {} : { address: patch.address }),
});
},
};
}
export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
const rows = new Map<string, InboundRecord>();
const deliveries = new Map<string, DeliveryRecord>();
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)
const of = (mailId: string): DeliveryRecord[] =>
[...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber));
return {
rows,
deliveries,
async insert(record) {
for (const r of rows.values()) {
if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) {
return { inserted: false };
}
}
rows.set(record.id, record);
return { inserted: true };
},
async knownProviderRefs(accountId, refs) {
return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef));
},
async list(query) {
return [...rows.values()]
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
.slice(0, query.limit)
.map((record) => ({ record, deliveries: of(record.id) }));
},
async get(mailId) {
const record = rows.get(mailId);
return record === undefined ? undefined : { record, deliveries: of(mailId) };
},
async setDelivery(delivery) {
deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery);
},
};
}
export function memoryOutbound(): OutboundStore & { rows: Map<string, OutboundRecord> } {
const rows = new Map<string, OutboundRecord>();
return {
rows,
async insert(record) {
if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) {
throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"');
}
rows.set(record.id, record);
},
async byIdempotencyKey(key) {
return [...rows.values()].find((r) => r.idempotencyKey === key);
},
async setState(id, state, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
state,
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
});
},
async list(query) {
return [...rows.values()]
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, query.limit);
},
};
}
export function memoryAttachments(): AttachmentStore & { files: Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
let n = 0;
return {
files,
async put(file) {
n += 1;
const ref = `up-${n}`;
files.set(ref, { ...file });
return { ref };
},
async get(ref) {
const f = files.get(ref);
return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes };
},
};
}
export type FakeProvider = MailProvider & {
readonly sent: OutboundMessage[];
readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>;
queue(...messages: RawMessage[]): void;
failPullWith(error: Error | undefined): void;
failSendWith(error: Error | undefined): void;
probeWith(report: MailProbeReport | undefined): void;
};
/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */
export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider {
const pending: RawMessage[] = [];
const sent: OutboundMessage[] = [];
const pullCalls: Array<{ cursor: MailCursor; limit: number }> = [];
let pullError: Error | undefined;
let sendError: Error | undefined;
let probeReport: MailProbeReport | undefined;
let uidSeq = 0;
return {
kind,
sent,
pullCalls,
queue(...messages) {
pending.push(...messages);
},
failPullWith(error) {
pullError = error;
},
failSendWith(error) {
sendError = error;
},
probeWith(report) {
probeReport = report;
},
async probe(accountArg) {
return (
probeReport ?? {
kind,
address: accountArg.address,
receive: { ok: true, latencyMs: 1 },
send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true },
}
);
},
async anchor(_accountArg, _backfillFrom, now) {
return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000));
},
async pull(_accountArg, cursor, opts) {
pullCalls.push({ cursor, limit: opts.limit });
if (pullError !== undefined) throw pullError;
const batch = pending.splice(0, opts.limit).map((m) => {
if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m;
uidSeq += 1;
return { ...m, cursorAfter: imapCursor('v1', uidSeq) };
});
const last = batch.at(-1)?.cursorAfter;
const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000));
return { messages: batch, cursor: next, more: pending.length > 0 };
},
async send(_accountArg, message) {
if (sendError !== undefined) throw sendError;
sent.push(message);
return { providerRef: `fake:${sent.length}` };
},
};
}
export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage {
return { providerRef, eml, receivedAt };
}
/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */
export function eml(parts: {
from?: string;
to?: string;
cc?: string;
subject?: string;
text?: string;
html?: string;
messageId?: string;
inReplyTo?: string;
references?: string;
date?: string;
attachment?: { name: string; contentType: string; content: string };
}): Uint8Array {
const headers = [
`From: ${parts.from ?? 'buyer@acme.com'}`,
`To: ${parts.to ?? 'sales@we.com'}`,
...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]),
`Subject: ${parts.subject ?? '询价'}`,
...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]),
...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]),
...(parts.references === undefined ? [] : [`References: ${parts.references}`]),
`Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`,
'MIME-Version: 1.0',
];
const text = parts.text ?? '你好,请报价';
if (parts.html === undefined && parts.attachment === undefined) {
return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF));
}
const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text];
if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html);
if (parts.attachment !== undefined) {
lines.push(
'--B1',
`Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`,
`Content-Disposition: attachment; filename="${parts.attachment.name}"`,
'Content-Transfer-Encoding: base64',
'',
Buffer.from(parts.attachment.content, 'utf8').toString('base64'),
);
}
lines.push('--B1--', '');
return new TextEncoder().encode(lines.join(CRLF));
}
- Step 2: 写失败测试(真解析器跑夹具)
// apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts
/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */
import { describe, expect, it } from 'vitest';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { eml } from './fakes.ts';
const parser = createPostalMimeParser();
const CRLF = '\r\n';
describe('createPostalMimeParser', () => {
it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => {
const parsed = await parser.parse(
eml({
from: '"张三" <buyer@acme.com>',
to: 'sales@we.com, "Ops" <ops@we.com>',
subject: '询价 RFQ-1',
messageId: '<m1@acme.com>',
inReplyTo: '<q1@we.com>',
references: '<root@we.com> <q1@we.com>',
text: '你好,请报价',
}),
);
expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(parsed.to).toEqual([
{ address: 'sales@we.com', name: '' },
{ address: 'ops@we.com', name: 'Ops' },
]);
expect(parsed.subject).toBe('询价 RFQ-1');
expect(parsed.messageId).toBe('<m1@acme.com>');
expect(parsed.inReplyTo).toBe('<q1@we.com>');
expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
expect(parsed.text?.trim()).toBe('你好,请报价');
expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z');
});
it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => {
const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价
const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好
const raw = [
'From: buyer@acme.com',
'To: sales@we.com',
`Subject: =?GBK?B?${subject}?=`,
'Content-Type: text/plain; charset=gbk',
'Content-Transfer-Encoding: base64',
'',
body,
'',
].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.subject).toBe('询价');
expect(parsed.text?.trim()).toBe('你好');
});
it('群组地址摊平;没有 Cc 就是空数组', async () => {
const raw = ['From: a@x.com', 'To: team: b@x.com, "C" <c@x.com>;', 'Subject: s', '', 'body', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.to).toEqual([
{ address: 'b@x.com', name: '' },
{ address: 'c@x.com', name: 'C' },
]);
expect(parsed.cc).toEqual([]);
});
it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => {
const raw = ['Subject: x', 'Content-Type: text/html', '', '<b>only html</b>', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.text).toBeUndefined();
expect(parsed.html).toContain('only html');
expect(parsed.messageId).toBeUndefined();
expect(parsed.from).toBeUndefined();
expect(parsed.date).toBeUndefined();
});
it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => {
const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } }));
expect(parsed.attachments).toHaveLength(1);
expect(parsed.attachments[0]?.name).toBe('bom.xlsx');
expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel');
expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array);
expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz');
});
});
- Step 3: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts
Expected: FAIL(解析器不存在)。
- Step 4: 写解析器
// apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts
/**
* MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。
* 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。
*/
import PostalMime from 'postal-mime';
import type { Address } from 'postal-mime';
import type { MailAddress } from '@contracts';
import type { MimeParser, ParsedMail } from '../../port.ts';
function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] {
const out: MailAddress[] = [];
for (const entry of list ?? []) {
if (entry.group !== undefined) {
for (const member of entry.group) out.push({ address: member.address, name: member.name });
} else if (entry.address !== undefined) {
out.push({ address: entry.address, name: entry.name });
}
}
return out;
}
/** References / In-Reply-To 是空白分隔的一串 <id>;拆开、去空 */
function idsOf(value: string | undefined): string[] {
if (value === undefined) return [];
return value
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s !== '');
}
function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array {
if (typeof content === 'string') return new TextEncoder().encode(content);
return content instanceof Uint8Array ? content : new Uint8Array(content);
}
export function createPostalMimeParser(): MimeParser {
return {
async parse(eml): Promise<ParsedMail> {
const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' });
const from =
email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined;
const date = email.date === undefined ? undefined : new Date(email.date);
const inReplyTo = idsOf(email.inReplyTo)[0];
return {
...(email.messageId === undefined ? {} : { messageId: email.messageId }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: idsOf(email.references),
...(from === undefined ? {} : { from }),
to: mailboxesOf(email.to),
cc: mailboxesOf(email.cc),
subject: email.subject ?? '',
...(email.text === undefined ? {} : { text: email.text }),
...(email.html === undefined ? {} : { html: email.html }),
...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }),
attachments: email.attachments.map((a) => ({
name: a.filename ?? '',
contentType: a.mimeType,
bytes: bytesOf(a.content),
})),
};
},
};
}
index.ts 追加:
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
- Step 5: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts && pnpm typecheck && pnpm lint
Expected: 5 tests PASS;门禁退出码 0。若 Address 类型的 group 判别在本版 d.ts 里报错,以 node_modules/postal-mime/postal-mime.d.ts 的 Address 定义为准改判别(本计划核对的是 3.0.0:Mailbox = {name, address, group?: undefined},群组形 {name, address?: undefined, group: Mailbox[]})。
- Step 6: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 9: usecase createMailService —— 账号 CRUD / 探测 / 视图
Files:
- Create:
apps/api/src/modules/mail/internal/usecase/mail-service.ts(整份;收 / 发的用例也在这份里,Task 10 / 11 只加测试) - Modify:
apps/api/src/modules/mail/index.ts(追加createMailService与MailServiceDeps) - Test:
apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts
Interfaces:
-
Produces:
createMailService(deps: MailServiceDeps): MailService;MailServiceDeps = { accounts, inbound, outbound, attachments, providers: Record<MailAccountKind, MailProvider>, mime, secrets, clock, ids, notify, limits? }。 -
probe的第二形是{ input, accountId? }:改已存账号的表单上「测试」不必重打密码 —— 没给 secret 且给了 accountId 就用库里那份密文。 -
Step 1: 写失败测试
// apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts
/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('acc'),
notify: { changed: (r) => void changes.push(r) },
});
return { service, accounts, imap, wecom, clock, changes };
}
const imapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } });
describe('账号', () => {
it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => {
const { service, accounts } = harness();
const view = await service.createAccount(imapInput);
expect(view.id).toBe('acc-1');
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('pw-1234');
expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`);
expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']);
});
it('名字唯一:重名 409;改名撞别人也 409', async () => {
const { service } = harness();
await service.createAccount(imapInput);
await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError);
const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' });
await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError);
});
it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => {
const { service, accounts } = harness();
const created = await service.createAccount(imapInput);
await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234');
await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9');
const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } });
const switched = await service.updateAccount(created.id, wecomInput);
expect(switched.secretBound).toBe(false);
expect(switched.address).toBe('');
});
it('默认账号只有一个;删了就没了(404)', async () => {
const { service } = harness();
const a = await service.createAccount(imapInput);
const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true });
expect((await service.getAccount(a.id)).isDefault).toBe(false);
expect((await service.getAccount(b.id)).isDefault).toBe(true);
await service.deleteAccount(a.id);
await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
});
it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => {
const { service, wecom, imap } = harness();
imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } });
const a = await service.createAccount(imapInput);
const report = await service.probe({ accountId: a.id });
expect(report.receive.error).toBe('LOGIN sales@we.com *** failed');
const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } }));
wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } });
await service.probe({ accountId: w.id });
expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work');
});
it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => {
const { service, accounts } = harness();
const report = await service.probe({ input: imapInput });
expect(report.receive.ok).toBe(true);
expect(accounts.rows.size).toBe(0);
await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError);
const a = await service.createAccount(imapInput);
const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id });
expect(borrowed.receive.ok).toBe(true);
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts
Expected: FAIL(mail-service.ts 不存在)。
- Step 3: 写用例(整份)
先把 contract.ts 里 probe 的签名改成:
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
// apps/api/src/modules/mail/internal/usecase/mail-service.ts
/**
* mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。
*
* 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重)
* → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。
* 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。
*/
import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts';
import type { InboundHandler, MailService } from '../../contract.ts';
import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts';
import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts';
import { leaseMsFor } from '../domain/due.ts';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from '../domain/errors.ts';
import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts';
import { errorText, redactSecrets } from '../domain/redact.ts';
import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts';
import type {
AttachmentStore,
ChangeNotifier,
Clock,
IdGenerator,
InboundStore,
MailAccountStore,
MailProvider,
MimeParser,
OutboundStore,
ProviderAccount,
RawMessage,
SecretBox,
} from '../../port.ts';
export type MailServiceDeps = {
readonly accounts: MailAccountStore;
readonly inbound: InboundStore;
readonly outbound: OutboundStore;
readonly attachments: AttachmentStore;
readonly providers: Readonly<Record<MailAccountKind, MailProvider>>;
readonly mime: MimeParser;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly ids: IdGenerator;
readonly notify: ChangeNotifier;
readonly limits?:
| {
readonly perTick?: number | undefined;
readonly tickBudgetMs?: number | undefined;
readonly attachmentBytes?: number | undefined;
readonly rawBytes?: number | undefined;
}
| undefined;
};
const DEFAULT_LIST_LIMIT = 50;
const MAX_LIST_LIMIT = 200;
type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
function tallyToOutcome(t: PollTally): MailPollOutcome {
return {
accountId: t.accountId,
pulled: t.pulled,
stored: t.stored,
dispatched: t.dispatched,
more: t.more,
...(t.error === undefined ? {} : { error: t.error }),
};
}
function safeFileName(messageId: string): string {
const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
return cleaned === '' ? 'mail' : cleaned;
}
function resultOf(record: OutboundRecord): MailSendResult {
return {
outboundId: record.id,
accountId: record.accountId,
messageId: record.messageId,
state: record.state === 'sent' ? 'sent' : 'failed',
...(record.error === null ? {} : { error: record.error }),
...(record.providerRef === null ? {} : { providerRef: record.providerRef }),
};
}
export function createMailService(deps: MailServiceDeps): MailService {
const limits = {
perTick: deps.limits?.perTick ?? 50,
tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000,
attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP,
rawBytes: deps.limits?.rawBytes ?? RAW_CAP,
};
const subscribers = new Map<string, InboundHandler>();
async function requireAccount(accountId: string): Promise<MailAccountRecord> {
const record = await deps.accounts.get(accountId);
if (record === undefined) throw new MailAccountNotFoundError(accountId);
return record;
}
/** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id);
const parsed: unknown = JSON.parse(deps.secrets.open(record.secretCipher));
if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id);
return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]));
}
function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
}
function redactWith(secret: Record<string, string>): (text: string) => string {
const values = Object.values(secret);
return (text) => redactSecrets(text, values);
}
function redactReport(report: MailProbeReport, secret: Record<string, string>): MailProbeReport {
const redact = redactWith(secret);
return {
...report,
receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) },
send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) },
};
}
function limitOf(query: { limit?: number | undefined }): number {
return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT));
}
/* ── 分发 ────────────────────────────────────────────────────────── */
async function dispatch(record: InboundRecord, ctx: { accountId: string; channelId: string | null }, only?: string): Promise<DeliveryRecord[]> {
const existing = (await deps.inbound.get(record.id))?.deliveries ?? [];
const out: DeliveryRecord[] = [];
for (const [name, handler] of subscribers) {
if (only !== undefined && name !== only) continue;
const current = existing.find((d) => d.subscriber === name);
// 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事
if (!canRedeliver(current?.state)) {
if (current !== undefined) out.push(current);
continue;
}
const startedAt = deps.clock.now();
const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null };
await deps.inbound.setDelivery(dispatching);
let settled: DeliveryRecord;
try {
const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined });
const state = stateOfOutcome(outcome);
settled = {
...dispatching,
state,
// skipped 的理由也放 error 列:人在列表上看得见「为什么没投」
error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null,
finishedAt: deps.clock.now(),
};
} catch (error) {
settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() };
}
await deps.inbound.setDelivery(settled);
out.push(settled);
}
return out;
}
/* ── 拉取 ────────────────────────────────────────────────────────── */
async function storeOne(record: MailAccountRecord, message: RawMessage): Promise<InboundRecord | undefined> {
// 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍
const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]);
if (known.has(message.providerRef)) return undefined;
const parsed = await deps.mime.parse(message.eml);
const attachments: InboundAttachmentRecord[] = [];
for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) {
let ref: string | null = null;
if (plan.store) {
const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array();
ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref;
}
attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note });
}
const built = buildInboundRecord({
id: deps.ids.next(),
accountId: record.id,
address: record.address,
providerRef: message.providerRef,
parsed,
eml: message.eml,
receivedAt: message.receivedAt,
attachments,
now: deps.clock.now(),
rawCap: limits.rawBytes,
});
const { inserted } = await deps.inbound.insert(built);
return inserted ? built : undefined;
}
async function pollAccount(record: MailAccountRecord): Promise<MailPollOutcome> {
const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec));
if (!claimed) throw new MailPollBusyError(record.id);
const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false };
let secret: Record<string, string> = {};
try {
secret = openSecret(record);
const provider = deps.providers[record.kind];
const account = providerAccount(record, secret);
const started = deps.clock.now();
let cursor = record.cursor;
if (cursor === null) {
cursor = await provider.anchor(account, record.backfillFrom, started);
await deps.accounts.savePoll(record.id, { cursor });
}
let budget = limits.perTick;
let more = true;
while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) {
const batch = await provider.pull(account, cursor, {
limit: budget,
now: deps.clock.now(),
backfillFrom: record.backfillFrom,
known: (refs) => deps.inbound.knownProviderRefs(record.id, refs),
});
tally.pulled += batch.messages.length;
budget -= batch.messages.length;
for (const message of batch.messages) {
const stored = await storeOne(record, message);
// 逐封推进:中途栽了,下一轮从这一封之后接着
if (message.cursorAfter !== undefined) {
cursor = message.cursorAfter;
await deps.accounts.savePoll(record.id, { cursor });
}
if (stored !== undefined) {
tally.stored += 1;
await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt });
await dispatch(stored, { accountId: record.id, channelId: record.channelId });
tally.dispatched += 1;
}
}
cursor = batch.cursor;
await deps.accounts.savePoll(record.id, { cursor });
more = batch.more;
if (batch.messages.length === 0) break;
}
tally.more = more;
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: null });
} catch (error) {
const text = redactWith(secret)(errorText(error));
tally.error = text;
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text });
} finally {
await deps.accounts.releasePolling(record.id);
}
if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound');
return tallyToOutcome(tally);
}
/* ── 服务 ────────────────────────────────────────────────────────── */
return {
async listAccounts() {
const now = deps.clock.now();
return (await deps.accounts.list()).map((r) => viewOf(r, now));
},
async getAccount(accountId) {
return viewOf(await requireAccount(accountId), deps.clock.now());
},
async createAccount(input) {
if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name);
const secretJson = secretOf(input);
const now = deps.clock.now();
const record = mergeAccount(input, {
id: deps.ids.next(),
existing: undefined,
secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson),
now,
});
await deps.accounts.save(record);
return viewOf(record, now);
},
async updateAccount(accountId, input) {
const existing = await requireAccount(accountId);
const clash = await deps.accounts.findByName(input.name);
if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name);
const secretJson = secretOf(input);
// 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废
const secretCipher =
secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null;
const now = deps.clock.now();
// kind / settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset)
const record = mergeAccount(input, { id: accountId, existing, secretCipher, now });
await deps.accounts.save(record);
return viewOf(record, now);
},
async deleteAccount(accountId) {
await requireAccount(accountId);
await deps.accounts.remove(accountId);
},
async probe(target) {
if ('input' in target) {
const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId);
const secretJson = secretOf(target.input);
const secret =
secretJson !== undefined
? (JSON.parse(secretJson) as Record<string, string>)
: stored !== undefined && stored.kind === target.input.kind
? openSecret(stored)
: (() => {
throw new MailSecretMissingError(target.accountId ?? 'draft');
})();
const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() });
const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret));
return redactReport(report, secret);
}
const record = await requireAccount(target.accountId);
const secret = openSecret(record);
const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret);
// wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改
if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) {
await deps.accounts.savePoll(record.id, { address: report.address });
}
return report;
},
async pollDue(now) {
const due = await deps.accounts.listDue(now);
// 账号间并行,单账号内串行;一个账号的异常只记在它自己头上
const settled = await Promise.allSettled(due.map((record) => pollAccount(record)));
return settled.map((r, i) =>
r.status === 'fulfilled'
? r.value
: { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) },
);
},
async pollNow(accountId) {
const record = await requireAccount(accountId);
if (!record.enabled) throw new MailAccountDisabledError(accountId);
return pollAccount(record);
},
subscribe(name, handler) {
if (subscribers.has(name)) throw new MailSubscriberTakenError(name);
subscribers.set(name, handler);
return () => {
subscribers.delete(name);
};
},
async listInbound(query) {
const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) });
return rows.map((r) => summaryOf(r.record, r.deliveries));
},
async getInbound(mailId) {
const hit = await deps.inbound.get(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
return detailOf(hit.record, hit.deliveries);
},
async attachment(mailId, index) {
const hit = await deps.inbound.get(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
const meta = hit.record.attachments.find((a) => a.index === index);
if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index);
const file = await deps.attachments.get(meta.ref);
if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index);
return { name: meta.name, contentType: meta.contentType, bytes: file.bytes };
},
async raw(mailId) {
const hit = await deps.inbound.get(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1);
return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw };
},
async redeliver(mailId, subscriber) {
const hit = await deps.inbound.get(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber);
const owner = await deps.accounts.get(hit.record.accountId);
const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber);
deps.notify.changed('inbound');
return deliveries.map(deliveryViewOf);
},
async send(input) {
const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from });
// 幂等键:放行重放不发两封;上一次失败 / 中断的那一行复用,重发
const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey);
if (prior !== undefined && prior.state === 'sent') return resultOf(prior);
const files: ParsedAttachment[] = [];
for (const a of input.attachments) {
if (a.dataBase64 !== undefined) {
files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) });
} else if (a.uploadRef !== undefined) {
const got = await deps.attachments.get(a.uploadRef);
if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]);
files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes });
} else {
throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]);
}
}
const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength));
if (problems.length > 0) throw new MailSendInvalidError(problems);
const secret = openSecret(account);
const now = deps.clock.now();
const id = prior?.id ?? deps.ids.next();
const messageId = prior?.messageId ?? messageIdFor(id, account.address);
if (prior === undefined) {
const record: OutboundRecord = {
id,
accountId: account.id,
messageId,
idempotencyKey: input.idempotencyKey ?? null,
to: [...input.to],
cc: [...input.cc],
bcc: [...input.bcc],
subject: input.subject,
text: input.text ?? null,
html: input.html ?? null,
inReplyTo: input.inReplyTo ?? null,
references: [...input.references],
attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })),
state: 'sending',
error: null,
providerRef: null,
requestedBy: { ...input.requestedBy },
createdAt: now,
sentAt: null,
};
await deps.outbound.insert(record);
}
try {
const sent = await deps.providers[account.kind].send(providerAccount(account, secret), {
messageId,
from: { address: account.address, name: account.displayName },
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
...(input.text === undefined ? {} : { text: input.text }),
...(input.html === undefined ? {} : { html: input.html }),
...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }),
references: input.references,
attachments: files,
});
await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) });
deps.notify.changed('outbound');
return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) };
} catch (error) {
const text = redactWith(secret)(errorText(error));
await deps.outbound.setState(id, 'failed', { error: text });
deps.notify.changed('outbound');
return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text };
}
},
async listOutbound(query) {
const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) });
return rows.map((r) => ({
id: r.id,
accountId: r.accountId,
messageId: r.messageId,
to: [...r.to],
cc: [...r.cc],
bcc: [...r.bcc],
subject: r.subject,
state: r.state,
error: r.error,
providerRef: r.providerRef,
requestedBy: r.requestedBy,
createdAt: r.createdAt.toISOString(),
sentAt: r.sentAt === null ? null : r.sentAt.toISOString(),
}));
},
};
}
index.ts 追加:
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
export { createMailService } from './internal/usecase/mail-service.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 6 tests PASS;门禁退出码 0。
- Step 5: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 10: usecase 测试 —— 拉取 / 去重 / 分发 / 重新投递
Files:
- Test:
apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts - Modify(仅当测试逼出缺陷):
apps/api/src/modules/mail/internal/usecase/mail-service.ts
Interfaces:
-
Consumes:Task 9 的
createMailService与 Task 8 的夹具。 -
Step 1: 写测试
// apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts
/**
* 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、
* 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。
*/
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail } from '@contracts';
import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import {
account,
eml,
fakeProvider,
fixedClock,
memoryAccounts,
memoryAttachments,
memoryInbound,
memoryOutbound,
plainSecrets,
rawMessage,
seqIds,
} from './fakes.ts';
function harness(over: { perTick?: number } = {}) {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const inbound = memoryInbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound,
outbound: memoryOutbound(),
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('in'),
notify: { changed: (r) => void changes.push(r) },
limits: { perTick: over.perTick ?? 50 },
});
return { service, accounts, inbound, attachments, imap, wecom, clock, changes };
}
describe('拉取', () => {
it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })),
rawMessage('uid:v1:2', eml({ messageId: '<m2@acme.com>', subject: '二' })),
);
const outcome = await h.service.pollNow('acc-1');
expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false });
const saved = h.accounts.rows.get('acc-1');
expect(saved?.cursor).toEqual(imapCursor('v1', 2));
expect(saved?.lastPolledAt).toEqual(h.clock.now());
expect(saved?.lastInboundAt).not.toBeNull();
expect(saved?.lastPollError).toBeNull();
expect(saved?.pollingUntil).toBeNull();
const list = await h.service.listInbound({});
// 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序
expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']);
const one = list.find((m) => m.subject === '一');
expect(one?.attachments[0]?.ref).toBe('up-1');
expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一');
expect(h.changes).toContain('inbound');
});
it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => {
const h = harness();
await h.accounts.save(account());
const hits: string[] = [];
h.service.subscribe('spy', async (m) => void hits.push(m.messageId));
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } })));
await h.service.pollNow('acc-1');
h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>' })),
rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
);
const again = await h.service.pollNow('acc-1');
expect(again.pulled).toBe(2);
expect(again.stored).toBe(0);
expect(hits).toEqual(['<m1@acme.com>']);
expect(h.attachments.files.size).toBe(1);
});
it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => {
const h = harness({ perTick: 2 });
await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } }));
h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' })));
const first = await h.service.pollNow('w');
expect(first).toMatchObject({ pulled: 2, stored: 2, more: true });
expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom');
const second = await h.service.pollNow('w');
expect(second).toMatchObject({ pulled: 1, stored: 1, more: false });
});
it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => {
const h = harness();
await h.accounts.save(account({ cursor: imapCursor('v1', 5) }));
h.imap.failPullWith(new Error('LOGIN pw-1234 rejected'));
const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toBe('LOGIN *** rejected');
const saved = h.accounts.rows.get('acc-1');
expect(saved?.lastPollError).toBe('LOGIN *** rejected');
expect(saved?.cursor).toEqual(imapCursor('v1', 5));
expect(saved?.pollingUntil).toBeNull();
h.imap.failPullWith(undefined);
expect((await h.service.pollNow('acc-1')).error).toBeUndefined();
expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull();
});
it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => {
const h = harness();
await h.accounts.save(account({ secretCipher: null }));
const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toContain('未配置凭证');
expect(h.imap.pullCalls).toHaveLength(0);
await h.accounts.save(account({ enabled: false }));
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError);
await h.accounts.save(account({ pollingUntil: new Date(h.clock.now().getTime() + 60_000) }));
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError);
});
it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => {
const h = harness();
await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' }));
await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null }));
await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false }));
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<x@a>' })));
const outcomes = await h.service.pollDue(h.clock.now());
expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']);
expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1);
expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证');
});
});
describe('分发', () => {
it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => {
const h = harness();
await h.accounts.save(account({ channelId: 'ch-9' }));
const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = [];
h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId }));
h.service.subscribe('skip', async () => ({ skipped: '与我无关' }));
const off = h.service.subscribe('boom', async () => {
throw new Error('通道实例已停用:ch-9');
});
expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError);
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', subject: '主题' })));
await h.service.pollNow('acc-1');
expect(seen[0]?.mail.subject).toBe('主题');
expect(seen[0]?.channelId).toBe('ch-9');
const detail = await h.service.getInbound('in-1');
expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([
['boom', 'failed', '通道实例已停用:ch-9'],
['ok', 'done', null],
['skip', 'skipped', '与我无关'],
]);
off();
h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
await h.service.pollNow('acc-1');
expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']);
});
it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => {
const h = harness();
await h.accounts.save(account());
let fail = true;
h.service.subscribe('flaky', async () => {
if (fail) throw new Error('第一次不行');
});
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
await h.service.pollNow('acc-1');
expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed');
fail = false;
const redone = await h.service.redeliver('in-1', 'flaky');
expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]);
await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError);
await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null });
expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching');
});
});
describe('附件与原件', () => {
it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } })));
await h.service.pollNow('acc-1');
const file = await h.service.attachment('in-1', 0);
expect(new TextDecoder().decode(file.bytes)).toBe('hello');
expect(file.name).toBe('a.txt');
await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来');
const raw = await h.service.raw('in-1');
expect(raw.name).toBe('m1_a.eml');
expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:');
});
});
- Step 2: 跑测试
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts
Expected: 9 tests PASS。红了就按测试名对回 Task 9 的用例修(常见:listInbound 排序断言 —— 两封 receivedAt 相同时按插入顺序,把断言里的顺序改成与夹具一致;raw.name 的 safeFileName 把 <m1@a> 收成 m1_a)。
- Step 3: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例测试:拉取 / 去重 / 逐封游标 / 失败停位 / 分发三态 / 重新投递 / 附件原件\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 11: usecase 测试 —— 发信
Files:
-
Test:
apps/api/src/modules/mail/__tests__/mail-service-send.test.ts -
Modify(仅当测试逼出缺陷):
apps/api/src/modules/mail/internal/usecase/mail-service.ts -
Step 1: 写测试
// apps/api/src/modules/mail/__tests__/mail-service-send.test.ts
/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */
import { describe, expect, it } from 'vitest';
import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const outbound = memoryOutbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound,
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('out'),
notify: { changed: () => undefined },
});
return { service, accounts, outbound, attachments, imap, clock };
}
const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } };
describe('send', () => {
it('默认账号发出:留痕 sent、Message-ID 是 <id@域>、发件人带显示名、返回 providerRef', async () => {
const h = harness();
await h.accounts.save(account());
const result = await h.service.send(base);
expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', providerRef: 'fake:1' });
expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' });
expect(h.imap.sent[0]?.messageId).toBe('<out-1@we.com>');
const row = h.outbound.rows.get('out-1');
expect(row?.state).toBe('sent');
expect(row?.sentAt).toEqual(h.clock.now());
expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] });
});
it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' }));
const result = await h.service.send(base);
expect(result.state).toBe('failed');
expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected');
expect(h.outbound.rows.get('out-1')?.state).toBe('failed');
});
it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.failSendWith(new Error('暂时不行'));
const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(failed.state).toBe('failed');
h.imap.failSendWith(undefined);
const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
const again = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
expect(h.imap.sent).toHaveLength(1);
expect(h.outbound.rows.size).toBe(1);
});
it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => {
const h = harness();
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError);
await h.accounts.save(account());
await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError);
await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError);
await h.accounts.save(account({ secretCipher: null }));
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError);
expect(h.outbound.rows.size).toBe(0);
});
it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => {
const h = harness();
await h.accounts.save(account());
const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' });
await h.service.send({
...base,
attachments: [
{ name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref },
{ name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') },
],
});
expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([
['q.pdf', 'application/pdf', 2],
['n.txt', 'text/plain', 2],
]);
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
});
it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => {
const h = harness();
await h.accounts.save(account({ isDefault: false }));
const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
expect(result.accountId).toBe('acc-1');
expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' });
});
});
- Step 2: 跑测试
Run: pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint
Expected: 全部 PASS;门禁退出码 0。
- Step 3: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail 用例测试:发信留痕 / 厂商失败不抛 / 幂等键 / 附件来源 / from 对账号\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 12: adapter:IMAP/SMTP provider(imapflow + nodemailer)
Files:
- Create:
apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts - Modify:
apps/api/src/modules/mail/index.ts(追加导出) - Test:
apps/api/src/modules/mail/__tests__/imap-helpers.test.ts(纯映射)·apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts(env 门控的真信箱)
Interfaces:
-
Produces:
createImapSmtpProvider(): MailProvider;导出的纯助手imapSettingsOf(account)internalDateOf(value, fallback)mailOptionsOf(message)orderFetched(rows)。 -
真实签名(本机核过 imapflow 1.7.6 / @types/nodemailer 8.0.1):
new ImapFlow({host, port, secure, auth:{user, pass}, logger: false});getMailboxLock(path, {readOnly: true})→{release()};client.mailbox→{uidValidity: bigint, uidNext: number};search({uid: 'n:*'} | {since: Date}, {uid: true})→number[] | false;fetch(number[], {uid, source, internalDate}, {uid: true})→ 异步迭代{uid, source?: Buffer, internalDate?: Date | string};logout()/close()。nodemailer:createTransport({host, port, secure, requireTLS, auth:{user, pass}, connectionTimeout, greetingTimeout, socketTimeout}),verify(): Promise<true>,sendMail({...}) → {messageId, response},close()。 -
Step 1: 写失败测试(纯映射)
// apps/api/src/modules/mail/__tests__/imap-helpers.test.ts
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
import { describe, expect, it } from 'vitest';
import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } };
describe('imapSettingsOf', () => {
it('空 settings 取腾讯默认;登录名缺省 = 地址', () => {
const s = imapSettingsOf(account);
expect(s.settings.imapHost).toBe('imap.exmail.qq.com');
expect(s.settings.smtpPort).toBe(465);
expect(s.login).toBe('sales@we.com');
expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com');
});
it('缺密码当场说清', () => {
expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码');
});
});
describe('internalDateOf', () => {
const fallback = new Date('2026-08-25T10:00:00Z');
it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => {
const d = new Date('2026-08-25T09:00:00Z');
expect(internalDateOf(d, fallback)).toBe(d);
expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z');
expect(internalDateOf('garbage', fallback)).toBe(fallback);
expect(internalDateOf(undefined, fallback)).toBe(fallback);
});
});
describe('orderFetched', () => {
it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => {
const rows = orderFetched('7', [
{ uid: 12, source: Buffer.from('b'), internalDate: undefined },
{ uid: 10, source: Buffer.from('a'), internalDate: undefined },
{ uid: 11, source: undefined, internalDate: undefined },
], new Date('2026-08-25T10:00:00Z'));
expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']);
expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12));
expect(rows[0]?.eml).toBeInstanceOf(Uint8Array);
});
});
describe('mailOptionsOf', () => {
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
const options = mailOptionsOf({
messageId: '<o1@we.com>',
from: { address: 'sales@we.com', name: '销售部' },
to: ['b@acme.com'],
cc: [],
bcc: ['c@we.com'],
subject: '报价',
text: '正文',
inReplyTo: '<m1@acme.com>',
references: ['<m1@acme.com>'],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(options.messageId).toBe('<o1@we.com>');
expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
expect(options.bcc).toEqual(['c@we.com']);
expect(options.inReplyTo).toBe('<m1@acme.com>');
expect(options.references).toEqual(['<m1@acme.com>']);
expect('html' in options).toBe(false);
expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' });
expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true);
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts
Expected: FAIL(适配器不存在)。
- Step 3: 写适配器
// apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts
/**
* 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。
*
* 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址;
* 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。
* 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。
* IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。
*/
import { ImapFlow } from 'imapflow';
import nodemailer from 'nodemailer';
import type Mail from 'nodemailer/lib/mailer';
import { imapSmtpSettingsSchema } from '@contracts';
import type { ImapSmtpSettings, MailProbeReport } from '@contracts';
import { imapCursor, newUids } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts';
const CONNECT_TIMEOUT_MS = 30_000;
const SOCKET_TIMEOUT_MS = 60_000;
export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } {
const settings = imapSmtpSettingsSchema.parse(account.settings);
const password = account.secret['password'];
if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)');
return { settings, login: settings.login ?? account.address, password };
}
export function internalDateOf(value: Date | string | undefined, fallback: Date): Date {
if (value instanceof Date) return value;
if (typeof value === 'string') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return fallback;
}
/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */
export function orderFetched(
uidValidity: string,
rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>,
now: Date,
): RawMessage[] {
return rows
.filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined)
.sort((a, b) => a.uid - b.uid)
.map((r) => ({
providerRef: `${uidValidity}:${r.uid}`,
eml: Uint8Array.from(r.source),
receivedAt: internalDateOf(r.internalDate, now),
cursorAfter: imapCursor(uidValidity, r.uid),
}));
}
export function mailOptionsOf(message: OutboundMessage): Mail.Options {
return {
messageId: message.messageId,
from: { name: message.from.name, address: message.from.address },
to: [...message.to],
cc: [...message.cc],
bcc: [...message.bcc],
subject: message.subject,
...(message.text === undefined ? {} : { text: message.text }),
...(message.html === undefined ? {} : { html: message.html }),
...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }),
references: [...message.references],
attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })),
};
}
function transporterOf(account: ProviderAccount) {
const { settings, login, password } = imapSettingsOf(account);
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpTls,
requireTLS: !settings.smtpTls,
auth: { user: login, pass: password },
connectionTimeout: CONNECT_TIMEOUT_MS,
greetingTimeout: CONNECT_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
}
async function withImap<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
const { settings, login, password } = imapSettingsOf(account);
const client = new ImapFlow({
host: settings.imapHost,
port: settings.imapPort,
secure: settings.imapTls,
auth: { user: login, pass: password },
logger: false,
connectionTimeout: CONNECT_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
await client.connect();
try {
return await fn(client, settings.folder);
} finally {
await client.logout().catch(() => client.close());
}
}
function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } {
const box = client.mailbox;
if (box === false) throw new Error('文件夹未打开');
return { uidValidity: String(box.uidValidity), uidNext: box.uidNext };
}
async function timed(run: () => Promise<void>): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> {
const started = Date.now();
try {
await run();
return { ok: true, latencyMs: Date.now() - started };
} catch (error) {
return { ok: false, error: errorText(error) };
}
}
export function createImapSmtpProvider(): MailProvider {
return {
kind: 'imap-smtp',
async probe(account): Promise<MailProbeReport> {
const receive = await timed(() =>
withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
lock.release();
}),
);
const send = await timed(async () => {
const transporter = transporterOf(account);
try {
await transporter.verify();
} finally {
transporter.close();
}
});
return {
kind: 'imap-smtp',
address: account.address,
receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error },
send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error },
};
},
async anchor(account, backfillFrom): Promise<MailCursor> {
return withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
try {
const box = mailboxOf(client);
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
const uids = await client.search({ since: backfillFrom }, { uid: true });
const first = uids === false || uids.length === 0 ? box.uidNext : Math.min(...uids);
return imapCursor(box.uidValidity, first - 1);
} finally {
lock.release();
}
});
},
async pull(account, cursor, opts) {
return withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
try {
const box = mailboxOf(client);
if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) {
// UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡
return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false };
}
const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true });
const fresh = newUids(found === false ? [] : found, cursor.lastUid);
const wanted = fresh.slice(0, opts.limit);
const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = [];
if (wanted.length > 0) {
for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) {
rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate });
}
}
const messages = orderFetched(box.uidValidity, rows, opts.now);
return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length };
} finally {
lock.release();
}
});
},
async send(account, message) {
const transporter = transporterOf(account);
try {
const info = await transporter.sendMail(mailOptionsOf(message));
return { providerRef: info.response };
} finally {
transporter.close();
}
},
};
}
index.ts 追加:
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
- Step 4: 写 env 门控的真信箱集成测试
// apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts
/**
* 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。
* JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com)
* JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事)
*/
import { describe, expect, it } from 'vitest';
import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts';
const user = process.env['JCAI_MAIL_IT_IMAP_USER'];
const pass = process.env['JCAI_MAIL_IT_IMAP_PASS'];
const enabled = user !== undefined && pass !== undefined;
if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n');
const account = {
id: 'it',
kind: 'imap-smtp' as const,
address: user ?? '',
displayName: 'JCAI IT',
settings: {
imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com',
smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com',
},
secret: { password: pass ?? '' },
};
const known = async (): Promise<Set<string>> => new Set<string>();
describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => {
const provider = createImapSmtpProvider();
it('探测:收发都 ok', async () => {
const report = await provider.probe(account);
expect(report.receive).toMatchObject({ ok: true });
expect(report.send).toMatchObject({ checked: true, ok: true });
}, 60_000);
it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => {
const now = new Date();
const cursor = await provider.anchor(account, null, now);
expect(cursor.kind).toBe('imap');
const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known });
expect(empty.messages).toEqual([]);
const since = new Date(now.getTime() - 7 * 86_400_000);
const back = await provider.anchor(account, since, now);
const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known });
expect(some.messages.length).toBeLessThanOrEqual(3);
for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0);
}, 120_000);
it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => {
const result = await provider.send(account, {
messageId: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
from: { address: account.address, name: 'JCAI IT' },
to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''],
cc: [],
bcc: [],
subject: 'JCAI mail IT',
text: '这是一封集成测试信',
references: [],
attachments: [],
});
expect(result.providerRef ?? '').toContain('250');
}, 60_000);
});
- Step 5: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 助手 6 tests PASS;IT 套件跳过并打印告警(没给真信箱);typecheck 退出码 0 —— 若 ImapFlowOptions 对 connectionTimeout / socketTimeout 报错,以 node_modules/imapflow/lib/imap-flow.d.ts 为准删掉不认识的键;import type Mail from 'nodemailer/lib/mailer' 报错则改成 import type { Options as MailOptions } from 'nodemailer/lib/mailer';。depcruise:imapflow / nodemailer 只在 adapter,过。
有真信箱时:JCAI_MAIL_IT_IMAP_USER=xxx@corp.com JCAI_MAIL_IT_IMAP_PASS=客户端专用密码 pnpm vitest run apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts → 2 PASS(第 3 条给 JCAI_MAIL_IT_SEND_TO 才跑)。
- Step 6: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 13: adapter:企业微信邮件 API provider
Files:
- Create:
apps/api/src/modules/mail/internal/adapter/wecom-provider.ts - Modify:
apps/api/src/modules/mail/index.ts(追加导出) - Test:
apps/api/src/modules/mail/__tests__/wecom-provider.test.ts(对假 fetch)
Interfaces:
-
Produces:
createWecomProvider(deps?: { fetch?: FetchLike; now?: () => number }): MailProvider;FetchLike;WecomApiError(errcode, errmsg, api)。 -
官方接口(核实 2026-08-25):
GET {apiBase}/cgi-bin/gettoken?corpid&corpsecret→{access_token, expires_in};POST /cgi-bin/exmail/app/get_email_alias→{email, alias_list};POST /cgi-bin/exmail/app/get_mail_listbody{begin_time, end_time, cursor?, limit≤1000}→{next_cursor, has_more(0|1), mail_list:[{mail_id}]};POST /cgi-bin/exmail/app/read_mailbody{mail_id}→{mail_data}(EML;编码文档未说);POST /cgi-bin/exmail/app/compose_sendbody{to:{emails}, cc?, bcc?, subject, content, content_type:'html'|'text', attachment_list?:[{file_name, content(base64)}]}→{errcode, errmsg};错误码-1系统繁忙、40014token 非法、42001token 过期、45009频率超限、48002无权限、60020IP 不在白名单。 -
Step 1: 写失败测试
// apps/api/src/modules/mail/__tests__/wecom-provider.test.ts
/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */
import { describe, expect, it } from 'vitest';
import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts';
import type { FetchLike } from '../internal/adapter/wecom-provider.ts';
import { wecomCursor } from '../internal/domain/cursor.ts';
type Call = { url: string; body: unknown };
function scripted(handlers: Record<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const counts = new Map<string, number>();
const fetch: FetchLike = async (url, init) => {
const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
calls.push({ url, body });
const key = Object.keys(handlers).find((k) => url.includes(k));
if (key === undefined) return { ok: false, status: 404, json: async () => ({}) };
const nth = (counts.get(key) ?? 0) + 1;
counts.set(key, nth);
return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) };
};
return { fetch, calls };
}
const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } };
const known = async (): Promise<Set<string>> => new Set<string>();
const now = new Date('2026-08-25T10:00:00Z');
const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n';
describe('token', () => {
it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => {
let clock = 0;
let issued = 0;
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }),
'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }),
});
const provider = createWecomProvider({ fetch, now: () => clock });
await provider.probe(account);
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次
clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3);
expect(calls[0]?.url).toContain('corpid=ww1');
expect(calls[0]?.url).toContain('corpsecret=cs-1');
});
it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => {
let busy = 2;
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report.receive.ok).toBe(false);
expect(report.receive.error).toContain('get_email_alias');
expect(report.receive.error).toContain('48002');
expect(report.send).toMatchObject({ checked: false });
await expect(
createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }),
).rejects.toBeInstanceOf(WecomApiError);
});
});
describe('probe / anchor', () => {
it('探到应用邮箱地址;发信一栏 checked=false', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } });
});
it('锚定 = 当下(或回填起点)的秒', async () => {
const provider = createWecomProvider({ fetch: scripted({}).fetch });
expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000)));
});
});
describe('pull', () => {
it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': (body) =>
body['cursor'] === undefined
? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }
: { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] },
'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }),
});
const provider = createWecomProvider({ fetch });
const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000);
const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async () => new Set(['m2']) });
const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body;
expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000), limit: 10 });
expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']);
expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML);
expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']);
expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(result.more).toBe(false);
});
it('取满 limit 就停,more=true', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }),
'read_mail': () => ({ errcode: 0, mail_data: EML }),
});
const result = await createWecomProvider({ fetch }).pull(account, wecomCursor(1), { limit: 2, now, backfillFrom: null, known });
expect(result.messages).toHaveLength(2);
expect(result.more).toBe(true);
});
});
describe('send', () => {
it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'compose_send': () => ({ errcode: 0, errmsg: 'ok' }),
});
const result = await createWecomProvider({ fetch }).send(account, {
messageId: '<x@y>',
from: { address: 'app@corp.wecom.work', name: '应用' },
to: ['a@x.com'],
cc: ['b@x.com'],
bcc: [],
subject: '报价',
text: '纯文本',
html: '<p>html</p>',
references: [],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(result).toEqual({});
const payload = calls.find((c) => c.url.includes('compose_send'))?.body;
expect(payload).toEqual({
to: { emails: ['a@x.com'] },
cc: { emails: ['b@x.com'] },
subject: '报价',
content: '<p>html</p>',
content_type: 'html',
attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }],
});
});
});
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/modules/mail/__tests__/wecom-provider.test.ts
Expected: FAIL(适配器不存在)。
- Step 3: 写适配器
// apps/api/src/modules/mail/internal/adapter/wecom-provider.ts
/**
* 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25):
* gettoken(corpid, corpsecret) → access_token(7200s)
* exmail/app/get_email_alias → 应用邮箱地址
* exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱)
* exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64)
* exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立)
* token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。
*/
import { wecomSettingsSchema } from '@contracts';
import type { MailProbeReport } from '@contracts';
import { wecomCursor, wecomWindow } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts';
export type FetchLike = (
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
type WecomResponse = Record<string, unknown> & { errcode?: number; errmsg?: string };
export class WecomApiError extends Error {
constructor(
readonly errcode: number,
readonly errmsg: string,
readonly api: string,
) {
super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`);
this.name = 'WecomApiError';
}
}
const TOKEN_EARLY_MS = 5 * 60_000;
const BUSY_RETRIES = 3;
const REQUEST_TIMEOUT_MS = 30_000;
const LIST_PAGE = 100;
/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */
export function emlBytesOf(mailData: string): Uint8Array {
const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048));
return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64'));
}
export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider {
const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
const nowMs = deps.now ?? (() => Date.now());
const tokens = new Map<string, { token: string; expiresAt: number }>();
async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
for (let attempt = 0; ; attempt += 1) {
const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`);
const body = (await res.json()) as WecomResponse;
const code = typeof body.errcode === 'number' ? body.errcode : 0;
if (code === 0) return body;
if (code === -1 && attempt < BUSY_RETRIES) continue;
throw new WecomApiError(code, String(body.errmsg ?? ''), api);
}
}
async function tokenOf(account: ProviderAccount, force = false): Promise<string> {
const cached = tokens.get(account.id);
if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token;
const settings = wecomSettingsSchema.parse(account.settings);
const secret = account.secret['corpSecret'];
if (secret === undefined || secret === '') throw new Error('缺应用 secret');
const body = await call(
'gettoken',
`${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`,
{ method: 'GET' },
);
const token = typeof body['access_token'] === 'string' ? body['access_token'] : '';
if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token');
const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200;
tokens.set(account.id, { token, expiresAt: nowMs() + expiresIn * 1000 });
return token;
}
async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise<WecomResponse> {
const settings = wecomSettingsSchema.parse(account.settings);
const run = (token: string): Promise<WecomResponse> =>
call(
api,
`${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`,
payload === undefined
? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) },
);
try {
return await run(await tokenOf(account));
} catch (error) {
if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) {
return run(await tokenOf(account, true));
}
throw error;
}
}
return {
kind: 'wecom',
async probe(account): Promise<MailProbeReport> {
const started = nowMs();
try {
const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias');
const address = typeof alias['email'] === 'string' ? alias['email'] : '';
return {
kind: 'wecom',
address,
receive: { ok: true, latencyMs: nowMs() - started },
send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' },
};
} catch (error) {
return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } };
}
},
async anchor(_account, backfillFrom, now): Promise<MailCursor> {
return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000));
},
async pull(account, cursor, opts) {
const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now);
const ids: string[] = [];
let pageCursor: string | undefined;
let exhausted = false;
// 取满 limit 就停,余下的下一轮接着(窗口重叠 + 唯一键保证不丢不重)
while (ids.length < opts.limit) {
const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', {
begin_time: beginTime,
end_time: endTime,
...(pageCursor === undefined ? {} : { cursor: pageCursor }),
limit: Math.min(LIST_PAGE, opts.limit - ids.length),
});
const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : [];
for (const item of list) if (typeof item.mail_id === 'string') ids.push(item.mail_id);
const hasMore = page['has_more'] === 1 || page['has_more'] === true;
pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined;
if (!hasMore || list.length === 0 || pageCursor === undefined) {
exhausted = true;
break;
}
}
const known = await opts.known(ids);
const messages: RawMessage[] = [];
for (const mailId of ids.slice(0, opts.limit)) {
if (known.has(mailId)) continue;
const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId });
const data = mail['mail_data'];
if (typeof data !== 'string' || data === '') continue;
messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now });
}
return { messages, cursor: wecomCursor(endTime), more: !exhausted };
},
async send(account, message) {
await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', {
to: { emails: [...message.to] },
...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }),
...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }),
subject: message.subject,
content: message.html ?? message.text ?? '',
content_type: message.html !== undefined ? 'html' : 'text',
...(message.attachments.length === 0
? {}
: { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }),
});
return {};
},
};
}
index.ts 追加:
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
- Step 4: 跑测试确认通过
Run: pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 全部 PASS(wecom 7 tests);门禁退出码 0。
- Step 5: Commit
git add apps/api/src/modules/mail
git commit -m "$(printf 'mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 14: app 装配 —— 上传件存取抽出、mail 接线(channel 订阅方 / 传输件 / 工具处理器)、轮询工作者、config / main
Files:
- Create:
apps/api/src/app/upload-store.ts·apps/api/src/app/mail-wiring.ts·apps/api/src/app/workers/mail-poller.ts - Modify:
apps/api/src/app/container.ts(装配 mail;mail.send处理器闭包;channel 传输件与订阅方;删模块级firstPartyToolHandlers)·apps/api/src/app/config.ts(mailPollTickMs)·apps/api/src/app/main.ts(起停轮询器)·apps/api/src/app/routes/governance-ops.ts(上传两段改调upload-store.ts)·apps/api/src/app/tools/first-party.ts(删mailSend) - Test:
apps/api/src/app/__tests__/mail-wiring.test.ts·apps/api/src/app/__tests__/mail-poller.test.ts
Interfaces:
-
Produces:
storeUpload(db, {name, contentType, bytes, uploadedBy, note?})→StoredUpload;readUpload(db, ref);toInboundMail(detail)→InboundMail;channelSubscriber(channel)→InboundHandler;channelTransport(mail)→MailTransport;mailSendTool(mail)→ToolHandler;pollMailOnce(deps);startMailPoller(deps, tickMs);Container.mail: MailService;AppConfig.mailPollTickMs。 -
Step 1: 写失败测试
// apps/api/src/app/__tests__/mail-wiring.test.ts
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
import type { InboundMail } from '@contracts';
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
const detail: MailInboundDetail = {
id: 'in-1',
accountId: 'acc-1',
messageId: '<m1@acme.com>',
from: { address: 'buyer@acme.com', name: '张三' },
to: [{ address: 'sales@we.com', name: '' }],
cc: [{ address: 'ops@we.com', name: 'Ops' }],
subject: '询价 RFQ-1',
sentAt: '2026-08-25T09:00:00.000Z',
receivedAt: '2026-08-25T09:30:00.000Z',
textPreview: '你好',
hasHtml: true,
attachments: [
{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' },
{ index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' },
],
deliveries: [],
inReplyTo: null,
references: ['<root@we.com>', '<q1@we.com>'],
text: null,
html: '<p>你好</p>',
size: 100,
rawAvailable: true,
};
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
const calls: MailSendInput[] = [];
return {
calls,
send: async (input) => {
calls.push(input);
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
},
};
}
describe('toInboundMail', () => {
it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => {
const mail: InboundMail = toInboundMail(detail);
expect(mail.threadId).toBe('<q1@we.com>');
expect(mail.from).toBe('buyer@acme.com');
expect(mail.to).toEqual(['sales@we.com']);
expect(mail.cc).toEqual(['ops@we.com']);
expect(mail.body).toBe('你好');
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
});
});
describe('channelSubscriber', () => {
it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => {
const calls: Array<[string, InboundMail]> = [];
const handler = channelSubscriber({
ingestMail: async (id, mail) => {
calls.push([id, mail]);
return undefined;
},
});
expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' });
expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined();
expect(calls[0]?.[0]).toBe('ch-1');
expect(calls[0]?.[1].messageId).toBe('<m1@acme.com>');
});
});
describe('channelTransport', () => {
it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => {
const mail = fakeSend();
const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } });
const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] });
expect(fresh.threadId).toBe('<out-1@we.com>');
});
it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => {
await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed');
});
});
describe('mailSendTool', () => {
const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' };
it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => {
const mail = fakeSend();
const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } });
await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(mail.calls[1]?.accountId).toBeUndefined();
});
it('dryRun 只校验不发', async () => {
const mail = fakeSend();
expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } });
expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false);
expect(mail.calls).toHaveLength(0);
});
it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => {
const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } });
const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail');
const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(noAccount.error?.code).toBe('no-account');
const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } });
expect(invalid.error?.code).toBe('invalid');
});
});
// apps/api/src/app/__tests__/mail-poller.test.ts
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MailPollOutcome } from '@contracts';
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
const calls: Date[] = [];
return {
calls,
mail: {
pollDue: async (now: Date) => {
calls.push(now);
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
return outcomes;
},
},
};
}
describe('pollMailOnce', () => {
it('回用例的结果;出错的账号写一行 stderr', async () => {
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
const out = await pollMailOnce({ container: { mail: mail as never } });
expect(out).toHaveLength(2);
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
write.mockRestore();
});
});
describe('startMailPoller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('按 tick 调;在跑时跳过;stop 后停', async () => {
const { mail, calls } = fakeMail([], 1000);
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
await vi.advanceTimersByTimeAsync(350);
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
await vi.advanceTimersByTimeAsync(1000);
expect(calls.length).toBeGreaterThanOrEqual(2);
poller.stop();
const before = calls.length;
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toHaveLength(before);
});
});
注:两处 mail as never 只是把测试桩缩到 pollDue 一个方法 —— Container['mail'] 是整个 MailService,桩不必长成它;as never 不在 §12.5 的禁止清单里(禁的是 as unknown as)。
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts
Expected: FAIL(文件不存在)。
- Step 3: 写 upload-store / mail-wiring / mail-poller
// apps/api/src/app/upload-store.ts
/**
* sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。
* 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { extractText } from './upload-extract.ts';
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
export const UPLOAD_CAP = 8 * 1024 * 1024;
function rowsOf<T>(result: unknown): T[] {
const bag = result as { rows?: T[] } | T[];
return Array.isArray(bag) ? bag : (bag.rows ?? []);
}
export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string };
/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */
export async function storeUpload(
db: Db,
file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined },
): Promise<StoredUpload> {
const got = extractText(file.name, file.contentType, file.bytes);
const ref = crypto.randomUUID();
const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`;
await db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)},
${got.text ?? null}, ${note}, ${file.uploadedBy})`);
return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note };
}
export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> {
const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>(
sql`select name, content_type, bytes from sys.upload where id = ${ref}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
if (hit === undefined) return undefined;
// pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科)
return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) };
}
apps/api/src/app/routes/governance-ops.ts 里上传两段改为(其余不动;UPLOAD_CAP 常量与 extractText import 删掉,改 import storeUpload, readUpload, UPLOAD_CAP from '../upload-store.ts'):
app.post(uploadFileRoute.path, async (c) => {
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const { name, contentType, dataBase64 } = parsed.data;
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
if (bytes.length === 0) {
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
return c.json(body, 400);
}
if (bytes.length > UPLOAD_CAP) {
const body: ApiError = {
code: 'validation-failed',
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
};
return c.json(body, 400);
}
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
});
app.get(downloadFileRoute.path, async (c) => {
const hit = await readUpload(container.db, c.req.param('id'));
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404);
}
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
});
// apps/api/src/app/mail-wiring.ts
/**
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
* 三段都是纯映射 + 一次调用,单测直接对函数。
*/
import type { InboundMail, MailSendInput } from '@contracts';
import type { MailTransport } from '@modules/channel';
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
import {
bodyTextOf,
MailAccountDisabledError,
MailAccountNotFoundError,
MailNoDefaultAccountError,
MailSecretMissingError,
MailSendInvalidError,
} from '@modules/mail';
import type { ToolHandler } from '@modules/toolbox';
/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */
export function toInboundMail(mail: MailInboundDetail): InboundMail {
const threadId = mail.inReplyTo ?? mail.references.at(-1);
return {
messageId: mail.messageId,
...(threadId === undefined ? {} : { threadId }),
from: mail.from.address,
to: mail.to.map((a) => a.address),
cc: mail.cc.map((a) => a.address),
subject: mail.subject,
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
receivedAt: mail.receivedAt,
attachments: mail.attachments.flatMap((a) =>
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
),
};
}
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
return async (mail, ctx) => {
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
};
}
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
return {
async send(input) {
const result = await mail.send({
from: input.from,
to: [...input.to],
cc: [...input.cc],
bcc: [],
subject: input.subject,
text: input.body,
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
references: input.threadId === undefined ? [] : [input.threadId],
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
requestedBy: { kind: 'channel' },
});
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
},
};
}
function recipientsOf(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
return [];
}
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
if (!Array.isArray(value)) return [];
return value.flatMap((v) => {
if (typeof v !== 'object' || v === null) return [];
const a = v as Record<string, unknown>;
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
if (uploadRef === undefined) return [];
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
return [{ name, contentType: 'application/octet-stream', uploadRef }];
});
}
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
return async ({ input, connection, idempotencyKey }) => {
const to = recipientsOf(input['to']);
const cc = recipientsOf(input['cc']);
const bcc = recipientsOf(input['bcc']);
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
// 账号:入参指定 > 连接配置('default' = 默认账号)
const configured = connection['account'];
const accountId =
typeof input['accountId'] === 'string' && input['accountId'] !== ''
? input['accountId']
: configured !== undefined && configured !== '' && configured !== 'default'
? configured
: undefined;
if (input['dryRun'] === true) {
const problems = [
...(to.length === 0 ? ['收件人至少一个'] : []),
...(subject === '' ? ['主题不能为空'] : []),
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
];
return problems.length === 0
? { ok: true, output: { queued: false, dryRun: true } }
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
}
try {
const result = await mail.send({
...(accountId === undefined ? {} : { accountId }),
to,
cc,
bcc,
subject,
...(body === undefined ? {} : { text: body }),
...(html === undefined ? {} : { html }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: inReplyTo === undefined ? [] : [inReplyTo],
attachments: attachmentsOf(input['attachments']),
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
requestedBy: { kind: 'tool', tool: 'mail.send' },
});
if (result.state === 'failed') {
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
}
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
} catch (error) {
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
if (
error instanceof MailNoDefaultAccountError ||
error instanceof MailAccountNotFoundError ||
error instanceof MailAccountDisabledError ||
error instanceof MailSecretMissingError
) {
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
}
throw error;
}
};
}
// apps/api/src/app/workers/mail-poller.ts
/**
* 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里,
* 两个后端连同一个库时一个信箱同一时刻只有一个在拉。
* 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。
*/
import type { MailPollOutcome } from '@modules/mail';
import type { Container } from '../container.ts';
export type MailPollerDeps = { readonly container: Pick<Container, 'mail'>; readonly now?: () => Date };
export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))());
for (const outcome of outcomes) {
if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`);
}
return outcomes;
}
export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } {
let stopped = false;
let running = false;
const tick = async (): Promise<void> => {
if (stopped || running) return;
running = true;
try {
await pollMailOnce(deps);
} catch (error) {
process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`);
} finally {
running = false;
}
};
const timer = setInterval(() => void tick(), tickMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}
- Step 4: 改 container / config / main / first-party
apps/api/src/app/config.ts:AppConfig 加 /** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */ readonly mailPollTickMs: number;,loadConfig 加 mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),。
apps/api/src/app/tools/first-party.ts:删掉 const mailSend: ToolHandler = … 整段(含注释)与 FIRST_PARTY_TOOLS 里的 ['mail.send', mailSend], 一行。
apps/api/src/app/container.ts:
- import:删
createSimulatedMailTransport(保留 channel 其余三个);加import type { MailService } from '@modules/mail'; import { createImapSmtpProvider, createInboundStore, createMailAccountStore, createMailService, createOutboundStore, createPostalMimeParser, createWecomProvider, } from '@modules/mail'; import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts'; import { readUpload, storeUpload } from './upload-store.ts'; Container类型加/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService;(放在channel之后)。- 删掉模块级
export const firstPartyToolHandlers = …(仓内无其它引用)。 - 在
const toolRegistry = createToolRegistry(db);之前加:/* * 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。 * 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。 * 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。 */ const mail = createMailService({ accounts: createMailAccountStore(db), inbound: createInboundStore(db), outbound: createOutboundStore(db), attachments: { put: async (file) => ({ ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref, }), get: (ref) => readUpload(db, ref), }, providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() }, mime: createPostalMimeParser(), secrets, clock: systemClock, ids, notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) }, }); const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);改为/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ const toolRuntime = createToolRuntime( new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler, );- channel 装配里
mail: createSimulatedMailTransport(db, ids),改为mail: channelTransport(mail),;紧跟const channel = createChannelService({...});之后加:// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面 mail.subscribe('channel', channelSubscriber(channel)); return {...}里channel,之后加mail,。
apps/api/src/app/main.ts:import { startMailPoller } from './workers/mail-poller.ts';;在 const advancer = … 之后加
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
shutdown 里 advancer.stop(); 之后加 mailPoller.stop();。
- Step 5: 跑测试确认通过
Run: pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise
Expected: 8 + 2 tests PASS;三道门禁退出码 0(app-not-into-internal:app 只 import @modules/mail 的出口;no-orphans:upload-store.ts / mail-wiring.ts 被 container 引用)。
- Step 6: Commit
git add apps/api/src/app
git commit -m "$(printf 'app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 15: 路由 /api/gov/mail/*
Files:
- Create:
apps/api/src/app/routes/mail.ts - Modify:
apps/api/src/app/server.ts(注册) - Test:
apps/api/src/app/__tests__/mail-routes.test.ts(Honoapp.request,真用例 + 内存 store,不起 PG)
Interfaces:
-
Produces:
registerMailRoutes(app: Hono<AppEnv>, deps: { mail: MailService }): void;mailErrorResponse(error)。 -
Step 1: 写失败测试
// apps/api/src/app/__tests__/mail-routes.test.ts
/**
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
*/
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import { createMailService, createPostalMimeParser } from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { registerMailRoutes } from '../routes/mail.ts';
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const mail = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('x'),
notify: { changed: () => undefined },
});
const app = new Hono<AppEnv>();
registerMailRoutes(app, { mail });
const json = (method: string, path: string, body?: unknown) =>
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { app, mail, accounts, imap, json };
}
const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } };
describe('账号路由', () => {
it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => {
const h = harness();
expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] });
expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400);
const created = await h.json('POST', '/api/gov/mail/accounts', input);
expect(created.status).toBe(200);
const view = (await created.json()) as { id: string; secretBound: boolean };
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('pw-1234');
expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409);
expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404);
expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200);
expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true });
expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404);
});
it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => {
const h = harness();
expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412);
const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string };
expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200);
h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } });
const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } });
});
it('立即拉取:200 outcome;停用 412;租约被占 409', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 });
await h.accounts.save(account({ enabled: false }));
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412);
await h.accounts.save(account({ pollingUntil: new Date('2027-01-01T00:00:00Z') }));
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409);
});
it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => {
const h = harness();
await h.accounts.save(account());
const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' });
expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '<x-1@we.com>' });
h.imap.failSendWith(new Error('535 nope'));
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' });
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400);
});
});
describe('入站 / 出站路由', () => {
it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => {
const h = harness();
await h.accounts.save(account());
h.mail.subscribe('spy', async () => undefined);
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } })));
await h.json('POST', '/api/gov/mail/accounts/acc-1/poll');
const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> };
expect(list.mails).toHaveLength(1);
expect(list.mails[0]?.deliveries).toHaveLength(1);
const id = list.mails[0]!.id;
expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404);
const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string };
expect(att.name).toBe('a.txt');
expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi');
expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404);
const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string };
expect(raw.contentType).toBe('message/rfc822');
expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] });
expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404);
expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400);
expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] });
});
});
/** 空 body 的 POST:redeliver 的 body 是可省的 */
function app_request_redeliver(h: ReturnType<typeof harness>, id: string): Promise<Response> {
return h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' });
}
- Step 2: 跑测试确认失败
Run: pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts
Expected: FAIL(routes/mail.ts 不存在)。
- Step 3: 写路由并注册
// apps/api/src/app/routes/mail.ts
/**
* 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判;
* 这里只做 zod 校验、调用用例、把领域错误翻成状态码。
* 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。
*/
import type { Context, Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
createMailAccountRoute,
deleteMailAccountRoute,
getMailAccountRoute,
getMailInboundRoute,
listMailAccountsRoute,
listMailInboundRoute,
listMailOutboundRoute,
mailAttachmentRoute,
mailRawRoute,
pollMailAccountRoute,
probeMailAccountRoute,
probeMailDraftRoute,
redeliverMailInboundRoute,
sendTestMailRoute,
updateMailAccountRoute,
} from '@contracts';
import type { MailService } from '@modules/mail';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberUnknownError,
} from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { staffOf } from '../http-env.ts';
export type MailRouteDeps = { readonly mail: MailService };
/** 领域错误 → 状态码;认不出的交回 onError(500) */
export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined {
if (
error instanceof MailAccountNotFoundError ||
error instanceof MailInboundNotFoundError ||
error instanceof MailAttachmentNotFoundError ||
error instanceof MailSubscriberUnknownError
) {
return { status: 404, body: { code: 'not-found', message: error.message } };
}
if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) {
return { status: 409, body: { code: 'conflict', message: error.message } };
}
if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) {
return { status: 412, body: { code: 'precondition-failed', message: error.message } };
}
if (error instanceof MailSendInvalidError) {
return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } };
}
return undefined;
}
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
const body: ApiError = { code: 'validation-failed', message, detail };
return c.json(body, 400);
}
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
const text = await c.req.text();
if (text.trim() === '') return {};
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
export function registerMailRoutes(app: Hono<AppEnv>, deps: MailRouteDeps): void {
const { mail } = deps;
const guarded = async (c: Context<AppEnv>, run: () => Promise<Response>): Promise<Response> => {
try {
return await run();
} catch (error) {
const mapped = mailErrorResponse(error);
if (mapped === undefined) throw error;
return c.json(mapped.body, mapped.status);
}
};
/* ── 账号 ── */
app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() }));
app.post(createMailAccountRoute.path, async (c) => {
const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
return guarded(c, async () => c.json(await mail.createAccount(parsed.data)));
});
app.post(probeMailDraftRoute.path, async (c) => {
const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
const accountId = c.req.query('accountId');
return guarded(c, async () =>
c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })),
);
});
app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId')))));
app.put(updateMailAccountRoute.path, async (c) => {
const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data)));
});
app.delete(deleteMailAccountRoute.path, async (c) =>
guarded(c, async () => {
await mail.deleteAccount(c.req.param('accountId'));
return c.json({ ok: true as const });
}),
);
app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') }))));
app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId')))));
app.post(sendTestMailRoute.path, async (c) => {
const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues);
const me = staffOf(c);
return guarded(c, async () =>
c.json(
await mail.send({
accountId: c.req.param('accountId'),
to: [parsed.data.to],
cc: [],
bcc: [],
subject: parsed.data.subject,
text: parsed.data.text,
references: [],
attachments: [],
requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) },
}),
),
);
});
/* ── 入站 ── */
app.get(listMailInboundRoute.path, async (c) => {
const query = listMailInboundRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) });
});
app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId')))));
app.get(mailAttachmentRoute.path, async (c) => {
const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') });
if (!params.success) return invalid(c, '附件序号不合法', params.error.issues);
return guarded(c, async () => {
const file = await mail.attachment(params.data.mailId, params.data.index);
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
});
});
app.get(mailRawRoute.path, async (c) =>
guarded(c, async () => {
const file = await mail.raw(c.req.param('mailId'));
return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') });
}),
);
app.post(redeliverMailInboundRoute.path, async (c) => {
const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues);
return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) }));
});
/* ── 出站 ── */
app.get(listMailOutboundRoute.path, async (c) => {
const query = listMailOutboundRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) });
});
}
apps/api/src/app/server.ts:import { registerMailRoutes } from './routes/mail.ts';,在 registerGovernanceOpsRoutes(app, container); 之后加 registerMailRoutes(app, container);。
- Step 4: 跑测试确认通过(含路由覆盖 IT)
Run: pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint
Expected: 路由 5 tests PASS;路由覆盖 IT PASS(15 条新路由都在注册表里 —— 它是 fail-closed 的,红了就是有路由没契约);门禁退出码 0。若 c.json(mapped.body, mapped.status) 因状态码联合类型报错,把 mailErrorResponse 的 status 类型改成 hono 导出的 ContentfulStatusCode(import type { ContentfulStatusCode } from 'hono/utils/http-status')。
- Step 5: Commit
git add apps/api/src/app
git commit -m "$(printf '邮件路由 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站 / 附件原件 / 重新投递;领域错误翻状态码,厂商失败在 body 里说\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 16: mail.send 工具契约 2.0.0、api-report、五道门禁
Files:
-
Modify:
apps/api/src/app/seed-mvp.ts(MAIL_TOOL与注册循环的样例 / 连接值) -
Modify:
api-report/**(pnpm api:check --update) -
Test: 既有
apps/api/src/modules/toolbox/__tests__/*(演进检查)+ 全量pnpm gates -
Step 1: 改 seed 的工具契约
把 seed-mvp.ts 里的 MAIL_TOOL 整段换成:
/**
* 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。
*
* 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。
* 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
*/
const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
displayName: '发送邮件',
version: '2.0.0',
description:
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
input: {
type: 'object',
required: ['to', 'subject'],
properties: {
to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' },
cc: { type: 'array', items: { type: 'string' }, description: '抄送' },
bcc: { type: 'array', items: { type: 'string' }, description: '密送' },
subject: { type: 'string' },
body: { type: 'string', description: '纯文本正文(与 html 至少一个)' },
html: { type: 'string', description: 'HTML 正文' },
attachments: {
type: 'array',
items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } },
description: '附件:上传件的 ref',
},
inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' },
accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' },
dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' },
},
},
output: {
type: 'object',
properties: {
queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' },
outboundId: { type: 'string' },
messageId: { type: 'string' },
state: { type: 'string' },
dryRun: { type: 'boolean' },
},
},
effect: 'write' as const,
errors: [
{ code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' },
{ code: 'no-account', retryable: false, message: '没有可用的邮箱账号' },
{ code: 'invalid', retryable: false, message: '入参不合法' },
],
idempotent: true,
connectionKeys: ['account'],
};
注册循环里的样例改为(to 变数组、加 dryRun;连接值按键给):
input: {
pn: 'LM358DR',
to: ['x@y.com'],
subject: '询价',
body: '样例正文',
dryRun: true,
history_days: 30,
market_stable: true,
lifecycle: '在产',
},
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
...(contract.connectionKeys.length === 0
? {}
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
- Step 2: 演进检查与试运行走一遍(单测)
在 apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts 末尾追加一条(照该文件既有的 checkEvolution 用例写法):
describe('mail.send 2.0.0 的演进', () => {
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
const v1 = {
name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)',
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'],
};
const v2 = {
...v1, version: '2.0.0', connectionKeys: ['account'],
input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } },
output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } },
};
expect(checkEvolution(v1, v2)).toEqual([]);
});
});
Run: pnpm vitest run apps/api/src/modules/toolbox
Expected: PASS(含新用例)。
- Step 3: 刷新契约快照并跑五道门禁
pnpm api:check --update && git add api-report
pnpm gates
Expected: api-report/apps/api/src/modules/mail/{index,contract,port}.d.ts 与 api-report/packages/contracts/... 有新增;pnpm gates 退出码 0(gen:tiers / typecheck / lint / depcruise / api:check / test 全绿;IT 套件在有 PG 时跑、没有时跳过并告警 —— 本轮验收要求 PG 在)。
- Step 4: 本机起一次真服务核一遍(有 PG + Keycloak 配置时)
pnpm db:migrate && pnpm db:seed
pnpm dev:api
Expected:启动日志无 [mail-poller] 报错(没有账号时每 tick 空转);pnpm db:seed 日志里 ② 功能工具已注册并入白名单,且 mail.send 未出现「未过两审」(dryRun 样例通过试运行)。
用服务账号 token(API 文档 §8 的取法)curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/api/gov/mail/accounts → {"accounts":[]}。
- Step 5: Commit
git add apps/api/src/app/seed-mvp.ts apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts api-report
git commit -m "$(printf 'mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
Task 17: 文档 —— ADR-013、CONTRACT.md、API 文档、前端对接说明、总表、debt、CLAUDE.md
Files:
-
Create:
docs/adr/ADR-013-mail-gateway.md·docs/API文档-邮件收发.md·docs/前端对接说明-邮件收发.md -
Modify:
apps/api/src/modules/channel/CONTRACT.md(「邮件通道是外界」一条)·docs/API文档-鉴权与人员.md(§6 总表 + 计数)·docs/debt.md(新段)·CLAUDE.md(现状快照一条 + 文档权威地图两行) -
Step 1: ADR-013
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
状态:已接受(2026-08-25)
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
(核实日期 2026-08-25,链接列在设计稿开头)。
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据)。
## 问题
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
## 决策
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下,
`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
订阅是代码级装配(不落库、不回放老信)。
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封,
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
厂商失败在 200 的 body 里说,不编状态码。
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
## 后果
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。
- 欠账见 `docs/debt.md`「邮件收发」段。
- Step 2: channel 的 CONTRACT.md
把 apps/api/src/modules/channel/CONTRACT.md 不变量里这一条:
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
系统内部机制一律真跑,不得 mock
改成:
- 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方,
把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认);
`createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用);
系统内部机制一律真跑,不得 mock
依赖: 那一行的 MailTransport(模拟通道)改为 MailTransport(app 注入,转 mail 模块)。
- Step 3: API 文档
docs/API文档-邮件收发.md
# API 文档 —— 邮件收发(ADR-013)
> 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用);
> 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。
> 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`;
> 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。
## 0. 通则
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/mail/*`;**读写都归运营中心 `ops`** —— 邮箱那一屏落在运营中心,设计端没有消费方;而入站正文装的是客户来往内容,不无故加宽可读面 |
| 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` |
| 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 |
| 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) |
| 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) |
状态码怎么读(通则之外本文特有的):
| 状态 | code | 何时 |
|---|---|---|
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 |
| 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 |
| 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** |
| 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) |
## 1. 账号
### `GET /api/gov/mail/accounts` —— access: `design|ops`
```json
{
"accounts": [
{
"id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
"enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null,
"settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" },
"secretBound": true,
"status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false },
"createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z"
},
{
"id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false,
"pollIntervalSec": 60, "channelId": null, "backfillFrom": null,
"settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" },
"secretBound": false,
"status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false },
"createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z"
}
]
}
address:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。status.cursor是人读摘要(还没拉过 = null);status.polling为真表示此刻某台后端正拉着。status.lastPollError是厂商原话(脱敏);为 null 表示上一轮没出错。
POST /api/gov/mail/accounts —— access: ops
body 按 kind 分两形(缺省值见 §5 schema):
{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
"isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
"settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
{ "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } }
→ 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。isDefault:true 会把别的账号的默认位放下。
POST /api/gov/mail/accounts/probe?accountId= —— access: ops
未存的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 ?accountId= 借库里那份密文。
没 secret 又没 accountId → 412。
{ "kind": "imap-smtp", "address": "sales@corp.com",
"receive": { "ok": true, "latencyMs": 412 },
"send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
{ "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work",
"receive": { "ok": true, "latencyMs": 230 },
"send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } }
GET /api/gov/mail/accounts/:accountId —— access: design|ops
→ 账号视图;404。
PUT /api/gov/mail/accounts/:accountId —— access: ops
body 同建账号;secret 缺省 = 不改密文;换 kind 又没给 secret → 旧密文作废(secretBound:false);
kind 或 settings 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。
DELETE /api/gov/mail/accounts/:accountId —— access: ops
→ { "ok": true }。留痕(入站 / 出站)不删。
POST /api/gov/mail/accounts/:accountId/probe —— access: ops
已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 address。412 凭证空。
POST /api/gov/mail/accounts/:accountId/poll —— access: ops
立即拉一轮(不等轮询到点):
{ "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false }
pulled服务器交出几封;stored真新入库几封(去重后);dispatched分发过几封;more为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。- 出错:
error带原话(同时写进账号的status.lastPollError),仍是 200。 - 409:另一台后端正占着租约;412:账号停用。
POST /api/gov/mail/accounts/:accountId/send-test —— access: ops
body { "to": "me@corp.com", "subject": "试发", "text": "正文" } →
{ "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "state": "sent", "providerRef": "250 OK: queued as …" }
失败照样 200:{ …, "state": "failed", "error": "Invalid login —— 535 …" };这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。
2. 入站
GET /api/gov/mail/inbound?accountId=&limit= —— access: design|ops
按收信时间倒序;limit 默认 50、上限 200(超了 400)。
{
"mails": [
{
"id": "in-01J…", "accountId": "6e1c…", "messageId": "<CAF…@mail.acme.com>",
"from": { "address": "buyer@acme.com", "name": "张三" },
"to": [{ "address": "sales@corp.com", "name": "" }], "cc": [],
"subject": "询价 RFQ-2026-081", "sentAt": "2026-08-25T09:57:40.000Z", "receivedAt": "2026-08-25T09:58:11.000Z",
"textPreview": "你好,请报价以下料号……", "hasHtml": true,
"attachments": [
{ "index": 0, "name": "BOM.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "size": 18234, "ref": "3f9e…", "note": "" },
{ "index": 1, "name": "datasheets.zip", "contentType": "application/zip", "size": 9437184, "ref": null, "note": "附件 9.0MB,超过 8MB 上限,未存原件" }
],
"deliveries": [
{ "subscriber": "channel", "state": "done", "error": null, "startedAt": "2026-08-25T09:58:11.200Z", "finishedAt": "2026-08-25T09:58:11.480Z" }
]
}
]
}
deliveries[].state:dispatching(正在投;超过 10 分钟还是它 = 进程中断,交人重投)/ done / skipped(error 里是理由,如「账号未绑定通道」)/ failed(error 是原话,如「通道实例已停用:ch-rfq-mail」)。
GET /api/gov/mail/inbound/:mailId —— access: design|ops
列表那一项 + inReplyTo references[] text html size rawAvailable。404。
GET /api/gov/mail/inbound/:mailId/attachments/:index —— access: design|ops
→ { "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }(与 GET /api/gov/upload/:id 同形)。可带 ?ticket= 用 <a href download>(ADR-012 §6)。
超限没存的(ref: null)→ 404。附件同时也是一个上传件(ref = sys.upload 的 id),活动那一侧读得到抽出来的文本。
GET /api/gov/mail/inbound/:mailId/raw —— access: design|ops
→ { "name": "CAF…_mail.acme.com.eml", "contentType": "message/rfc822", "dataBase64": "…" };超 16MB 没存 → 404。可带 ?ticket=。
POST /api/gov/mail/inbound/:mailId/redeliver —— access: ops
body { "subscriber": "channel" }(可省 = 全部订阅方;body 也可整个不带)→ { "deliveries": [ … ] }。
正在 dispatching 的那条跳过(原样回);不认识的订阅方 404。从这一刻起对该订阅方是至少一次:channel 那条链会再起一单。
3. 出站
GET /api/gov/mail/outbound?accountId=&limit= —— access: design|ops
{
"mails": [
{ "id": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "to": ["buyer@acme.com"], "cc": [], "bcc": [],
"subject": "回复:询价 RFQ-2026-081", "state": "sent", "error": null, "providerRef": "250 OK: queued as …",
"requestedBy": { "kind": "tool", "tool": "mail.send" }, "createdAt": "2026-08-25T11:02:00.000Z", "sentAt": "2026-08-25T11:02:01.300Z" }
]
}
requestedBy.kind:tool(活动放行时经 mail.send 落地)/ channel(channel.sendMail)/ ops(运营面试发,带 staffId)。state:sending(在发;久停不动 = 进程中断)/ sent / failed。
4. 活动怎么用(不是 HTTP)
- 收:账号绑
channelId→ 来信经channel.ingestMail归一进事件流 → 触发器起单 / 运行中任务的listen监听命中 → 任务拿到data事件(载荷带 subject / body / from / channelId 与提取键)。附件以ref进信封,活动经上传件读文本。 - 发:活动声明工具
mail.send(2.0.0)—— 入参to[]subjectbody/htmlcc[]bcc[]attachments[{uploadRef}]inReplyToaccountId;写效应,放行时才真发;出参queued / outboundId / messageId / state;错误send-failed(可重试)/no-account/invalid。
5. Schema 附录(与 packages/contracts/src/mail.ts 同名)
type MailAccountKind = 'imap-smtp' | 'wecom';
type ImapSmtpSettings = { imapHost: string /* imap.exmail.qq.com */; imapPort: number /* 993 */; imapTls: boolean /* true */;
smtpHost: string /* smtp.exmail.qq.com */; smtpPort: number /* 465 */; smtpTls: boolean /* true = 隐式 TLS;false = STARTTLS */;
login?: string /* 缺省 = address */; folder: string /* INBOX */ };
type WecomSettings = { corpId: string; apiBase: string /* https://qyapi.weixin.qq.com */ };
// POST / PUT 的 body(缺省值:displayName '' · enabled true · isDefault false · pollIntervalSec 30(10–3600)· channelId null · backfillFrom null)
type MailAccountInput =
| { kind: 'imap-smtp'; address: string; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
channelId?: string | null; backfillFrom?: string | null; settings?: Partial<ImapSmtpSettings>; secret?: { password: string } }
| { kind: 'wecom'; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
channelId?: string | null; backfillFrom?: string | null; settings: { corpId: string; apiBase?: string }; secret?: { corpSecret: string } };
type MailAccountStatus = { lastPolledAt: string | null; lastPollError: string | null; lastInboundAt: string | null; cursor: string | null; polling: boolean };
type MailAccountView = (
| { kind: 'imap-smtp'; settings: ImapSmtpSettings }
| { kind: 'wecom'; settings: WecomSettings }
) & { id: string; name: string; address: string; displayName: string; enabled: boolean; isDefault: boolean; pollIntervalSec: number;
channelId: string | null; backfillFrom: string | null; secretBound: boolean; status: MailAccountStatus; createdAt: string; updatedAt: string };
type MailProbeReport = { kind: MailAccountKind; address?: string;
receive: { ok: boolean; error?: string; latencyMs?: number };
send: { checked: boolean; ok?: boolean; error?: string; note?: string } };
type MailPollOutcome = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
type MailAddress = { address: string; name: string };
type MailDeliveryView = { subscriber: string; state: 'dispatching' | 'done' | 'failed' | 'skipped'; error: string | null; startedAt: string; finishedAt: string | null };
type MailInboundAttachment = { index: number; name: string; contentType: string; size: number; ref: string | null; note: string };
type MailInboundSummary = { id: string; accountId: string; messageId: string; from: MailAddress; to: MailAddress[]; cc: MailAddress[]; subject: string;
sentAt: string | null; receivedAt: string; textPreview: string; hasHtml: boolean; attachments: MailInboundAttachment[]; deliveries: MailDeliveryView[] };
type MailInboundDetail = MailInboundSummary & { inReplyTo: string | null; references: string[]; text: string | null; html: string | null; size: number; rawAvailable: boolean };
type MailSendResult = { outboundId: string; accountId: string; messageId: string; state: 'sent' | 'failed'; error?: string; providerRef?: string };
type MailOutboundView = { id: string; accountId: string; messageId: string; to: string[]; cc: string[]; bcc: string[]; subject: string;
state: 'sending' | 'sent' | 'failed'; error: string | null; providerRef: string | null; requestedBy: Record<string, unknown>; createdAt: string; sentAt: string | null };
6. curl 实录
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
# 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码)
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \
-d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq .
# 探测(已存)/ 立即拉取 / 试发
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq .
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq .
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq .
# 来信、附件、重新投递
curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}'
curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq .
# 出站
curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}'
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
- [ ] **Step 4: 前端对接说明 `docs/前端对接说明-邮件收发.md`**
```markdown
# 邮件收发 —— 前端对接说明
> 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲
> **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与
> `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。
## 0. 一分钟看懂
- 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。
界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。
- 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。
表单按 `kind` 切换字段;两种都有「探测」。
- 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。
- 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。
- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。读接口两端都能调,设计中心可只读展示。
## 1. 建议的信息架构
运营中心 / 系统 / 邮箱 ├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯 │ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除 │ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe) ├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递) └─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间
## 2. 账号表单(字段清单与默认值)
公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)·
`pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)·
`backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。
`kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)·
`settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)·
`settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。
`kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。
`address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。
保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。
## 3. 四个动作的交互与「界面不撒谎」
| 动作 | 接口 | 怎么摆 |
|---|---|---|
| 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 |
| 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 |
| 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 |
| 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 |
列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。
## 4. 来信与投递状态
- 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。
- 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。
**`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。
- 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。
- 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形,
预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。
`ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。
- 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。
- 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。
## 5. 发信记录
列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。
`sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。
## 6. SSE 失效映射
`app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀
(账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。
`reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。
## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉)
**IMAP/SMTP 路**
1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框;
公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。
4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。
**企业微信邮件 API 路**
1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。
发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。
## 8. 错误处理
| 状态 | 何时 | 前端动作 |
|---|---|---|
| 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 |
| 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 |
| 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 |
| 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 |
## 9. 接入完成的判据
- [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true
- [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话
- [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址
- [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由
- [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note
- [ ] 重新投递前有提示,投递后状态刷新
- [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点)
- [ ] 试发失败时列表里看得到 `failed` 与原话
- [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话
## 10. 本文与真实契约的核对状态
路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过;字段名与 `packages/contracts/src/mail.ts` 同名。
示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。
- Step 5: 总表、debt、CLAUDE.md
docs/API文档-鉴权与人员.md §6 总表:在 | \ops` | DELETE | `/api/gov/access/roles/:role` |` 之后加 8 行:
| `ops` | POST | `/api/gov/mail/accounts` |
| `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) |
| `ops` | PUT | `/api/gov/mail/accounts/:accountId` |
| `ops` | DELETE | `/api/gov/mail/accounts/:accountId` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` |
| `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` |
| `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` |
在 | \ops` | DELETE | `/api/gov/access/roles/:role` |` 那一组的末尾再加 7 行(与上面的写路由并成一组,邮件读写同归 ops):
| `ops` | GET | `/api/gov/mail/accounts` |
| `ops` | GET | `/api/gov/mail/accounts/:accountId` |
| `ops` | GET | `/api/gov/mail/inbound` |
| `ops` | GET | `/api/gov/mail/inbound/:mailId` |
| `ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) |
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
| `ops` | GET | `/api/gov/mail/outbound` |
计数那一行改为:计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 27 + 两端 15 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。
docs/debt.md 末尾追加:
## 2026-08-25 · 邮件收发接上了,留下七笔账(ADR-013)
① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次;
根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。
② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。
③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。
④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。
⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。
⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。
⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。
要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。
另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。
CLAUDE.md:现状快照末尾加一条(照既有格式):
- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与
企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 →
`sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。
分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。
发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、
运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。
15 条 `/api/gov/mail/*` 路由(读两端、写 ops);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。
欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由。
文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后):
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
- Step 6: 核对与提交
Run: pnpm gates
Expected: 退出码 0(文档改动不影响门禁;api-report 已在 Task 16 刷新)。
核一遍:grep -c "/api/gov/mail" docs/API文档-鉴权与人员.md = 15;grep -n "mail" tools/module-tiers.json 有一行。
git add docs apps/api/src/modules/channel/CONTRACT.md CLAUDE.md
git commit -m "$(printf '文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账七笔、CLAUDE 快照\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
自审(spec 覆盖 / 占位 / 类型一致)
| spec 节 | 落在 |
|---|---|
| §2 决策(两路 / 三依赖 / subscribe / 多账号 / 方案 A) | Task 1(依赖 + L1)· Task 4(subscribe 契约)· Task 12 / 13(两路) |
| §5.1 contract / §5.2 port | Task 4(+ Task 9 的 probe 第二形 {input, accountId?}) |
| §5.3 domain 八件 | Task 4(cursor / due / records / errors)· Task 5(inbound-record / redact)· Task 6(send-input / delivery / account-view) |
| §5.4 usecase 要点(拉 / 分发 / 发 / 探测 / 凭证 / 默认账号 / 到期扫描 / 账号变更) | Task 9 实现,Task 10 / 11 测试 |
| §5.5 adapter 六件 | Task 7(schema / store)· Task 8(postal-mime)· Task 12(imap-smtp)· Task 13(wecom)· Task 8 的 fakes(假 provider:住在 __tests__/fakes.ts 而不是 adapter —— 它只服务测试,不进生产装配) |
| §6 四张表 + journal + reset-runs | Task 7 |
| §7.1 / §7.2 / §7.3 / §7.4 契约、15 条路由、mail.send 2.0.0、总表 | Task 2 / 3 / 16 / 17 |
| §8 app 装配 / 工作者 / 路由 / channel 订阅方 / 上传件抽出 | Task 14 / 15 |
| §9 租约 / 去重 / 分发语义 / 脱敏 / 两缝 / 界面不撒谎 | Task 7(租约 IT)· Task 10(去重 / 分发)· Task 5(脱敏)· Task 17(ADR 写明两缝) |
| §10 腾讯侧前置 | Task 17 前端对接说明 §7 |
| §11 连带改动 | Task 1(tiers / 编码规范)· Task 14(first-party / 上传)· Task 16(seed / api-report)· Task 17(CONTRACT.md / ADR / 文档 / debt / CLAUDE) |
| §12 测试策略 | 各任务 Step 1;IT:Task 7(store + 租约)、Task 12(真信箱 env 门控)、Task 15(路由覆盖) |
| §13 欠账 | Task 17 debt 七笔(spec 的七条 + channel 无 upsert 路由这一条是实施核仓时补的) |
与 spec 的差异已在开头「与 spec 的差异」列明(工具契约保留 v1 字段 / dryRun / send 不抛 / nodemailer 8)。
类型名在各任务间核过:MailAccountRecord InboundRecord DeliveryRecord OutboundRecord ParsedMail OutboundMessage RawMessage
ProviderAccount MailCursor 定义于 Task 4,Task 7–15 一律按此名引用;known 回调在 Task 4 的 port、Task 9 的用例、Task 12 / 13 的 provider 三处同名。