Archived
Task 9 的评审点过「批末无条件覆盖游标、没有单调性护栏」,当时两个适配器还没落地、判不了实情。 照原计划的 wecom pull 读下来是两个真缺陷,都只在积压超过一轮预算(50 封)时现形: ① 被 limit 截断时仍把游标推到窗口上沿 —— 没读到的那几封从此跳过去了; 重叠窗只兜 10 分钟,兜不住一次积压(停机重启、首次回填)。 ② 滤重(known)发生在截断**之后** —— 一个窗口里若前 50 条恰好都已入库,这一轮交出 0 封, 而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。 改法:滤重提到分页循环里(每页先滤再数够不够),游标只在把窗口翻完时才推进, 截断时原样退回 —— 已入库的下一轮被 known 滤掉,每轮照旧往前吃一批,不卡也不丢。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
7013 lines
321 KiB
Markdown
7013 lines
321 KiB
Markdown
# 邮件收发模块(`mail`)实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 新增后端模块 `mail`(L1):腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)的收信轮询、订阅分发、发信留痕与账号配置接口;`channel` 成为首个订阅方,活动经 `mail.send` 工具发信。
|
||
|
||
**Architecture:** `mail` 只依赖 `@contracts` / `@shared/db` 与端口(`MailProvider` × 2 适配器、`MimeParser`、三个 store、`AttachmentStore`、`SecretBox`);app 层装配:轮询工作者(跨进程租约)、`channel` 订阅方、`mail.send` 工具处理器、15 条 `/api/gov/mail/*` 路由。入站先持久(两把唯一键去重)再分发(逐订阅方留痕,自动只投一次);出站先落 `sending` 行再经 provider 发。
|
||
|
||
**Tech Stack:** TypeScript / Hono / Drizzle + pg / zod v4 / vitest;新依赖 `imapflow@^1.7.6`、`nodemailer@^8.0.11` + `@types/nodemailer@^8.0.1`、`postal-mime@^3.0.0`。
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-25-mail-design.md`(本计划与 spec 的三处差异见「与 spec 的差异」)。
|
||
|
||
## Global Constraints
|
||
|
||
- 五道门禁以退出码验:`pnpm gates`(= `gen:tiers → typecheck → lint → depcruise → api:check → test`);api-report 用 `pnpm api:check --update && git add api-report` 刷新。
|
||
- §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO 零 `Date.now`/`new Date()`/`Math.random`;不删测试;不动门禁配置;模块内 `.ts` 后缀显式;`apps/api/src/modules/**` 公开签名显式类型(`explicit-module-boundary-types`)。
|
||
- 模块只 import 更低层 `index.ts`;`mail` 是 L1(`tools/module-tiers.json` tier 1);`channel` / `toolbox` **不 import** `mail`,全经 app 注入。
|
||
- Drizzle / pg / imapflow / nodemailer / postal-mime 只准出现在 `internal/adapter/` 与 `apps/api/src/app/`。
|
||
- 凭证:只回 `secretBound: boolean`,密文永不出接口;错误文本先 `redactSecrets`。
|
||
- 界面不撒谎:探测 / 试发 / 拉取的厂商失败在 200 的 body 里带原话,不编状态码。
|
||
- 邮件通道是外界:`createFakeMailProvider` 是允许的替身;系统内部机制(store / usecase / 路由)真跑。
|
||
- 提交信息中文、末尾带 `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` 与 `Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr` 两行(仓内惯例)。
|
||
- **不改** `apps/web/**`、`channel` 的 index/contract/port、`ingest` 路由、任何门禁配置。
|
||
|
||
## 与 spec 的差异(实施前核仓逼出的三处,以本计划为准)
|
||
|
||
1. **`mail.send` 工具契约保留 v1 字段**:toolbox 的 `checkEvolution` 不许删入参 / 出参字段 —— v2 保留 `body`(纯文本正文,不改名 `text`)与出参 `queued`;新增 `html` / `cc` / `bcc` / `attachments` / `inReplyTo` / `accountId` / `dryRun`。`to` 改为数组(演进检查不管类型;处理器仍容忍逗号分隔的字符串)。`connectionKeys: ['account']`(值 `default` = 用默认账号)—— 静态审对无连接键只 warn,但「从哪个信箱发」本来就是一个连接目标。
|
||
2. **`toolbox.register` 的试运行会真调处理器**:样例入参带 `dryRun: true`,处理器只校验不发。
|
||
3. **`send()` 不抛 `MailSendFailedError`**:厂商失败落 `failed` 行并以 `MailSendResult.state='failed'` + `error` 返回;只有入参 / 账号问题才抛(`MailSendInvalidError` 400、`MailNoDefaultAccountError` / `MailAccountDisabledError` / `MailSecretMissingError` 412、`MailAccountNotFoundError` 404)。channel 注入件与工具处理器各自把 `failed` 翻成自己的失败形态。
|
||
4. **nodemailer 用 8.x**:9.0.x 没有 DefinitelyTyped 类型包(`@types/nodemailer` 最新 8.0.1),8.0.11 + `@types/nodemailer@8.0.1` 类型对齐。
|
||
|
||
## 文件结构
|
||
|
||
```
|
||
packages/contracts/src/mail.ts # 账号 / 入站 / 出站 / 探测的 zod schema(§Task 2)
|
||
packages/contracts/src/api/mail.ts # 15 条路由契约(§Task 3)
|
||
packages/contracts/src/__tests__/mail.test.ts
|
||
db/migrations/0033_mail.sql + meta/_journal.json # 四张表(§Task 8)
|
||
apps/api/src/modules/mail/
|
||
package.json · index.ts · contract.ts · port.ts · CONTRACT.md
|
||
internal/domain/records.ts # 库形 / 传输形类型(纯类型)
|
||
internal/domain/cursor.ts # 游标两形、UID 过滤、wecom 窗口
|
||
internal/domain/due.ts # 到期判定、租约时长
|
||
internal/domain/inbound-record.ts # 解析结果 → 入站记录 / 视图、附件规划、正文提取
|
||
internal/domain/redact.ts # 脱敏、错误文本
|
||
internal/domain/send-input.ts # 发信校验、选账号、Message-ID
|
||
internal/domain/delivery.ts # 分发状态机
|
||
internal/domain/account-view.ts # 账号记录 ↔ 输入 / 视图
|
||
internal/usecase/mail-service.ts # MailService
|
||
internal/adapter/mail-schema.ts # Drizzle 表
|
||
internal/adapter/mail-store.ts # 三个 store + 租约 SQL
|
||
internal/adapter/postal-mime-parser.ts
|
||
internal/adapter/imap-smtp-provider.ts
|
||
internal/adapter/wecom-provider.ts
|
||
internal/adapter/fake-provider.ts # 外界替身
|
||
__tests__/fakes.ts # 内存 store / 时钟 / EML 夹具
|
||
__tests__/{cursor,due,inbound-record,redact,send-input,account-view,delivery}.test.ts
|
||
__tests__/{mail-service-accounts,mail-service-poll,mail-service-send}.test.ts
|
||
__tests__/postal-mime-parser.test.ts · imap-helpers.test.ts · imap-smtp-provider.it.test.ts · wecom-provider.test.ts
|
||
__tests__/mail-store.it.test.ts
|
||
apps/api/src/app/upload-store.ts # sys.upload 存取抽出(路由与 mail 注入件同用)
|
||
apps/api/src/app/mail-wiring.ts # toInboundMail / channel 订阅方 / channel 传输件 / 工具处理器
|
||
apps/api/src/app/workers/mail-poller.ts
|
||
apps/api/src/app/routes/mail.ts
|
||
apps/api/src/app/__tests__/{mail-wiring,mail-routes,mail-poller,mail-routes-registry}.test.ts
|
||
apps/api/src/app/{container,config,main,reset-runs,seed-mvp}.ts · tools/first-party.ts · routes/governance-ops.ts(上传两段抽出)
|
||
tools/module-tiers.json · docs/编码规范.md §4 · apps/api/package.json
|
||
docs/adr/ADR-013-mail-gateway.md · docs/API文档-邮件收发.md · docs/前端对接说明-邮件收发.md · docs/API文档-鉴权与人员.md §6 · docs/debt.md · CLAUDE.md · apps/api/src/modules/channel/CONTRACT.md
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: 依赖与模块登记
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/package.json`(dependencies / devDependencies)
|
||
- Modify: `tools/module-tiers.json`(tier 1 加 `mail`)
|
||
- Modify: `docs/编码规范.md`(§4 层级表 L1 加一行)
|
||
- Generated: `tools/depcruise-tiers.generated.cjs`(`pnpm gen:tiers` 产物,入库)
|
||
|
||
**Interfaces:**
|
||
- Produces: 包可 import `imapflow` / `nodemailer` / `postal-mime`;`apps/api/src/modules/mail/` 目录被门禁认作 L1。
|
||
|
||
- [ ] **Step 1: 加依赖**
|
||
|
||
```bash
|
||
cd /Users/xcs/project2/tmp/BPOS-MVP
|
||
pnpm --filter @app/api add imapflow@^1.7.6 nodemailer@^8.0.11 postal-mime@^3.0.0
|
||
pnpm --filter @app/api add -D @types/nodemailer@^8.0.1
|
||
node -e "const p=require('./apps/api/package.json');console.log(p.dependencies.imapflow,p.dependencies.nodemailer,p.dependencies['postal-mime'],p.devDependencies['@types/nodemailer'])"
|
||
```
|
||
Expected: 四个版本号各打印一行(`^1.7.6 ^8.0.11 ^3.0.0 ^8.0.1`)。
|
||
|
||
- [ ] **Step 2: 登记层级**
|
||
|
||
`tools/module-tiers.json` 的 `"1"` 数组改为:
|
||
|
||
```json
|
||
"1": [
|
||
"event",
|
||
"org",
|
||
"llm",
|
||
"ledger",
|
||
"mail"
|
||
],
|
||
```
|
||
|
||
`docs/编码规范.md` §4 层级表,在 `| L1 | \`ledger\` |` 那一行之后加:
|
||
|
||
```markdown
|
||
| L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 |
|
||
```
|
||
|
||
- [ ] **Step 3: 重新生成 tier 规则并验门禁**
|
||
|
||
Run: `pnpm gen:tiers && pnpm depcruise`
|
||
Expected: 退出码 0;`git diff --stat tools/depcruise-tiers.generated.cjs` 显示该文件有变更(多了 `tier-order-mail` 规则)。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/api/package.json pnpm-lock.yaml tools/module-tiers.json tools/depcruise-tiers.generated.cjs docs/编码规范.md
|
||
git commit -m "$(printf 'mail 模块登记为 L1;批准 imapflow / nodemailer / postal-mime 三个依赖(设计稿 §2)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 契约 schema `packages/contracts/src/mail.ts`
|
||
|
||
**Files:**
|
||
- Create: `packages/contracts/src/mail.ts`
|
||
- Modify: `packages/contracts/src/index.ts`(加 `export * from './mail.ts';`)
|
||
- Test: `packages/contracts/src/__tests__/mail.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces(后续任务全靠这些名字):`MAIL_ACCOUNT_KINDS` `mailAccountKindSchema` `MailAccountKind` `MAIL_ACCOUNT_KIND_LABEL` `imapSmtpSettingsSchema` `ImapSmtpSettings` `wecomSettingsSchema` `WecomSettings` `mailAccountInputSchema` `MailAccountInput` `mailAccountStatusSchema` `mailAccountViewSchema` `MailAccountView` `mailProbeReportSchema` `MailProbeReport` `MAIL_DELIVERY_STATES` `mailDeliveryStateSchema` `MailDeliveryState` `mailDeliveryViewSchema` `MailDeliveryView` `mailAddressSchema` `MailAddress` `mailInboundAttachmentSchema` `MailInboundAttachment` `mailInboundSummarySchema` `MailInboundSummary` `mailInboundDetailSchema` `MailInboundDetail` `mailSendInputSchema` `MailSendInput` `mailSendResultSchema` `MailSendResult` `MAIL_OUTBOUND_STATES` `mailOutboundStateSchema` `MailOutboundState` `mailOutboundViewSchema` `MailOutboundView` `mailPollOutcomeSchema` `MailPollOutcome`。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// packages/contracts/src/__tests__/mail.test.ts
|
||
/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
mailAccountInputSchema,
|
||
mailAccountViewSchema,
|
||
mailSendInputSchema,
|
||
MAIL_ACCOUNT_KINDS,
|
||
} from '../mail.ts';
|
||
|
||
describe('mailAccountInputSchema', () => {
|
||
it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => {
|
||
const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' });
|
||
expect(parsed.kind).toBe('imap-smtp');
|
||
if (parsed.kind !== 'imap-smtp') throw new Error('unreachable');
|
||
expect(parsed.settings).toEqual({
|
||
imapHost: 'imap.exmail.qq.com',
|
||
imapPort: 993,
|
||
imapTls: true,
|
||
smtpHost: 'smtp.exmail.qq.com',
|
||
smtpPort: 465,
|
||
smtpTls: true,
|
||
folder: 'INBOX',
|
||
});
|
||
expect(parsed.enabled).toBe(true);
|
||
expect(parsed.isDefault).toBe(false);
|
||
expect(parsed.pollIntervalSec).toBe(30);
|
||
expect(parsed.channelId).toBeNull();
|
||
expect(parsed.backfillFrom).toBeNull();
|
||
expect(parsed.secret).toBeUndefined();
|
||
});
|
||
|
||
it('wecom:corpId 必填,地址不由人填(探测回填)', () => {
|
||
const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } });
|
||
if (parsed.kind !== 'wecom') throw new Error('unreachable');
|
||
expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com');
|
||
expect('address' in parsed).toBe(false);
|
||
expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false);
|
||
});
|
||
|
||
it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => {
|
||
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false);
|
||
expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false);
|
||
expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false);
|
||
expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']);
|
||
});
|
||
});
|
||
|
||
describe('mailAccountViewSchema', () => {
|
||
it('视图只有 secretBound,没有任何密文 / 密码字段', () => {
|
||
const keys = Object.keys(mailAccountViewSchema.options[0]!.shape);
|
||
expect(keys).toContain('secretBound');
|
||
expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('mailSendInputSchema', () => {
|
||
it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => {
|
||
const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } });
|
||
expect(ok.success).toBe(true);
|
||
if (ok.success) expect(ok.data.attachments).toEqual([]);
|
||
expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false);
|
||
expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts`
|
||
Expected: FAIL(`Cannot find module '../mail.ts'`)。
|
||
|
||
- [ ] **Step 3: 写 schema**
|
||
|
||
```ts
|
||
// packages/contracts/src/mail.ts
|
||
/**
|
||
* 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。
|
||
*
|
||
* 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。
|
||
* 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱),
|
||
* 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。
|
||
*/
|
||
import { z } from 'zod';
|
||
import { channelIdSchema } from './id.ts';
|
||
|
||
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
|
||
export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS);
|
||
export type MailAccountKind = z.infer<typeof mailAccountKindSchema>;
|
||
export const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>> = {
|
||
'imap-smtp': '标准协议(IMAP/SMTP)',
|
||
wecom: '企业微信邮件 API(应用邮箱)',
|
||
};
|
||
|
||
/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */
|
||
export const imapSmtpSettingsSchema = z.object({
|
||
imapHost: z.string().min(1).default('imap.exmail.qq.com'),
|
||
imapPort: z.number().int().min(1).max(65535).default(993),
|
||
/** true = 隐式 TLS(993);false = 明文(不建议) */
|
||
imapTls: z.boolean().default(true),
|
||
smtpHost: z.string().min(1).default('smtp.exmail.qq.com'),
|
||
smtpPort: z.number().int().min(1).max(65535).default(465),
|
||
/** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */
|
||
smtpTls: z.boolean().default(true),
|
||
/** 登录名;缺省 = address */
|
||
login: z.string().min(1).optional(),
|
||
folder: z.string().min(1).default('INBOX'),
|
||
});
|
||
export type ImapSmtpSettings = z.infer<typeof imapSmtpSettingsSchema>;
|
||
|
||
/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */
|
||
export const wecomSettingsSchema = z.object({
|
||
corpId: z.string().min(1),
|
||
apiBase: z.url().default('https://qyapi.weixin.qq.com'),
|
||
});
|
||
export type WecomSettings = z.infer<typeof wecomSettingsSchema>;
|
||
|
||
const accountCommon = {
|
||
name: z.string().min(1).max(64),
|
||
displayName: z.string().max(64).default(''),
|
||
enabled: z.boolean().default(true),
|
||
isDefault: z.boolean().default(false),
|
||
/** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */
|
||
pollIntervalSec: z.number().int().min(10).max(3600).default(30),
|
||
/** 入站交给哪个通道;空 = channel 订阅方 skipped */
|
||
channelId: channelIdSchema.nullable().default(null),
|
||
/** 从这一天起回填;空 = 首次拉取锚在当下,不回填整箱 */
|
||
backfillFrom: z.iso.datetime().nullable().default(null),
|
||
};
|
||
|
||
export const mailAccountInputSchema = z.discriminatedUnion('kind', [
|
||
z.object({
|
||
kind: z.literal('imap-smtp'),
|
||
address: z.email(),
|
||
...accountCommon,
|
||
settings: imapSmtpSettingsSchema.prefault({}),
|
||
/** 只写不读:缺省 = 不改(与 LLM 密钥同款) */
|
||
secret: z.object({ password: z.string().min(1) }).optional(),
|
||
}),
|
||
z.object({
|
||
kind: z.literal('wecom'),
|
||
...accountCommon,
|
||
settings: wecomSettingsSchema,
|
||
secret: z.object({ corpSecret: z.string().min(1) }).optional(),
|
||
}),
|
||
]);
|
||
export type MailAccountInput = z.infer<typeof mailAccountInputSchema>;
|
||
|
||
export const mailAccountStatusSchema = z.object({
|
||
lastPolledAt: z.iso.datetime().nullable(),
|
||
/** 厂商原话(已脱敏);空 = 上一轮没出错 */
|
||
lastPollError: z.string().nullable(),
|
||
lastInboundAt: z.iso.datetime().nullable(),
|
||
/** 游标的人读摘要;还没拉过 = null */
|
||
cursor: z.string().nullable(),
|
||
/** 此刻租约在手(某个进程正在拉) */
|
||
polling: z.boolean(),
|
||
});
|
||
|
||
const viewCommon = {
|
||
id: z.string(),
|
||
name: z.string(),
|
||
/** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */
|
||
address: z.string(),
|
||
displayName: z.string(),
|
||
enabled: z.boolean(),
|
||
isDefault: z.boolean(),
|
||
pollIntervalSec: z.number().int(),
|
||
channelId: z.string().nullable(),
|
||
backfillFrom: z.iso.datetime().nullable(),
|
||
secretBound: z.boolean(),
|
||
status: mailAccountStatusSchema,
|
||
createdAt: z.iso.datetime(),
|
||
updatedAt: z.iso.datetime(),
|
||
};
|
||
export const mailAccountViewSchema = z.discriminatedUnion('kind', [
|
||
z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }),
|
||
z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }),
|
||
]);
|
||
export type MailAccountView = z.infer<typeof mailAccountViewSchema>;
|
||
|
||
/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */
|
||
export const mailProbeReportSchema = z.object({
|
||
kind: mailAccountKindSchema,
|
||
address: z.string().optional(),
|
||
receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }),
|
||
send: z.object({
|
||
checked: z.boolean(),
|
||
ok: z.boolean().optional(),
|
||
error: z.string().optional(),
|
||
note: z.string().optional(),
|
||
}),
|
||
});
|
||
export type MailProbeReport = z.infer<typeof mailProbeReportSchema>;
|
||
|
||
export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const;
|
||
export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES);
|
||
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;
|
||
|
||
export const mailDeliveryViewSchema = z.object({
|
||
subscriber: z.string(),
|
||
state: mailDeliveryStateSchema,
|
||
error: z.string().nullable(),
|
||
startedAt: z.iso.datetime(),
|
||
finishedAt: z.iso.datetime().nullable(),
|
||
});
|
||
export type MailDeliveryView = z.infer<typeof mailDeliveryViewSchema>;
|
||
|
||
export const mailAddressSchema = z.object({ address: z.string(), name: z.string() });
|
||
export type MailAddress = z.infer<typeof mailAddressSchema>;
|
||
|
||
export const mailInboundAttachmentSchema = z.object({
|
||
index: z.number().int(),
|
||
name: z.string(),
|
||
contentType: z.string(),
|
||
size: z.number().int(),
|
||
/** `sys.upload` 的 id;超限没存 = null,理由在 note */
|
||
ref: z.string().nullable(),
|
||
note: z.string(),
|
||
});
|
||
export type MailInboundAttachment = z.infer<typeof mailInboundAttachmentSchema>;
|
||
|
||
export const mailInboundSummarySchema = z.object({
|
||
id: z.string(),
|
||
accountId: z.string(),
|
||
messageId: z.string(),
|
||
from: mailAddressSchema,
|
||
to: z.array(mailAddressSchema),
|
||
cc: z.array(mailAddressSchema),
|
||
subject: z.string(),
|
||
sentAt: z.iso.datetime().nullable(),
|
||
receivedAt: z.iso.datetime(),
|
||
/** 正文前 200 字 */
|
||
textPreview: z.string(),
|
||
hasHtml: z.boolean(),
|
||
attachments: z.array(mailInboundAttachmentSchema),
|
||
deliveries: z.array(mailDeliveryViewSchema),
|
||
});
|
||
export type MailInboundSummary = z.infer<typeof mailInboundSummarySchema>;
|
||
|
||
export const mailInboundDetailSchema = mailInboundSummarySchema.extend({
|
||
inReplyTo: z.string().nullable(),
|
||
references: z.array(z.string()),
|
||
text: z.string().nullable(),
|
||
html: z.string().nullable(),
|
||
size: z.number().int(),
|
||
/** 原件 .eml 存下来了(≤16MB) */
|
||
rawAvailable: z.boolean(),
|
||
});
|
||
export type MailInboundDetail = z.infer<typeof mailInboundDetailSchema>;
|
||
|
||
export const mailSendInputSchema = z.object({
|
||
/** 指定账号;缺省用默认账号 */
|
||
accountId: z.string().optional(),
|
||
/** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */
|
||
from: z.string().optional(),
|
||
to: z.array(z.email()).min(1),
|
||
cc: z.array(z.email()).default([]),
|
||
bcc: z.array(z.email()).default([]),
|
||
subject: z.string().min(1),
|
||
text: z.string().optional(),
|
||
html: z.string().optional(),
|
||
inReplyTo: z.string().optional(),
|
||
references: z.array(z.string()).default([]),
|
||
attachments: z
|
||
.array(
|
||
z.object({
|
||
name: z.string().min(1),
|
||
contentType: z.string().default('application/octet-stream'),
|
||
/** 二选一:现给字节,或引用 sys.upload 的 id */
|
||
dataBase64: z.string().optional(),
|
||
uploadRef: z.string().optional(),
|
||
}),
|
||
)
|
||
.default([]),
|
||
/** 放行落地带的幂等键 —— 同键不发两封 */
|
||
idempotencyKey: z.string().optional(),
|
||
requestedBy: z.object({
|
||
kind: z.enum(['tool', 'channel', 'ops']),
|
||
taskId: z.string().optional(),
|
||
staffId: z.string().optional(),
|
||
tool: z.string().optional(),
|
||
}),
|
||
});
|
||
export type MailSendInput = z.infer<typeof mailSendInputSchema>;
|
||
|
||
export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const;
|
||
export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES);
|
||
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;
|
||
|
||
export const mailSendResultSchema = z.object({
|
||
outboundId: z.string(),
|
||
accountId: z.string(),
|
||
messageId: z.string(),
|
||
state: z.enum(['sent', 'failed']),
|
||
error: z.string().optional(),
|
||
providerRef: z.string().optional(),
|
||
});
|
||
export type MailSendResult = z.infer<typeof mailSendResultSchema>;
|
||
|
||
export const mailOutboundViewSchema = z.object({
|
||
id: z.string(),
|
||
accountId: z.string(),
|
||
messageId: z.string(),
|
||
to: z.array(z.string()),
|
||
cc: z.array(z.string()),
|
||
bcc: z.array(z.string()),
|
||
subject: z.string(),
|
||
state: mailOutboundStateSchema,
|
||
error: z.string().nullable(),
|
||
providerRef: z.string().nullable(),
|
||
requestedBy: z.record(z.string(), z.unknown()),
|
||
createdAt: z.iso.datetime(),
|
||
sentAt: z.iso.datetime().nullable(),
|
||
});
|
||
export type MailOutboundView = z.infer<typeof mailOutboundViewSchema>;
|
||
|
||
export const mailPollOutcomeSchema = z.object({
|
||
accountId: z.string(),
|
||
/** provider 交出的封数 */
|
||
pulled: z.number().int(),
|
||
/** 真新入库的封数(去重后) */
|
||
stored: z.number().int(),
|
||
dispatched: z.number().int(),
|
||
/** 预算用尽还有余下,下一 tick 接着 */
|
||
more: z.boolean(),
|
||
error: z.string().optional(),
|
||
});
|
||
export type MailPollOutcome = z.infer<typeof mailPollOutcomeSchema>;
|
||
```
|
||
|
||
在 `packages/contracts/src/index.ts` 的 `export * from './org.ts';` 之后加一行:
|
||
|
||
```ts
|
||
export * from './mail.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts && pnpm typecheck`
|
||
Expected: 4 tests PASS;typecheck 退出码 0。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/contracts/src/mail.ts packages/contracts/src/index.ts packages/contracts/src/__tests__/mail.test.ts
|
||
git commit -m "$(printf '邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 路由契约 `packages/contracts/src/api/mail.ts`
|
||
|
||
**Files:**
|
||
- Create: `packages/contracts/src/api/mail.ts`
|
||
- Modify: `packages/contracts/src/api/index.ts`(加 `export * from './mail.ts';`)
|
||
- Test: `apps/api/src/app/__tests__/mail-routes-registry.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces 15 条契约常量:`listMailAccountsRoute` `createMailAccountRoute` `probeMailDraftRoute` `getMailAccountRoute` `updateMailAccountRoute` `deleteMailAccountRoute` `probeMailAccountRoute` `pollMailAccountRoute` `sendTestMailRoute` `listMailInboundRoute` `getMailInboundRoute` `mailAttachmentRoute` `mailRawRoute` `redeliverMailInboundRoute` `listMailOutboundRoute`;`MAIL_ROUTES`(全部 15 条的数组,给文档 / 测试数)。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/app/__tests__/mail-routes-registry.test.ts
|
||
/**
|
||
* 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞),
|
||
* 读写都归运营中心(设计稿 §7.2 + 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: 写路由契约**
|
||
|
||
```ts
|
||
// packages/contracts/src/api/mail.ts
|
||
/**
|
||
* 邮件收发的路由契约(设计稿 §7.2;ADR-013)。
|
||
*
|
||
* 读写都归运营中心 —— 邮箱那一屏落在运营中心,设计端没有消费方;入站正文装的是客户来往内容,不无故加宽可读面。
|
||
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
|
||
* 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。
|
||
*/
|
||
import { z } from 'zod';
|
||
import type { RouteContract } from './http.ts';
|
||
import {
|
||
mailAccountInputSchema,
|
||
mailAccountViewSchema,
|
||
mailDeliveryViewSchema,
|
||
mailInboundDetailSchema,
|
||
mailInboundSummarySchema,
|
||
mailOutboundViewSchema,
|
||
mailPollOutcomeSchema,
|
||
mailProbeReportSchema,
|
||
mailSendResultSchema,
|
||
} from '../mail.ts';
|
||
|
||
const accountParams = z.object({ accountId: z.string().min(1) });
|
||
const mailParams = z.object({ mailId: z.string().min(1) });
|
||
const listQuery = z.object({
|
||
accountId: z.string().optional(),
|
||
/** 默认 50,上限 200 */
|
||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||
});
|
||
|
||
export const listMailAccountsRoute = {
|
||
method: 'GET',
|
||
path: '/api/gov/mail/accounts',
|
||
access: '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` 末尾加:
|
||
|
||
```ts
|
||
export * from './mail.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts && pnpm typecheck`
|
||
Expected: 3 tests PASS;typecheck 退出码 0。若 `buildRouteRegistry` 抛「路由撞车」,说明某条字面路径与参数模版同方法撞了 —— 按抛出的两条路径改 path,不改注册表。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/contracts/src/api/mail.ts packages/contracts/src/api/index.ts apps/api/src/app/__tests__/mail-routes-registry.test.ts
|
||
git commit -m "$(printf '邮件路由契约 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站列表 / 附件与原件 / 重新投递\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 模块骨架 + domain:类型、错误、游标、到期
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/package.json` · `index.ts` · `contract.ts` · `port.ts` · `CONTRACT.md`
|
||
- Create: `apps/api/src/modules/mail/internal/domain/{records,errors,cursor,due}.ts`
|
||
- Test: `apps/api/src/modules/mail/__tests__/{cursor,due}.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`MailService` / `InboundHandler`(contract)· 全部端口类型(port)· 记录类型(records)· 11 个错误类 · `MailCursor` 与游标函数 · `leaseMsFor` `isDue` `isPolling`。后续任务只往 `index.ts` 追加导出行。
|
||
|
||
- [ ] **Step 1: 写失败测试(游标与到期)**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/cursor.test.ts
|
||
/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
advanceImap,
|
||
cursorSummary,
|
||
imapCursor,
|
||
imapNeedsReanchor,
|
||
newUids,
|
||
parseCursor,
|
||
wecomCursor,
|
||
wecomWindow,
|
||
WECOM_OVERLAP_SEC,
|
||
} from '../internal/domain/cursor.ts';
|
||
|
||
describe('imap 游标', () => {
|
||
it('逐封推进只往前不往后', () => {
|
||
const c = imapCursor('7', 100);
|
||
expect(advanceImap(c, 105).lastUid).toBe(105);
|
||
expect(advanceImap(c, 90).lastUid).toBe(100);
|
||
});
|
||
|
||
it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => {
|
||
expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false);
|
||
expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true);
|
||
expect(imapNeedsReanchor(null, '7')).toBe(true);
|
||
expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true);
|
||
});
|
||
|
||
it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => {
|
||
expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]);
|
||
expect(newUids([100], 100)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('wecom 游标', () => {
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => {
|
||
const w = wecomWindow(wecomCursor(1_000_000), null, now);
|
||
expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) });
|
||
});
|
||
it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => {
|
||
const end = Math.floor(now.getTime() / 1000);
|
||
expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end });
|
||
const since = new Date('2026-08-20T00:00:00Z');
|
||
expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000));
|
||
});
|
||
it('lastTime 往前退不会退成负数', () => {
|
||
expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('摘要与解析', () => {
|
||
it('人读摘要;没拉过是 null', () => {
|
||
expect(cursorSummary(null)).toBeNull();
|
||
expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7');
|
||
expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z');
|
||
});
|
||
it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => {
|
||
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3));
|
||
expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5));
|
||
expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull();
|
||
expect(parseCursor(null)).toBeNull();
|
||
expect(parseCursor('garbage')).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/due.test.ts
|
||
/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts';
|
||
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null };
|
||
|
||
describe('isDue', () => {
|
||
it('从没拉过 → 到期;停用 → 永不到期', () => {
|
||
expect(isDue(base, now)).toBe(true);
|
||
expect(isDue({ ...base, enabled: false }, now)).toBe(false);
|
||
});
|
||
it('上次 + 间隔 <= 现在才到期', () => {
|
||
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false);
|
||
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true);
|
||
});
|
||
it('租约还没过期 → 别人在拉,不到期;过期了照常', () => {
|
||
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false);
|
||
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('leaseMsFor / isPolling', () => {
|
||
it('租约 = max(5 分钟, 3 × 间隔)', () => {
|
||
expect(leaseMsFor(30)).toBe(MIN_LEASE_MS);
|
||
expect(leaseMsFor(600)).toBe(1_800_000);
|
||
});
|
||
it('polling 只在租约未过期时为真', () => {
|
||
expect(isPolling(null, now)).toBe(false);
|
||
expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true);
|
||
expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/cursor.test.ts apps/api/src/modules/mail/__tests__/due.test.ts`
|
||
Expected: FAIL(模块不存在)。
|
||
|
||
- [ ] **Step 3: 写骨架与 domain**
|
||
|
||
```json
|
||
// apps/api/src/modules/mail/package.json
|
||
{
|
||
"name": "@modules/mail",
|
||
"version": "0.0.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"exports": { ".": "./index.ts" }
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/errors.ts
|
||
/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */
|
||
export class MailAccountNotFoundError extends Error {
|
||
constructor(readonly accountId: string) {
|
||
super(`邮箱账号不存在:${accountId}`);
|
||
this.name = 'MailAccountNotFoundError';
|
||
}
|
||
}
|
||
export class MailAccountNameTakenError extends Error {
|
||
constructor(readonly accountName: string) {
|
||
super(`邮箱账号名已被占用:${accountName}`);
|
||
this.name = 'MailAccountNameTakenError';
|
||
}
|
||
}
|
||
export class MailAccountDisabledError extends Error {
|
||
constructor(readonly accountId: string) {
|
||
super(`邮箱账号已停用:${accountId}`);
|
||
this.name = 'MailAccountDisabledError';
|
||
}
|
||
}
|
||
export class MailNoDefaultAccountError extends Error {
|
||
constructor() {
|
||
super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认');
|
||
this.name = 'MailNoDefaultAccountError';
|
||
}
|
||
}
|
||
export class MailSecretMissingError extends Error {
|
||
constructor(readonly accountId: string) {
|
||
super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`);
|
||
this.name = 'MailSecretMissingError';
|
||
}
|
||
}
|
||
export class MailPollBusyError extends Error {
|
||
constructor(readonly accountId: string) {
|
||
super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`);
|
||
this.name = 'MailPollBusyError';
|
||
}
|
||
}
|
||
export class MailInboundNotFoundError extends Error {
|
||
constructor(readonly mailId: string) {
|
||
super(`这封信不在了:${mailId}`);
|
||
this.name = 'MailInboundNotFoundError';
|
||
}
|
||
}
|
||
export class MailAttachmentNotFoundError extends Error {
|
||
constructor(readonly mailId: string, readonly index: number) {
|
||
super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`);
|
||
this.name = 'MailAttachmentNotFoundError';
|
||
}
|
||
}
|
||
export class MailSubscriberUnknownError extends Error {
|
||
constructor(readonly subscriber: string) {
|
||
super(`没有这个订阅方:${subscriber}`);
|
||
this.name = 'MailSubscriberUnknownError';
|
||
}
|
||
}
|
||
export class MailSubscriberTakenError extends Error {
|
||
constructor(readonly subscriber: string) {
|
||
super(`订阅方已注册,不许重名:${subscriber}`);
|
||
this.name = 'MailSubscriberTakenError';
|
||
}
|
||
}
|
||
export class MailSendInvalidError extends Error {
|
||
constructor(readonly problems: readonly string[]) {
|
||
super(`发信入参不合法:${problems.join('; ')}`);
|
||
this.name = 'MailSendInvalidError';
|
||
}
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/cursor.ts
|
||
/**
|
||
* 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。
|
||
* IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。
|
||
*/
|
||
export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number };
|
||
export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number };
|
||
export type MailCursor = ImapCursor | WecomCursor;
|
||
|
||
/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */
|
||
export const WECOM_OVERLAP_SEC = 600;
|
||
|
||
export function imapCursor(uidValidity: string, lastUid: number): ImapCursor {
|
||
return { kind: 'imap', uidValidity, lastUid };
|
||
}
|
||
|
||
export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor {
|
||
return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor;
|
||
}
|
||
|
||
export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean {
|
||
return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity;
|
||
}
|
||
|
||
/**
|
||
* IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)——
|
||
* 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
|
||
*/
|
||
export function newUids(uids: readonly number[], lastUid: number): number[] {
|
||
return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b);
|
||
}
|
||
|
||
export function wecomCursor(lastTime: number): WecomCursor {
|
||
return { kind: 'wecom', lastTime };
|
||
}
|
||
|
||
export function wecomWindow(
|
||
cursor: MailCursor | null,
|
||
backfillFrom: Date | null,
|
||
now: Date,
|
||
): { beginTime: number; endTime: number } {
|
||
const endTime = Math.floor(now.getTime() / 1000);
|
||
if (cursor !== null && cursor.kind === 'wecom') {
|
||
return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime };
|
||
}
|
||
if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime };
|
||
// 无游标不回填:窗口空,这一轮只是把游标锚在当下
|
||
return { beginTime: endTime, endTime };
|
||
}
|
||
|
||
export function cursorSummary(cursor: MailCursor | null): string | null {
|
||
if (cursor === null) return null;
|
||
if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`;
|
||
return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`;
|
||
}
|
||
|
||
/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */
|
||
export function parseCursor(raw: unknown): MailCursor | null {
|
||
if (typeof raw !== 'object' || raw === null) return null;
|
||
const c = raw as Record<string, unknown>;
|
||
if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && typeof c['lastUid'] === 'number') {
|
||
return imapCursor(c['uidValidity'], c['lastUid']);
|
||
}
|
||
if (c['kind'] === 'wecom' && typeof c['lastTime'] === 'number') return wecomCursor(c['lastTime']);
|
||
return null;
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/due.ts
|
||
/** 到期判定与租约时长(设计稿 §9.1) */
|
||
export const MIN_LEASE_MS = 5 * 60_000;
|
||
|
||
/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */
|
||
export function leaseMsFor(pollIntervalSec: number): number {
|
||
return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000);
|
||
}
|
||
|
||
export function isPolling(pollingUntil: Date | null, now: Date): boolean {
|
||
return pollingUntil !== null && pollingUntil.getTime() > now.getTime();
|
||
}
|
||
|
||
export function isDue(
|
||
account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null },
|
||
now: Date,
|
||
): boolean {
|
||
if (!account.enabled) return false;
|
||
if (isPolling(account.pollingUntil, now)) return false;
|
||
if (account.lastPolledAt === null) return true;
|
||
return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime();
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/records.ts
|
||
/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */
|
||
import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts';
|
||
import type { MailCursor } from './cursor.ts';
|
||
|
||
export type MailAccountRecord = {
|
||
readonly id: string;
|
||
readonly kind: MailAccountKind;
|
||
readonly name: string;
|
||
readonly address: string;
|
||
readonly displayName: string;
|
||
readonly enabled: boolean;
|
||
readonly isDefault: boolean;
|
||
readonly pollIntervalSec: number;
|
||
readonly channelId: string | null;
|
||
readonly backfillFrom: Date | null;
|
||
/** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */
|
||
readonly settings: Record<string, unknown>;
|
||
/** 密封的 JSON:{password} | {corpSecret};永不出接口 */
|
||
readonly secretCipher: string | null;
|
||
readonly cursor: MailCursor | null;
|
||
readonly lastPolledAt: Date | null;
|
||
readonly lastPollError: string | null;
|
||
readonly lastInboundAt: Date | null;
|
||
readonly pollingUntil: Date | null;
|
||
readonly createdAt: Date;
|
||
readonly updatedAt: Date;
|
||
};
|
||
|
||
export type InboundAttachmentRecord = {
|
||
readonly index: number;
|
||
readonly name: string;
|
||
readonly contentType: string;
|
||
readonly size: number;
|
||
readonly ref: string | null;
|
||
readonly note: string;
|
||
};
|
||
|
||
export type InboundRecord = {
|
||
readonly id: string;
|
||
readonly accountId: string;
|
||
readonly messageId: string;
|
||
readonly providerRef: string;
|
||
readonly inReplyTo: string | null;
|
||
readonly references: readonly string[];
|
||
readonly from: MailAddress;
|
||
readonly to: readonly MailAddress[];
|
||
readonly cc: readonly MailAddress[];
|
||
readonly subject: string;
|
||
readonly text: string | null;
|
||
readonly html: string | null;
|
||
readonly sentAt: Date | null;
|
||
readonly receivedAt: Date;
|
||
readonly attachments: readonly InboundAttachmentRecord[];
|
||
readonly raw: Uint8Array | null;
|
||
readonly rawNote: string;
|
||
readonly size: number;
|
||
readonly createdAt: Date;
|
||
};
|
||
|
||
export type DeliveryRecord = {
|
||
readonly mailId: string;
|
||
readonly subscriber: string;
|
||
readonly state: MailDeliveryState;
|
||
readonly error: string | null;
|
||
readonly startedAt: Date;
|
||
readonly finishedAt: Date | null;
|
||
};
|
||
|
||
export type OutboundAttachmentRecord = {
|
||
readonly name: string;
|
||
readonly contentType: string;
|
||
readonly size: number;
|
||
readonly ref: string | null;
|
||
};
|
||
|
||
export type OutboundRecord = {
|
||
readonly id: string;
|
||
readonly accountId: string;
|
||
readonly messageId: string;
|
||
readonly idempotencyKey: string | null;
|
||
readonly to: readonly string[];
|
||
readonly cc: readonly string[];
|
||
readonly bcc: readonly string[];
|
||
readonly subject: string;
|
||
readonly text: string | null;
|
||
readonly html: string | null;
|
||
readonly inReplyTo: string | null;
|
||
readonly references: readonly string[];
|
||
readonly attachments: readonly OutboundAttachmentRecord[];
|
||
readonly state: MailOutboundState;
|
||
readonly error: string | null;
|
||
readonly providerRef: string | null;
|
||
readonly requestedBy: Record<string, unknown>;
|
||
readonly createdAt: Date;
|
||
readonly sentAt: Date | null;
|
||
};
|
||
|
||
export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array };
|
||
|
||
/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */
|
||
export type ParsedMail = {
|
||
readonly messageId?: string | undefined;
|
||
readonly inReplyTo?: string | undefined;
|
||
readonly references: readonly string[];
|
||
readonly from?: MailAddress | undefined;
|
||
readonly to: readonly MailAddress[];
|
||
readonly cc: readonly MailAddress[];
|
||
readonly subject: string;
|
||
readonly text?: string | undefined;
|
||
readonly html?: string | undefined;
|
||
readonly date?: Date | undefined;
|
||
readonly attachments: readonly ParsedAttachment[];
|
||
};
|
||
|
||
/** 交给 provider 的出信 —— 附件已是字节 */
|
||
export type OutboundMessage = {
|
||
readonly messageId: string;
|
||
readonly from: MailAddress;
|
||
readonly to: readonly string[];
|
||
readonly cc: readonly string[];
|
||
readonly bcc: readonly string[];
|
||
readonly subject: string;
|
||
readonly text?: string | undefined;
|
||
readonly html?: string | undefined;
|
||
readonly inReplyTo?: string | undefined;
|
||
readonly references: readonly string[];
|
||
readonly attachments: readonly ParsedAttachment[];
|
||
};
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/contract.ts
|
||
/**
|
||
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
|
||
*/
|
||
import type {
|
||
MailAccountInput,
|
||
MailAccountView,
|
||
MailDeliveryView,
|
||
MailInboundDetail,
|
||
MailInboundSummary,
|
||
MailOutboundView,
|
||
MailPollOutcome,
|
||
MailProbeReport,
|
||
MailSendInput,
|
||
MailSendResult,
|
||
} from '@contracts';
|
||
|
||
export type {
|
||
MailAccountInput,
|
||
MailAccountView,
|
||
MailDeliveryView,
|
||
MailInboundDetail,
|
||
MailInboundSummary,
|
||
MailOutboundView,
|
||
MailPollOutcome,
|
||
MailProbeReport,
|
||
MailSendInput,
|
||
MailSendResult,
|
||
};
|
||
|
||
export {
|
||
MailAccountDisabledError,
|
||
MailAccountNameTakenError,
|
||
MailAccountNotFoundError,
|
||
MailAttachmentNotFoundError,
|
||
MailInboundNotFoundError,
|
||
MailNoDefaultAccountError,
|
||
MailPollBusyError,
|
||
MailSecretMissingError,
|
||
MailSendInvalidError,
|
||
MailSubscriberTakenError,
|
||
MailSubscriberUnknownError,
|
||
} from './internal/domain/errors.ts';
|
||
|
||
/**
|
||
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
|
||
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
|
||
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
|
||
*/
|
||
export type InboundHandler = (
|
||
mail: MailInboundDetail,
|
||
ctx: { readonly accountId: string; readonly channelId: string | undefined },
|
||
) => Promise<void | { readonly skipped: string }>;
|
||
|
||
export type MailService = {
|
||
/* ── 账号 ── */
|
||
listAccounts(): Promise<MailAccountView[]>;
|
||
getAccount(accountId: string): Promise<MailAccountView>;
|
||
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
|
||
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
|
||
/** 留痕不删(入站 / 出站表对账号不设外键) */
|
||
deleteAccount(accountId: string): Promise<void>;
|
||
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
|
||
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
|
||
/* ── 收 ── */
|
||
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
|
||
pollDue(now: Date): Promise<MailPollOutcome[]>;
|
||
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
|
||
pollNow(accountId: string): Promise<MailPollOutcome>;
|
||
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
|
||
subscribe(name: string, handler: InboundHandler): () => void;
|
||
listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
|
||
getInbound(mailId: string): Promise<MailInboundDetail>;
|
||
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
|
||
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
|
||
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>;
|
||
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
|
||
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
|
||
/* ── 发 ── */
|
||
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
|
||
send(input: MailSendInput): Promise<MailSendResult>;
|
||
listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
|
||
};
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/port.ts
|
||
/**
|
||
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
|
||
*/
|
||
import type { MailAccountKind, MailProbeReport } from '@contracts';
|
||
import type { MailCursor } from './internal/domain/cursor.ts';
|
||
import type {
|
||
DeliveryRecord,
|
||
InboundRecord,
|
||
MailAccountRecord,
|
||
OutboundMessage,
|
||
OutboundRecord,
|
||
ParsedMail,
|
||
} from './internal/domain/records.ts';
|
||
|
||
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
|
||
|
||
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
|
||
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
|
||
export type Clock = { now(): Date };
|
||
export type IdGenerator = { next(): string };
|
||
|
||
export type MailAccountStore = {
|
||
list(): Promise<MailAccountRecord[]>;
|
||
get(id: string): Promise<MailAccountRecord | undefined>;
|
||
findByName(name: string): Promise<MailAccountRecord | undefined>;
|
||
/** upsert;`isDefault:true` 时先把别的置 false(同一事务) */
|
||
save(record: MailAccountRecord): Promise<void>;
|
||
remove(id: string): Promise<void>;
|
||
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
|
||
listDue(now: Date): Promise<MailAccountRecord[]>;
|
||
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
|
||
claimPolling(id: string, leaseMs: number): Promise<boolean>;
|
||
releasePolling(id: string): Promise<void>;
|
||
/** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */
|
||
savePoll(
|
||
id: string,
|
||
patch: {
|
||
cursor?: MailCursor;
|
||
polledAt?: Date;
|
||
error?: string | null;
|
||
lastInboundAt?: Date;
|
||
address?: string;
|
||
},
|
||
): Promise<void>;
|
||
};
|
||
|
||
export type InboundStore = {
|
||
/** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */
|
||
insert(record: InboundRecord): Promise<{ inserted: boolean }>;
|
||
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
|
||
list(query: { accountId?: string | undefined; limit: number }): Promise<Array<{ record: InboundRecord; deliveries: DeliveryRecord[] }>>;
|
||
get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>;
|
||
/** upsert on (mail_id, subscriber) */
|
||
setDelivery(delivery: DeliveryRecord): Promise<void>;
|
||
};
|
||
|
||
export type OutboundStore = {
|
||
insert(record: OutboundRecord): Promise<void>;
|
||
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
|
||
setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
|
||
list(query: { accountId?: string | undefined; limit: number }): Promise<OutboundRecord[]>;
|
||
};
|
||
|
||
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
|
||
export type AttachmentStore = {
|
||
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>;
|
||
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||
};
|
||
|
||
/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
|
||
export type ProviderAccount = {
|
||
readonly id: string;
|
||
readonly kind: MailAccountKind;
|
||
readonly address: string;
|
||
readonly displayName: string;
|
||
readonly settings: Record<string, unknown>;
|
||
readonly secret: Record<string, string>;
|
||
};
|
||
|
||
export type RawMessage = {
|
||
readonly providerRef: string;
|
||
readonly eml: Uint8Array;
|
||
readonly receivedAt: Date;
|
||
/** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */
|
||
readonly cursorAfter?: MailCursor | undefined;
|
||
};
|
||
|
||
/** 外界:一种账号类型一个适配器(设计稿 §5.5) */
|
||
export type MailProvider = {
|
||
readonly kind: MailAccountKind;
|
||
probe(account: ProviderAccount): Promise<MailProbeReport>;
|
||
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
|
||
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
|
||
pull(
|
||
account: ProviderAccount,
|
||
cursor: MailCursor,
|
||
opts: {
|
||
readonly limit: number;
|
||
readonly now: Date;
|
||
readonly backfillFrom: Date | null;
|
||
/** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */
|
||
readonly known: (refs: readonly string[]) => Promise<Set<string>>;
|
||
},
|
||
): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
|
||
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>;
|
||
};
|
||
|
||
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
|
||
|
||
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
|
||
export type ChangeNotifier = { changed(reason: string): void };
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/index.ts
|
||
/**
|
||
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||
*/
|
||
export type {
|
||
InboundHandler,
|
||
MailAccountInput,
|
||
MailAccountView,
|
||
MailDeliveryView,
|
||
MailInboundDetail,
|
||
MailInboundSummary,
|
||
MailOutboundView,
|
||
MailPollOutcome,
|
||
MailProbeReport,
|
||
MailSendInput,
|
||
MailSendResult,
|
||
MailService,
|
||
} from './contract.ts';
|
||
export {
|
||
MailAccountDisabledError,
|
||
MailAccountNameTakenError,
|
||
MailAccountNotFoundError,
|
||
MailAttachmentNotFoundError,
|
||
MailInboundNotFoundError,
|
||
MailNoDefaultAccountError,
|
||
MailPollBusyError,
|
||
MailSecretMissingError,
|
||
MailSendInvalidError,
|
||
MailSubscriberTakenError,
|
||
MailSubscriberUnknownError,
|
||
} from './contract.ts';
|
||
|
||
export type {
|
||
AttachmentStore,
|
||
ChangeNotifier,
|
||
Clock,
|
||
DeliveryRecord,
|
||
IdGenerator,
|
||
InboundRecord,
|
||
InboundStore,
|
||
MailAccountRecord,
|
||
MailAccountStore,
|
||
MailCursor,
|
||
MailProvider,
|
||
MimeParser,
|
||
OutboundMessage,
|
||
OutboundRecord,
|
||
OutboundStore,
|
||
ParsedMail,
|
||
ProviderAccount,
|
||
RawMessage,
|
||
SecretBox,
|
||
} from './port.ts';
|
||
|
||
export {
|
||
advanceImap,
|
||
cursorSummary,
|
||
imapCursor,
|
||
imapNeedsReanchor,
|
||
newUids,
|
||
parseCursor,
|
||
wecomCursor,
|
||
wecomWindow,
|
||
} from './internal/domain/cursor.ts';
|
||
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
|
||
```
|
||
|
||
```markdown
|
||
<!-- apps/api/src/modules/mail/CONTRACT.md -->
|
||
# module: mail
|
||
|
||
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
|
||
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
|
||
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
|
||
SSE(app 经 `ChangeNotifier` 端口接到 broadcaster)
|
||
|
||
公开出口: index.ts
|
||
- 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` /
|
||
`MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` /
|
||
`MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出
|
||
- 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412)
|
||
`MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409)
|
||
`MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404)
|
||
`MailSubscriberTakenError` `MailSendInvalidError`(400)
|
||
- 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser`
|
||
`SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord`
|
||
`OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor`
|
||
- 用例工厂:`createMailService(deps: MailServiceDeps): MailService`
|
||
- 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow`
|
||
`cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、
|
||
入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、
|
||
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
|
||
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
|
||
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
|
||
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`;
|
||
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
|
||
|
||
层级: L1(编码规范 §4)
|
||
依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime
|
||
被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块:
|
||
channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`;
|
||
`mail.send` 工具处理器在 app 里闭包持本服务
|
||
|
||
不变量(ADR-013):
|
||
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
|
||
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
|
||
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
|
||
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
|
||
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
|
||
- **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记
|
||
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
|
||
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
|
||
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
|
||
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
|
||
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
|
||
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
|
||
wecom 的 `address` 探到之前是空串
|
||
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
|
||
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
|
||
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身
|
||
|
||
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: cursor 7 + due 5 = 12 tests PASS;三道门禁退出码 0(`no-orphans` 对 index/contract/port 与 domain 文件:records / errors 被 contract.ts 与 port.ts 引用,cursor / due 被 index.ts 引用,不算孤儿)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: domain:入站记录与脱敏
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/domain/inbound-record.ts` · `redact.ts`
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加导出)
|
||
- Test: `apps/api/src/modules/mail/__tests__/{inbound-record,redact}.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`ATTACHMENT_CAP` `RAW_CAP` `PREVIEW_CHARS` `fallbackMessageId(providerRef, address)` `threadIdOf(parsed)` `stripHtml(html)` `bodyTextOf(parsed)` `textPreviewOf(text, html)` `planAttachments(attachments, cap?)` → `AttachmentPlan[]` `rawOf(eml, cap?)` `buildInboundRecord(input)` `deliveryViewOf(d)` `summaryOf(record, deliveries)` `detailOf(record, deliveries)`;`secretVariants(secret)` `redactSecrets(text, secrets)` `errorText(error)`。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/inbound-record.test.ts
|
||
/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { ParsedMail } from '../internal/domain/records.ts';
|
||
import {
|
||
ATTACHMENT_CAP,
|
||
bodyTextOf,
|
||
buildInboundRecord,
|
||
detailOf,
|
||
fallbackMessageId,
|
||
planAttachments,
|
||
rawOf,
|
||
stripHtml,
|
||
summaryOf,
|
||
textPreviewOf,
|
||
threadIdOf,
|
||
} from '../internal/domain/inbound-record.ts';
|
||
|
||
const parsed: ParsedMail = {
|
||
messageId: '<m1@acme.com>',
|
||
inReplyTo: '<q1@we.com>',
|
||
references: ['<root@we.com>', '<q1@we.com>'],
|
||
from: { address: 'buyer@acme.com', name: '张三' },
|
||
to: [{ address: 'sales@we.com', name: '' }],
|
||
cc: [],
|
||
subject: '询价 RFQ-1',
|
||
text: '你好,请报价',
|
||
html: '<p>你好,请报价</p>',
|
||
date: new Date('2026-08-25T09:00:00Z'),
|
||
attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }],
|
||
};
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
|
||
describe('线程与正文', () => {
|
||
it('线程 = In-Reply-To;没有就取 References 末项;都没有就是 undefined', () => {
|
||
expect(threadIdOf(parsed)).toBe('<q1@we.com>');
|
||
expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<b>');
|
||
expect(threadIdOf({ references: [] })).toBeUndefined();
|
||
});
|
||
it('缺 Message-ID 用 providerRef@域顶上,不抛', () => {
|
||
expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe('<uid:7:12@we.com>');
|
||
expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
|
||
});
|
||
it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
|
||
expect(stripHtml('<style>p{}</style><p>你好&<b>世界</b></p>\n<script>x()</script> 再见')).toBe('你好&世界 再见');
|
||
expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
|
||
expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
|
||
expect(bodyTextOf({})).toBe('');
|
||
});
|
||
it('预览 200 字', () => {
|
||
expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
|
||
expect(textPreviewOf(null, '<p>来自 html</p>')).toBe('来自 html');
|
||
});
|
||
});
|
||
|
||
describe('附件与原件', () => {
|
||
it('超上限的附件不存,留注;下标按原顺序', () => {
|
||
const plan = planAttachments(
|
||
[
|
||
{ name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) },
|
||
{ name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) },
|
||
],
|
||
ATTACHMENT_CAP,
|
||
);
|
||
expect(plan.map((p) => [p.index, p.store])).toEqual([
|
||
[0, true],
|
||
[1, false],
|
||
]);
|
||
expect(plan[1]!.note).toContain('8MB');
|
||
});
|
||
it('原件超上限留空 + 注', () => {
|
||
expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' });
|
||
const big = rawOf(new Uint8Array(9), 8);
|
||
expect(big.raw).toBeNull();
|
||
expect(big.note).not.toBe('');
|
||
});
|
||
});
|
||
|
||
describe('buildInboundRecord / 视图', () => {
|
||
const record = buildInboundRecord({
|
||
id: 'in-1',
|
||
accountId: 'acc-1',
|
||
address: 'sales@we.com',
|
||
providerRef: 'uid:7:12',
|
||
parsed,
|
||
eml: new Uint8Array([1, 2, 3]),
|
||
receivedAt: now,
|
||
attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }],
|
||
now,
|
||
});
|
||
|
||
it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => {
|
||
expect(record.messageId).toBe('<m1@acme.com>');
|
||
expect(record.inReplyTo).toBe('<q1@we.com>');
|
||
expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
|
||
expect(record.size).toBe(3);
|
||
expect(record.raw).toEqual(new Uint8Array([1, 2, 3]));
|
||
expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z');
|
||
});
|
||
it('缺 from / 缺 Message-ID 也能落记录', () => {
|
||
const bare = buildInboundRecord({
|
||
id: 'in-2',
|
||
accountId: 'acc-1',
|
||
address: 'sales@we.com',
|
||
providerRef: 'uid:7:13',
|
||
parsed: { references: [], to: [], cc: [], subject: '', attachments: [] },
|
||
eml: new Uint8Array(),
|
||
receivedAt: now,
|
||
attachments: [],
|
||
now,
|
||
});
|
||
expect(bare.messageId).toBe('<uid:7:13@we.com>');
|
||
expect(bare.from).toEqual({ address: '', name: '' });
|
||
});
|
||
it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => {
|
||
const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }];
|
||
const summary = summaryOf(record, deliveries);
|
||
expect(summary.textPreview).toBe('你好,请报价');
|
||
expect(summary.hasHtml).toBe(true);
|
||
expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() });
|
||
expect('text' in summary).toBe(false);
|
||
const detail = detailOf(record, deliveries);
|
||
expect(detail.text).toBe('你好,请报价');
|
||
expect(detail.rawAvailable).toBe(true);
|
||
expect(detail.references).toEqual(['<root@we.com>', '<q1@we.com>']);
|
||
});
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/redact.test.ts
|
||
/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts';
|
||
|
||
describe('redactSecrets', () => {
|
||
it('三形都换成 ***', () => {
|
||
const secret = 'p@ss w0rd';
|
||
const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`;
|
||
expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***');
|
||
});
|
||
it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => {
|
||
expect(secretVariants('')).toEqual([]);
|
||
expect(secretVariants('abc')).toEqual([]);
|
||
expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc');
|
||
});
|
||
it('没命中原样返回;正则元字符当字面', () => {
|
||
expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here');
|
||
expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***');
|
||
});
|
||
});
|
||
|
||
describe('errorText', () => {
|
||
it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => {
|
||
expect(errorText(new Error('boom'))).toBe('boom');
|
||
const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' });
|
||
expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed');
|
||
expect(errorText('raw')).toBe('raw');
|
||
expect(errorText(undefined)).toBe('未知错误');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/inbound-record.test.ts apps/api/src/modules/mail/__tests__/redact.test.ts`
|
||
Expected: FAIL(模块不存在)。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/inbound-record.ts
|
||
/**
|
||
* 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做,
|
||
* 存完把 ref 交回来再 `buildInboundRecord`。
|
||
*/
|
||
import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts';
|
||
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts';
|
||
|
||
/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */
|
||
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
|
||
export const RAW_CAP = 16 * 1024 * 1024;
|
||
export const PREVIEW_CHARS = 200;
|
||
|
||
function domainOf(address: string): string {
|
||
const at = address.lastIndexOf('@');
|
||
return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
|
||
}
|
||
|
||
/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */
|
||
export function fallbackMessageId(providerRef: string, address: string): string {
|
||
return `<${providerRef}@${domainOf(address)}>`;
|
||
}
|
||
|
||
/** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */
|
||
export function threadIdOf(parsed: Pick<ParsedMail, 'inReplyTo' | 'references'>): string | undefined {
|
||
if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo;
|
||
return parsed.references.at(-1);
|
||
}
|
||
|
||
const ENTITIES: Readonly<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
||
|
||
export function stripHtml(html: string): string {
|
||
return html
|
||
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||
.replace(/<br\s*\/?>|<\/(p|div|tr|li|h[1-6])>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {
|
||
if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
|
||
if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1)));
|
||
return ENTITIES[code.toLowerCase()] ?? whole;
|
||
})
|
||
.replace(/[ \t\r\f\v]+/g, ' ')
|
||
.replace(/\s*\n\s*/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
export function bodyTextOf(parsed: Pick<ParsedMail, 'text' | 'html'>): string {
|
||
if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text;
|
||
if (parsed.html !== undefined) return stripHtml(parsed.html);
|
||
return '';
|
||
}
|
||
|
||
export function textPreviewOf(text: string | null, html: string | null): string {
|
||
const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) });
|
||
return body.slice(0, PREVIEW_CHARS);
|
||
}
|
||
|
||
export type AttachmentPlan = {
|
||
readonly index: number;
|
||
readonly name: string;
|
||
readonly contentType: string;
|
||
readonly size: number;
|
||
readonly store: boolean;
|
||
readonly note: string;
|
||
};
|
||
|
||
export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] {
|
||
return attachments.map((a, index) => {
|
||
const size = a.bytes.byteLength;
|
||
const store = size <= cap;
|
||
return {
|
||
index,
|
||
name: a.name === '' ? `attachment-${index + 1}` : a.name,
|
||
contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType,
|
||
size,
|
||
store,
|
||
note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`,
|
||
};
|
||
});
|
||
}
|
||
|
||
export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } {
|
||
if (eml.byteLength <= cap) return { raw: eml, note: '' };
|
||
return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` };
|
||
}
|
||
|
||
export function buildInboundRecord(input: {
|
||
readonly id: string;
|
||
readonly accountId: string;
|
||
readonly address: string;
|
||
readonly providerRef: string;
|
||
readonly parsed: ParsedMail;
|
||
readonly eml: Uint8Array;
|
||
readonly receivedAt: Date;
|
||
readonly attachments: readonly InboundAttachmentRecord[];
|
||
readonly now: Date;
|
||
readonly rawCap?: number | undefined;
|
||
}): InboundRecord {
|
||
const { parsed } = input;
|
||
const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address);
|
||
const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP);
|
||
return {
|
||
id: input.id,
|
||
accountId: input.accountId,
|
||
messageId,
|
||
providerRef: input.providerRef,
|
||
inReplyTo: parsed.inReplyTo ?? null,
|
||
references: [...parsed.references],
|
||
from: parsed.from ?? { address: '', name: '' },
|
||
to: [...parsed.to],
|
||
cc: [...parsed.cc],
|
||
subject: parsed.subject,
|
||
text: parsed.text ?? null,
|
||
html: parsed.html ?? null,
|
||
sentAt: parsed.date ?? null,
|
||
receivedAt: input.receivedAt,
|
||
attachments: [...input.attachments],
|
||
raw,
|
||
rawNote: note,
|
||
size: input.eml.byteLength,
|
||
createdAt: input.now,
|
||
};
|
||
}
|
||
|
||
export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView {
|
||
return {
|
||
subscriber: d.subscriber,
|
||
state: d.state,
|
||
error: d.error,
|
||
startedAt: d.startedAt.toISOString(),
|
||
finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary {
|
||
return {
|
||
id: record.id,
|
||
accountId: record.accountId,
|
||
messageId: record.messageId,
|
||
from: record.from,
|
||
to: [...record.to],
|
||
cc: [...record.cc],
|
||
subject: record.subject,
|
||
sentAt: record.sentAt === null ? null : record.sentAt.toISOString(),
|
||
receivedAt: record.receivedAt.toISOString(),
|
||
textPreview: textPreviewOf(record.text, record.html),
|
||
hasHtml: record.html !== null,
|
||
attachments: record.attachments.map((a) => ({ ...a })),
|
||
deliveries: deliveries.map(deliveryViewOf),
|
||
};
|
||
}
|
||
|
||
export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail {
|
||
return {
|
||
...summaryOf(record, deliveries),
|
||
inReplyTo: record.inReplyTo,
|
||
references: [...record.references],
|
||
text: record.text,
|
||
html: record.html,
|
||
size: record.size,
|
||
rawAvailable: record.raw !== null,
|
||
};
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/redact.ts
|
||
/**
|
||
* 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。
|
||
* 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。
|
||
*/
|
||
const MIN_SECRET_CHARS = 4;
|
||
|
||
export function secretVariants(secret: string): string[] {
|
||
if (secret.length < MIN_SECRET_CHARS) return [];
|
||
const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)];
|
||
return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))];
|
||
}
|
||
|
||
function escapeRegex(s: string): string {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
export function redactSecrets(text: string, secrets: readonly string[]): string {
|
||
let out = text;
|
||
for (const secret of secrets) {
|
||
for (const variant of secretVariants(secret)) {
|
||
out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***');
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */
|
||
export function errorText(error: unknown): string {
|
||
if (error instanceof Error) {
|
||
const bag = error as Error & { response?: unknown; responseText?: unknown };
|
||
const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : '';
|
||
return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`;
|
||
}
|
||
if (error === undefined || error === null) return '未知错误';
|
||
return String(error);
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export {
|
||
ATTACHMENT_CAP,
|
||
bodyTextOf,
|
||
buildInboundRecord,
|
||
detailOf,
|
||
planAttachments,
|
||
RAW_CAP,
|
||
stripHtml,
|
||
summaryOf,
|
||
threadIdOf,
|
||
} from './internal/domain/inbound-record.ts';
|
||
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm lint`
|
||
Expected: 全部 PASS;lint 退出码 0(domain 里 `Buffer` 是全局对象不是 import,不触 `domain-is-pure-core`)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: domain:发信校验、选账号、分发状态、账号视图
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/domain/{send-input,delivery,account-view}.ts`
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加导出)
|
||
- Test: `apps/api/src/modules/mail/__tests__/{send-input,delivery,account-view}.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`validateSend(input, kind, sizes)` → `string[]`;`pickAccount(accounts, {accountId?, from?})` → `MailAccountRecord`(抛 `MailAccountNotFoundError` / `MailAccountDisabledError` / `MailNoDefaultAccountError`);`messageIdFor(id, address)`;`canRedeliver(state)`;`stateOfOutcome(outcome)`;`viewOf(record, now)` → `MailAccountView`;`secretOf(input)` → `string | undefined`(JSON);`cursorReset(existing, input)`;`mergeAccount(input, base)` → `MailAccountRecord`。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/send-input.test.ts
|
||
/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { MailAccountRecord } from '../internal/domain/records.ts';
|
||
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts';
|
||
import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts';
|
||
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
|
||
return {
|
||
id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
|
||
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null,
|
||
lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
|
||
...over,
|
||
};
|
||
}
|
||
const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] };
|
||
|
||
describe('validateSend', () => {
|
||
it('合法入参零问题', () => {
|
||
expect(validateSend(base, 'imap-smtp', [])).toEqual([]);
|
||
});
|
||
it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => {
|
||
expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']);
|
||
expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB');
|
||
expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB');
|
||
});
|
||
it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => {
|
||
expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200');
|
||
expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('pickAccount', () => {
|
||
const a = account({ id: 'a', isDefault: true });
|
||
const b = account({ id: 'b', address: 'b@we.com', enabled: false });
|
||
it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => {
|
||
expect(pickAccount([a, b], {}).id).toBe('a');
|
||
expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError);
|
||
});
|
||
it('指定 id:不存在 404、停用 412', () => {
|
||
expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a');
|
||
expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError);
|
||
expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError);
|
||
});
|
||
it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => {
|
||
expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a');
|
||
expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError);
|
||
expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError);
|
||
});
|
||
});
|
||
|
||
describe('messageIdFor', () => {
|
||
it('<id@域>;地址没有域用 local', () => {
|
||
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
|
||
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
|
||
});
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/delivery.test.ts
|
||
/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts';
|
||
|
||
describe('delivery', () => {
|
||
it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => {
|
||
expect(canRedeliver(undefined)).toBe(true);
|
||
expect(canRedeliver('done')).toBe(true);
|
||
expect(canRedeliver('failed')).toBe(true);
|
||
expect(canRedeliver('skipped')).toBe(true);
|
||
expect(canRedeliver('dispatching')).toBe(false);
|
||
});
|
||
it('回调的返回值定状态', () => {
|
||
expect(stateOfOutcome(undefined)).toBe('done');
|
||
expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped');
|
||
});
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/account-view.test.ts
|
||
/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { mailAccountInputSchema } from '@contracts';
|
||
import type { MailAccountRecord } from '../internal/domain/records.ts';
|
||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||
import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts';
|
||
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } });
|
||
|
||
describe('mergeAccount / secretOf', () => {
|
||
it('新建:id / 时间 / 密文来自 base,游标空', () => {
|
||
const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now });
|
||
expect(record.id).toBe('acc-1');
|
||
expect(record.address).toBe('sales@we.com');
|
||
expect(record.secretCipher).toBe('sealed');
|
||
expect(record.cursor).toBeNull();
|
||
expect(record.createdAt).toBe(now);
|
||
expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' }));
|
||
expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined();
|
||
});
|
||
it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => {
|
||
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
|
||
expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe('');
|
||
const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now });
|
||
const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' };
|
||
expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work');
|
||
});
|
||
it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => {
|
||
const existing: MailAccountRecord = {
|
||
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
|
||
cursor: imapCursor('7', 9),
|
||
lastPollError: '旧错误',
|
||
};
|
||
expect(cursorReset(existing, input)).toBe(false);
|
||
const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now });
|
||
expect(same.cursor).toEqual(imapCursor('7', 9));
|
||
const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } });
|
||
expect(cursorReset(existing, changed)).toBe(true);
|
||
const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now });
|
||
expect(reset.cursor).toBeNull();
|
||
expect(reset.lastPollError).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('viewOf', () => {
|
||
it('不带密文只带 secretBound;status 带游标摘要与 polling', () => {
|
||
const record: MailAccountRecord = {
|
||
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }),
|
||
cursor: imapCursor('7', 9),
|
||
pollingUntil: new Date(now.getTime() + 1000),
|
||
};
|
||
const view = viewOf(record, now);
|
||
expect(view.secretBound).toBe(true);
|
||
expect(JSON.stringify(view)).not.toContain('sealed');
|
||
expect(view.status.cursor).toBe('UID 9 · validity 7');
|
||
expect(view.status.polling).toBe(true);
|
||
expect(view.kind).toBe('imap-smtp');
|
||
if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/send-input.test.ts apps/api/src/modules/mail/__tests__/delivery.test.ts apps/api/src/modules/mail/__tests__/account-view.test.ts`
|
||
Expected: FAIL(模块不存在)。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/send-input.ts
|
||
/** 发信校验与选账号(设计稿 §5.3 / §5.4) */
|
||
import type { MailAccountKind, MailSendInput } from '@contracts';
|
||
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts';
|
||
import type { MailAccountRecord } from './records.ts';
|
||
|
||
export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024;
|
||
export const SEND_TOTAL_CAP = 20 * 1024 * 1024;
|
||
/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */
|
||
export const WECOM_TOTAL_CAP = 50 * 1024 * 1024;
|
||
export const WECOM_ATTACHMENT_COUNT_CAP = 200;
|
||
|
||
type SendShape = Pick<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;
|
||
|
||
export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] {
|
||
const problems: string[] = [];
|
||
if (input.to.length === 0) problems.push('收件人至少一个');
|
||
if (input.subject.trim() === '') problems.push('主题不能为空');
|
||
if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) {
|
||
problems.push('正文 text / html 至少一个');
|
||
}
|
||
const total = attachmentSizes.reduce((sum, n) => sum + n, 0);
|
||
if (kind === 'wecom') {
|
||
if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`);
|
||
if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB');
|
||
} else {
|
||
const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP);
|
||
if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`);
|
||
if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB');
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
export function pickAccount(
|
||
accounts: readonly MailAccountRecord[],
|
||
requested: { readonly accountId?: string | undefined; readonly from?: string | undefined },
|
||
): MailAccountRecord {
|
||
if (requested.accountId !== undefined) {
|
||
const hit = accounts.find((a) => a.id === requested.accountId);
|
||
if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId);
|
||
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
|
||
return hit;
|
||
}
|
||
if (requested.from !== undefined) {
|
||
const wanted = requested.from.trim().toLowerCase();
|
||
const hit = accounts.find((a) => a.address.toLowerCase() === wanted);
|
||
// 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗
|
||
if (hit === undefined) throw new MailAccountNotFoundError(requested.from);
|
||
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
|
||
return hit;
|
||
}
|
||
const fallback = accounts.find((a) => a.isDefault && a.enabled);
|
||
if (fallback === undefined) throw new MailNoDefaultAccountError();
|
||
return fallback;
|
||
}
|
||
|
||
export function messageIdFor(id: string, address: string): string {
|
||
const at = address.lastIndexOf('@');
|
||
const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
|
||
return `<${id}@${domain}>`;
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/delivery.ts
|
||
/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */
|
||
import type { MailDeliveryState } from '@contracts';
|
||
|
||
export function canRedeliver(current: MailDeliveryState | undefined): boolean {
|
||
return current !== 'dispatching';
|
||
}
|
||
|
||
export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' {
|
||
return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done';
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/domain/account-view.ts
|
||
/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */
|
||
import type { MailAccountInput, MailAccountView } from '@contracts';
|
||
import { mailAccountViewSchema } from '@contracts';
|
||
import { cursorSummary } from './cursor.ts';
|
||
import { isPolling } from './due.ts';
|
||
import type { MailAccountRecord } from './records.ts';
|
||
|
||
export function viewOf(record: MailAccountRecord, now: Date): MailAccountView {
|
||
return mailAccountViewSchema.parse({
|
||
id: record.id,
|
||
kind: record.kind,
|
||
name: record.name,
|
||
address: record.address,
|
||
displayName: record.displayName,
|
||
enabled: record.enabled,
|
||
isDefault: record.isDefault,
|
||
pollIntervalSec: record.pollIntervalSec,
|
||
channelId: record.channelId,
|
||
backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(),
|
||
settings: record.settings,
|
||
secretBound: record.secretCipher !== null && record.secretCipher !== '',
|
||
status: {
|
||
lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(),
|
||
lastPollError: record.lastPollError,
|
||
lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(),
|
||
cursor: cursorSummary(record.cursor),
|
||
polling: isPolling(record.pollingUntil, now),
|
||
},
|
||
createdAt: record.createdAt.toISOString(),
|
||
updatedAt: record.updatedAt.toISOString(),
|
||
});
|
||
}
|
||
|
||
/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */
|
||
export function secretOf(input: MailAccountInput): string | undefined {
|
||
return input.secret === undefined ? undefined : JSON.stringify(input.secret);
|
||
}
|
||
|
||
function sameSettings(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||
return [...keys].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null));
|
||
}
|
||
|
||
/** kind 或 settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */
|
||
export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean {
|
||
if (existing === undefined) return false;
|
||
return existing.kind !== input.kind || !sameSettings(existing.settings, input.settings);
|
||
}
|
||
|
||
export function mergeAccount(
|
||
input: MailAccountInput,
|
||
base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date },
|
||
): MailAccountRecord {
|
||
const reset = cursorReset(base.existing, input);
|
||
const existing = base.existing;
|
||
return {
|
||
id: base.id,
|
||
kind: input.kind,
|
||
name: input.name,
|
||
// wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准
|
||
address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '',
|
||
displayName: input.displayName,
|
||
enabled: input.enabled,
|
||
isDefault: input.isDefault,
|
||
pollIntervalSec: input.pollIntervalSec,
|
||
channelId: input.channelId,
|
||
backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom),
|
||
settings: { ...input.settings },
|
||
secretCipher: base.secretCipher,
|
||
cursor: reset ? null : (existing?.cursor ?? null),
|
||
lastPolledAt: existing?.lastPolledAt ?? null,
|
||
lastPollError: reset ? null : (existing?.lastPollError ?? null),
|
||
lastInboundAt: existing?.lastInboundAt ?? null,
|
||
pollingUntil: existing?.pollingUntil ?? null,
|
||
createdAt: existing?.createdAt ?? base.now,
|
||
updatedAt: base.now,
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
|
||
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
|
||
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 全部 PASS;三道门禁退出码 0。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 迁移 0033 + Drizzle 表 + 三个 store(真 PG 集成测试)
|
||
|
||
**Files:**
|
||
- Create: `db/migrations/0033_mail.sql`;Modify: `db/migrations/meta/_journal.json`(追加 idx 33)
|
||
- Create: `apps/api/src/modules/mail/internal/adapter/mail-schema.ts` · `mail-store.ts`
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加 adapter 与表导出);`apps/api/src/app/reset-runs.ts`(三张留痕表)
|
||
- Test: `apps/api/src/modules/mail/__tests__/mail-store.it.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`createMailAccountStore(db)` `createInboundStore(db)` `createOutboundStore(db)`(各实现 port.ts 同名类型);表 `mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`。
|
||
|
||
- [ ] **Step 1: 写迁移与 journal**
|
||
|
||
```sql
|
||
-- db/migrations/0033_mail.sql
|
||
-- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。
|
||
--
|
||
-- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉:
|
||
-- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与
|
||
-- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。
|
||
-- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。
|
||
-- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。
|
||
create table if not exists sys.mail_account (
|
||
id text primary key,
|
||
name text not null,
|
||
kind text not null check (kind in ('imap-smtp', 'wecom')),
|
||
address text not null default '',
|
||
display_name text not null default '',
|
||
enabled boolean not null default true,
|
||
is_default boolean not null default false,
|
||
poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600),
|
||
channel_id text,
|
||
backfill_from timestamptz,
|
||
settings jsonb not null default '{}'::jsonb,
|
||
secret_cipher text,
|
||
cursor jsonb,
|
||
last_polled_at timestamptz,
|
||
last_poll_error text,
|
||
last_inbound_at timestamptz,
|
||
polling_until timestamptz,
|
||
created_at timestamptz not null default now(),
|
||
updated_at timestamptz not null default now()
|
||
);
|
||
create unique index if not exists mail_account_name_key on sys.mail_account (name);
|
||
-- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底
|
||
create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default;
|
||
create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
|
||
|
||
create table if not exists sys.mail_inbound (
|
||
id text primary key,
|
||
account_id text not null,
|
||
message_id text not null,
|
||
provider_ref text not null,
|
||
in_reply_to text,
|
||
reference_ids jsonb not null default '[]'::jsonb,
|
||
from_addr text not null default '',
|
||
from_name text not null default '',
|
||
to_list jsonb not null default '[]'::jsonb,
|
||
cc_list jsonb not null default '[]'::jsonb,
|
||
subject text not null default '',
|
||
text_body text,
|
||
html_body text,
|
||
sent_at timestamptz,
|
||
received_at timestamptz not null,
|
||
attachments jsonb not null default '[]'::jsonb,
|
||
raw bytea,
|
||
raw_note text not null default '',
|
||
size integer not null default 0,
|
||
created_at timestamptz not null default now()
|
||
);
|
||
create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
|
||
create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
|
||
create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc);
|
||
|
||
create table if not exists sys.mail_inbound_delivery (
|
||
mail_id text not null references sys.mail_inbound(id) on delete cascade,
|
||
subscriber text not null,
|
||
state text not null check (state in ('dispatching', 'done', 'failed', 'skipped')),
|
||
error text,
|
||
started_at timestamptz not null default now(),
|
||
finished_at timestamptz,
|
||
primary key (mail_id, subscriber)
|
||
);
|
||
|
||
create table if not exists sys.mail_outbound (
|
||
id text primary key,
|
||
account_id text not null,
|
||
message_id text not null,
|
||
idempotency_key text,
|
||
to_list jsonb not null,
|
||
cc_list jsonb not null default '[]'::jsonb,
|
||
bcc_list jsonb not null default '[]'::jsonb,
|
||
subject text not null,
|
||
text_body text,
|
||
html_body text,
|
||
in_reply_to text,
|
||
reference_ids jsonb not null default '[]'::jsonb,
|
||
attachments jsonb not null default '[]'::jsonb,
|
||
state text not null check (state in ('sending', 'sent', 'failed')),
|
||
error text,
|
||
provider_ref text,
|
||
requested_by jsonb not null default '{}'::jsonb,
|
||
created_at timestamptz not null default now(),
|
||
sent_at timestamptz
|
||
);
|
||
create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
|
||
create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc);
|
||
```
|
||
|
||
`db/migrations/meta/_journal.json` 的 `entries` 末尾追加(注意上一条后面补逗号):
|
||
|
||
```json
|
||
{
|
||
"idx": 33,
|
||
"version": "7",
|
||
"when": 1785130120181,
|
||
"tag": "0033_mail",
|
||
"breakpoints": true
|
||
}
|
||
```
|
||
|
||
`apps/api/src/app/reset-runs.ts` 的 `RUN_TABLES` 在 `'sys.outbound_mail',` 之后加:
|
||
|
||
```ts
|
||
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
|
||
'sys.mail_inbound',
|
||
'sys.mail_inbound_delivery',
|
||
'sys.mail_outbound',
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试(真 PG)**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/mail-store.it.test.ts
|
||
/**
|
||
* mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、
|
||
* 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import { Pool } from 'pg';
|
||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||
import { startItDatabase } from '@testing';
|
||
import type { ItDatabase } from '@testing';
|
||
import type { Db } from '@shared/db.ts';
|
||
import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts';
|
||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||
import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts';
|
||
|
||
const database: ItDatabase | undefined = await startItDatabase();
|
||
let poolA: Pool | undefined;
|
||
let poolB: Pool | undefined;
|
||
let db: Db;
|
||
let dbB: Db;
|
||
|
||
const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url);
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
|
||
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
|
||
return {
|
||
id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
|
||
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z',
|
||
cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
|
||
...over,
|
||
};
|
||
}
|
||
function inbound(over: Partial<InboundRecord>): InboundRecord {
|
||
return {
|
||
id: 'in-1', accountId: 'acc-a', messageId: '<m1@x>', providerRef: 'uid:7:1', inReplyTo: null, references: [],
|
||
from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null,
|
||
sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawNote: '', size: 3, createdAt: now,
|
||
...over,
|
||
};
|
||
}
|
||
function outbound(over: Partial<OutboundRecord>): OutboundRecord {
|
||
return {
|
||
id: 'out-1', accountId: 'acc-a', messageId: '<o1@we.com>', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [],
|
||
subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null,
|
||
providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
if (database === undefined) return;
|
||
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||
await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade');
|
||
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||
db = drizzle(poolA);
|
||
dbB = drizzle(poolB);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await poolA?.end();
|
||
await poolB?.end();
|
||
await database?.stop();
|
||
});
|
||
|
||
describe.skipIf(database === undefined)('mail store(真 PG)', () => {
|
||
it('账号:save / get / findByName / list;isDefault 只留一个', async () => {
|
||
const store = createMailAccountStore(db);
|
||
await store.save(account({ id: 'acc-a', name: '甲', isDefault: true }));
|
||
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true }));
|
||
expect((await store.get('acc-a'))?.isDefault).toBe(false);
|
||
expect((await store.get('acc-b'))?.isDefault).toBe(true);
|
||
expect((await store.findByName('甲'))?.id).toBe('acc-a');
|
||
expect((await store.list()).map((a) => a.id)).toEqual(['acc-a', 'acc-b']);
|
||
});
|
||
|
||
it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => {
|
||
const store = createMailAccountStore(db);
|
||
await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null });
|
||
const got = await store.get('acc-a');
|
||
expect(got?.cursor).toEqual(imapCursor('7', 12));
|
||
expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString());
|
||
await store.savePoll('acc-a', { error: '连不上' });
|
||
expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12));
|
||
expect((await store.get('acc-a'))?.lastPollError).toBe('连不上');
|
||
await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined();
|
||
});
|
||
|
||
it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => {
|
||
const store = createMailAccountStore(db);
|
||
await store.save(account({ id: 'acc-a', name: '甲', lastPolledAt: new Date(now.getTime() - 60_000) }));
|
||
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', lastPolledAt: new Date(now.getTime() - 5_000) }));
|
||
await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false }));
|
||
await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) }));
|
||
expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a']);
|
||
});
|
||
|
||
it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => {
|
||
const a = createMailAccountStore(db);
|
||
const b = createMailAccountStore(dbB);
|
||
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' }));
|
||
for (let round = 0; round < 20; round += 1) {
|
||
const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]);
|
||
expect(wins.filter(Boolean)).toHaveLength(1);
|
||
await a.releasePolling('acc-lease');
|
||
}
|
||
expect(await a.claimPolling('acc-lease', 60_000)).toBe(true);
|
||
expect(await b.claimPolling('acc-lease', 60_000)).toBe(false);
|
||
await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`);
|
||
expect(await b.claimPolling('acc-lease', 60_000)).toBe(true);
|
||
await b.releasePolling('acc-lease');
|
||
// 停用的账号抢不到
|
||
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false }));
|
||
expect(await a.claimPolling('acc-lease', 60_000)).toBe(false);
|
||
});
|
||
|
||
it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => {
|
||
const store = createInboundStore(db);
|
||
expect(await store.insert(inbound({}))).toEqual({ inserted: true });
|
||
expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false });
|
||
expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '<other@x>' }))).toEqual({ inserted: false });
|
||
expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1']));
|
||
expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set());
|
||
const got = await store.get('in-1');
|
||
expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3]));
|
||
expect(got?.record.raw).toBeInstanceOf(Uint8Array);
|
||
expect(Buffer.isBuffer(got?.record.raw)).toBe(false);
|
||
});
|
||
|
||
it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => {
|
||
const store = createInboundStore(db);
|
||
await store.insert(inbound({ id: 'in-2', messageId: '<m2@x>', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) }));
|
||
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null });
|
||
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now });
|
||
const list = await store.list({ accountId: 'acc-a', limit: 10 });
|
||
expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']);
|
||
expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]);
|
||
expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]);
|
||
expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']);
|
||
});
|
||
|
||
it('出站:insert / byIdempotencyKey / setState / list', async () => {
|
||
const store = createOutboundStore(db);
|
||
await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' }));
|
||
await store.insert(outbound({ id: 'out-2', messageId: '<o2@we.com>' }));
|
||
expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1');
|
||
expect(await store.byIdempotencyKey('k-none')).toBeUndefined();
|
||
await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now });
|
||
await store.setState('out-2', 'failed', { error: '535 auth failed' });
|
||
const list = await store.list({ accountId: 'acc-a', limit: 10 });
|
||
expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' });
|
||
expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' });
|
||
// 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在
|
||
await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts`
|
||
Expected: FAIL(`mail-store.ts` 不存在)。本机 `pnpm db:up` 起着 PG 时用 `localhost:54329`;起不了就走 Testcontainers;两者皆无 → 套件跳过并告警(那样本任务不算验过,先把库起来)。
|
||
|
||
- [ ] **Step 4: 写表与 store**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/adapter/mail-schema.ts
|
||
/**
|
||
* mail 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。
|
||
*/
|
||
import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||
import type { MailAddress } from '@contracts';
|
||
import { sys } from '@shared/db.ts';
|
||
|
||
/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */
|
||
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
||
dataType() {
|
||
return 'bytea';
|
||
},
|
||
});
|
||
|
||
export const mailAccountTable = sys.table('mail_account', {
|
||
id: text('id').primaryKey(),
|
||
name: text('name').notNull(),
|
||
kind: text('kind').notNull(),
|
||
address: text('address').notNull().default(''),
|
||
displayName: text('display_name').notNull().default(''),
|
||
enabled: boolean('enabled').notNull().default(true),
|
||
isDefault: boolean('is_default').notNull().default(false),
|
||
pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
|
||
channelId: text('channel_id'),
|
||
backfillFrom: timestamp('backfill_from', { withTimezone: true }),
|
||
settings: jsonb('settings').$type<Record<string, unknown>>().notNull().default({}),
|
||
/** AES-256-GCM 密文;永不出接口 */
|
||
secretCipher: text('secret_cipher'),
|
||
cursor: jsonb('cursor'),
|
||
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
|
||
lastPollError: text('last_poll_error'),
|
||
lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }),
|
||
/** 跨进程租约 —— 与 sys.task.advancing_until 同款 */
|
||
pollingUntil: timestamp('polling_until', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const mailInboundTable = sys.table('mail_inbound', {
|
||
id: text('id').primaryKey(),
|
||
accountId: text('account_id').notNull(),
|
||
messageId: text('message_id').notNull(),
|
||
providerRef: text('provider_ref').notNull(),
|
||
inReplyTo: text('in_reply_to'),
|
||
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
|
||
fromAddr: text('from_addr').notNull().default(''),
|
||
fromName: text('from_name').notNull().default(''),
|
||
toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
|
||
ccList: jsonb('cc_list').$type<MailAddress[]>().notNull().default([]),
|
||
subject: text('subject').notNull().default(''),
|
||
textBody: text('text_body'),
|
||
htmlBody: text('html_body'),
|
||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
|
||
attachments: jsonb('attachments')
|
||
.$type<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
|
||
.notNull()
|
||
.default([]),
|
||
raw: bytea('raw'),
|
||
rawNote: text('raw_note').notNull().default(''),
|
||
size: integer('size').notNull().default(0),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
|
||
export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', {
|
||
mailId: text('mail_id').notNull(),
|
||
subscriber: text('subscriber').notNull(),
|
||
state: text('state').notNull(),
|
||
error: text('error'),
|
||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||
});
|
||
|
||
export const mailOutboundTable = sys.table('mail_outbound', {
|
||
id: text('id').primaryKey(),
|
||
accountId: text('account_id').notNull(),
|
||
messageId: text('message_id').notNull(),
|
||
idempotencyKey: text('idempotency_key'),
|
||
toList: jsonb('to_list').$type<string[]>().notNull(),
|
||
ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
|
||
bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
|
||
subject: text('subject').notNull(),
|
||
textBody: text('text_body'),
|
||
htmlBody: text('html_body'),
|
||
inReplyTo: text('in_reply_to'),
|
||
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
|
||
attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
|
||
state: text('state').notNull(),
|
||
error: text('error'),
|
||
providerRef: text('provider_ref'),
|
||
requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/adapter/mail-store.ts
|
||
/**
|
||
* mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||
* 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。
|
||
*/
|
||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||
import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts';
|
||
import type { Db } from '@shared/db.ts';
|
||
import type {
|
||
DeliveryRecord,
|
||
InboundRecord,
|
||
InboundStore,
|
||
MailAccountRecord,
|
||
MailAccountStore,
|
||
OutboundRecord,
|
||
OutboundStore,
|
||
} from '../../port.ts';
|
||
import { parseCursor } from '../domain/cursor.ts';
|
||
import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts';
|
||
|
||
function rowsOf(result: unknown): unknown[] {
|
||
if (Array.isArray(result)) return result;
|
||
const rows = (result as { rows?: unknown }).rows;
|
||
return Array.isArray(rows) ? rows : [];
|
||
}
|
||
|
||
/* ── 账号 ─────────────────────────────────────────────────────────────── */
|
||
|
||
type AccountRow = typeof mailAccountTable.$inferSelect;
|
||
|
||
function toAccount(row: AccountRow): MailAccountRecord {
|
||
return {
|
||
id: row.id,
|
||
kind: mailAccountKindSchema.parse(row.kind),
|
||
name: row.name,
|
||
address: row.address,
|
||
displayName: row.displayName,
|
||
enabled: row.enabled,
|
||
isDefault: row.isDefault,
|
||
pollIntervalSec: row.pollIntervalSec,
|
||
channelId: row.channelId,
|
||
backfillFrom: row.backfillFrom,
|
||
settings: row.settings,
|
||
secretCipher: row.secretCipher,
|
||
cursor: parseCursor(row.cursor),
|
||
lastPolledAt: row.lastPolledAt,
|
||
lastPollError: row.lastPollError,
|
||
lastInboundAt: row.lastInboundAt,
|
||
pollingUntil: row.pollingUntil,
|
||
createdAt: row.createdAt,
|
||
updatedAt: row.updatedAt,
|
||
};
|
||
}
|
||
|
||
function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert {
|
||
return {
|
||
id: record.id,
|
||
name: record.name,
|
||
kind: record.kind,
|
||
address: record.address,
|
||
displayName: record.displayName,
|
||
enabled: record.enabled,
|
||
isDefault: record.isDefault,
|
||
pollIntervalSec: record.pollIntervalSec,
|
||
channelId: record.channelId,
|
||
backfillFrom: record.backfillFrom,
|
||
settings: record.settings,
|
||
secretCipher: record.secretCipher,
|
||
cursor: record.cursor,
|
||
lastPolledAt: record.lastPolledAt,
|
||
lastPollError: record.lastPollError,
|
||
lastInboundAt: record.lastInboundAt,
|
||
pollingUntil: record.pollingUntil,
|
||
createdAt: record.createdAt,
|
||
updatedAt: record.updatedAt,
|
||
};
|
||
}
|
||
|
||
export function createMailAccountStore(db: Db): MailAccountStore {
|
||
return {
|
||
async list() {
|
||
const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name);
|
||
return rows.map(toAccount);
|
||
},
|
||
async get(id) {
|
||
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1);
|
||
return rows[0] === undefined ? undefined : toAccount(rows[0]);
|
||
},
|
||
async findByName(name) {
|
||
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1);
|
||
return rows[0] === undefined ? undefined : toAccount(rows[0]);
|
||
},
|
||
async save(record) {
|
||
const values = accountValues(record);
|
||
await db.transaction(async (tx) => {
|
||
// 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底
|
||
if (record.isDefault) {
|
||
await tx
|
||
.update(mailAccountTable)
|
||
.set({ isDefault: false })
|
||
.where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`));
|
||
}
|
||
await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values });
|
||
});
|
||
},
|
||
async remove(id) {
|
||
await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id));
|
||
},
|
||
async listDue(now) {
|
||
const rows = await db
|
||
.select()
|
||
.from(mailAccountTable)
|
||
.where(
|
||
sql`${mailAccountTable.enabled}
|
||
and (${mailAccountTable.lastPolledAt} is null
|
||
or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now})
|
||
and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`,
|
||
)
|
||
.orderBy(mailAccountTable.name);
|
||
return rows.map(toAccount);
|
||
},
|
||
async claimPolling(id, leaseMs) {
|
||
const result = await db.execute(sql`
|
||
update sys.mail_account
|
||
set polling_until = now() + (${leaseMs} || ' milliseconds')::interval
|
||
where id = ${id} and enabled
|
||
and (polling_until is null or polling_until < now())
|
||
returning id`);
|
||
return rowsOf(result).length > 0;
|
||
},
|
||
async releasePolling(id) {
|
||
// 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉
|
||
await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined);
|
||
},
|
||
async savePoll(id, patch) {
|
||
const set: Partial<typeof mailAccountTable.$inferInsert> = {};
|
||
if (patch.cursor !== undefined) set.cursor = patch.cursor;
|
||
if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt;
|
||
if (patch.error !== undefined) set.lastPollError = patch.error;
|
||
if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt;
|
||
if (patch.address !== undefined) set.address = patch.address;
|
||
if (Object.keys(set).length === 0) return;
|
||
await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id));
|
||
},
|
||
};
|
||
}
|
||
|
||
/* ── 入站 ─────────────────────────────────────────────────────────────── */
|
||
|
||
type InboundRow = typeof mailInboundTable.$inferSelect;
|
||
type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect;
|
||
|
||
function toInbound(row: InboundRow): InboundRecord {
|
||
return {
|
||
id: row.id,
|
||
accountId: row.accountId,
|
||
messageId: row.messageId,
|
||
providerRef: row.providerRef,
|
||
inReplyTo: row.inReplyTo,
|
||
references: row.referenceIds,
|
||
from: { address: row.fromAddr, name: row.fromName },
|
||
to: row.toList,
|
||
cc: row.ccList,
|
||
subject: row.subject,
|
||
text: row.textBody,
|
||
html: row.htmlBody,
|
||
sentAt: row.sentAt,
|
||
receivedAt: row.receivedAt,
|
||
attachments: row.attachments,
|
||
/*
|
||
* pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 ——
|
||
* 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。
|
||
*/
|
||
raw: row.raw === null ? null : Uint8Array.from(row.raw),
|
||
rawNote: row.rawNote,
|
||
size: row.size,
|
||
createdAt: row.createdAt,
|
||
};
|
||
}
|
||
|
||
function toDelivery(row: DeliveryRow): DeliveryRecord {
|
||
return {
|
||
mailId: row.mailId,
|
||
subscriber: row.subscriber,
|
||
state: mailDeliveryStateSchema.parse(row.state),
|
||
error: row.error,
|
||
startedAt: row.startedAt,
|
||
finishedAt: row.finishedAt,
|
||
};
|
||
}
|
||
|
||
export function createInboundStore(db: Db): InboundStore {
|
||
async function deliveriesOf(mailIds: readonly string[]): Promise<Map<string, DeliveryRecord[]>> {
|
||
const out = new Map<string, DeliveryRecord[]>();
|
||
if (mailIds.length === 0) return out;
|
||
const rows = await db
|
||
.select()
|
||
.from(mailInboundDeliveryTable)
|
||
.where(inArray(mailInboundDeliveryTable.mailId, [...mailIds]))
|
||
.orderBy(mailInboundDeliveryTable.subscriber);
|
||
for (const row of rows) {
|
||
const list = out.get(row.mailId) ?? [];
|
||
list.push(toDelivery(row));
|
||
out.set(row.mailId, list);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
return {
|
||
async insert(record) {
|
||
const rows = await db
|
||
.insert(mailInboundTable)
|
||
.values({
|
||
id: record.id,
|
||
accountId: record.accountId,
|
||
messageId: record.messageId,
|
||
providerRef: record.providerRef,
|
||
inReplyTo: record.inReplyTo,
|
||
referenceIds: [...record.references],
|
||
fromAddr: record.from.address,
|
||
fromName: record.from.name,
|
||
toList: [...record.to],
|
||
ccList: [...record.cc],
|
||
subject: record.subject,
|
||
textBody: record.text,
|
||
htmlBody: record.html,
|
||
sentAt: record.sentAt,
|
||
receivedAt: record.receivedAt,
|
||
attachments: [...record.attachments],
|
||
raw: record.raw === null ? null : Buffer.from(record.raw),
|
||
rawNote: record.rawNote,
|
||
size: record.size,
|
||
createdAt: record.createdAt,
|
||
})
|
||
.onConflictDoNothing()
|
||
.returning({ id: mailInboundTable.id });
|
||
return { inserted: rows.length > 0 };
|
||
},
|
||
async knownProviderRefs(accountId, refs) {
|
||
if (refs.length === 0) return new Set();
|
||
const rows = await db
|
||
.select({ providerRef: mailInboundTable.providerRef })
|
||
.from(mailInboundTable)
|
||
.where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs])));
|
||
return new Set(rows.map((r) => r.providerRef));
|
||
},
|
||
async list(query) {
|
||
const rows = await db
|
||
.select()
|
||
.from(mailInboundTable)
|
||
.where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId))
|
||
.orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt))
|
||
.limit(query.limit);
|
||
const deliveries = await deliveriesOf(rows.map((r) => r.id));
|
||
return rows.map((row) => ({ record: toInbound(row), deliveries: deliveries.get(row.id) ?? [] }));
|
||
},
|
||
async get(mailId) {
|
||
const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1);
|
||
const row = rows[0];
|
||
if (row === undefined) return undefined;
|
||
const deliveries = await deliveriesOf([mailId]);
|
||
return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] };
|
||
},
|
||
async setDelivery(delivery) {
|
||
const values = {
|
||
mailId: delivery.mailId,
|
||
subscriber: delivery.subscriber,
|
||
state: delivery.state,
|
||
error: delivery.error,
|
||
startedAt: delivery.startedAt,
|
||
finishedAt: delivery.finishedAt,
|
||
};
|
||
await db
|
||
.insert(mailInboundDeliveryTable)
|
||
.values(values)
|
||
.onConflictDoUpdate({
|
||
target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber],
|
||
set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt },
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
/* ── 出站 ─────────────────────────────────────────────────────────────── */
|
||
|
||
type OutboundRow = typeof mailOutboundTable.$inferSelect;
|
||
|
||
function toOutbound(row: OutboundRow): OutboundRecord {
|
||
return {
|
||
id: row.id,
|
||
accountId: row.accountId,
|
||
messageId: row.messageId,
|
||
idempotencyKey: row.idempotencyKey,
|
||
to: row.toList,
|
||
cc: row.ccList,
|
||
bcc: row.bccList,
|
||
subject: row.subject,
|
||
text: row.textBody,
|
||
html: row.htmlBody,
|
||
inReplyTo: row.inReplyTo,
|
||
references: row.referenceIds,
|
||
attachments: row.attachments,
|
||
state: mailOutboundStateSchema.parse(row.state),
|
||
error: row.error,
|
||
providerRef: row.providerRef,
|
||
requestedBy: row.requestedBy,
|
||
createdAt: row.createdAt,
|
||
sentAt: row.sentAt,
|
||
};
|
||
}
|
||
|
||
export function createOutboundStore(db: Db): OutboundStore {
|
||
return {
|
||
async insert(record) {
|
||
await db.insert(mailOutboundTable).values({
|
||
id: record.id,
|
||
accountId: record.accountId,
|
||
messageId: record.messageId,
|
||
idempotencyKey: record.idempotencyKey,
|
||
toList: [...record.to],
|
||
ccList: [...record.cc],
|
||
bccList: [...record.bcc],
|
||
subject: record.subject,
|
||
textBody: record.text,
|
||
htmlBody: record.html,
|
||
inReplyTo: record.inReplyTo,
|
||
referenceIds: [...record.references],
|
||
attachments: [...record.attachments],
|
||
state: record.state,
|
||
error: record.error,
|
||
providerRef: record.providerRef,
|
||
requestedBy: record.requestedBy,
|
||
createdAt: record.createdAt,
|
||
sentAt: record.sentAt,
|
||
});
|
||
},
|
||
async byIdempotencyKey(key) {
|
||
const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1);
|
||
return rows[0] === undefined ? undefined : toOutbound(rows[0]);
|
||
},
|
||
async setState(id, state, patch) {
|
||
await db
|
||
.update(mailOutboundTable)
|
||
.set({
|
||
state,
|
||
...(patch.error === undefined ? {} : { error: patch.error }),
|
||
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
|
||
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
|
||
})
|
||
.where(eq(mailOutboundTable.id, id));
|
||
},
|
||
async list(query) {
|
||
const rows = await db
|
||
.select()
|
||
.from(mailOutboundTable)
|
||
.where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId))
|
||
.orderBy(desc(mailOutboundTable.createdAt))
|
||
.limit(query.limit);
|
||
return rows.map(toOutbound);
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
|
||
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 7 tests PASS(套件没跳过 —— 输出里不能有「集成测试跳过」);三道门禁退出码 0。
|
||
另跑一次 `pnpm vitest run apps/api/src/app/__tests__/route-coverage.it.test.ts`:它对 `jcai_it_routes` 库跑全部迁移,0033 能应用即绿。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add db/migrations/0033_mail.sql db/migrations/meta/_journal.json apps/api/src/modules/mail apps/api/src/app/reset-runs.ts
|
||
git commit -m "$(printf '迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: adapter:postal-mime 解析器 + 测试夹具
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts`
|
||
- Create: `apps/api/src/modules/mail/__tests__/fakes.ts`(EML 夹具 + 内存 store + 假 provider + 时钟;后续用例测试共用)
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加导出)
|
||
- Test: `apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`createPostalMimeParser(): MimeParser`;夹具 `eml(parts)` `fixedClock(iso?)` `seqIds(prefix?)` `plainSecrets` `memoryAccounts(clock)` `memoryInbound()` `memoryOutbound()` `memoryAttachments()` `fakeProvider(kind?)` → `FakeProvider`。
|
||
- postal-mime 3.0.0 的真实形状(本机核过):`PostalMime.parse(Uint8Array)` → `{ from?: {address,name}, to?: Address[], cc?: Address[], subject?, messageId?, inReplyTo?, references?: string(空格分隔), date?: ISO 串, text?, html?, attachments: [{filename, mimeType, disposition, content: ArrayBuffer}] }`;GBK 主题 / 正文解得出;群组地址形 `{name, group: Mailbox[]}`。
|
||
|
||
- [ ] **Step 1: 写夹具**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/fakes.ts
|
||
/**
|
||
* 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。
|
||
* 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。
|
||
*/
|
||
import type { MailAccountKind, MailProbeReport } from '@contracts';
|
||
import { isDue } from '../internal/domain/due.ts';
|
||
import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts';
|
||
import type {
|
||
AttachmentStore,
|
||
Clock,
|
||
DeliveryRecord,
|
||
IdGenerator,
|
||
InboundRecord,
|
||
InboundStore,
|
||
MailAccountRecord,
|
||
MailAccountStore,
|
||
MailCursor,
|
||
MailProvider,
|
||
OutboundMessage,
|
||
OutboundRecord,
|
||
OutboundStore,
|
||
RawMessage,
|
||
SecretBox,
|
||
} from '../port.ts';
|
||
|
||
const CRLF = '\r\n';
|
||
|
||
export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } {
|
||
let t = new Date(iso).getTime();
|
||
return {
|
||
now: () => new Date(t),
|
||
tick(ms) {
|
||
t += ms;
|
||
},
|
||
};
|
||
}
|
||
|
||
export function seqIds(prefix = 'id'): IdGenerator {
|
||
let n = 0;
|
||
return {
|
||
next: () => {
|
||
n += 1;
|
||
return `${prefix}-${n}`;
|
||
},
|
||
};
|
||
}
|
||
|
||
export const plainSecrets: SecretBox = {
|
||
seal: (s) => `sealed:${s}`,
|
||
open: (c) => {
|
||
if (!c.startsWith('sealed:')) throw new Error('密文格式非法');
|
||
return c.slice('sealed:'.length);
|
||
},
|
||
};
|
||
|
||
export function account(over: Partial<MailAccountRecord> = {}): MailAccountRecord {
|
||
const now = new Date('2026-08-25T09:00:00Z');
|
||
return {
|
||
id: 'acc-1',
|
||
kind: 'imap-smtp',
|
||
name: '销售信箱',
|
||
address: 'sales@we.com',
|
||
displayName: '销售部',
|
||
enabled: true,
|
||
isDefault: true,
|
||
pollIntervalSec: 30,
|
||
channelId: 'ch-1',
|
||
backfillFrom: null,
|
||
settings: { folder: 'INBOX' },
|
||
secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`,
|
||
cursor: null,
|
||
lastPolledAt: null,
|
||
lastPollError: null,
|
||
lastInboundAt: null,
|
||
pollingUntil: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<string, MailAccountRecord> } {
|
||
const rows = new Map<string, MailAccountRecord>();
|
||
return {
|
||
rows,
|
||
async list() {
|
||
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||
},
|
||
async get(id) {
|
||
return rows.get(id);
|
||
},
|
||
async findByName(name) {
|
||
return [...rows.values()].find((r) => r.name === name);
|
||
},
|
||
async save(record) {
|
||
if (record.isDefault) {
|
||
for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false });
|
||
}
|
||
rows.set(record.id, record);
|
||
},
|
||
async remove(id) {
|
||
rows.delete(id);
|
||
},
|
||
async listDue(now) {
|
||
return [...rows.values()].filter((r) => isDue(r, now));
|
||
},
|
||
async claimPolling(id, leaseMs) {
|
||
const r = rows.get(id);
|
||
const now = clock.now();
|
||
if (r === undefined || !r.enabled) return false;
|
||
if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false;
|
||
rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) });
|
||
return true;
|
||
},
|
||
async releasePolling(id) {
|
||
const r = rows.get(id);
|
||
if (r !== undefined) rows.set(id, { ...r, pollingUntil: null });
|
||
},
|
||
async savePoll(id, patch) {
|
||
const r = rows.get(id);
|
||
if (r === undefined) return;
|
||
rows.set(id, {
|
||
...r,
|
||
...(patch.cursor === undefined ? {} : { cursor: patch.cursor }),
|
||
...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }),
|
||
...(patch.error === undefined ? {} : { lastPollError: patch.error }),
|
||
...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }),
|
||
...(patch.address === undefined ? {} : { address: patch.address }),
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
|
||
const rows = new Map<string, InboundRecord>();
|
||
const deliveries = new Map<string, DeliveryRecord>();
|
||
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)
|
||
const of = (mailId: string): DeliveryRecord[] =>
|
||
[...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber));
|
||
return {
|
||
rows,
|
||
deliveries,
|
||
async insert(record) {
|
||
for (const r of rows.values()) {
|
||
if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) {
|
||
return { inserted: false };
|
||
}
|
||
}
|
||
rows.set(record.id, record);
|
||
return { inserted: true };
|
||
},
|
||
async knownProviderRefs(accountId, refs) {
|
||
return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef));
|
||
},
|
||
async list(query) {
|
||
return [...rows.values()]
|
||
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
|
||
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
|
||
.slice(0, query.limit)
|
||
.map((record) => ({ record, deliveries: of(record.id) }));
|
||
},
|
||
async get(mailId) {
|
||
const record = rows.get(mailId);
|
||
return record === undefined ? undefined : { record, deliveries: of(mailId) };
|
||
},
|
||
async setDelivery(delivery) {
|
||
deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery);
|
||
},
|
||
};
|
||
}
|
||
|
||
export function memoryOutbound(): OutboundStore & { rows: Map<string, OutboundRecord> } {
|
||
const rows = new Map<string, OutboundRecord>();
|
||
return {
|
||
rows,
|
||
async insert(record) {
|
||
if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) {
|
||
throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"');
|
||
}
|
||
rows.set(record.id, record);
|
||
},
|
||
async byIdempotencyKey(key) {
|
||
return [...rows.values()].find((r) => r.idempotencyKey === key);
|
||
},
|
||
async setState(id, state, patch) {
|
||
const r = rows.get(id);
|
||
if (r === undefined) return;
|
||
rows.set(id, {
|
||
...r,
|
||
state,
|
||
...(patch.error === undefined ? {} : { error: patch.error }),
|
||
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
|
||
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
|
||
});
|
||
},
|
||
async list(query) {
|
||
return [...rows.values()]
|
||
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
|
||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||
.slice(0, query.limit);
|
||
},
|
||
};
|
||
}
|
||
|
||
export function memoryAttachments(): AttachmentStore & { files: Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
|
||
const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
|
||
let n = 0;
|
||
return {
|
||
files,
|
||
async put(file) {
|
||
n += 1;
|
||
const ref = `up-${n}`;
|
||
files.set(ref, { ...file });
|
||
return { ref };
|
||
},
|
||
async get(ref) {
|
||
const f = files.get(ref);
|
||
return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes };
|
||
},
|
||
};
|
||
}
|
||
|
||
export type FakeProvider = MailProvider & {
|
||
readonly sent: OutboundMessage[];
|
||
readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>;
|
||
queue(...messages: RawMessage[]): void;
|
||
failPullWith(error: Error | undefined): void;
|
||
failSendWith(error: Error | undefined): void;
|
||
probeWith(report: MailProbeReport | undefined): void;
|
||
};
|
||
|
||
/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */
|
||
export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider {
|
||
const pending: RawMessage[] = [];
|
||
const sent: OutboundMessage[] = [];
|
||
const pullCalls: Array<{ cursor: MailCursor; limit: number }> = [];
|
||
let pullError: Error | undefined;
|
||
let sendError: Error | undefined;
|
||
let probeReport: MailProbeReport | undefined;
|
||
let uidSeq = 0;
|
||
return {
|
||
kind,
|
||
sent,
|
||
pullCalls,
|
||
queue(...messages) {
|
||
pending.push(...messages);
|
||
},
|
||
failPullWith(error) {
|
||
pullError = error;
|
||
},
|
||
failSendWith(error) {
|
||
sendError = error;
|
||
},
|
||
probeWith(report) {
|
||
probeReport = report;
|
||
},
|
||
async probe(accountArg) {
|
||
return (
|
||
probeReport ?? {
|
||
kind,
|
||
address: accountArg.address,
|
||
receive: { ok: true, latencyMs: 1 },
|
||
send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true },
|
||
}
|
||
);
|
||
},
|
||
async anchor(_accountArg, _backfillFrom, now) {
|
||
return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000));
|
||
},
|
||
async pull(_accountArg, cursor, opts) {
|
||
pullCalls.push({ cursor, limit: opts.limit });
|
||
if (pullError !== undefined) throw pullError;
|
||
const batch = pending.splice(0, opts.limit).map((m) => {
|
||
if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m;
|
||
uidSeq += 1;
|
||
return { ...m, cursorAfter: imapCursor('v1', uidSeq) };
|
||
});
|
||
const last = batch.at(-1)?.cursorAfter;
|
||
const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000));
|
||
return { messages: batch, cursor: next, more: pending.length > 0 };
|
||
},
|
||
async send(_accountArg, message) {
|
||
if (sendError !== undefined) throw sendError;
|
||
sent.push(message);
|
||
return { providerRef: `fake:${sent.length}` };
|
||
},
|
||
};
|
||
}
|
||
|
||
export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage {
|
||
return { providerRef, eml, receivedAt };
|
||
}
|
||
|
||
/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */
|
||
export function eml(parts: {
|
||
from?: string;
|
||
to?: string;
|
||
cc?: string;
|
||
subject?: string;
|
||
text?: string;
|
||
html?: string;
|
||
messageId?: string;
|
||
inReplyTo?: string;
|
||
references?: string;
|
||
date?: string;
|
||
attachment?: { name: string; contentType: string; content: string };
|
||
}): Uint8Array {
|
||
const headers = [
|
||
`From: ${parts.from ?? 'buyer@acme.com'}`,
|
||
`To: ${parts.to ?? 'sales@we.com'}`,
|
||
...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]),
|
||
`Subject: ${parts.subject ?? '询价'}`,
|
||
...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]),
|
||
...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]),
|
||
...(parts.references === undefined ? [] : [`References: ${parts.references}`]),
|
||
`Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`,
|
||
'MIME-Version: 1.0',
|
||
];
|
||
const text = parts.text ?? '你好,请报价';
|
||
if (parts.html === undefined && parts.attachment === undefined) {
|
||
return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF));
|
||
}
|
||
const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text];
|
||
if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html);
|
||
if (parts.attachment !== undefined) {
|
||
lines.push(
|
||
'--B1',
|
||
`Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`,
|
||
`Content-Disposition: attachment; filename="${parts.attachment.name}"`,
|
||
'Content-Transfer-Encoding: base64',
|
||
'',
|
||
Buffer.from(parts.attachment.content, 'utf8').toString('base64'),
|
||
);
|
||
}
|
||
lines.push('--B1--', '');
|
||
return new TextEncoder().encode(lines.join(CRLF));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试(真解析器跑夹具)**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts
|
||
/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
|
||
import { eml } from './fakes.ts';
|
||
|
||
const parser = createPostalMimeParser();
|
||
const CRLF = '\r\n';
|
||
|
||
describe('createPostalMimeParser', () => {
|
||
it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => {
|
||
const parsed = await parser.parse(
|
||
eml({
|
||
from: '"张三" <buyer@acme.com>',
|
||
to: 'sales@we.com, "Ops" <ops@we.com>',
|
||
subject: '询价 RFQ-1',
|
||
messageId: '<m1@acme.com>',
|
||
inReplyTo: '<q1@we.com>',
|
||
references: '<root@we.com> <q1@we.com>',
|
||
text: '你好,请报价',
|
||
}),
|
||
);
|
||
expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
|
||
expect(parsed.to).toEqual([
|
||
{ address: 'sales@we.com', name: '' },
|
||
{ address: 'ops@we.com', name: 'Ops' },
|
||
]);
|
||
expect(parsed.subject).toBe('询价 RFQ-1');
|
||
expect(parsed.messageId).toBe('<m1@acme.com>');
|
||
expect(parsed.inReplyTo).toBe('<q1@we.com>');
|
||
expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
|
||
expect(parsed.text?.trim()).toBe('你好,请报价');
|
||
expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z');
|
||
});
|
||
|
||
it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => {
|
||
const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价
|
||
const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好
|
||
const raw = [
|
||
'From: buyer@acme.com',
|
||
'To: sales@we.com',
|
||
`Subject: =?GBK?B?${subject}?=`,
|
||
'Content-Type: text/plain; charset=gbk',
|
||
'Content-Transfer-Encoding: base64',
|
||
'',
|
||
body,
|
||
'',
|
||
].join(CRLF);
|
||
const parsed = await parser.parse(new TextEncoder().encode(raw));
|
||
expect(parsed.subject).toBe('询价');
|
||
expect(parsed.text?.trim()).toBe('你好');
|
||
});
|
||
|
||
it('群组地址摊平;没有 Cc 就是空数组', async () => {
|
||
const raw = ['From: a@x.com', 'To: team: b@x.com, "C" <c@x.com>;', 'Subject: s', '', 'body', ''].join(CRLF);
|
||
const parsed = await parser.parse(new TextEncoder().encode(raw));
|
||
expect(parsed.to).toEqual([
|
||
{ address: 'b@x.com', name: '' },
|
||
{ address: 'c@x.com', name: 'C' },
|
||
]);
|
||
expect(parsed.cc).toEqual([]);
|
||
});
|
||
|
||
it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => {
|
||
const raw = ['Subject: x', 'Content-Type: text/html', '', '<b>only html</b>', ''].join(CRLF);
|
||
const parsed = await parser.parse(new TextEncoder().encode(raw));
|
||
expect(parsed.text).toBeUndefined();
|
||
expect(parsed.html).toContain('only html');
|
||
expect(parsed.messageId).toBeUndefined();
|
||
expect(parsed.from).toBeUndefined();
|
||
expect(parsed.date).toBeUndefined();
|
||
});
|
||
|
||
it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => {
|
||
const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } }));
|
||
expect(parsed.attachments).toHaveLength(1);
|
||
expect(parsed.attachments[0]?.name).toBe('bom.xlsx');
|
||
expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel');
|
||
expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array);
|
||
expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts`
|
||
Expected: FAIL(解析器不存在)。
|
||
|
||
- [ ] **Step 4: 写解析器**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts
|
||
/**
|
||
* MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。
|
||
* 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。
|
||
*/
|
||
import PostalMime from 'postal-mime';
|
||
import type { Address } from 'postal-mime';
|
||
import type { MailAddress } from '@contracts';
|
||
import type { MimeParser, ParsedMail } from '../../port.ts';
|
||
|
||
function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] {
|
||
const out: MailAddress[] = [];
|
||
for (const entry of list ?? []) {
|
||
if (entry.group !== undefined) {
|
||
for (const member of entry.group) out.push({ address: member.address, name: member.name });
|
||
} else if (entry.address !== undefined) {
|
||
out.push({ address: entry.address, name: entry.name });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** References / In-Reply-To 是空白分隔的一串 <id>;拆开、去空 */
|
||
function idsOf(value: string | undefined): string[] {
|
||
if (value === undefined) return [];
|
||
return value
|
||
.split(/\s+/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s !== '');
|
||
}
|
||
|
||
function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array {
|
||
if (typeof content === 'string') return new TextEncoder().encode(content);
|
||
return content instanceof Uint8Array ? content : new Uint8Array(content);
|
||
}
|
||
|
||
export function createPostalMimeParser(): MimeParser {
|
||
return {
|
||
async parse(eml): Promise<ParsedMail> {
|
||
const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' });
|
||
const from =
|
||
email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined;
|
||
const date = email.date === undefined ? undefined : new Date(email.date);
|
||
const inReplyTo = idsOf(email.inReplyTo)[0];
|
||
return {
|
||
...(email.messageId === undefined ? {} : { messageId: email.messageId }),
|
||
...(inReplyTo === undefined ? {} : { inReplyTo }),
|
||
references: idsOf(email.references),
|
||
...(from === undefined ? {} : { from }),
|
||
to: mailboxesOf(email.to),
|
||
cc: mailboxesOf(email.cc),
|
||
subject: email.subject ?? '',
|
||
...(email.text === undefined ? {} : { text: email.text }),
|
||
...(email.html === undefined ? {} : { html: email.html }),
|
||
...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }),
|
||
attachments: email.attachments.map((a) => ({
|
||
name: a.filename ?? '',
|
||
contentType: a.mimeType,
|
||
bytes: bytesOf(a.content),
|
||
})),
|
||
};
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: 5 tests PASS;门禁退出码 0。若 `Address` 类型的 `group` 判别在本版 d.ts 里报错,以 `node_modules/postal-mime/postal-mime.d.ts` 的 `Address` 定义为准改判别(本计划核对的是 3.0.0:`Mailbox = {name, address, group?: undefined}`,群组形 `{name, address?: undefined, group: Mailbox[]}`)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: usecase `createMailService` —— 账号 CRUD / 探测 / 视图
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/usecase/mail-service.ts`(整份;收 / 发的用例也在这份里,Task 10 / 11 只加测试)
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加 `createMailService` 与 `MailServiceDeps`)
|
||
- Test: `apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`createMailService(deps: MailServiceDeps): MailService`;`MailServiceDeps = { accounts, inbound, outbound, attachments, providers: Record<MailAccountKind, MailProvider>, mime, secrets, clock, ids, notify, limits? }`。
|
||
- `probe` 的第二形是 `{ input, accountId? }`:改已存账号的表单上「测试」不必重打密码 —— 没给 secret 且给了 accountId 就用库里那份密文。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts
|
||
/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { mailAccountInputSchema } from '@contracts';
|
||
import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts';
|
||
import { createMailService } from '../internal/usecase/mail-service.ts';
|
||
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
|
||
import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
|
||
|
||
function harness() {
|
||
const clock = fixedClock();
|
||
const accounts = memoryAccounts(clock);
|
||
const imap = fakeProvider('imap-smtp');
|
||
const wecom = fakeProvider('wecom');
|
||
const changes: string[] = [];
|
||
const service = createMailService({
|
||
accounts,
|
||
inbound: memoryInbound(),
|
||
outbound: memoryOutbound(),
|
||
attachments: memoryAttachments(),
|
||
providers: { 'imap-smtp': imap, wecom },
|
||
mime: createPostalMimeParser(),
|
||
secrets: plainSecrets,
|
||
clock,
|
||
ids: seqIds('acc'),
|
||
notify: { changed: (r) => void changes.push(r) },
|
||
});
|
||
return { service, accounts, imap, wecom, clock, changes };
|
||
}
|
||
const imapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } });
|
||
|
||
describe('账号', () => {
|
||
it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => {
|
||
const { service, accounts } = harness();
|
||
const view = await service.createAccount(imapInput);
|
||
expect(view.id).toBe('acc-1');
|
||
expect(view.secretBound).toBe(true);
|
||
expect(JSON.stringify(view)).not.toContain('pw-1234');
|
||
expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`);
|
||
expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']);
|
||
});
|
||
|
||
it('名字唯一:重名 409;改名撞别人也 409', async () => {
|
||
const { service } = harness();
|
||
await service.createAccount(imapInput);
|
||
await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError);
|
||
const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' });
|
||
await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError);
|
||
});
|
||
|
||
it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => {
|
||
const { service, accounts } = harness();
|
||
const created = await service.createAccount(imapInput);
|
||
await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' });
|
||
expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234');
|
||
await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } });
|
||
expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9');
|
||
const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } });
|
||
const switched = await service.updateAccount(created.id, wecomInput);
|
||
expect(switched.secretBound).toBe(false);
|
||
expect(switched.address).toBe('');
|
||
});
|
||
|
||
it('默认账号只有一个;删了就没了(404)', async () => {
|
||
const { service } = harness();
|
||
const a = await service.createAccount(imapInput);
|
||
const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true });
|
||
expect((await service.getAccount(a.id)).isDefault).toBe(false);
|
||
expect((await service.getAccount(b.id)).isDefault).toBe(true);
|
||
await service.deleteAccount(a.id);
|
||
await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
|
||
await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
|
||
});
|
||
|
||
it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => {
|
||
const { service, wecom, imap } = harness();
|
||
imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } });
|
||
const a = await service.createAccount(imapInput);
|
||
const report = await service.probe({ accountId: a.id });
|
||
expect(report.receive.error).toBe('LOGIN sales@we.com *** failed');
|
||
const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } }));
|
||
wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } });
|
||
await service.probe({ accountId: w.id });
|
||
expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work');
|
||
});
|
||
|
||
it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => {
|
||
const { service, accounts } = harness();
|
||
const report = await service.probe({ input: imapInput });
|
||
expect(report.receive.ok).toBe(true);
|
||
expect(accounts.rows.size).toBe(0);
|
||
await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError);
|
||
const a = await service.createAccount(imapInput);
|
||
const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id });
|
||
expect(borrowed.receive.ok).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts`
|
||
Expected: FAIL(`mail-service.ts` 不存在)。
|
||
|
||
- [ ] **Step 3: 写用例(整份)**
|
||
|
||
先把 `contract.ts` 里 `probe` 的签名改成:
|
||
|
||
```ts
|
||
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/usecase/mail-service.ts
|
||
/**
|
||
* mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。
|
||
*
|
||
* 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重)
|
||
* → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。
|
||
* 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。
|
||
*/
|
||
import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts';
|
||
import type { InboundHandler, MailService } from '../../contract.ts';
|
||
import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts';
|
||
import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts';
|
||
import { leaseMsFor } from '../domain/due.ts';
|
||
import {
|
||
MailAccountDisabledError,
|
||
MailAccountNameTakenError,
|
||
MailAccountNotFoundError,
|
||
MailAttachmentNotFoundError,
|
||
MailInboundNotFoundError,
|
||
MailPollBusyError,
|
||
MailSecretMissingError,
|
||
MailSendInvalidError,
|
||
MailSubscriberTakenError,
|
||
MailSubscriberUnknownError,
|
||
} from '../domain/errors.ts';
|
||
import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts';
|
||
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts';
|
||
import { errorText, redactSecrets } from '../domain/redact.ts';
|
||
import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts';
|
||
import type {
|
||
AttachmentStore,
|
||
ChangeNotifier,
|
||
Clock,
|
||
IdGenerator,
|
||
InboundStore,
|
||
MailAccountStore,
|
||
MailProvider,
|
||
MimeParser,
|
||
OutboundStore,
|
||
ProviderAccount,
|
||
RawMessage,
|
||
SecretBox,
|
||
} from '../../port.ts';
|
||
|
||
export type MailServiceDeps = {
|
||
readonly accounts: MailAccountStore;
|
||
readonly inbound: InboundStore;
|
||
readonly outbound: OutboundStore;
|
||
readonly attachments: AttachmentStore;
|
||
readonly providers: Readonly<Record<MailAccountKind, MailProvider>>;
|
||
readonly mime: MimeParser;
|
||
readonly secrets: SecretBox;
|
||
readonly clock: Clock;
|
||
readonly ids: IdGenerator;
|
||
readonly notify: ChangeNotifier;
|
||
readonly limits?:
|
||
| {
|
||
readonly perTick?: number | undefined;
|
||
readonly tickBudgetMs?: number | undefined;
|
||
readonly attachmentBytes?: number | undefined;
|
||
readonly rawBytes?: number | undefined;
|
||
}
|
||
| undefined;
|
||
};
|
||
|
||
const DEFAULT_LIST_LIMIT = 50;
|
||
const MAX_LIST_LIMIT = 200;
|
||
|
||
type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
|
||
|
||
function tallyToOutcome(t: PollTally): MailPollOutcome {
|
||
return {
|
||
accountId: t.accountId,
|
||
pulled: t.pulled,
|
||
stored: t.stored,
|
||
dispatched: t.dispatched,
|
||
more: t.more,
|
||
...(t.error === undefined ? {} : { error: t.error }),
|
||
};
|
||
}
|
||
|
||
function safeFileName(messageId: string): string {
|
||
const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
||
return cleaned === '' ? 'mail' : cleaned;
|
||
}
|
||
|
||
function resultOf(record: OutboundRecord): MailSendResult {
|
||
return {
|
||
outboundId: record.id,
|
||
accountId: record.accountId,
|
||
messageId: record.messageId,
|
||
state: record.state === 'sent' ? 'sent' : 'failed',
|
||
...(record.error === null ? {} : { error: record.error }),
|
||
...(record.providerRef === null ? {} : { providerRef: record.providerRef }),
|
||
};
|
||
}
|
||
|
||
export function createMailService(deps: MailServiceDeps): MailService {
|
||
const limits = {
|
||
perTick: deps.limits?.perTick ?? 50,
|
||
tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000,
|
||
attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP,
|
||
rawBytes: deps.limits?.rawBytes ?? RAW_CAP,
|
||
};
|
||
const subscribers = new Map<string, InboundHandler>();
|
||
|
||
async function requireAccount(accountId: string): Promise<MailAccountRecord> {
|
||
const record = await deps.accounts.get(accountId);
|
||
if (record === undefined) throw new MailAccountNotFoundError(accountId);
|
||
return record;
|
||
}
|
||
|
||
/** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
|
||
function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
|
||
if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id);
|
||
const parsed: unknown = JSON.parse(deps.secrets.open(record.secretCipher));
|
||
if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id);
|
||
return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]));
|
||
}
|
||
|
||
function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
|
||
return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
|
||
}
|
||
|
||
function redactWith(secret: Record<string, string>): (text: string) => string {
|
||
const values = Object.values(secret);
|
||
return (text) => redactSecrets(text, values);
|
||
}
|
||
|
||
function redactReport(report: MailProbeReport, secret: Record<string, string>): MailProbeReport {
|
||
const redact = redactWith(secret);
|
||
return {
|
||
...report,
|
||
receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) },
|
||
send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) },
|
||
};
|
||
}
|
||
|
||
function limitOf(query: { limit?: number | undefined }): number {
|
||
return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT));
|
||
}
|
||
|
||
/* ── 分发 ────────────────────────────────────────────────────────── */
|
||
|
||
async function dispatch(record: InboundRecord, ctx: { accountId: string; channelId: string | null }, only?: string): Promise<DeliveryRecord[]> {
|
||
const existing = (await deps.inbound.get(record.id))?.deliveries ?? [];
|
||
const out: DeliveryRecord[] = [];
|
||
for (const [name, handler] of subscribers) {
|
||
if (only !== undefined && name !== only) continue;
|
||
const current = existing.find((d) => d.subscriber === name);
|
||
// 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事
|
||
if (!canRedeliver(current?.state)) {
|
||
if (current !== undefined) out.push(current);
|
||
continue;
|
||
}
|
||
const startedAt = deps.clock.now();
|
||
const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null };
|
||
await deps.inbound.setDelivery(dispatching);
|
||
let settled: DeliveryRecord;
|
||
try {
|
||
const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined });
|
||
const state = stateOfOutcome(outcome);
|
||
settled = {
|
||
...dispatching,
|
||
state,
|
||
// skipped 的理由也放 error 列:人在列表上看得见「为什么没投」
|
||
error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null,
|
||
finishedAt: deps.clock.now(),
|
||
};
|
||
} catch (error) {
|
||
settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() };
|
||
}
|
||
await deps.inbound.setDelivery(settled);
|
||
out.push(settled);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/* ── 拉取 ────────────────────────────────────────────────────────── */
|
||
|
||
async function storeOne(record: MailAccountRecord, message: RawMessage): Promise<InboundRecord | undefined> {
|
||
// 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍
|
||
const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]);
|
||
if (known.has(message.providerRef)) return undefined;
|
||
const parsed = await deps.mime.parse(message.eml);
|
||
const attachments: InboundAttachmentRecord[] = [];
|
||
for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) {
|
||
let ref: string | null = null;
|
||
if (plan.store) {
|
||
const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array();
|
||
ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref;
|
||
}
|
||
attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note });
|
||
}
|
||
const built = buildInboundRecord({
|
||
id: deps.ids.next(),
|
||
accountId: record.id,
|
||
address: record.address,
|
||
providerRef: message.providerRef,
|
||
parsed,
|
||
eml: message.eml,
|
||
receivedAt: message.receivedAt,
|
||
attachments,
|
||
now: deps.clock.now(),
|
||
rawCap: limits.rawBytes,
|
||
});
|
||
const { inserted } = await deps.inbound.insert(built);
|
||
return inserted ? built : undefined;
|
||
}
|
||
|
||
async function pollAccount(record: MailAccountRecord): Promise<MailPollOutcome> {
|
||
const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec));
|
||
if (!claimed) throw new MailPollBusyError(record.id);
|
||
const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false };
|
||
let secret: Record<string, string> = {};
|
||
try {
|
||
secret = openSecret(record);
|
||
const provider = deps.providers[record.kind];
|
||
const account = providerAccount(record, secret);
|
||
const started = deps.clock.now();
|
||
let cursor = record.cursor;
|
||
if (cursor === null) {
|
||
cursor = await provider.anchor(account, record.backfillFrom, started);
|
||
await deps.accounts.savePoll(record.id, { cursor });
|
||
}
|
||
let budget = limits.perTick;
|
||
let more = true;
|
||
while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) {
|
||
const batch = await provider.pull(account, cursor, {
|
||
limit: budget,
|
||
now: deps.clock.now(),
|
||
backfillFrom: record.backfillFrom,
|
||
known: (refs) => deps.inbound.knownProviderRefs(record.id, refs),
|
||
});
|
||
tally.pulled += batch.messages.length;
|
||
budget -= batch.messages.length;
|
||
for (const message of batch.messages) {
|
||
const stored = await storeOne(record, message);
|
||
// 逐封推进:中途栽了,下一轮从这一封之后接着
|
||
if (message.cursorAfter !== undefined) {
|
||
cursor = message.cursorAfter;
|
||
await deps.accounts.savePoll(record.id, { cursor });
|
||
}
|
||
if (stored !== undefined) {
|
||
tally.stored += 1;
|
||
await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt });
|
||
await dispatch(stored, { accountId: record.id, channelId: record.channelId });
|
||
tally.dispatched += 1;
|
||
}
|
||
}
|
||
cursor = batch.cursor;
|
||
await deps.accounts.savePoll(record.id, { cursor });
|
||
more = batch.more;
|
||
if (batch.messages.length === 0) break;
|
||
}
|
||
tally.more = more;
|
||
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: null });
|
||
} catch (error) {
|
||
const text = redactWith(secret)(errorText(error));
|
||
tally.error = text;
|
||
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text });
|
||
} finally {
|
||
await deps.accounts.releasePolling(record.id);
|
||
}
|
||
if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound');
|
||
return tallyToOutcome(tally);
|
||
}
|
||
|
||
/* ── 服务 ────────────────────────────────────────────────────────── */
|
||
|
||
return {
|
||
async listAccounts() {
|
||
const now = deps.clock.now();
|
||
return (await deps.accounts.list()).map((r) => viewOf(r, now));
|
||
},
|
||
|
||
async getAccount(accountId) {
|
||
return viewOf(await requireAccount(accountId), deps.clock.now());
|
||
},
|
||
|
||
async createAccount(input) {
|
||
if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name);
|
||
const secretJson = secretOf(input);
|
||
const now = deps.clock.now();
|
||
const record = mergeAccount(input, {
|
||
id: deps.ids.next(),
|
||
existing: undefined,
|
||
secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson),
|
||
now,
|
||
});
|
||
await deps.accounts.save(record);
|
||
return viewOf(record, now);
|
||
},
|
||
|
||
async updateAccount(accountId, input) {
|
||
const existing = await requireAccount(accountId);
|
||
const clash = await deps.accounts.findByName(input.name);
|
||
if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name);
|
||
const secretJson = secretOf(input);
|
||
// 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废
|
||
const secretCipher =
|
||
secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null;
|
||
const now = deps.clock.now();
|
||
// kind / settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset)
|
||
const record = mergeAccount(input, { id: accountId, existing, secretCipher, now });
|
||
await deps.accounts.save(record);
|
||
return viewOf(record, now);
|
||
},
|
||
|
||
async deleteAccount(accountId) {
|
||
await requireAccount(accountId);
|
||
await deps.accounts.remove(accountId);
|
||
},
|
||
|
||
async probe(target) {
|
||
if ('input' in target) {
|
||
const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId);
|
||
const secretJson = secretOf(target.input);
|
||
const secret =
|
||
secretJson !== undefined
|
||
? (JSON.parse(secretJson) as Record<string, string>)
|
||
: stored !== undefined && stored.kind === target.input.kind
|
||
? openSecret(stored)
|
||
: (() => {
|
||
throw new MailSecretMissingError(target.accountId ?? 'draft');
|
||
})();
|
||
const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() });
|
||
const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret));
|
||
return redactReport(report, secret);
|
||
}
|
||
const record = await requireAccount(target.accountId);
|
||
const secret = openSecret(record);
|
||
const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret);
|
||
// wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改
|
||
if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) {
|
||
await deps.accounts.savePoll(record.id, { address: report.address });
|
||
}
|
||
return report;
|
||
},
|
||
|
||
async pollDue(now) {
|
||
const due = await deps.accounts.listDue(now);
|
||
// 账号间并行,单账号内串行;一个账号的异常只记在它自己头上
|
||
const settled = await Promise.allSettled(due.map((record) => pollAccount(record)));
|
||
return settled.map((r, i) =>
|
||
r.status === 'fulfilled'
|
||
? r.value
|
||
: { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) },
|
||
);
|
||
},
|
||
|
||
async pollNow(accountId) {
|
||
const record = await requireAccount(accountId);
|
||
if (!record.enabled) throw new MailAccountDisabledError(accountId);
|
||
return pollAccount(record);
|
||
},
|
||
|
||
subscribe(name, handler) {
|
||
if (subscribers.has(name)) throw new MailSubscriberTakenError(name);
|
||
subscribers.set(name, handler);
|
||
return () => {
|
||
subscribers.delete(name);
|
||
};
|
||
},
|
||
|
||
async listInbound(query) {
|
||
const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) });
|
||
return rows.map((r) => summaryOf(r.record, r.deliveries));
|
||
},
|
||
|
||
async getInbound(mailId) {
|
||
const hit = await deps.inbound.get(mailId);
|
||
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
|
||
return detailOf(hit.record, hit.deliveries);
|
||
},
|
||
|
||
async attachment(mailId, index) {
|
||
const hit = await deps.inbound.get(mailId);
|
||
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
|
||
const meta = hit.record.attachments.find((a) => a.index === index);
|
||
if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index);
|
||
const file = await deps.attachments.get(meta.ref);
|
||
if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index);
|
||
return { name: meta.name, contentType: meta.contentType, bytes: file.bytes };
|
||
},
|
||
|
||
async raw(mailId) {
|
||
const hit = await deps.inbound.get(mailId);
|
||
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
|
||
if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1);
|
||
return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw };
|
||
},
|
||
|
||
async redeliver(mailId, subscriber) {
|
||
const hit = await deps.inbound.get(mailId);
|
||
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
|
||
if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber);
|
||
const owner = await deps.accounts.get(hit.record.accountId);
|
||
const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber);
|
||
deps.notify.changed('inbound');
|
||
return deliveries.map(deliveryViewOf);
|
||
},
|
||
|
||
async send(input) {
|
||
const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from });
|
||
// 幂等键:放行重放不发两封;上一次失败 / 中断的那一行复用,重发
|
||
const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey);
|
||
if (prior !== undefined && prior.state === 'sent') return resultOf(prior);
|
||
|
||
const files: ParsedAttachment[] = [];
|
||
for (const a of input.attachments) {
|
||
if (a.dataBase64 !== undefined) {
|
||
files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) });
|
||
} else if (a.uploadRef !== undefined) {
|
||
const got = await deps.attachments.get(a.uploadRef);
|
||
if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]);
|
||
files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes });
|
||
} else {
|
||
throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]);
|
||
}
|
||
}
|
||
const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength));
|
||
if (problems.length > 0) throw new MailSendInvalidError(problems);
|
||
const secret = openSecret(account);
|
||
|
||
const now = deps.clock.now();
|
||
const id = prior?.id ?? deps.ids.next();
|
||
const messageId = prior?.messageId ?? messageIdFor(id, account.address);
|
||
if (prior === undefined) {
|
||
const record: OutboundRecord = {
|
||
id,
|
||
accountId: account.id,
|
||
messageId,
|
||
idempotencyKey: input.idempotencyKey ?? null,
|
||
to: [...input.to],
|
||
cc: [...input.cc],
|
||
bcc: [...input.bcc],
|
||
subject: input.subject,
|
||
text: input.text ?? null,
|
||
html: input.html ?? null,
|
||
inReplyTo: input.inReplyTo ?? null,
|
||
references: [...input.references],
|
||
attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })),
|
||
state: 'sending',
|
||
error: null,
|
||
providerRef: null,
|
||
requestedBy: { ...input.requestedBy },
|
||
createdAt: now,
|
||
sentAt: null,
|
||
};
|
||
await deps.outbound.insert(record);
|
||
}
|
||
|
||
try {
|
||
const sent = await deps.providers[account.kind].send(providerAccount(account, secret), {
|
||
messageId,
|
||
from: { address: account.address, name: account.displayName },
|
||
to: input.to,
|
||
cc: input.cc,
|
||
bcc: input.bcc,
|
||
subject: input.subject,
|
||
...(input.text === undefined ? {} : { text: input.text }),
|
||
...(input.html === undefined ? {} : { html: input.html }),
|
||
...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }),
|
||
references: input.references,
|
||
attachments: files,
|
||
});
|
||
await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) });
|
||
deps.notify.changed('outbound');
|
||
return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) };
|
||
} catch (error) {
|
||
const text = redactWith(secret)(errorText(error));
|
||
await deps.outbound.setState(id, 'failed', { error: text });
|
||
deps.notify.changed('outbound');
|
||
return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text };
|
||
}
|
||
},
|
||
|
||
async listOutbound(query) {
|
||
const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) });
|
||
return rows.map((r) => ({
|
||
id: r.id,
|
||
accountId: r.accountId,
|
||
messageId: r.messageId,
|
||
to: [...r.to],
|
||
cc: [...r.cc],
|
||
bcc: [...r.bcc],
|
||
subject: r.subject,
|
||
state: r.state,
|
||
error: r.error,
|
||
providerRef: r.providerRef,
|
||
requestedBy: r.requestedBy,
|
||
createdAt: r.createdAt.toISOString(),
|
||
sentAt: r.sentAt === null ? null : r.sentAt.toISOString(),
|
||
}));
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
|
||
export { createMailService } from './internal/usecase/mail-service.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 6 tests PASS;门禁退出码 0。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: usecase 测试 —— 拉取 / 去重 / 分发 / 重新投递
|
||
|
||
**Files:**
|
||
- Test: `apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts`
|
||
- Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes:Task 9 的 `createMailService` 与 Task 8 的夹具。
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts
|
||
/**
|
||
* 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、
|
||
* 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { MailInboundDetail } from '@contracts';
|
||
import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts';
|
||
import { createMailService } from '../internal/usecase/mail-service.ts';
|
||
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
|
||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||
import {
|
||
account,
|
||
eml,
|
||
fakeProvider,
|
||
fixedClock,
|
||
memoryAccounts,
|
||
memoryAttachments,
|
||
memoryInbound,
|
||
memoryOutbound,
|
||
plainSecrets,
|
||
rawMessage,
|
||
seqIds,
|
||
} from './fakes.ts';
|
||
|
||
function harness(over: { perTick?: number } = {}) {
|
||
const clock = fixedClock();
|
||
const accounts = memoryAccounts(clock);
|
||
const inbound = memoryInbound();
|
||
const attachments = memoryAttachments();
|
||
const imap = fakeProvider('imap-smtp');
|
||
const wecom = fakeProvider('wecom');
|
||
const changes: string[] = [];
|
||
const service = createMailService({
|
||
accounts,
|
||
inbound,
|
||
outbound: memoryOutbound(),
|
||
attachments,
|
||
providers: { 'imap-smtp': imap, wecom },
|
||
mime: createPostalMimeParser(),
|
||
secrets: plainSecrets,
|
||
clock,
|
||
ids: seqIds('in'),
|
||
notify: { changed: (r) => void changes.push(r) },
|
||
limits: { perTick: over.perTick ?? 50 },
|
||
});
|
||
return { service, accounts, inbound, attachments, imap, wecom, clock, changes };
|
||
}
|
||
|
||
describe('拉取', () => {
|
||
it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.imap.queue(
|
||
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })),
|
||
rawMessage('uid:v1:2', eml({ messageId: '<m2@acme.com>', subject: '二' })),
|
||
);
|
||
const outcome = await h.service.pollNow('acc-1');
|
||
expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false });
|
||
const saved = h.accounts.rows.get('acc-1');
|
||
expect(saved?.cursor).toEqual(imapCursor('v1', 2));
|
||
expect(saved?.lastPolledAt).toEqual(h.clock.now());
|
||
expect(saved?.lastInboundAt).not.toBeNull();
|
||
expect(saved?.lastPollError).toBeNull();
|
||
expect(saved?.pollingUntil).toBeNull();
|
||
const list = await h.service.listInbound({});
|
||
// 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序
|
||
expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']);
|
||
const one = list.find((m) => m.subject === '一');
|
||
expect(one?.attachments[0]?.ref).toBe('up-1');
|
||
expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一');
|
||
expect(h.changes).toContain('inbound');
|
||
});
|
||
|
||
it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
const hits: string[] = [];
|
||
h.service.subscribe('spy', async (m) => void hits.push(m.messageId));
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } })));
|
||
await h.service.pollNow('acc-1');
|
||
h.imap.queue(
|
||
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>' })),
|
||
rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
|
||
);
|
||
const again = await h.service.pollNow('acc-1');
|
||
expect(again.pulled).toBe(2);
|
||
expect(again.stored).toBe(0);
|
||
expect(hits).toEqual(['<m1@acme.com>']);
|
||
expect(h.attachments.files.size).toBe(1);
|
||
});
|
||
|
||
it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => {
|
||
const h = harness({ perTick: 2 });
|
||
await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } }));
|
||
h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' })));
|
||
const first = await h.service.pollNow('w');
|
||
expect(first).toMatchObject({ pulled: 2, stored: 2, more: true });
|
||
expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom');
|
||
const second = await h.service.pollNow('w');
|
||
expect(second).toMatchObject({ pulled: 1, stored: 1, more: false });
|
||
});
|
||
|
||
it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account({ cursor: imapCursor('v1', 5) }));
|
||
h.imap.failPullWith(new Error('LOGIN pw-1234 rejected'));
|
||
const outcome = await h.service.pollNow('acc-1');
|
||
expect(outcome.error).toBe('LOGIN *** rejected');
|
||
const saved = h.accounts.rows.get('acc-1');
|
||
expect(saved?.lastPollError).toBe('LOGIN *** rejected');
|
||
expect(saved?.cursor).toEqual(imapCursor('v1', 5));
|
||
expect(saved?.pollingUntil).toBeNull();
|
||
h.imap.failPullWith(undefined);
|
||
expect((await h.service.pollNow('acc-1')).error).toBeUndefined();
|
||
expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull();
|
||
});
|
||
|
||
it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account({ secretCipher: null }));
|
||
const outcome = await h.service.pollNow('acc-1');
|
||
expect(outcome.error).toContain('未配置凭证');
|
||
expect(h.imap.pullCalls).toHaveLength(0);
|
||
await h.accounts.save(account({ enabled: false }));
|
||
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError);
|
||
await h.accounts.save(account({ pollingUntil: new Date(h.clock.now().getTime() + 60_000) }));
|
||
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError);
|
||
});
|
||
|
||
it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' }));
|
||
await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null }));
|
||
await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false }));
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<x@a>' })));
|
||
const outcomes = await h.service.pollDue(h.clock.now());
|
||
expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']);
|
||
expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1);
|
||
expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证');
|
||
});
|
||
});
|
||
|
||
describe('分发', () => {
|
||
it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account({ channelId: 'ch-9' }));
|
||
const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = [];
|
||
h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId }));
|
||
h.service.subscribe('skip', async () => ({ skipped: '与我无关' }));
|
||
const off = h.service.subscribe('boom', async () => {
|
||
throw new Error('通道实例已停用:ch-9');
|
||
});
|
||
expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError);
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', subject: '主题' })));
|
||
await h.service.pollNow('acc-1');
|
||
expect(seen[0]?.mail.subject).toBe('主题');
|
||
expect(seen[0]?.channelId).toBe('ch-9');
|
||
const detail = await h.service.getInbound('in-1');
|
||
expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([
|
||
['boom', 'failed', '通道实例已停用:ch-9'],
|
||
['ok', 'done', null],
|
||
['skip', 'skipped', '与我无关'],
|
||
]);
|
||
off();
|
||
h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
|
||
await h.service.pollNow('acc-1');
|
||
expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']);
|
||
});
|
||
|
||
it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
let fail = true;
|
||
h.service.subscribe('flaky', async () => {
|
||
if (fail) throw new Error('第一次不行');
|
||
});
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
|
||
await h.service.pollNow('acc-1');
|
||
expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed');
|
||
fail = false;
|
||
const redone = await h.service.redeliver('in-1', 'flaky');
|
||
expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]);
|
||
await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError);
|
||
await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null });
|
||
expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching');
|
||
});
|
||
});
|
||
|
||
describe('附件与原件', () => {
|
||
it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } })));
|
||
await h.service.pollNow('acc-1');
|
||
const file = await h.service.attachment('in-1', 0);
|
||
expect(new TextDecoder().decode(file.bytes)).toBe('hello');
|
||
expect(file.name).toBe('a.txt');
|
||
await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来');
|
||
const raw = await h.service.raw('in-1');
|
||
expect(raw.name).toBe('m1_a.eml');
|
||
expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts`
|
||
Expected: 9 tests PASS。红了就按测试名对回 Task 9 的用例修(常见:`listInbound` 排序断言 —— 两封 `receivedAt` 相同时按插入顺序,把断言里的顺序改成与夹具一致;`raw.name` 的 `safeFileName` 把 `<m1@a>` 收成 `m1_a`)。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail 用例测试:拉取 / 去重 / 逐封游标 / 失败停位 / 分发三态 / 重新投递 / 附件原件\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: usecase 测试 —— 发信
|
||
|
||
**Files:**
|
||
- Test: `apps/api/src/modules/mail/__tests__/mail-service-send.test.ts`
|
||
- Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/mail-service-send.test.ts
|
||
/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts';
|
||
import { createMailService } from '../internal/usecase/mail-service.ts';
|
||
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
|
||
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
|
||
|
||
function harness() {
|
||
const clock = fixedClock();
|
||
const accounts = memoryAccounts(clock);
|
||
const outbound = memoryOutbound();
|
||
const attachments = memoryAttachments();
|
||
const imap = fakeProvider('imap-smtp');
|
||
const wecom = fakeProvider('wecom');
|
||
const service = createMailService({
|
||
accounts,
|
||
inbound: memoryInbound(),
|
||
outbound,
|
||
attachments,
|
||
providers: { 'imap-smtp': imap, wecom },
|
||
mime: createPostalMimeParser(),
|
||
secrets: plainSecrets,
|
||
clock,
|
||
ids: seqIds('out'),
|
||
notify: { changed: () => undefined },
|
||
});
|
||
return { service, accounts, outbound, attachments, imap, clock };
|
||
}
|
||
const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } };
|
||
|
||
describe('send', () => {
|
||
it('默认账号发出:留痕 sent、Message-ID 是 <id@域>、发件人带显示名、返回 providerRef', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
const result = await h.service.send(base);
|
||
expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', providerRef: 'fake:1' });
|
||
expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' });
|
||
expect(h.imap.sent[0]?.messageId).toBe('<out-1@we.com>');
|
||
const row = h.outbound.rows.get('out-1');
|
||
expect(row?.state).toBe('sent');
|
||
expect(row?.sentAt).toEqual(h.clock.now());
|
||
expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] });
|
||
});
|
||
|
||
it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' }));
|
||
const result = await h.service.send(base);
|
||
expect(result.state).toBe('failed');
|
||
expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected');
|
||
expect(h.outbound.rows.get('out-1')?.state).toBe('failed');
|
||
});
|
||
|
||
it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.imap.failSendWith(new Error('暂时不行'));
|
||
const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' });
|
||
expect(failed.state).toBe('failed');
|
||
h.imap.failSendWith(undefined);
|
||
const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' });
|
||
expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
|
||
const again = await h.service.send({ ...base, idempotencyKey: 'k-1' });
|
||
expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
|
||
expect(h.imap.sent).toHaveLength(1);
|
||
expect(h.outbound.rows.size).toBe(1);
|
||
});
|
||
|
||
it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => {
|
||
const h = harness();
|
||
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError);
|
||
await h.accounts.save(account());
|
||
await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError);
|
||
await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError);
|
||
await h.accounts.save(account({ secretCipher: null }));
|
||
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError);
|
||
expect(h.outbound.rows.size).toBe(0);
|
||
});
|
||
|
||
it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' });
|
||
await h.service.send({
|
||
...base,
|
||
attachments: [
|
||
{ name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref },
|
||
{ name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') },
|
||
],
|
||
});
|
||
expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([
|
||
['q.pdf', 'application/pdf', 2],
|
||
['n.txt', 'text/plain', 2],
|
||
]);
|
||
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
|
||
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
|
||
});
|
||
|
||
it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account({ isDefault: false }));
|
||
const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
|
||
expect(result.accountId).toBe('acc-1');
|
||
expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
|
||
expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint`
|
||
Expected: 全部 PASS;门禁退出码 0。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail 用例测试:发信留痕 / 厂商失败不抛 / 幂等键 / 附件来源 / from 对账号\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: adapter:IMAP/SMTP provider(imapflow + nodemailer)
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts`
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加导出)
|
||
- Test: `apps/api/src/modules/mail/__tests__/imap-helpers.test.ts`(纯映射)· `apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts`(env 门控的真信箱)
|
||
|
||
**Interfaces:**
|
||
- Produces:`createImapSmtpProvider(): MailProvider`;导出的纯助手 `imapSettingsOf(account)` `internalDateOf(value, fallback)` `mailOptionsOf(message)` `orderFetched(rows)`。
|
||
- 真实签名(本机核过 imapflow 1.7.6 / @types/nodemailer 8.0.1):`new ImapFlow({host, port, secure, auth:{user, pass}, logger: false})`;`getMailboxLock(path, {readOnly: true})` → `{release()}`;`client.mailbox` → `{uidValidity: bigint, uidNext: number}`;`search({uid: 'n:*'} | {since: Date}, {uid: true})` → `number[] | false`;`fetch(number[], {uid, source, internalDate}, {uid: true})` → 异步迭代 `{uid, source?: Buffer, internalDate?: Date | string}`;`logout()` / `close()`。nodemailer:`createTransport({host, port, secure, requireTLS, auth:{user, pass}, connectionTimeout, greetingTimeout, socketTimeout})`,`verify(): Promise<true>`,`sendMail({...}) → {messageId, response}`,`close()`。
|
||
|
||
- [ ] **Step 1: 写失败测试(纯映射)**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/imap-helpers.test.ts
|
||
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
|
||
import { imapCursor } from '../internal/domain/cursor.ts';
|
||
|
||
const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } };
|
||
|
||
describe('imapSettingsOf', () => {
|
||
it('空 settings 取腾讯默认;登录名缺省 = 地址', () => {
|
||
const s = imapSettingsOf(account);
|
||
expect(s.settings.imapHost).toBe('imap.exmail.qq.com');
|
||
expect(s.settings.smtpPort).toBe(465);
|
||
expect(s.login).toBe('sales@we.com');
|
||
expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com');
|
||
});
|
||
it('缺密码当场说清', () => {
|
||
expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码');
|
||
});
|
||
});
|
||
|
||
describe('internalDateOf', () => {
|
||
const fallback = new Date('2026-08-25T10:00:00Z');
|
||
it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => {
|
||
const d = new Date('2026-08-25T09:00:00Z');
|
||
expect(internalDateOf(d, fallback)).toBe(d);
|
||
expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z');
|
||
expect(internalDateOf('garbage', fallback)).toBe(fallback);
|
||
expect(internalDateOf(undefined, fallback)).toBe(fallback);
|
||
});
|
||
});
|
||
|
||
describe('orderFetched', () => {
|
||
it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => {
|
||
const rows = orderFetched('7', [
|
||
{ uid: 12, source: Buffer.from('b'), internalDate: undefined },
|
||
{ uid: 10, source: Buffer.from('a'), internalDate: undefined },
|
||
{ uid: 11, source: undefined, internalDate: undefined },
|
||
], new Date('2026-08-25T10:00:00Z'));
|
||
expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']);
|
||
expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12));
|
||
expect(rows[0]?.eml).toBeInstanceOf(Uint8Array);
|
||
});
|
||
});
|
||
|
||
describe('mailOptionsOf', () => {
|
||
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
|
||
const options = mailOptionsOf({
|
||
messageId: '<o1@we.com>',
|
||
from: { address: 'sales@we.com', name: '销售部' },
|
||
to: ['b@acme.com'],
|
||
cc: [],
|
||
bcc: ['c@we.com'],
|
||
subject: '报价',
|
||
text: '正文',
|
||
inReplyTo: '<m1@acme.com>',
|
||
references: ['<m1@acme.com>'],
|
||
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
|
||
});
|
||
expect(options.messageId).toBe('<o1@we.com>');
|
||
expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
|
||
expect(options.bcc).toEqual(['c@we.com']);
|
||
expect(options.inReplyTo).toBe('<m1@acme.com>');
|
||
expect(options.references).toEqual(['<m1@acme.com>']);
|
||
expect('html' in options).toBe(false);
|
||
expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' });
|
||
expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts`
|
||
Expected: FAIL(适配器不存在)。
|
||
|
||
- [ ] **Step 3: 写适配器**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts
|
||
/**
|
||
* 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。
|
||
*
|
||
* 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址;
|
||
* 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。
|
||
* 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。
|
||
* IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。
|
||
*/
|
||
import { ImapFlow } from 'imapflow';
|
||
import nodemailer from 'nodemailer';
|
||
import type Mail from 'nodemailer/lib/mailer';
|
||
import { imapSmtpSettingsSchema } from '@contracts';
|
||
import type { ImapSmtpSettings, MailProbeReport } from '@contracts';
|
||
import { imapCursor, newUids } from '../domain/cursor.ts';
|
||
import type { MailCursor } from '../domain/cursor.ts';
|
||
import { errorText } from '../domain/redact.ts';
|
||
import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts';
|
||
|
||
const CONNECT_TIMEOUT_MS = 30_000;
|
||
const SOCKET_TIMEOUT_MS = 60_000;
|
||
|
||
export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } {
|
||
const settings = imapSmtpSettingsSchema.parse(account.settings);
|
||
const password = account.secret['password'];
|
||
if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)');
|
||
return { settings, login: settings.login ?? account.address, password };
|
||
}
|
||
|
||
export function internalDateOf(value: Date | string | undefined, fallback: Date): Date {
|
||
if (value instanceof Date) return value;
|
||
if (typeof value === 'string') {
|
||
const parsed = new Date(value);
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */
|
||
export function orderFetched(
|
||
uidValidity: string,
|
||
rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>,
|
||
now: Date,
|
||
): RawMessage[] {
|
||
return rows
|
||
.filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined)
|
||
.sort((a, b) => a.uid - b.uid)
|
||
.map((r) => ({
|
||
providerRef: `${uidValidity}:${r.uid}`,
|
||
eml: Uint8Array.from(r.source),
|
||
receivedAt: internalDateOf(r.internalDate, now),
|
||
cursorAfter: imapCursor(uidValidity, r.uid),
|
||
}));
|
||
}
|
||
|
||
export function mailOptionsOf(message: OutboundMessage): Mail.Options {
|
||
return {
|
||
messageId: message.messageId,
|
||
from: { name: message.from.name, address: message.from.address },
|
||
to: [...message.to],
|
||
cc: [...message.cc],
|
||
bcc: [...message.bcc],
|
||
subject: message.subject,
|
||
...(message.text === undefined ? {} : { text: message.text }),
|
||
...(message.html === undefined ? {} : { html: message.html }),
|
||
...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }),
|
||
references: [...message.references],
|
||
attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })),
|
||
};
|
||
}
|
||
|
||
function transporterOf(account: ProviderAccount) {
|
||
const { settings, login, password } = imapSettingsOf(account);
|
||
return nodemailer.createTransport({
|
||
host: settings.smtpHost,
|
||
port: settings.smtpPort,
|
||
secure: settings.smtpTls,
|
||
requireTLS: !settings.smtpTls,
|
||
auth: { user: login, pass: password },
|
||
connectionTimeout: CONNECT_TIMEOUT_MS,
|
||
greetingTimeout: CONNECT_TIMEOUT_MS,
|
||
socketTimeout: SOCKET_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
async function withImap<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
|
||
const { settings, login, password } = imapSettingsOf(account);
|
||
const client = new ImapFlow({
|
||
host: settings.imapHost,
|
||
port: settings.imapPort,
|
||
secure: settings.imapTls,
|
||
auth: { user: login, pass: password },
|
||
logger: false,
|
||
connectionTimeout: CONNECT_TIMEOUT_MS,
|
||
socketTimeout: SOCKET_TIMEOUT_MS,
|
||
});
|
||
await client.connect();
|
||
try {
|
||
return await fn(client, settings.folder);
|
||
} finally {
|
||
await client.logout().catch(() => client.close());
|
||
}
|
||
}
|
||
|
||
function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } {
|
||
const box = client.mailbox;
|
||
if (box === false) throw new Error('文件夹未打开');
|
||
return { uidValidity: String(box.uidValidity), uidNext: box.uidNext };
|
||
}
|
||
|
||
async function timed(run: () => Promise<void>): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> {
|
||
const started = Date.now();
|
||
try {
|
||
await run();
|
||
return { ok: true, latencyMs: Date.now() - started };
|
||
} catch (error) {
|
||
return { ok: false, error: errorText(error) };
|
||
}
|
||
}
|
||
|
||
export function createImapSmtpProvider(): MailProvider {
|
||
return {
|
||
kind: 'imap-smtp',
|
||
|
||
async probe(account): Promise<MailProbeReport> {
|
||
const receive = await timed(() =>
|
||
withImap(account, async (client, folder) => {
|
||
const lock = await client.getMailboxLock(folder, { readOnly: true });
|
||
lock.release();
|
||
}),
|
||
);
|
||
const send = await timed(async () => {
|
||
const transporter = transporterOf(account);
|
||
try {
|
||
await transporter.verify();
|
||
} finally {
|
||
transporter.close();
|
||
}
|
||
});
|
||
return {
|
||
kind: 'imap-smtp',
|
||
address: account.address,
|
||
receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error },
|
||
send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error },
|
||
};
|
||
},
|
||
|
||
async anchor(account, backfillFrom): Promise<MailCursor> {
|
||
return withImap(account, async (client, folder) => {
|
||
const lock = await client.getMailboxLock(folder, { readOnly: true });
|
||
try {
|
||
const box = mailboxOf(client);
|
||
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
|
||
const uids = await client.search({ since: backfillFrom }, { uid: true });
|
||
const first = uids === false || uids.length === 0 ? box.uidNext : Math.min(...uids);
|
||
return imapCursor(box.uidValidity, first - 1);
|
||
} finally {
|
||
lock.release();
|
||
}
|
||
});
|
||
},
|
||
|
||
async pull(account, cursor, opts) {
|
||
return withImap(account, async (client, folder) => {
|
||
const lock = await client.getMailboxLock(folder, { readOnly: true });
|
||
try {
|
||
const box = mailboxOf(client);
|
||
if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) {
|
||
// UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡
|
||
return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false };
|
||
}
|
||
const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true });
|
||
const fresh = newUids(found === false ? [] : found, cursor.lastUid);
|
||
const wanted = fresh.slice(0, opts.limit);
|
||
const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = [];
|
||
if (wanted.length > 0) {
|
||
for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) {
|
||
rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate });
|
||
}
|
||
}
|
||
const messages = orderFetched(box.uidValidity, rows, opts.now);
|
||
return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length };
|
||
} finally {
|
||
lock.release();
|
||
}
|
||
});
|
||
},
|
||
|
||
async send(account, message) {
|
||
const transporter = transporterOf(account);
|
||
try {
|
||
const info = await transporter.sendMail(mailOptionsOf(message));
|
||
return { providerRef: info.response };
|
||
} finally {
|
||
transporter.close();
|
||
}
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 写 env 门控的真信箱集成测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts
|
||
/**
|
||
* 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。
|
||
* JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com)
|
||
* JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事)
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts';
|
||
|
||
const user = process.env['JCAI_MAIL_IT_IMAP_USER'];
|
||
const pass = process.env['JCAI_MAIL_IT_IMAP_PASS'];
|
||
const enabled = user !== undefined && pass !== undefined;
|
||
if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n');
|
||
|
||
const account = {
|
||
id: 'it',
|
||
kind: 'imap-smtp' as const,
|
||
address: user ?? '',
|
||
displayName: 'JCAI IT',
|
||
settings: {
|
||
imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com',
|
||
smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com',
|
||
},
|
||
secret: { password: pass ?? '' },
|
||
};
|
||
const known = async (): Promise<Set<string>> => new Set<string>();
|
||
|
||
describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => {
|
||
const provider = createImapSmtpProvider();
|
||
|
||
it('探测:收发都 ok', async () => {
|
||
const report = await provider.probe(account);
|
||
expect(report.receive).toMatchObject({ ok: true });
|
||
expect(report.send).toMatchObject({ checked: true, ok: true });
|
||
}, 60_000);
|
||
|
||
it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => {
|
||
const now = new Date();
|
||
const cursor = await provider.anchor(account, null, now);
|
||
expect(cursor.kind).toBe('imap');
|
||
const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known });
|
||
expect(empty.messages).toEqual([]);
|
||
const since = new Date(now.getTime() - 7 * 86_400_000);
|
||
const back = await provider.anchor(account, since, now);
|
||
const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known });
|
||
expect(some.messages.length).toBeLessThanOrEqual(3);
|
||
for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0);
|
||
}, 120_000);
|
||
|
||
it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => {
|
||
const result = await provider.send(account, {
|
||
messageId: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
|
||
from: { address: account.address, name: 'JCAI IT' },
|
||
to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''],
|
||
cc: [],
|
||
bcc: [],
|
||
subject: 'JCAI mail IT',
|
||
text: '这是一封集成测试信',
|
||
references: [],
|
||
attachments: [],
|
||
});
|
||
expect(result.providerRef ?? '').toContain('250');
|
||
}, 60_000);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 助手 6 tests PASS;IT 套件跳过并打印告警(没给真信箱);typecheck 退出码 0 —— 若 `ImapFlowOptions` 对 `connectionTimeout` / `socketTimeout` 报错,以 `node_modules/imapflow/lib/imap-flow.d.ts` 为准删掉不认识的键;`import type Mail from 'nodemailer/lib/mailer'` 报错则改成 `import type { Options as MailOptions } from 'nodemailer/lib/mailer';`。depcruise:imapflow / nodemailer 只在 adapter,过。
|
||
有真信箱时:`JCAI_MAIL_IT_IMAP_USER=xxx@corp.com JCAI_MAIL_IT_IMAP_PASS=客户端专用密码 pnpm vitest run apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts` → 2 PASS(第 3 条给 `JCAI_MAIL_IT_SEND_TO` 才跑)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: adapter:企业微信邮件 API provider
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/modules/mail/internal/adapter/wecom-provider.ts`
|
||
- Modify: `apps/api/src/modules/mail/index.ts`(追加导出)
|
||
- Test: `apps/api/src/modules/mail/__tests__/wecom-provider.test.ts`(对假 fetch)
|
||
|
||
**Interfaces:**
|
||
- Produces:`createWecomProvider(deps?: { fetch?: FetchLike; now?: () => number }): MailProvider`;`FetchLike`;`WecomApiError(errcode, errmsg, api)`。
|
||
- 官方接口(核实 2026-08-25):`GET {apiBase}/cgi-bin/gettoken?corpid&corpsecret` → `{access_token, expires_in}`;`POST /cgi-bin/exmail/app/get_email_alias` → `{email, alias_list}`;`POST /cgi-bin/exmail/app/get_mail_list` body `{begin_time, end_time, cursor?, limit≤1000}` → `{next_cursor, has_more(0|1), mail_list:[{mail_id}]}`;`POST /cgi-bin/exmail/app/read_mail` body `{mail_id}` → `{mail_data}`(EML;编码文档未说);`POST /cgi-bin/exmail/app/compose_send` body `{to:{emails}, cc?, bcc?, subject, content, content_type:'html'|'text', attachment_list?:[{file_name, content(base64)}]}` → `{errcode, errmsg}`;错误码 `-1` 系统繁忙、`40014` token 非法、`42001` token 过期、`45009` 频率超限、`48002` 无权限、`60020` IP 不在白名单。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/__tests__/wecom-provider.test.ts
|
||
/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts';
|
||
import type { FetchLike } from '../internal/adapter/wecom-provider.ts';
|
||
import { wecomCursor } from '../internal/domain/cursor.ts';
|
||
|
||
type Call = { url: string; body: unknown };
|
||
|
||
function scripted(handlers: Record<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
|
||
const calls: Call[] = [];
|
||
const counts = new Map<string, number>();
|
||
const fetch: FetchLike = async (url, init) => {
|
||
const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
|
||
calls.push({ url, body });
|
||
const key = Object.keys(handlers).find((k) => url.includes(k));
|
||
if (key === undefined) return { ok: false, status: 404, json: async () => ({}) };
|
||
const nth = (counts.get(key) ?? 0) + 1;
|
||
counts.set(key, nth);
|
||
return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) };
|
||
};
|
||
return { fetch, calls };
|
||
}
|
||
|
||
const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } };
|
||
const known = async (): Promise<Set<string>> => new Set<string>();
|
||
const now = new Date('2026-08-25T10:00:00Z');
|
||
const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n';
|
||
|
||
describe('token', () => {
|
||
it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => {
|
||
let clock = 0;
|
||
let issued = 0;
|
||
const { fetch, calls } = scripted({
|
||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }),
|
||
'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }),
|
||
});
|
||
const provider = createWecomProvider({ fetch, now: () => clock });
|
||
await provider.probe(account);
|
||
await provider.probe(account);
|
||
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次
|
||
clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟
|
||
await provider.probe(account);
|
||
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3);
|
||
expect(calls[0]?.url).toContain('corpid=ww1');
|
||
expect(calls[0]?.url).toContain('corpsecret=cs-1');
|
||
});
|
||
|
||
it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => {
|
||
let busy = 2;
|
||
const { fetch } = scripted({
|
||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
|
||
'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }),
|
||
});
|
||
const report = await createWecomProvider({ fetch }).probe(account);
|
||
expect(report.receive.ok).toBe(false);
|
||
expect(report.receive.error).toContain('get_email_alias');
|
||
expect(report.receive.error).toContain('48002');
|
||
expect(report.send).toMatchObject({ checked: false });
|
||
await expect(
|
||
createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }),
|
||
).rejects.toBeInstanceOf(WecomApiError);
|
||
});
|
||
});
|
||
|
||
describe('probe / anchor', () => {
|
||
it('探到应用邮箱地址;发信一栏 checked=false', async () => {
|
||
const { fetch } = scripted({
|
||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
|
||
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }),
|
||
});
|
||
const report = await createWecomProvider({ fetch }).probe(account);
|
||
expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } });
|
||
});
|
||
it('锚定 = 当下(或回填起点)的秒', async () => {
|
||
const provider = createWecomProvider({ fetch: scripted({}).fetch });
|
||
expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
|
||
expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000)));
|
||
});
|
||
});
|
||
|
||
describe('pull', () => {
|
||
it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => {
|
||
const { fetch, calls } = scripted({
|
||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
|
||
'get_mail_list': (body) =>
|
||
body['cursor'] === undefined
|
||
? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }
|
||
: { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] },
|
||
'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }),
|
||
});
|
||
const provider = createWecomProvider({ fetch });
|
||
const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000);
|
||
const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async (refs) => new Set(refs.filter((r) => r === '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) });
|
||
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 cursor = wecomCursor(1);
|
||
const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known });
|
||
expect(result.messages).toHaveLength(2);
|
||
expect(result.more).toBe(true);
|
||
// **截断时游标原样退回**:没读到的那几封不能被跳过去(重叠窗兜不住一次积压)
|
||
expect(result.cursor).toEqual(cursor);
|
||
});
|
||
});
|
||
|
||
describe('send', () => {
|
||
it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => {
|
||
const { fetch, calls } = scripted({
|
||
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
|
||
'compose_send': () => ({ errcode: 0, errmsg: 'ok' }),
|
||
});
|
||
const result = await createWecomProvider({ fetch }).send(account, {
|
||
messageId: '<x@y>',
|
||
from: { address: 'app@corp.wecom.work', name: '应用' },
|
||
to: ['a@x.com'],
|
||
cc: ['b@x.com'],
|
||
bcc: [],
|
||
subject: '报价',
|
||
text: '纯文本',
|
||
html: '<p>html</p>',
|
||
references: [],
|
||
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
|
||
});
|
||
expect(result).toEqual({});
|
||
const payload = calls.find((c) => c.url.includes('compose_send'))?.body;
|
||
expect(payload).toEqual({
|
||
to: { emails: ['a@x.com'] },
|
||
cc: { emails: ['b@x.com'] },
|
||
subject: '报价',
|
||
content: '<p>html</p>',
|
||
content_type: 'html',
|
||
attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }],
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/wecom-provider.test.ts`
|
||
Expected: FAIL(适配器不存在)。
|
||
|
||
- [ ] **Step 3: 写适配器**
|
||
|
||
```ts
|
||
// apps/api/src/modules/mail/internal/adapter/wecom-provider.ts
|
||
/**
|
||
* 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25):
|
||
* gettoken(corpid, corpsecret) → access_token(7200s)
|
||
* exmail/app/get_email_alias → 应用邮箱地址
|
||
* exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱)
|
||
* exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64)
|
||
* exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立)
|
||
* token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。
|
||
*/
|
||
import { wecomSettingsSchema } from '@contracts';
|
||
import type { MailProbeReport } from '@contracts';
|
||
import { wecomCursor, wecomWindow } from '../domain/cursor.ts';
|
||
import type { MailCursor } from '../domain/cursor.ts';
|
||
import { errorText } from '../domain/redact.ts';
|
||
import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts';
|
||
|
||
export type FetchLike = (
|
||
url: string,
|
||
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
|
||
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
|
||
|
||
type WecomResponse = Record<string, unknown> & { errcode?: number; errmsg?: string };
|
||
|
||
export class WecomApiError extends Error {
|
||
constructor(
|
||
readonly errcode: number,
|
||
readonly errmsg: string,
|
||
readonly api: string,
|
||
) {
|
||
super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`);
|
||
this.name = 'WecomApiError';
|
||
}
|
||
}
|
||
|
||
const TOKEN_EARLY_MS = 5 * 60_000;
|
||
const BUSY_RETRIES = 3;
|
||
const REQUEST_TIMEOUT_MS = 30_000;
|
||
const LIST_PAGE = 100;
|
||
|
||
/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */
|
||
export function emlBytesOf(mailData: string): Uint8Array {
|
||
const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048));
|
||
return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64'));
|
||
}
|
||
|
||
export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider {
|
||
const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
|
||
const nowMs = deps.now ?? (() => Date.now());
|
||
const tokens = new Map<string, { token: string; expiresAt: number }>();
|
||
|
||
async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
|
||
for (let attempt = 0; ; attempt += 1) {
|
||
const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||
if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`);
|
||
const body = (await res.json()) as WecomResponse;
|
||
const code = typeof body.errcode === 'number' ? body.errcode : 0;
|
||
if (code === 0) return body;
|
||
if (code === -1 && attempt < BUSY_RETRIES) continue;
|
||
throw new WecomApiError(code, String(body.errmsg ?? ''), api);
|
||
}
|
||
}
|
||
|
||
async function tokenOf(account: ProviderAccount, force = false): Promise<string> {
|
||
const cached = tokens.get(account.id);
|
||
if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token;
|
||
const settings = wecomSettingsSchema.parse(account.settings);
|
||
const secret = account.secret['corpSecret'];
|
||
if (secret === undefined || secret === '') throw new Error('缺应用 secret');
|
||
const body = await call(
|
||
'gettoken',
|
||
`${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`,
|
||
{ method: 'GET' },
|
||
);
|
||
const token = typeof body['access_token'] === 'string' ? body['access_token'] : '';
|
||
if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token');
|
||
const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200;
|
||
tokens.set(account.id, { token, expiresAt: nowMs() + expiresIn * 1000 });
|
||
return token;
|
||
}
|
||
|
||
async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise<WecomResponse> {
|
||
const settings = wecomSettingsSchema.parse(account.settings);
|
||
const run = (token: string): Promise<WecomResponse> =>
|
||
call(
|
||
api,
|
||
`${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`,
|
||
payload === undefined
|
||
? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }
|
||
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) },
|
||
);
|
||
try {
|
||
return await run(await tokenOf(account));
|
||
} catch (error) {
|
||
if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) {
|
||
return run(await tokenOf(account, true));
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
return {
|
||
kind: 'wecom',
|
||
|
||
async probe(account): Promise<MailProbeReport> {
|
||
const started = nowMs();
|
||
try {
|
||
const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias');
|
||
const address = typeof alias['email'] === 'string' ? alias['email'] : '';
|
||
return {
|
||
kind: 'wecom',
|
||
address,
|
||
receive: { ok: true, latencyMs: nowMs() - started },
|
||
send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' },
|
||
};
|
||
} catch (error) {
|
||
return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } };
|
||
}
|
||
},
|
||
|
||
async anchor(_account, backfillFrom, now): Promise<MailCursor> {
|
||
return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000));
|
||
},
|
||
|
||
async pull(account, cursor, opts) {
|
||
const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now);
|
||
/*
|
||
* **先滤库里已有的,再数够不够** —— 顺序反了会卡死:一个窗口里若前 limit 条恰好都已入库,
|
||
* 这一轮就交出 0 封,而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。
|
||
* 滤在前,每一轮都实实在在往前吃 limit 封**新**的。
|
||
*/
|
||
const fresh: string[] = [];
|
||
let pageCursor: string | undefined;
|
||
let exhausted = false;
|
||
while (fresh.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: LIST_PAGE,
|
||
});
|
||
const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : [];
|
||
const ids = list.flatMap((item) => (typeof item.mail_id === 'string' ? [item.mail_id] : []));
|
||
const known = await opts.known(ids);
|
||
for (const id of ids) if (!known.has(id)) fresh.push(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 wanted = fresh.slice(0, opts.limit);
|
||
const messages: RawMessage[] = [];
|
||
for (const mailId of wanted) {
|
||
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 });
|
||
}
|
||
/*
|
||
* 游标**只在把这个窗口翻完时**才推到窗口上沿;被 limit 截断时原样退回旧游标 ——
|
||
* 否则没读到的那几封就被跳过去了(重叠窗只兜 10 分钟,兜不住一次积压)。
|
||
* 退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。
|
||
*/
|
||
return { messages, cursor: exhausted ? wecomCursor(endTime) : cursor, more: !exhausted || fresh.length > wanted.length };
|
||
},
|
||
|
||
async send(account, message) {
|
||
await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', {
|
||
to: { emails: [...message.to] },
|
||
...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }),
|
||
...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }),
|
||
subject: message.subject,
|
||
content: message.html ?? message.text ?? '',
|
||
content_type: message.html !== undefined ? 'html' : 'text',
|
||
...(message.attachments.length === 0
|
||
? {}
|
||
: { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }),
|
||
});
|
||
return {};
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`index.ts` 追加:
|
||
|
||
```ts
|
||
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
|
||
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 全部 PASS(wecom 7 tests);门禁退出码 0。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/modules/mail
|
||
git commit -m "$(printf 'mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: app 装配 —— 上传件存取抽出、mail 接线(channel 订阅方 / 传输件 / 工具处理器)、轮询工作者、config / main
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/app/upload-store.ts` · `apps/api/src/app/mail-wiring.ts` · `apps/api/src/app/workers/mail-poller.ts`
|
||
- Modify: `apps/api/src/app/container.ts`(装配 mail;`mail.send` 处理器闭包;channel 传输件与订阅方;删模块级 `firstPartyToolHandlers`)· `apps/api/src/app/config.ts`(`mailPollTickMs`)· `apps/api/src/app/main.ts`(起停轮询器)· `apps/api/src/app/routes/governance-ops.ts`(上传两段改调 `upload-store.ts`)· `apps/api/src/app/tools/first-party.ts`(删 `mailSend`)
|
||
- Test: `apps/api/src/app/__tests__/mail-wiring.test.ts` · `apps/api/src/app/__tests__/mail-poller.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:`storeUpload(db, {name, contentType, bytes, uploadedBy, note?})` → `StoredUpload`;`readUpload(db, ref)`;`toInboundMail(detail)` → `InboundMail`;`channelSubscriber(channel)` → `InboundHandler`;`channelTransport(mail)` → `MailTransport`;`mailSendTool(mail)` → `ToolHandler`;`pollMailOnce(deps)`;`startMailPoller(deps, tickMs)`;`Container.mail: MailService`;`AppConfig.mailPollTickMs`。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/app/__tests__/mail-wiring.test.ts
|
||
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
|
||
import type { InboundMail } from '@contracts';
|
||
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
|
||
|
||
const detail: MailInboundDetail = {
|
||
id: 'in-1',
|
||
accountId: 'acc-1',
|
||
messageId: '<m1@acme.com>',
|
||
from: { address: 'buyer@acme.com', name: '张三' },
|
||
to: [{ address: 'sales@we.com', name: '' }],
|
||
cc: [{ address: 'ops@we.com', name: 'Ops' }],
|
||
subject: '询价 RFQ-1',
|
||
sentAt: '2026-08-25T09:00:00.000Z',
|
||
receivedAt: '2026-08-25T09:30:00.000Z',
|
||
textPreview: '你好',
|
||
hasHtml: true,
|
||
attachments: [
|
||
{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' },
|
||
{ index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' },
|
||
],
|
||
deliveries: [],
|
||
inReplyTo: null,
|
||
references: ['<root@we.com>', '<q1@we.com>'],
|
||
text: null,
|
||
html: '<p>你好</p>',
|
||
size: 100,
|
||
rawAvailable: true,
|
||
};
|
||
|
||
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
|
||
const calls: MailSendInput[] = [];
|
||
return {
|
||
calls,
|
||
send: async (input) => {
|
||
calls.push(input);
|
||
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('toInboundMail', () => {
|
||
it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => {
|
||
const mail: InboundMail = toInboundMail(detail);
|
||
expect(mail.threadId).toBe('<q1@we.com>');
|
||
expect(mail.from).toBe('buyer@acme.com');
|
||
expect(mail.to).toEqual(['sales@we.com']);
|
||
expect(mail.cc).toEqual(['ops@we.com']);
|
||
expect(mail.body).toBe('你好');
|
||
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
|
||
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
|
||
});
|
||
});
|
||
|
||
describe('channelSubscriber', () => {
|
||
it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => {
|
||
const calls: Array<[string, InboundMail]> = [];
|
||
const handler = channelSubscriber({
|
||
ingestMail: async (id, mail) => {
|
||
calls.push([id, mail]);
|
||
return undefined;
|
||
},
|
||
});
|
||
expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' });
|
||
expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined();
|
||
expect(calls[0]?.[0]).toBe('ch-1');
|
||
expect(calls[0]?.[1].messageId).toBe('<m1@acme.com>');
|
||
});
|
||
});
|
||
|
||
describe('channelTransport', () => {
|
||
it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => {
|
||
const mail = fakeSend();
|
||
const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
|
||
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
|
||
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } });
|
||
const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] });
|
||
expect(fresh.threadId).toBe('<out-1@we.com>');
|
||
});
|
||
it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => {
|
||
await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed');
|
||
});
|
||
});
|
||
|
||
describe('mailSendTool', () => {
|
||
const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' };
|
||
it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => {
|
||
const mail = fakeSend();
|
||
const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
|
||
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
|
||
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } });
|
||
await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||
expect(mail.calls[1]?.accountId).toBeUndefined();
|
||
});
|
||
it('dryRun 只校验不发', async () => {
|
||
const mail = fakeSend();
|
||
expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } });
|
||
expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false);
|
||
expect(mail.calls).toHaveLength(0);
|
||
});
|
||
it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => {
|
||
const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||
expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } });
|
||
const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail');
|
||
const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
|
||
expect(noAccount.error?.code).toBe('no-account');
|
||
const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } });
|
||
expect(invalid.error?.code).toBe('invalid');
|
||
});
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/app/__tests__/mail-poller.test.ts
|
||
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { MailPollOutcome } from '@contracts';
|
||
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
|
||
|
||
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
|
||
const calls: Date[] = [];
|
||
return {
|
||
calls,
|
||
mail: {
|
||
pollDue: async (now: Date) => {
|
||
calls.push(now);
|
||
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
|
||
return outcomes;
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('pollMailOnce', () => {
|
||
it('回用例的结果;出错的账号写一行 stderr', async () => {
|
||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
|
||
const out = await pollMailOnce({ container: { mail: mail as never } });
|
||
expect(out).toHaveLength(2);
|
||
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
|
||
write.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe('startMailPoller', () => {
|
||
beforeEach(() => vi.useFakeTimers());
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it('按 tick 调;在跑时跳过;stop 后停', async () => {
|
||
const { mail, calls } = fakeMail([], 1000);
|
||
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
|
||
await vi.advanceTimersByTimeAsync(350);
|
||
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
|
||
await vi.advanceTimersByTimeAsync(1000);
|
||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||
poller.stop();
|
||
const before = calls.length;
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
expect(calls).toHaveLength(before);
|
||
});
|
||
});
|
||
```
|
||
|
||
注:两处 `mail as never` 只是把测试桩缩到 `pollDue` 一个方法 —— `Container['mail']` 是整个 `MailService`,桩不必长成它;`as never` 不在 §12.5 的禁止清单里(禁的是 `as unknown as`)。
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts`
|
||
Expected: FAIL(文件不存在)。
|
||
|
||
- [ ] **Step 3: 写 upload-store / mail-wiring / mail-poller**
|
||
|
||
```ts
|
||
// apps/api/src/app/upload-store.ts
|
||
/**
|
||
* sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。
|
||
* 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。
|
||
*/
|
||
import { sql } from 'drizzle-orm';
|
||
import type { Db } from '@shared/db.ts';
|
||
import { extractText } from './upload-extract.ts';
|
||
|
||
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
||
export const UPLOAD_CAP = 8 * 1024 * 1024;
|
||
|
||
function rowsOf<T>(result: unknown): T[] {
|
||
const bag = result as { rows?: T[] } | T[];
|
||
return Array.isArray(bag) ? bag : (bag.rows ?? []);
|
||
}
|
||
|
||
export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string };
|
||
|
||
/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */
|
||
export async function storeUpload(
|
||
db: Db,
|
||
file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined },
|
||
): Promise<StoredUpload> {
|
||
const got = extractText(file.name, file.contentType, file.bytes);
|
||
const ref = crypto.randomUUID();
|
||
const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`;
|
||
await db.execute(sql`
|
||
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
|
||
values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)},
|
||
${got.text ?? null}, ${note}, ${file.uploadedBy})`);
|
||
return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note };
|
||
}
|
||
|
||
export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> {
|
||
const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>(
|
||
sql`select name, content_type, bytes from sys.upload where id = ${ref}`,
|
||
);
|
||
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
|
||
if (hit === undefined) return undefined;
|
||
// pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科)
|
||
return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) };
|
||
}
|
||
```
|
||
|
||
`apps/api/src/app/routes/governance-ops.ts` 里上传两段改为(其余不动;`UPLOAD_CAP` 常量与 `extractText` import 删掉,改 import `storeUpload, readUpload, UPLOAD_CAP` from `'../upload-store.ts'`):
|
||
|
||
```ts
|
||
app.post(uploadFileRoute.path, async (c) => {
|
||
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
|
||
if (!parsed.success) {
|
||
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
||
return c.json(body, 400);
|
||
}
|
||
const { name, contentType, dataBase64 } = parsed.data;
|
||
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
|
||
if (bytes.length === 0) {
|
||
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
|
||
return c.json(body, 400);
|
||
}
|
||
if (bytes.length > UPLOAD_CAP) {
|
||
const body: ApiError = {
|
||
code: 'validation-failed',
|
||
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
|
||
};
|
||
return c.json(body, 400);
|
||
}
|
||
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
||
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
|
||
});
|
||
|
||
app.get(downloadFileRoute.path, async (c) => {
|
||
const hit = await readUpload(container.db, c.req.param('id'));
|
||
if (hit === undefined) {
|
||
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
|
||
return c.json(body, 404);
|
||
}
|
||
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/app/mail-wiring.ts
|
||
/**
|
||
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
|
||
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
|
||
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
|
||
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
|
||
* 三段都是纯映射 + 一次调用,单测直接对函数。
|
||
*/
|
||
import type { InboundMail, MailSendInput } from '@contracts';
|
||
import type { MailTransport } from '@modules/channel';
|
||
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
|
||
import {
|
||
bodyTextOf,
|
||
MailAccountDisabledError,
|
||
MailAccountNotFoundError,
|
||
MailNoDefaultAccountError,
|
||
MailSecretMissingError,
|
||
MailSendInvalidError,
|
||
} from '@modules/mail';
|
||
import type { ToolHandler } from '@modules/toolbox';
|
||
|
||
/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */
|
||
export function toInboundMail(mail: MailInboundDetail): InboundMail {
|
||
const threadId = mail.inReplyTo ?? mail.references.at(-1);
|
||
return {
|
||
messageId: mail.messageId,
|
||
...(threadId === undefined ? {} : { threadId }),
|
||
from: mail.from.address,
|
||
to: mail.to.map((a) => a.address),
|
||
cc: mail.cc.map((a) => a.address),
|
||
subject: mail.subject,
|
||
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
|
||
receivedAt: mail.receivedAt,
|
||
attachments: mail.attachments.flatMap((a) =>
|
||
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
|
||
),
|
||
};
|
||
}
|
||
|
||
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
|
||
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
|
||
return async (mail, ctx) => {
|
||
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
|
||
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
|
||
};
|
||
}
|
||
|
||
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
|
||
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
|
||
return {
|
||
async send(input) {
|
||
const result = await mail.send({
|
||
from: input.from,
|
||
to: [...input.to],
|
||
cc: [...input.cc],
|
||
bcc: [],
|
||
subject: input.subject,
|
||
text: input.body,
|
||
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
|
||
references: input.threadId === undefined ? [] : [input.threadId],
|
||
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
|
||
requestedBy: { kind: 'channel' },
|
||
});
|
||
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
|
||
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
|
||
},
|
||
};
|
||
}
|
||
|
||
function recipientsOf(value: unknown): string[] {
|
||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
|
||
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
|
||
return [];
|
||
}
|
||
|
||
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.flatMap((v) => {
|
||
if (typeof v !== 'object' || v === null) return [];
|
||
const a = v as Record<string, unknown>;
|
||
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
|
||
if (uploadRef === undefined) return [];
|
||
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
|
||
return [{ name, contentType: 'application/octet-stream', uploadRef }];
|
||
});
|
||
}
|
||
|
||
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
|
||
return async ({ input, connection, idempotencyKey }) => {
|
||
const to = recipientsOf(input['to']);
|
||
const cc = recipientsOf(input['cc']);
|
||
const bcc = recipientsOf(input['bcc']);
|
||
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
|
||
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
|
||
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
|
||
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
|
||
// 账号:入参指定 > 连接配置('default' = 默认账号)
|
||
const configured = connection['account'];
|
||
const accountId =
|
||
typeof input['accountId'] === 'string' && input['accountId'] !== ''
|
||
? input['accountId']
|
||
: configured !== undefined && configured !== '' && configured !== 'default'
|
||
? configured
|
||
: undefined;
|
||
|
||
if (input['dryRun'] === true) {
|
||
const problems = [
|
||
...(to.length === 0 ? ['收件人至少一个'] : []),
|
||
...(subject === '' ? ['主题不能为空'] : []),
|
||
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
|
||
];
|
||
return problems.length === 0
|
||
? { ok: true, output: { queued: false, dryRun: true } }
|
||
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
|
||
}
|
||
|
||
try {
|
||
const result = await mail.send({
|
||
...(accountId === undefined ? {} : { accountId }),
|
||
to,
|
||
cc,
|
||
bcc,
|
||
subject,
|
||
...(body === undefined ? {} : { text: body }),
|
||
...(html === undefined ? {} : { html }),
|
||
...(inReplyTo === undefined ? {} : { inReplyTo }),
|
||
references: inReplyTo === undefined ? [] : [inReplyTo],
|
||
attachments: attachmentsOf(input['attachments']),
|
||
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
||
requestedBy: { kind: 'tool', tool: 'mail.send' },
|
||
});
|
||
if (result.state === 'failed') {
|
||
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
|
||
}
|
||
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
|
||
} catch (error) {
|
||
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
|
||
if (
|
||
error instanceof MailNoDefaultAccountError ||
|
||
error instanceof MailAccountNotFoundError ||
|
||
error instanceof MailAccountDisabledError ||
|
||
error instanceof MailSecretMissingError
|
||
) {
|
||
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// apps/api/src/app/workers/mail-poller.ts
|
||
/**
|
||
* 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里,
|
||
* 两个后端连同一个库时一个信箱同一时刻只有一个在拉。
|
||
* 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。
|
||
*/
|
||
import type { MailPollOutcome } from '@modules/mail';
|
||
import type { Container } from '../container.ts';
|
||
|
||
export type MailPollerDeps = { readonly container: Pick<Container, 'mail'>; readonly now?: () => Date };
|
||
|
||
export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
|
||
const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))());
|
||
for (const outcome of outcomes) {
|
||
if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`);
|
||
}
|
||
return outcomes;
|
||
}
|
||
|
||
export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } {
|
||
let stopped = false;
|
||
let running = false;
|
||
const tick = async (): Promise<void> => {
|
||
if (stopped || running) return;
|
||
running = true;
|
||
try {
|
||
await pollMailOnce(deps);
|
||
} catch (error) {
|
||
process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`);
|
||
} finally {
|
||
running = false;
|
||
}
|
||
};
|
||
const timer = setInterval(() => void tick(), tickMs);
|
||
timer.unref();
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearInterval(timer);
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 改 container / config / main / first-party**
|
||
|
||
`apps/api/src/app/config.ts`:`AppConfig` 加 `/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */ readonly mailPollTickMs: number;`,`loadConfig` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),`。
|
||
|
||
`apps/api/src/app/tools/first-party.ts`:删掉 `const mailSend: ToolHandler = …` 整段(含注释)与 `FIRST_PARTY_TOOLS` 里的 `['mail.send', mailSend],` 一行。
|
||
|
||
`apps/api/src/app/container.ts`:
|
||
|
||
1. import:删 `createSimulatedMailTransport`(保留 channel 其余三个);加
|
||
```ts
|
||
import type { MailService } from '@modules/mail';
|
||
import {
|
||
createImapSmtpProvider,
|
||
createInboundStore,
|
||
createMailAccountStore,
|
||
createMailService,
|
||
createOutboundStore,
|
||
createPostalMimeParser,
|
||
createWecomProvider,
|
||
} from '@modules/mail';
|
||
import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts';
|
||
import { readUpload, storeUpload } from './upload-store.ts';
|
||
```
|
||
2. `Container` 类型加 `/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService;`(放在 `channel` 之后)。
|
||
3. 删掉模块级 `export const firstPartyToolHandlers = …`(仓内无其它引用)。
|
||
4. 在 `const toolRegistry = createToolRegistry(db);` 之前加:
|
||
```ts
|
||
/*
|
||
* 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。
|
||
* 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。
|
||
* 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。
|
||
*/
|
||
const mail = createMailService({
|
||
accounts: createMailAccountStore(db),
|
||
inbound: createInboundStore(db),
|
||
outbound: createOutboundStore(db),
|
||
attachments: {
|
||
put: async (file) => ({
|
||
ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref,
|
||
}),
|
||
get: (ref) => readUpload(db, ref),
|
||
},
|
||
providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() },
|
||
mime: createPostalMimeParser(),
|
||
secrets,
|
||
clock: systemClock,
|
||
ids,
|
||
notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) },
|
||
});
|
||
```
|
||
5. `const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);` 改为
|
||
```ts
|
||
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
||
const toolRuntime = createToolRuntime(
|
||
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
|
||
httpToolHandler,
|
||
);
|
||
```
|
||
6. channel 装配里 `mail: createSimulatedMailTransport(db, ids),` 改为 `mail: channelTransport(mail),`;紧跟 `const channel = createChannelService({...});` 之后加:
|
||
```ts
|
||
// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面
|
||
mail.subscribe('channel', channelSubscriber(channel));
|
||
```
|
||
7. `return {...}` 里 `channel,` 之后加 `mail,`。
|
||
|
||
`apps/api/src/app/main.ts`:`import { startMailPoller } from './workers/mail-poller.ts';`;在 `const advancer = …` 之后加
|
||
```ts
|
||
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
|
||
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
|
||
```
|
||
`shutdown` 里 `advancer.stop();` 之后加 `mailPoller.stop();`。
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise`
|
||
Expected: 8 + 2 tests PASS;三道门禁退出码 0(`app-not-into-internal`:app 只 import `@modules/mail` 的出口;`no-orphans`:`upload-store.ts` / `mail-wiring.ts` 被 container 引用)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/app
|
||
git commit -m "$(printf 'app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: 路由 `/api/gov/mail/*`
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/app/routes/mail.ts`
|
||
- Modify: `apps/api/src/app/server.ts`(注册)
|
||
- Test: `apps/api/src/app/__tests__/mail-routes.test.ts`(Hono `app.request`,真用例 + 内存 store,不起 PG)
|
||
|
||
**Interfaces:**
|
||
- Produces:`registerMailRoutes(app: Hono<AppEnv>, deps: { mail: MailService }): void`;`mailErrorResponse(error)`。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
// apps/api/src/app/__tests__/mail-routes.test.ts
|
||
/**
|
||
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
|
||
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
|
||
*/
|
||
import { Hono } from 'hono';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createMailService, createPostalMimeParser } from '@modules/mail';
|
||
import type { AppEnv } from '../http-env.ts';
|
||
import { registerMailRoutes } from '../routes/mail.ts';
|
||
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
|
||
|
||
function harness() {
|
||
const clock = fixedClock();
|
||
const accounts = memoryAccounts(clock);
|
||
const imap = fakeProvider('imap-smtp');
|
||
const mail = createMailService({
|
||
accounts,
|
||
inbound: memoryInbound(),
|
||
outbound: memoryOutbound(),
|
||
attachments: memoryAttachments(),
|
||
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
|
||
mime: createPostalMimeParser(),
|
||
secrets: plainSecrets,
|
||
clock,
|
||
ids: seqIds('x'),
|
||
notify: { changed: () => undefined },
|
||
});
|
||
const app = new Hono<AppEnv>();
|
||
registerMailRoutes(app, { mail });
|
||
const json = (method: string, path: string, body?: unknown) =>
|
||
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||
return { app, mail, accounts, imap, json };
|
||
}
|
||
const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } };
|
||
|
||
describe('账号路由', () => {
|
||
it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => {
|
||
const h = harness();
|
||
expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] });
|
||
expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400);
|
||
const created = await h.json('POST', '/api/gov/mail/accounts', input);
|
||
expect(created.status).toBe(200);
|
||
const view = (await created.json()) as { id: string; secretBound: boolean };
|
||
expect(view.secretBound).toBe(true);
|
||
expect(JSON.stringify(view)).not.toContain('pw-1234');
|
||
expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409);
|
||
expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200);
|
||
expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404);
|
||
expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200);
|
||
expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true });
|
||
expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404);
|
||
});
|
||
|
||
it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => {
|
||
const h = harness();
|
||
expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412);
|
||
const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string };
|
||
expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200);
|
||
h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } });
|
||
const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`);
|
||
expect(res.status).toBe(200);
|
||
expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } });
|
||
});
|
||
|
||
it('立即拉取:200 outcome;停用 412;租约被占 409', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
|
||
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 });
|
||
await h.accounts.save(account({ enabled: false }));
|
||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412);
|
||
await h.accounts.save(account({ pollingUntil: new Date('2027-01-01T00:00:00Z') }));
|
||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409);
|
||
});
|
||
|
||
it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' });
|
||
expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '<x-1@we.com>' });
|
||
h.imap.failSendWith(new Error('535 nope'));
|
||
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' });
|
||
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe('入站 / 出站路由', () => {
|
||
it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => {
|
||
const h = harness();
|
||
await h.accounts.save(account());
|
||
h.mail.subscribe('spy', async () => undefined);
|
||
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } })));
|
||
await h.json('POST', '/api/gov/mail/accounts/acc-1/poll');
|
||
const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> };
|
||
expect(list.mails).toHaveLength(1);
|
||
expect(list.mails[0]?.deliveries).toHaveLength(1);
|
||
const id = list.mails[0]!.id;
|
||
expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200);
|
||
expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404);
|
||
const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string };
|
||
expect(att.name).toBe('a.txt');
|
||
expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi');
|
||
expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404);
|
||
const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string };
|
||
expect(raw.contentType).toBe('message/rfc822');
|
||
expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] });
|
||
expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404);
|
||
expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400);
|
||
expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] });
|
||
});
|
||
});
|
||
|
||
/** 空 body 的 POST:redeliver 的 body 是可省的 */
|
||
function app_request_redeliver(h: ReturnType<typeof harness>, id: string): Promise<Response> {
|
||
return h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts`
|
||
Expected: FAIL(`routes/mail.ts` 不存在)。
|
||
|
||
- [ ] **Step 3: 写路由并注册**
|
||
|
||
```ts
|
||
// apps/api/src/app/routes/mail.ts
|
||
/**
|
||
* 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判;
|
||
* 这里只做 zod 校验、调用用例、把领域错误翻成状态码。
|
||
* 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。
|
||
*/
|
||
import type { Context, Hono } from 'hono';
|
||
import type { ApiError } from '@contracts';
|
||
import {
|
||
createMailAccountRoute,
|
||
deleteMailAccountRoute,
|
||
getMailAccountRoute,
|
||
getMailInboundRoute,
|
||
listMailAccountsRoute,
|
||
listMailInboundRoute,
|
||
listMailOutboundRoute,
|
||
mailAttachmentRoute,
|
||
mailRawRoute,
|
||
pollMailAccountRoute,
|
||
probeMailAccountRoute,
|
||
probeMailDraftRoute,
|
||
redeliverMailInboundRoute,
|
||
sendTestMailRoute,
|
||
updateMailAccountRoute,
|
||
} from '@contracts';
|
||
import type { MailService } from '@modules/mail';
|
||
import {
|
||
MailAccountDisabledError,
|
||
MailAccountNameTakenError,
|
||
MailAccountNotFoundError,
|
||
MailAttachmentNotFoundError,
|
||
MailInboundNotFoundError,
|
||
MailNoDefaultAccountError,
|
||
MailPollBusyError,
|
||
MailSecretMissingError,
|
||
MailSendInvalidError,
|
||
MailSubscriberUnknownError,
|
||
} from '@modules/mail';
|
||
import type { AppEnv } from '../http-env.ts';
|
||
import { staffOf } from '../http-env.ts';
|
||
|
||
export type MailRouteDeps = { readonly mail: MailService };
|
||
|
||
/** 领域错误 → 状态码;认不出的交回 onError(500) */
|
||
export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined {
|
||
if (
|
||
error instanceof MailAccountNotFoundError ||
|
||
error instanceof MailInboundNotFoundError ||
|
||
error instanceof MailAttachmentNotFoundError ||
|
||
error instanceof MailSubscriberUnknownError
|
||
) {
|
||
return { status: 404, body: { code: 'not-found', message: error.message } };
|
||
}
|
||
if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) {
|
||
return { status: 409, body: { code: 'conflict', message: error.message } };
|
||
}
|
||
if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) {
|
||
return { status: 412, body: { code: 'precondition-failed', message: error.message } };
|
||
}
|
||
if (error instanceof MailSendInvalidError) {
|
||
return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } };
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
|
||
const body: ApiError = { code: 'validation-failed', message, detail };
|
||
return c.json(body, 400);
|
||
}
|
||
|
||
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
||
const text = await c.req.text();
|
||
if (text.trim() === '') return {};
|
||
try {
|
||
return JSON.parse(text) as unknown;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
export function registerMailRoutes(app: Hono<AppEnv>, deps: MailRouteDeps): void {
|
||
const { mail } = deps;
|
||
|
||
const guarded = async (c: Context<AppEnv>, run: () => Promise<Response>): Promise<Response> => {
|
||
try {
|
||
return await run();
|
||
} catch (error) {
|
||
const mapped = mailErrorResponse(error);
|
||
if (mapped === undefined) throw error;
|
||
return c.json(mapped.body, mapped.status);
|
||
}
|
||
};
|
||
|
||
/* ── 账号 ── */
|
||
|
||
app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() }));
|
||
|
||
app.post(createMailAccountRoute.path, async (c) => {
|
||
const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c));
|
||
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
|
||
return guarded(c, async () => c.json(await mail.createAccount(parsed.data)));
|
||
});
|
||
|
||
app.post(probeMailDraftRoute.path, async (c) => {
|
||
const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c));
|
||
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
|
||
const accountId = c.req.query('accountId');
|
||
return guarded(c, async () =>
|
||
c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })),
|
||
);
|
||
});
|
||
|
||
app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId')))));
|
||
|
||
app.put(updateMailAccountRoute.path, async (c) => {
|
||
const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c));
|
||
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
|
||
return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data)));
|
||
});
|
||
|
||
app.delete(deleteMailAccountRoute.path, async (c) =>
|
||
guarded(c, async () => {
|
||
await mail.deleteAccount(c.req.param('accountId'));
|
||
return c.json({ ok: true as const });
|
||
}),
|
||
);
|
||
|
||
app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') }))));
|
||
|
||
app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId')))));
|
||
|
||
app.post(sendTestMailRoute.path, async (c) => {
|
||
const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c));
|
||
if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues);
|
||
const me = staffOf(c);
|
||
return guarded(c, async () =>
|
||
c.json(
|
||
await mail.send({
|
||
accountId: c.req.param('accountId'),
|
||
to: [parsed.data.to],
|
||
cc: [],
|
||
bcc: [],
|
||
subject: parsed.data.subject,
|
||
text: parsed.data.text,
|
||
references: [],
|
||
attachments: [],
|
||
requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) },
|
||
}),
|
||
),
|
||
);
|
||
});
|
||
|
||
/* ── 入站 ── */
|
||
|
||
app.get(listMailInboundRoute.path, async (c) => {
|
||
const query = listMailInboundRoute.query.safeParse(c.req.query());
|
||
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||
return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) });
|
||
});
|
||
|
||
app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId')))));
|
||
|
||
app.get(mailAttachmentRoute.path, async (c) => {
|
||
const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') });
|
||
if (!params.success) return invalid(c, '附件序号不合法', params.error.issues);
|
||
return guarded(c, async () => {
|
||
const file = await mail.attachment(params.data.mailId, params.data.index);
|
||
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
|
||
});
|
||
});
|
||
|
||
app.get(mailRawRoute.path, async (c) =>
|
||
guarded(c, async () => {
|
||
const file = await mail.raw(c.req.param('mailId'));
|
||
return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') });
|
||
}),
|
||
);
|
||
|
||
app.post(redeliverMailInboundRoute.path, async (c) => {
|
||
const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c));
|
||
if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues);
|
||
return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) }));
|
||
});
|
||
|
||
/* ── 出站 ── */
|
||
|
||
app.get(listMailOutboundRoute.path, async (c) => {
|
||
const query = listMailOutboundRoute.query.safeParse(c.req.query());
|
||
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||
return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) });
|
||
});
|
||
}
|
||
```
|
||
|
||
`apps/api/src/app/server.ts`:`import { registerMailRoutes } from './routes/mail.ts';`,在 `registerGovernanceOpsRoutes(app, container);` 之后加 `registerMailRoutes(app, container);`。
|
||
|
||
- [ ] **Step 4: 跑测试确认通过(含路由覆盖 IT)**
|
||
|
||
Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: 路由 5 tests PASS;路由覆盖 IT PASS(15 条新路由都在注册表里 —— 它是 fail-closed 的,红了就是有路由没契约);门禁退出码 0。若 `c.json(mapped.body, mapped.status)` 因状态码联合类型报错,把 `mailErrorResponse` 的 `status` 类型改成 hono 导出的 `ContentfulStatusCode`(`import type { ContentfulStatusCode } from 'hono/utils/http-status'`)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/app
|
||
git commit -m "$(printf '邮件路由 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站 / 附件原件 / 重新投递;领域错误翻状态码,厂商失败在 body 里说\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: `mail.send` 工具契约 2.0.0、api-report、五道门禁
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/app/seed-mvp.ts`(`MAIL_TOOL` 与注册循环的样例 / 连接值)
|
||
- Modify: `api-report/**`(`pnpm api:check --update`)
|
||
- Test: 既有 `apps/api/src/modules/toolbox/__tests__/*`(演进检查)+ 全量 `pnpm gates`
|
||
|
||
- [ ] **Step 1: 改 seed 的工具契约**
|
||
|
||
把 `seed-mvp.ts` 里的 `MAIL_TOOL` 整段换成:
|
||
|
||
```ts
|
||
/**
|
||
* 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。
|
||
*
|
||
* 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。
|
||
* 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。
|
||
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
|
||
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
|
||
*/
|
||
const MAIL_TOOL = {
|
||
...HISTORY_TOOL,
|
||
name: 'mail.send',
|
||
displayName: '发送邮件',
|
||
version: '2.0.0',
|
||
description:
|
||
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
|
||
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
|
||
input: {
|
||
type: 'object',
|
||
required: ['to', 'subject'],
|
||
properties: {
|
||
to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' },
|
||
cc: { type: 'array', items: { type: 'string' }, description: '抄送' },
|
||
bcc: { type: 'array', items: { type: 'string' }, description: '密送' },
|
||
subject: { type: 'string' },
|
||
body: { type: 'string', description: '纯文本正文(与 html 至少一个)' },
|
||
html: { type: 'string', description: 'HTML 正文' },
|
||
attachments: {
|
||
type: 'array',
|
||
items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } },
|
||
description: '附件:上传件的 ref',
|
||
},
|
||
inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' },
|
||
accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' },
|
||
dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' },
|
||
},
|
||
},
|
||
output: {
|
||
type: 'object',
|
||
properties: {
|
||
queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' },
|
||
outboundId: { type: 'string' },
|
||
messageId: { type: 'string' },
|
||
state: { type: 'string' },
|
||
dryRun: { type: 'boolean' },
|
||
},
|
||
},
|
||
effect: 'write' as const,
|
||
errors: [
|
||
{ code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' },
|
||
{ code: 'no-account', retryable: false, message: '没有可用的邮箱账号' },
|
||
{ code: 'invalid', retryable: false, message: '入参不合法' },
|
||
],
|
||
idempotent: true,
|
||
connectionKeys: ['account'],
|
||
};
|
||
```
|
||
|
||
注册循环里的样例改为(`to` 变数组、加 `dryRun`;连接值按键给):
|
||
|
||
```ts
|
||
input: {
|
||
pn: 'LM358DR',
|
||
to: ['x@y.com'],
|
||
subject: '询价',
|
||
body: '样例正文',
|
||
dryRun: true,
|
||
history_days: 30,
|
||
market_stable: true,
|
||
lifecycle: '在产',
|
||
},
|
||
```
|
||
|
||
```ts
|
||
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
|
||
...(contract.connectionKeys.length === 0
|
||
? {}
|
||
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
|
||
```
|
||
|
||
- [ ] **Step 2: 演进检查与试运行走一遍(单测)**
|
||
|
||
在 `apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts` 末尾追加一条(照该文件既有的 `checkEvolution` 用例写法):
|
||
|
||
```ts
|
||
describe('mail.send 2.0.0 的演进', () => {
|
||
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
|
||
const v1 = {
|
||
name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)',
|
||
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
|
||
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
|
||
effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'],
|
||
};
|
||
const v2 = {
|
||
...v1, version: '2.0.0', connectionKeys: ['account'],
|
||
input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } },
|
||
output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } },
|
||
};
|
||
expect(checkEvolution(v1, v2)).toEqual([]);
|
||
});
|
||
});
|
||
```
|
||
|
||
Run: `pnpm vitest run apps/api/src/modules/toolbox`
|
||
Expected: PASS(含新用例)。
|
||
|
||
- [ ] **Step 3: 刷新契约快照并跑五道门禁**
|
||
|
||
```bash
|
||
pnpm api:check --update && git add api-report
|
||
pnpm gates
|
||
```
|
||
Expected: `api-report/apps/api/src/modules/mail/{index,contract,port}.d.ts` 与 `api-report/packages/contracts/...` 有新增;`pnpm gates` 退出码 0(gen:tiers / typecheck / lint / depcruise / api:check / test 全绿;IT 套件在有 PG 时跑、没有时跳过并告警 —— 本轮验收要求 PG 在)。
|
||
|
||
- [ ] **Step 4: 本机起一次真服务核一遍(有 PG + Keycloak 配置时)**
|
||
|
||
```bash
|
||
pnpm db:migrate && pnpm db:seed
|
||
pnpm dev:api
|
||
```
|
||
Expected:启动日志无 `[mail-poller]` 报错(没有账号时每 tick 空转);`pnpm db:seed` 日志里 `② 功能工具已注册并入白名单`,且 `mail.send` 未出现「未过两审」(dryRun 样例通过试运行)。
|
||
用服务账号 token(API 文档 §8 的取法)`curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/api/gov/mail/accounts` → `{"accounts":[]}`。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/app/seed-mvp.ts apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts api-report
|
||
git commit -m "$(printf 'mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: 文档 —— ADR-013、CONTRACT.md、API 文档、前端对接说明、总表、debt、CLAUDE.md
|
||
|
||
**Files:**
|
||
- Create: `docs/adr/ADR-013-mail-gateway.md` · `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md`
|
||
- Modify: `apps/api/src/modules/channel/CONTRACT.md`(「邮件通道是外界」一条)· `docs/API文档-鉴权与人员.md`(§6 总表 + 计数)· `docs/debt.md`(新段)· `CLAUDE.md`(现状快照一条 + 文档权威地图两行)
|
||
|
||
- [ ] **Step 1: ADR-013**
|
||
|
||
```markdown
|
||
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
|
||
|
||
状态:已接受(2026-08-25)
|
||
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
|
||
(核实日期 2026-08-25,链接列在设计稿开头)。
|
||
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据)。
|
||
|
||
## 问题
|
||
|
||
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
|
||
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
|
||
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
|
||
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
|
||
|
||
## 决策
|
||
|
||
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
|
||
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
|
||
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
|
||
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
|
||
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
|
||
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
|
||
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
|
||
重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下,
|
||
`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
|
||
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
|
||
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
|
||
订阅是代码级装配(不落库、不回放老信)。
|
||
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
|
||
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
|
||
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`;带 `idempotencyKey` 且已 `sent` 的不发第二封,
|
||
失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成
|
||
`send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,
|
||
放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。
|
||
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
|
||
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
|
||
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
|
||
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
|
||
厂商失败在 200 的 body 里说,不编状态码。
|
||
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
|
||
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
|
||
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
|
||
|
||
## 后果
|
||
|
||
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
|
||
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
|
||
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
|
||
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
|
||
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
|
||
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
|
||
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
|
||
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。
|
||
- 欠账见 `docs/debt.md`「邮件收发」段。
|
||
```
|
||
|
||
- [ ] **Step 2: channel 的 CONTRACT.md**
|
||
|
||
把 `apps/api/src/modules/channel/CONTRACT.md` 不变量里这一条:
|
||
|
||
```
|
||
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
|
||
系统内部机制一律真跑,不得 mock
|
||
```
|
||
|
||
改成:
|
||
|
||
```
|
||
- 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方,
|
||
把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认);
|
||
`createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用);
|
||
系统内部机制一律真跑,不得 mock
|
||
```
|
||
|
||
`依赖:` 那一行的 `MailTransport`(模拟通道)改为 `MailTransport`(app 注入,转 mail 模块)。
|
||
|
||
- [ ] **Step 3: API 文档 `docs/API文档-邮件收发.md`**
|
||
|
||
```markdown
|
||
# API 文档 —— 邮件收发(ADR-013)
|
||
|
||
> 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用);
|
||
> 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。
|
||
> 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`;
|
||
> 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。
|
||
|
||
## 0. 通则
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 前缀 | `/api/gov/mail/*`;**读写都归运营中心 `ops`** —— 邮箱那一屏落在运营中心,设计端没有消费方;而入站正文装的是客户来往内容,不无故加宽可读面 |
|
||
| 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` |
|
||
| 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 |
|
||
| 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) |
|
||
| 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) |
|
||
|
||
状态码怎么读(通则之外本文特有的):
|
||
|
||
| 状态 | code | 何时 |
|
||
|---|---|---|
|
||
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 |
|
||
| 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 |
|
||
| 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** |
|
||
| 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) |
|
||
|
||
## 1. 账号
|
||
|
||
### `GET /api/gov/mail/accounts` —— access: `design|ops`
|
||
|
||
```json
|
||
{
|
||
"accounts": [
|
||
{
|
||
"id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
|
||
"enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null,
|
||
"settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" },
|
||
"secretBound": true,
|
||
"status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false },
|
||
"createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z"
|
||
},
|
||
{
|
||
"id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false,
|
||
"pollIntervalSec": 60, "channelId": null, "backfillFrom": null,
|
||
"settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" },
|
||
"secretBound": false,
|
||
"status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false },
|
||
"createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- `address`:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。
|
||
- `status.cursor` 是人读摘要(还没拉过 = null);`status.polling` 为真表示此刻某台后端正拉着。
|
||
- `status.lastPollError` 是厂商原话(脱敏);为 null 表示上一轮没出错。
|
||
|
||
### `POST /api/gov/mail/accounts` —— access: `ops`
|
||
|
||
body 按 `kind` 分两形(缺省值见 §5 schema):
|
||
|
||
```json
|
||
{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
|
||
"isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
|
||
"settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
|
||
```
|
||
|
||
```json
|
||
{ "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } }
|
||
```
|
||
|
||
→ 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。`isDefault:true` 会把别的账号的默认位放下。
|
||
|
||
### `POST /api/gov/mail/accounts/probe?accountId=` —— access: `ops`
|
||
|
||
**未存**的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 `?accountId=` 借库里那份密文。
|
||
没 secret 又没 `accountId` → 412。
|
||
|
||
```json
|
||
{ "kind": "imap-smtp", "address": "sales@corp.com",
|
||
"receive": { "ok": true, "latencyMs": 412 },
|
||
"send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
|
||
```
|
||
|
||
```json
|
||
{ "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work",
|
||
"receive": { "ok": true, "latencyMs": 230 },
|
||
"send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } }
|
||
```
|
||
|
||
### `GET /api/gov/mail/accounts/:accountId` —— access: `design|ops`
|
||
|
||
→ 账号视图;404。
|
||
|
||
### `PUT /api/gov/mail/accounts/:accountId` —— access: `ops`
|
||
|
||
body 同建账号;`secret` 缺省 = 不改密文;换 `kind` 又没给 secret → 旧密文作废(`secretBound:false`);
|
||
`kind` 或 `settings` 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。
|
||
|
||
### `DELETE /api/gov/mail/accounts/:accountId` —— access: `ops`
|
||
|
||
→ `{ "ok": true }`。留痕(入站 / 出站)不删。
|
||
|
||
### `POST /api/gov/mail/accounts/:accountId/probe` —— access: `ops`
|
||
|
||
已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 `address`。412 凭证空。
|
||
|
||
### `POST /api/gov/mail/accounts/:accountId/poll` —— access: `ops`
|
||
|
||
立即拉一轮(不等轮询到点):
|
||
|
||
```json
|
||
{ "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false }
|
||
```
|
||
|
||
- `pulled` 服务器交出几封;`stored` 真新入库几封(去重后);`dispatched` 分发过几封;`more` 为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。
|
||
- 出错:`error` 带原话(同时写进账号的 `status.lastPollError`),仍是 200。
|
||
- 409:另一台后端正占着租约;412:账号停用。
|
||
|
||
### `POST /api/gov/mail/accounts/:accountId/send-test` —— access: `ops`
|
||
|
||
body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` →
|
||
|
||
```json
|
||
{ "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "state": "sent", "providerRef": "250 OK: queued as …" }
|
||
```
|
||
|
||
失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。
|
||
|
||
## 2. 入站
|
||
|
||
### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `design|ops`
|
||
|
||
按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。
|
||
|
||
```json
|
||
{
|
||
"mails": [
|
||
{
|
||
"id": "in-01J…", "accountId": "6e1c…", "messageId": "<CAF…@mail.acme.com>",
|
||
"from": { "address": "buyer@acme.com", "name": "张三" },
|
||
"to": [{ "address": "sales@corp.com", "name": "" }], "cc": [],
|
||
"subject": "询价 RFQ-2026-081", "sentAt": "2026-08-25T09:57:40.000Z", "receivedAt": "2026-08-25T09:58:11.000Z",
|
||
"textPreview": "你好,请报价以下料号……", "hasHtml": true,
|
||
"attachments": [
|
||
{ "index": 0, "name": "BOM.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "size": 18234, "ref": "3f9e…", "note": "" },
|
||
{ "index": 1, "name": "datasheets.zip", "contentType": "application/zip", "size": 9437184, "ref": null, "note": "附件 9.0MB,超过 8MB 上限,未存原件" }
|
||
],
|
||
"deliveries": [
|
||
{ "subscriber": "channel", "state": "done", "error": null, "startedAt": "2026-08-25T09:58:11.200Z", "finishedAt": "2026-08-25T09:58:11.480Z" }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`deliveries[].state`:`dispatching`(正在投;超过 10 分钟还是它 = 进程中断,交人重投)/ `done` / `skipped`(`error` 里是理由,如「账号未绑定通道」)/ `failed`(`error` 是原话,如「通道实例已停用:ch-rfq-mail」)。
|
||
|
||
### `GET /api/gov/mail/inbound/:mailId` —— access: `design|ops`
|
||
|
||
列表那一项 + `inReplyTo` `references[]` `text` `html` `size` `rawAvailable`。404。
|
||
|
||
### `GET /api/gov/mail/inbound/:mailId/attachments/:index` —— access: `design|ops`
|
||
|
||
→ `{ "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形)。可带 `?ticket=` 用 `<a href download>`(ADR-012 §6)。
|
||
超限没存的(`ref: null`)→ 404。附件同时也是一个上传件(`ref` = `sys.upload` 的 id),活动那一侧读得到抽出来的文本。
|
||
|
||
### `GET /api/gov/mail/inbound/:mailId/raw` —— access: `design|ops`
|
||
|
||
→ `{ "name": "CAF…_mail.acme.com.eml", "contentType": "message/rfc822", "dataBase64": "…" }`;超 16MB 没存 → 404。可带 `?ticket=`。
|
||
|
||
### `POST /api/gov/mail/inbound/:mailId/redeliver` —— access: `ops`
|
||
|
||
body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不带)→ `{ "deliveries": [ … ] }`。
|
||
正在 `dispatching` 的那条跳过(原样回);不认识的订阅方 404。**从这一刻起对该订阅方是至少一次**:channel 那条链会再起一单。
|
||
|
||
## 3. 出站
|
||
|
||
### `GET /api/gov/mail/outbound?accountId=&limit=` —— access: `design|ops`
|
||
|
||
```json
|
||
{
|
||
"mails": [
|
||
{ "id": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "to": ["buyer@acme.com"], "cc": [], "bcc": [],
|
||
"subject": "回复:询价 RFQ-2026-081", "state": "sent", "error": null, "providerRef": "250 OK: queued as …",
|
||
"requestedBy": { "kind": "tool", "tool": "mail.send" }, "createdAt": "2026-08-25T11:02:00.000Z", "sentAt": "2026-08-25T11:02:01.300Z" }
|
||
]
|
||
}
|
||
```
|
||
|
||
`requestedBy.kind`:`tool`(活动放行时经 `mail.send` 落地)/ `channel`(channel.sendMail)/ `ops`(运营面试发,带 `staffId`)。`state`:`sending`(在发;久停不动 = 进程中断)/ `sent` / `failed`。
|
||
|
||
## 4. 活动怎么用(不是 HTTP)
|
||
|
||
- **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。
|
||
- **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。
|
||
|
||
## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名)
|
||
|
||
```ts
|
||
type MailAccountKind = 'imap-smtp' | 'wecom';
|
||
|
||
type ImapSmtpSettings = { imapHost: string /* imap.exmail.qq.com */; imapPort: number /* 993 */; imapTls: boolean /* true */;
|
||
smtpHost: string /* smtp.exmail.qq.com */; smtpPort: number /* 465 */; smtpTls: boolean /* true = 隐式 TLS;false = STARTTLS */;
|
||
login?: string /* 缺省 = address */; folder: string /* INBOX */ };
|
||
type WecomSettings = { corpId: string; apiBase: string /* https://qyapi.weixin.qq.com */ };
|
||
|
||
// POST / PUT 的 body(缺省值:displayName '' · enabled true · isDefault false · pollIntervalSec 30(10–3600)· channelId null · backfillFrom null)
|
||
type MailAccountInput =
|
||
| { kind: 'imap-smtp'; address: string; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
|
||
channelId?: string | null; backfillFrom?: string | null; settings?: Partial<ImapSmtpSettings>; secret?: { password: string } }
|
||
| { kind: 'wecom'; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
|
||
channelId?: string | null; backfillFrom?: string | null; settings: { corpId: string; apiBase?: string }; secret?: { corpSecret: string } };
|
||
|
||
type MailAccountStatus = { lastPolledAt: string | null; lastPollError: string | null; lastInboundAt: string | null; cursor: string | null; polling: boolean };
|
||
type MailAccountView = (
|
||
| { kind: 'imap-smtp'; settings: ImapSmtpSettings }
|
||
| { kind: 'wecom'; settings: WecomSettings }
|
||
) & { id: string; name: string; address: string; displayName: string; enabled: boolean; isDefault: boolean; pollIntervalSec: number;
|
||
channelId: string | null; backfillFrom: string | null; secretBound: boolean; status: MailAccountStatus; createdAt: string; updatedAt: string };
|
||
|
||
type MailProbeReport = { kind: MailAccountKind; address?: string;
|
||
receive: { ok: boolean; error?: string; latencyMs?: number };
|
||
send: { checked: boolean; ok?: boolean; error?: string; note?: string } };
|
||
|
||
type MailPollOutcome = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
|
||
|
||
type MailAddress = { address: string; name: string };
|
||
type MailDeliveryView = { subscriber: string; state: 'dispatching' | 'done' | 'failed' | 'skipped'; error: string | null; startedAt: string; finishedAt: string | null };
|
||
type MailInboundAttachment = { index: number; name: string; contentType: string; size: number; ref: string | null; note: string };
|
||
type MailInboundSummary = { id: string; accountId: string; messageId: string; from: MailAddress; to: MailAddress[]; cc: MailAddress[]; subject: string;
|
||
sentAt: string | null; receivedAt: string; textPreview: string; hasHtml: boolean; attachments: MailInboundAttachment[]; deliveries: MailDeliveryView[] };
|
||
type MailInboundDetail = MailInboundSummary & { inReplyTo: string | null; references: string[]; text: string | null; html: string | null; size: number; rawAvailable: boolean };
|
||
|
||
type MailSendResult = { outboundId: string; accountId: string; messageId: string; state: 'sent' | 'failed'; error?: string; providerRef?: string };
|
||
type MailOutboundView = { id: string; accountId: string; messageId: string; to: string[]; cc: string[]; bcc: string[]; subject: string;
|
||
state: 'sending' | 'sent' | 'failed'; error: string | null; providerRef: string | null; requestedBy: Record<string, unknown>; createdAt: string; sentAt: string | null };
|
||
```
|
||
|
||
## 6. curl 实录
|
||
|
||
```bash
|
||
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
|
||
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
|
||
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
|
||
|
||
# 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码)
|
||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \
|
||
-d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq .
|
||
|
||
# 探测(已存)/ 立即拉取 / 试发
|
||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq .
|
||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq .
|
||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq .
|
||
|
||
# 来信、附件、重新投递
|
||
curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}'
|
||
curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx
|
||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq .
|
||
|
||
# 出站
|
||
curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}'
|
||
```
|
||
|
||
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
|
||
```
|
||
|
||
- [ ] **Step 4: 前端对接说明 `docs/前端对接说明-邮件收发.md`**
|
||
|
||
```markdown
|
||
# 邮件收发 —— 前端对接说明
|
||
|
||
> 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲
|
||
> **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与
|
||
> `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。
|
||
|
||
## 0. 一分钟看懂
|
||
|
||
- 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。
|
||
界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。
|
||
- 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。
|
||
表单按 `kind` 切换字段;两种都有「探测」。
|
||
- 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。
|
||
- 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。
|
||
- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。读接口两端都能调,设计中心可只读展示。
|
||
|
||
## 1. 建议的信息架构
|
||
|
||
```
|
||
运营中心 / 系统 / 邮箱
|
||
├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯
|
||
│ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除
|
||
│ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe)
|
||
├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递)
|
||
└─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间
|
||
```
|
||
|
||
## 2. 账号表单(字段清单与默认值)
|
||
|
||
公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)·
|
||
`pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)·
|
||
`backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。
|
||
|
||
`kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)·
|
||
`settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)·
|
||
`settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。
|
||
|
||
`kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。
|
||
`address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。
|
||
|
||
保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。
|
||
|
||
## 3. 四个动作的交互与「界面不撒谎」
|
||
|
||
| 动作 | 接口 | 怎么摆 |
|
||
|---|---|---|
|
||
| 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 |
|
||
| 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 |
|
||
| 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 |
|
||
| 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 |
|
||
|
||
列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。
|
||
|
||
## 4. 来信与投递状态
|
||
|
||
- 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。
|
||
- 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。
|
||
**`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。
|
||
- 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。
|
||
- 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形,
|
||
预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。
|
||
`ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。
|
||
- 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。
|
||
- 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。
|
||
|
||
## 5. 发信记录
|
||
|
||
列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。
|
||
`sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。
|
||
|
||
## 6. SSE 失效映射
|
||
|
||
`app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀
|
||
(账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。
|
||
`reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。
|
||
|
||
## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉)
|
||
|
||
**IMAP/SMTP 路**
|
||
1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
|
||
2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
|
||
3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框;
|
||
公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。
|
||
4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。
|
||
|
||
**企业微信邮件 API 路**
|
||
1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
|
||
2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
|
||
3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
|
||
4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。
|
||
|
||
发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。
|
||
|
||
## 8. 错误处理
|
||
|
||
| 状态 | 何时 | 前端动作 |
|
||
|---|---|---|
|
||
| 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 |
|
||
| 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 |
|
||
| 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 |
|
||
| 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 |
|
||
|
||
## 9. 接入完成的判据
|
||
|
||
- [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true
|
||
- [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话
|
||
- [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址
|
||
- [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由
|
||
- [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note
|
||
- [ ] 重新投递前有提示,投递后状态刷新
|
||
- [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点)
|
||
- [ ] 试发失败时列表里看得到 `failed` 与原话
|
||
- [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话
|
||
|
||
## 10. 本文与真实契约的核对状态
|
||
|
||
路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过;字段名与 `packages/contracts/src/mail.ts` 同名。
|
||
示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。
|
||
```
|
||
|
||
- [ ] **Step 5: 总表、debt、CLAUDE.md**
|
||
|
||
`docs/API文档-鉴权与人员.md` §6 总表:在 `| \`ops\` | DELETE | \`/api/gov/access/roles/:role\` |` 之后加 8 行:
|
||
|
||
```markdown
|
||
| `ops` | POST | `/api/gov/mail/accounts` |
|
||
| `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) |
|
||
| `ops` | PUT | `/api/gov/mail/accounts/:accountId` |
|
||
| `ops` | DELETE | `/api/gov/mail/accounts/:accountId` |
|
||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` |
|
||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` |
|
||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` |
|
||
| `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` |
|
||
```
|
||
|
||
在 `| \`ops\` | DELETE | \`/api/gov/access/roles/:role\` |` 那一组的末尾再加 7 行(与上面的写路由并成一组,邮件读写同归 ops):
|
||
|
||
```markdown
|
||
| `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` 末尾追加:
|
||
|
||
```markdown
|
||
## 2026-08-25 · 邮件收发接上了,留下七笔账(ADR-013)
|
||
|
||
① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次;
|
||
根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。
|
||
② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。
|
||
③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。
|
||
④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。
|
||
⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。
|
||
⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。
|
||
⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。
|
||
要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。
|
||
另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。
|
||
```
|
||
|
||
`CLAUDE.md`:现状快照末尾加一条(照既有格式):
|
||
|
||
```markdown
|
||
- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与
|
||
企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 →
|
||
`sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。
|
||
分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。
|
||
发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、
|
||
运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。
|
||
15 条 `/api/gov/mail/*` 路由(读两端、写 ops);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。
|
||
欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由。
|
||
```
|
||
|
||
文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后):
|
||
|
||
```markdown
|
||
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
|
||
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
|
||
```
|
||
|
||
- [ ] **Step 6: 核对与提交**
|
||
|
||
Run: `pnpm gates`
|
||
Expected: 退出码 0(文档改动不影响门禁;api-report 已在 Task 16 刷新)。
|
||
核一遍:`grep -c "/api/gov/mail" docs/API文档-鉴权与人员.md` = 15;`grep -n "mail" tools/module-tiers.json` 有一行。
|
||
|
||
```bash
|
||
git add docs apps/api/src/modules/channel/CONTRACT.md CLAUDE.md
|
||
git commit -m "$(printf '文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账七笔、CLAUDE 快照\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')"
|
||
```
|
||
|
||
---
|
||
|
||
## 自审(spec 覆盖 / 占位 / 类型一致)
|
||
|
||
| spec 节 | 落在 |
|
||
|---|---|
|
||
| §2 决策(两路 / 三依赖 / subscribe / 多账号 / 方案 A) | Task 1(依赖 + L1)· Task 4(subscribe 契约)· Task 12 / 13(两路) |
|
||
| §5.1 contract / §5.2 port | Task 4(+ Task 9 的 `probe` 第二形 `{input, accountId?}`) |
|
||
| §5.3 domain 八件 | Task 4(cursor / due / records / errors)· Task 5(inbound-record / redact)· Task 6(send-input / delivery / account-view) |
|
||
| §5.4 usecase 要点(拉 / 分发 / 发 / 探测 / 凭证 / 默认账号 / 到期扫描 / 账号变更) | Task 9 实现,Task 10 / 11 测试 |
|
||
| §5.5 adapter 六件 | Task 7(schema / store)· Task 8(postal-mime)· Task 12(imap-smtp)· Task 13(wecom)· Task 8 的 fakes(假 provider:住在 `__tests__/fakes.ts` 而不是 adapter —— 它只服务测试,不进生产装配) |
|
||
| §6 四张表 + journal + reset-runs | Task 7 |
|
||
| §7.1 / §7.2 / §7.3 / §7.4 契约、15 条路由、mail.send 2.0.0、总表 | Task 2 / 3 / 16 / 17 |
|
||
| §8 app 装配 / 工作者 / 路由 / channel 订阅方 / 上传件抽出 | Task 14 / 15 |
|
||
| §9 租约 / 去重 / 分发语义 / 脱敏 / 两缝 / 界面不撒谎 | Task 7(租约 IT)· Task 10(去重 / 分发)· Task 5(脱敏)· Task 17(ADR 写明两缝) |
|
||
| §10 腾讯侧前置 | Task 17 前端对接说明 §7 |
|
||
| §11 连带改动 | Task 1(tiers / 编码规范)· Task 14(first-party / 上传)· Task 16(seed / api-report)· Task 17(CONTRACT.md / ADR / 文档 / debt / CLAUDE) |
|
||
| §12 测试策略 | 各任务 Step 1;IT:Task 7(store + 租约)、Task 12(真信箱 env 门控)、Task 15(路由覆盖) |
|
||
| §13 欠账 | Task 17 debt 七笔(spec 的七条 + channel 无 upsert 路由这一条是实施核仓时补的) |
|
||
|
||
与 spec 的差异已在开头「与 spec 的差异」列明(工具契约保留 v1 字段 / dryRun / send 不抛 / nodemailer 8)。
|
||
类型名在各任务间核过:`MailAccountRecord` `InboundRecord` `DeliveryRecord` `OutboundRecord` `ParsedMail` `OutboundMessage` `RawMessage`
|
||
`ProviderAccount` `MailCursor` 定义于 Task 4,Task 7–15 一律按此名引用;`known` 回调在 Task 4 的 port、Task 9 的用例、Task 12 / 13 的 provider 三处同名。
|