Archived
邮件收发模块设计稿:两条接入路(IMAP/SMTP · 企业微信邮件 API)、订阅分发、出站留痕、跨进程租约
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
@@ -0,0 +1,591 @@
|
|||||||
|
# 邮件收发模块(`mail`)—— 后端设计
|
||||||
|
|
||||||
|
- 日期:2026-08-25
|
||||||
|
- 状态:设计已通过(用户 2026-08-25 裁决),待实施(实施计划另出)
|
||||||
|
- 范围:**只做后端**(apps/api + packages/contracts + db + 文档);web 一个文件不动,
|
||||||
|
前端按本轮产出的《前端对接说明-邮件收发》另做
|
||||||
|
- 依据(官方文档,核实日期 2026-08-25):
|
||||||
|
- 腾讯企业邮箱客户端协议设置(企业微信帮助中心):<https://open.work.weixin.qq.com/help2/pc/19886>
|
||||||
|
- 企业微信 · 邮件概述:<https://developer.work.weixin.qq.com/document/path/95486>
|
||||||
|
- 发送普通邮件 `compose_send`:<https://developer.work.weixin.qq.com/document/path/97445>
|
||||||
|
- 获取收件箱邮件列表 `get_mail_list`:<https://developer.work.weixin.qq.com/document/path/97369>
|
||||||
|
- 获取邮件内容 `read_mail`:<https://developer.work.weixin.qq.com/document/path/97979>
|
||||||
|
- 查询应用邮箱账号 `get_email_alias`:<https://developer.work.weixin.qq.com/document/path/97991>
|
||||||
|
- 邮箱回调通知(公共邮箱 `receive_email`):<https://developer.work.weixin.qq.com/document/path/100180>
|
||||||
|
- 创建公共邮箱(`create_auth_code` 拿客户端专用密码):<https://developer.work.weixin.qq.com/document/path/95511>
|
||||||
|
- 获取 access_token:<https://developer.work.weixin.qq.com/document/path/91039>
|
||||||
|
- 全局错误码 / 访问频率限制:<https://developer.work.weixin.qq.com/document/path/90313> / <https://developer.work.weixin.qq.com/document/path/90312>
|
||||||
|
- 老版企业邮箱开放接口(`api.exmail.qq.com`):<https://exmail.qq.com/qy_mng_logic/doc>
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
现状:`channel` 模块(L2)已有整条「入站归一 → 事件(归属解析 / 触发器起单 / 任务 `listen` 监听 /
|
||||||
|
消息兜底)」与出站 `MailTransport` 端口,但两端都是**外界的替身**:出站 `createSimulatedMailTransport`
|
||||||
|
落库不真发,入站只能经 `POST /api/ingest/channel/:channelId/mail` 注入;`mail.send` 工具处理器回
|
||||||
|
`{queued:true}` 从不触达世界;全仓没有 IMAP/SMTP 代码,也没有周期作业调度器
|
||||||
|
(pg-boss 声明了零引用);channel 配置只读、无凭证列。
|
||||||
|
|
||||||
|
目标:新增后端模块 `mail`,把这条链的两端做实 ——
|
||||||
|
|
||||||
|
- **收**:轮询拉取指定邮箱(腾讯企业邮箱)的来信,解析、留痕、交给**注册了回调的订阅方**;
|
||||||
|
`channel` 是第一个订阅方,活动经现有事件链收到信(触发器起单 / `listen` 监听)。
|
||||||
|
- **发**:提供给系统内部调用的发信接口;活动经 `mail.send` 工具发信(写效应,放行时落地)。
|
||||||
|
- **配**:账号参数(含凭证)由前端配置(本轮只出接口与文档),多账号 + 默认发件账号。
|
||||||
|
|
||||||
|
非目标:前端页面、HTTP webhook 注册、IMAP IDLE 推送、INBOX 以外的文件夹、经 API 创建公共邮箱 /
|
||||||
|
邮件群组、日程 / 会议邮件、企业发信配额控制(只在文档里写腾讯的限额)。
|
||||||
|
|
||||||
|
## 2. 已定决策(用户 2026-08-25 裁决)
|
||||||
|
|
||||||
|
| 问题 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 接入路 | **两条都做**:标准协议 IMAP/SMTP 与企业微信邮件 API(应用邮箱);一个 `MailProvider` 端口两个适配器,账号配置里选 `kind` |
|
||||||
|
| 新依赖 | **批准 `imapflow` + `nodemailer` + `postal-mime`**(§12.5 #3;三者 MIT / MIT-0,同一作者维护;postal-mime 零依赖,用 Node 自带 `TextDecoder` 解 GBK/GB18030,本机 Node 24 验过) |
|
||||||
|
| 「注册回调」的外延 | **进程内端口 `subscribe`**;不做 HTTP webhook |
|
||||||
|
| 账号数 | **多账号 + 默认发件账号** |
|
||||||
|
| 放置 | **新模块 `mail`(L1)+ `channel` 作首个订阅方**;channel 契约一字不动 |
|
||||||
|
|
||||||
|
## 3. 腾讯企业邮箱三条接入路(官方文档核实)与取法
|
||||||
|
|
||||||
|
| 路 | 收 | 发 | 前提 | 结论 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ① 标准协议 | IMAP `imap.exmail.qq.com:993`(SSL);**无推送**,只能轮询;IDLE 官方未提及 | SMTP `smtp.exmail.qq.com:465`(SSL;官方帮助只列 465,客服 FAQ 另列 587/25) | 账号=完整邮箱地址;密码=邮箱密码,开了安全登录 / 微信绑定则必须用**客户端专用密码**;管理员先在「协作 → 邮件 → 安全管理 → 客户端访问权限」放开,成员在「设置 → 收发信设置」开 IMAP/SMTP;公共邮箱也能生成专用密码(API 建箱时 `create_auth_code:1` 一次性返回) | **做**。任意指定邮箱都能接,不依赖企业微信应用授权 |
|
||||||
|
| ② 企业微信邮件 API | `get_mail_list(begin_time,end_time,cursor,limit≤1000)` → `mail_id`;`read_mail(mail_id)` → `mail_data`(原始 EML,仍要解 MIME);**只读应用邮箱自己的收件箱** | `compose_send`(to/cc/bcc 的 emails 或 userids,subject,content,content_type html/text,attachment_list base64;正文+附件 ≤50MB、附件 ≤200 个);发件人恒为应用邮箱;**不回 Message-ID** | 企业微信 ≥ 4.0.20;自建应用在管理端「协作 → 邮件」授邮件权限,系统自动分配应用邮箱(地址可经 `update_email_alias` 改);`gettoken(corpid, corpsecret)` → access_token 有效 7200s;频率限制每企业每接口 1 万次/分 | **做**。回调 `public_email_change/receive_email` 只对应用创建的公共邮箱、只推「新邮件数」不推内容,**不用** |
|
||||||
|
| ③ 老版开放接口 `api.exmail.qq.com` | 只有「新邮件提醒」(未读数;回调模式推新信通知),无读信 | 无 | — | **不可用** |
|
||||||
|
|
||||||
|
两条路共同点:**都是轮询拉 + MIME 解析**,腾讯没有带内容的推送。因此收信是工作者定时拉,
|
||||||
|
不是 webhook。
|
||||||
|
|
||||||
|
## 4. 架构与数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────── 外界 ────────────────┐
|
||||||
|
│ 腾讯企业邮箱 IMAP/SMTP │ 企业微信邮件 API │
|
||||||
|
└──────────┬────────────┴───────┬────────┘
|
||||||
|
│ MailProvider(端口,两个适配器)
|
||||||
|
app/workers/mail-poller ─┤ 每 5s 一 tick,挑到期账号,一条 SQL 判+占租约
|
||||||
|
▼
|
||||||
|
modules/mail(L1) ── pull → MimeParser(postal-mime)→ 附件入 sys.upload
|
||||||
|
→ sys.mail_inbound(唯一键去重)→ 逐封推进游标
|
||||||
|
→ 分发给订阅方(sys.mail_inbound_delivery 逐订阅方留痕)
|
||||||
|
│ subscribe('channel', …) ← app 装配
|
||||||
|
▼
|
||||||
|
modules/channel(L2).ingestMail(channelId, InboundMail) ── 不动 ──▶ event → 触发器起单 / 任务 listen / 消息兜底
|
||||||
|
|
||||||
|
发:活动 mail.send 工具(write 效应 → 放行时 ToolGateway.execute)─┐
|
||||||
|
channel.sendMail → MailTransport(app 注入:转 mail.send)──────┼──▶ modules/mail.send
|
||||||
|
运营面试发路由 ───────────────────────────────────────────────┘ → sys.mail_outbound(先落 sending)
|
||||||
|
→ MailProvider.send → sent / failed(厂商原话)
|
||||||
|
```
|
||||||
|
|
||||||
|
层级:`mail` 是 **L1**(只依赖 `@contracts`、`@shared/db` 与端口);`channel`、`toolbox` 不 import 它,
|
||||||
|
全部经 app 装配(与 channel 依赖 event 的方式相同)。
|
||||||
|
|
||||||
|
## 5. `mail` 模块(`apps/api/src/modules/mail`)
|
||||||
|
|
||||||
|
### 5.1 `contract.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MailAccountInput, MailAccountView, MailAccountKind, ProbeReport,
|
||||||
|
InboundSummary, InboundDetail, DeliveryView, OutboundView, SendInput, SendResult,
|
||||||
|
} from '@contracts'; // schema 定义在 packages/contracts/src/mail.ts(§7.1)
|
||||||
|
|
||||||
|
export class MailAccountNotFoundError extends Error { constructor(readonly accountId: string) }
|
||||||
|
export class MailAccountNameTakenError extends Error { constructor(readonly name: string) } // 409
|
||||||
|
export class MailAccountDisabledError extends Error { constructor(readonly accountId: string) } // 412
|
||||||
|
export class MailNoDefaultAccountError extends Error {} // 412
|
||||||
|
export class MailSecretMissingError extends Error { constructor(readonly accountId: string) } // 412
|
||||||
|
export class MailPollBusyError extends Error { constructor(readonly accountId: string) } // 409(另一进程占着租约)
|
||||||
|
export class MailInboundNotFoundError extends Error { constructor(readonly mailId: string) } // 404
|
||||||
|
export class MailAttachmentNotFoundError extends Error { constructor(readonly mailId: string, readonly index: number) }
|
||||||
|
export class MailSubscriberUnknownError extends Error { constructor(readonly subscriber: string) } // 404
|
||||||
|
export class MailSendFailedError extends Error { // 发信失败:已落 failed 行
|
||||||
|
constructor(readonly outboundId: string, readonly vendorMessage: string, readonly retryable: boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订阅方回调:返回 `{skipped}` 表示这封信与我无关(留痕为 skipped,不算失败) */
|
||||||
|
export type InboundHandler = (
|
||||||
|
mail: InboundDetail,
|
||||||
|
ctx: { readonly accountId: string; readonly channelId: string | undefined },
|
||||||
|
) => Promise<void | { readonly skipped: string }>;
|
||||||
|
|
||||||
|
export type PollOutcome = {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly pulled: number; // provider 交出的封数
|
||||||
|
readonly stored: number; // 真新入库的封数(去重后)
|
||||||
|
readonly dispatched: number; // 分发过的封数
|
||||||
|
readonly more: boolean; // 预算用尽还有余下,下一 tick 接着
|
||||||
|
readonly error?: string; // 厂商原话(已脱敏)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailService = {
|
||||||
|
// 账号
|
||||||
|
listAccounts(): Promise<MailAccountView[]>;
|
||||||
|
getAccount(accountId: string): Promise<MailAccountView>;
|
||||||
|
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||||||
|
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>; // secret 缺省 = 不改
|
||||||
|
deleteAccount(accountId: string): Promise<void>;
|
||||||
|
probe(target: { accountId: string } | { input: MailAccountInput }): Promise<ProbeReport>; // 已存 / 未存两种
|
||||||
|
// 收
|
||||||
|
pollDue(now: Date): Promise<PollOutcome[]>; // 工作者用:到期且未被占的账号各拉一轮
|
||||||
|
pollNow(accountId: string): Promise<PollOutcome>; // 路由用:租约被占 → MailPollBusyError
|
||||||
|
subscribe(name: string, handler: InboundHandler): () => void; // 同名重复注册 → 抛
|
||||||
|
listInbound(query: { accountId?: string; limit?: number }): Promise<InboundSummary[]>;
|
||||||
|
getInbound(mailId: string): Promise<InboundDetail>;
|
||||||
|
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
|
||||||
|
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; // 原件 .eml;超限没存 → MailAttachmentNotFoundError
|
||||||
|
redeliver(mailId: string, subscriber?: string): Promise<DeliveryView[]>; // 人按的钮,至少一次
|
||||||
|
// 发
|
||||||
|
send(input: SendInput): Promise<SendResult>;
|
||||||
|
listOutbound(query: { accountId?: string; limit?: number }): Promise<OutboundView[]>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `port.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; // 与 llm/toolbox 同款,app 注入同一实例
|
||||||
|
export type Clock = { now(): Date };
|
||||||
|
export type IdGenerator = { next(): string };
|
||||||
|
|
||||||
|
/** 库形与传输形(domain 定义,这里只列名):`MailCursor`(§5.3 两形)、`Address = {address, name}`、`DeliveryState = 'dispatching'|'done'|'failed'|'skipped'`、
|
||||||
|
* `InboundRecord` / `OutboundRecord` = §6 两张表的行(驼峰)、`OutboundMessage` = 交给 provider 的出信(from/to/cc/bcc/subject/text/html/messageId/inReplyTo/references/attachments 字节) */
|
||||||
|
/** 账号记录(库形):`MailAccountView` 的全部字段(不含 status / secretBound)+ 下面三列;settings 为按 kind 的非密配置;secretCipher 为密封的 JSON({password} | {corpSecret}) */
|
||||||
|
export type MailAccountRecord = MailAccountViewFields & { secretCipher: string | null; cursor: MailCursor | null; pollingUntil: Date | null; lastPolledAt: Date | null; lastPollError: string | null; lastInboundAt: Date | null };
|
||||||
|
|
||||||
|
export type MailAccountStore = {
|
||||||
|
list(): Promise<MailAccountRecord[]>;
|
||||||
|
get(id: string): Promise<MailAccountRecord | undefined>;
|
||||||
|
save(record: MailAccountRecord): Promise<void>; // upsert;name 唯一、默认至多一个由库约束
|
||||||
|
remove(id: string): Promise<void>;
|
||||||
|
listDue(now: Date): Promise<MailAccountRecord[]>; // enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空/过期
|
||||||
|
claimPolling(id: string, now: Date, leaseMs: number): Promise<boolean>; // 一条 SQL 判+占(§9.1)
|
||||||
|
releasePolling(id: string): Promise<void>;
|
||||||
|
savePoll(id: string, patch: { cursor?: MailCursor; polledAt?: Date; error?: string | null; lastInboundAt?: Date; address?: string }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InboundStore = {
|
||||||
|
insert(record: InboundRecord): Promise<{ inserted: boolean }>; // (account_id, message_id) / (account_id, provider_ref) 冲突 → false
|
||||||
|
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>; // wecom 拉列表后先滤,不重复 read_mail
|
||||||
|
list(query: { accountId?: string; limit: number }): Promise<InboundSummary[]>;
|
||||||
|
get(mailId: string): Promise<InboundDetail | undefined>;
|
||||||
|
setDelivery(d: { mailId: string; subscriber: string; state: DeliveryState; error?: string; startedAt: Date; finishedAt?: Date }): Promise<void>;
|
||||||
|
deliveries(mailId: string): Promise<DeliveryView[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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; limit: number }): Promise<OutboundView[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
|
||||||
|
export type AttachmentStore = {
|
||||||
|
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string; text?: string }>;
|
||||||
|
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 外界:一种账号类型一个适配器。凭证明文只在这一层与 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; readonly cursorAfter?: MailCursor };
|
||||||
|
export type MailProvider = {
|
||||||
|
readonly kind: MailAccountKind;
|
||||||
|
probe(account: ProviderAccount): Promise<ProbeReport>;
|
||||||
|
/** 无游标时由 provider 锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
|
||||||
|
anchor(account: ProviderAccount, backfillFrom: Date | undefined, now: Date): Promise<MailCursor>;
|
||||||
|
pull(account: ProviderAccount, cursor: MailCursor, opts: { limit: number; now: Date }): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
|
||||||
|
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedMail = { messageId?: string; inReplyTo?: string; references: string[]; from?: { address: string; name: string }; to: Address[]; cc: Address[]; subject: string; text?: string; html?: string; date?: Date; attachments: Array<{ name: string; contentType: string; bytes: Uint8Array }> };
|
||||||
|
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
|
||||||
|
|
||||||
|
/** 变更通知 —— app 接到 broadcaster(`{kind:'system', id:'mail'}`);模块不知道 SSE 的存在 */
|
||||||
|
export type ChangeNotifier = { changed(reason: string): void };
|
||||||
|
|
||||||
|
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?: { perTick?: number; tickBudgetMs?: number; attachmentBytes?: number; rawBytes?: number }; // 默认 50 封 / 20s / 8MB / 16MB
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `internal/domain/`(纯函数,零 IO)
|
||||||
|
|
||||||
|
- `cursor.ts`:`MailCursor` 两形 `{kind:'imap', uidValidity, lastUid}` / `{kind:'wecom', lastTime}`;
|
||||||
|
`cursorSummary(cursor)` 人读摘要(「UID 1234 · validity 5」/「至 2026-08-25 10:00」);
|
||||||
|
`wecomWindow(cursor, backfillFrom, now)` → `{beginTime: lastTime − 600s, endTime: now}`(重叠 10 分钟治时钟偏,重复由唯一键挡)。
|
||||||
|
- `due.ts`:`isDue(record, now)`、`leaseMsFor(intervalSec) = max(5min, 3×interval)`。
|
||||||
|
- `inbound-record.ts`:`inboundRecordOf(parsed, raw, account, receivedAt, ids)`:`messageId` 缺失时用
|
||||||
|
`<providerRef@account.address>` 顶(不抛);`threadId = inReplyTo ?? references 末项`;
|
||||||
|
`bodyText = text ?? stripHtml(html) ?? ''`;附件按上限分「存」与「超限留注」。
|
||||||
|
- `redact.ts`:`redactSecrets(text, secrets)` —— 明文、base64、URL-encoded 三形都换成 `***`。
|
||||||
|
- `send-input.ts`:`validateSend(input)`(收件人非空、text/html 至少一个、附件单个 ≤8MB、总 ≤20MB;
|
||||||
|
wecom 额外 ≤50MB / ≤200 个)、`pickAccount(accounts, requested?)`(指定的 → 必须 enabled;不指定 → 唯一 `isDefault`;没有 → `MailNoDefaultAccountError`)、
|
||||||
|
`messageIdFor(id, address) = <id@域>`。
|
||||||
|
- `delivery.ts`:状态机 `dispatching → done | failed | skipped`,`redeliver` 只从 `done/failed/skipped` 出发。
|
||||||
|
- `account-view.ts`:`viewOf(record)`(去密文、`secretBound = cipher != null`、状态子对象、`polling = pollingUntil > now`)。
|
||||||
|
|
||||||
|
### 5.4 `internal/usecase/mail-service.ts` 要点
|
||||||
|
|
||||||
|
- **拉一轮**(`pollAccount`):`claimPolling` 抢不到 → busy;凭证空 → 记 `last_poll_error='未配置凭证'`、还租约;
|
||||||
|
无游标 → `provider.anchor`;循环 `pull`(封顶 50 封 / 20s):逐封 `mime.parse` → 附件 `attachments.put`
|
||||||
|
(超限 `ref:null` + 注)→ `inbound.insert`(冲突 = 已有,跳过)→ **带 `cursorAfter` 的逐封存游标** →
|
||||||
|
新入库的才 `dispatch`;整批完存 `cursor` + `polledAt` + `error:null`;任何异常:存 `error`(脱敏)、
|
||||||
|
游标停在最后一封成功处;`finally` 还租约;有变化就 `notify.changed('inbound')`。
|
||||||
|
- **分发**(`dispatch`):对每个已注册订阅方顺序执行,先写 `dispatching` 再调,`done / skipped / failed(原话)`;
|
||||||
|
一个订阅方失败不影响其它。**不自动重试**(§9.3)。
|
||||||
|
- **发**(`send`):`validateSend` → `pickAccount` → 若带 `idempotencyKey` 且已有 `sent` 行 → 直接回它
|
||||||
|
(放行重放不发两封)→ 落 `sending` 行 → `provider.send` → `sent`(providerRef)或 `failed` + 抛
|
||||||
|
`MailSendFailedError`;附件 `uploadRef` 经 `attachments.get` 取字节。
|
||||||
|
- **到期扫描**(`pollDue`):账号间 `Promise.allSettled` 并行,单账号内串行;一个账号的异常只记在它自己的 `last_poll_error`。
|
||||||
|
- **账号变更**:`kind` 或 `settings` 变了 → 游标清空(下一轮重锚)、`last_poll_error` 清空;删除账号时另一进程若正拉着,它的 `savePoll` 落在不存在的行上 = no-op,不抛。
|
||||||
|
- **探测**(`probe`):已存账号用库里凭证,未存用入参里的;wecom 探到应用邮箱地址后回填 `address`(已存账号)。
|
||||||
|
- **凭证**:`createAccount/updateAccount` 里 `secret` 有值才 `seal`,空 = 保留原密文(与 llm 同款);
|
||||||
|
视图只回 `secretBound`。
|
||||||
|
- **默认账号**:`isDefault:true` 保存时先把别的置 false(同一事务;库上再钉一个部分唯一索引兜底)。
|
||||||
|
|
||||||
|
### 5.5 `internal/adapter/`
|
||||||
|
|
||||||
|
- `mail-schema.ts`:四张 Drizzle 表(§6)。`mail-store.ts`:三个 store;`claimPolling` 用原生 SQL(§9.1)。
|
||||||
|
- `imap-smtp-provider.ts`(imapflow + nodemailer):
|
||||||
|
- `anchor`:连上、`mailboxOpen(folder, {readOnly:true})`,无 backfill → `{uidValidity, lastUid: uidNext−1}`;
|
||||||
|
有 backfill → `search({since})` 取最小 UID − 1。
|
||||||
|
- `pull`:`readOnly` 打开(**不改已读标记**,轮询不动人的信箱);`uidValidity` 变了 → 重锚不回填、记一条 error 说明;
|
||||||
|
`fetch(`${lastUid+1}:*`, {uid:true, source:true, internalDate:true})`,按 UID 升序,每封 `cursorAfter = {…, lastUid: uid}`;
|
||||||
|
`more = 取满 limit`。**陷阱**:IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义),
|
||||||
|
客户端必须再按 `uid > lastUid` 过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
|
||||||
|
- `send`:`nodemailer.createTransport({host, port, secure: smtpTls, auth:{user: login ?? address, pass}})`,
|
||||||
|
`sendMail({messageId, from: '"displayName" <address>', to, cc, bcc, subject, text, html, inReplyTo, references, attachments})`;
|
||||||
|
`probe`:IMAP 登录 + `transporter.verify()` 各报 ok / 厂商原话。
|
||||||
|
- 超时:连接 30s、单封拉取 60s。
|
||||||
|
- `wecom-provider.ts`(原生 `fetch`,可注入以便测试):
|
||||||
|
- token 缓存**在实例闭包里**按账号存(不是模块级,§12.5 #6),提前 5 分钟刷新;40014 / 42001 刷一次重试;
|
||||||
|
45009 → 本轮停、记 error;`-1` 重试 ≤3 次。
|
||||||
|
- `anchor`:`{lastTime: backfillFrom ?? now}`;`pull`:`get_mail_list(window, cursor, limit)` 翻页,
|
||||||
|
`knownProviderRefs` 先滤,新的才 `read_mail` → `mail_data`;**文档未说 `mail_data` 编码**,
|
||||||
|
实现时探针定:先按原文解,解不出 `From:`/`Subject:` 再按 base64 兜底,探明后写死一档并记注释。
|
||||||
|
- `send`:`compose_send` 按文档校验 50MB / 200 附件;发件人恒为应用邮箱;
|
||||||
|
**不回 Message-ID** → `providerRef` 空,线程级归属(①级)对 wecom 发出的信不成立,③级地址窗仍可用(写进文档)。
|
||||||
|
- `probe`:`gettoken` + `get_email_alias` → `receive.ok` 与地址;`send.checked=false` + 注「发信权限只能在真发时验证」——
|
||||||
|
界面不撒谎,不把「拿到 token」说成「能发」。
|
||||||
|
- `postal-mime-parser.ts`:`PostalMime.parse(eml)` → `ParsedMail`;地址、Message-ID、References 归一为裸串;
|
||||||
|
日期解析失败留空。
|
||||||
|
- `fake-provider.ts`:内存假 provider(可喂 EML 夹具、可让 send 失败)—— **邮件通道是外界,替身允许**;
|
||||||
|
它是实现端口的适配器,不是测试里的 mock。
|
||||||
|
|
||||||
|
## 6. 数据模型与迁移 `db/migrations/0033_mail.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create table 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 '', -- imap-smtp:登录邮箱;wecom:应用邮箱(探测后回填)
|
||||||
|
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, -- 入站交给哪个通道;空 = channel 订阅方 skipped
|
||||||
|
backfill_from timestamptz,
|
||||||
|
settings jsonb not null default '{}', -- 按 kind 的非密配置(§7.1 schema)
|
||||||
|
secret_cipher text, -- AES-256-GCM 密封的 JSON;永不出接口
|
||||||
|
cursor jsonb,
|
||||||
|
last_polled_at timestamptz,
|
||||||
|
last_poll_error text,
|
||||||
|
last_inbound_at timestamptz,
|
||||||
|
polling_until timestamptz, -- 跨进程租约(与 sys.task.advancing_until 同款)
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create unique index mail_account_name_key on sys.mail_account (name);
|
||||||
|
create unique index mail_account_one_default on sys.mail_account ((true)) where is_default; -- 默认账号至多一个
|
||||||
|
create index mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
|
||||||
|
|
||||||
|
create table sys.mail_inbound (
|
||||||
|
id text primary key,
|
||||||
|
account_id text not null, -- 不设外键:账号删了留痕还在
|
||||||
|
message_id text not null,
|
||||||
|
provider_ref text not null, -- imap:UID(带 validity 前缀);wecom:mail_id
|
||||||
|
in_reply_to text,
|
||||||
|
reference_ids jsonb not null default '[]',
|
||||||
|
from_addr text not null default '',
|
||||||
|
from_name text not null default '',
|
||||||
|
to_list jsonb not null default '[]', -- [{address,name}]
|
||||||
|
cc_list jsonb not null default '[]',
|
||||||
|
subject text not null default '',
|
||||||
|
text_body text,
|
||||||
|
html_body text,
|
||||||
|
sent_at timestamptz,
|
||||||
|
received_at timestamptz not null,
|
||||||
|
attachments jsonb not null default '[]', -- [{index,name,contentType,size,ref|null,note}],ref = sys.upload.id
|
||||||
|
raw bytea, -- 原件;> 16MB 留空
|
||||||
|
raw_note text not null default '',
|
||||||
|
size integer not null default 0,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create unique index mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
|
||||||
|
create unique index mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
|
||||||
|
create index mail_inbound_time_idx on sys.mail_inbound (received_at desc);
|
||||||
|
|
||||||
|
create table 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 sys.mail_outbound (
|
||||||
|
id text primary key,
|
||||||
|
account_id text not null,
|
||||||
|
message_id text not null, -- 自造 <uuid@域>;线程级归属的对表来源
|
||||||
|
idempotency_key text, -- 放行落地带的键;同键不发两封
|
||||||
|
to_list jsonb not null,
|
||||||
|
cc_list jsonb not null default '[]',
|
||||||
|
bcc_list jsonb not null default '[]',
|
||||||
|
subject text not null,
|
||||||
|
text_body text,
|
||||||
|
html_body text,
|
||||||
|
in_reply_to text,
|
||||||
|
reference_ids jsonb not null default '[]',
|
||||||
|
attachments jsonb not null default '[]', -- [{name,contentType,size,ref}]
|
||||||
|
state text not null check (state in ('sending','sent','failed')),
|
||||||
|
error text,
|
||||||
|
provider_ref text,
|
||||||
|
requested_by jsonb not null default '{}', -- {kind:'tool'|'channel'|'ops', taskId?, staffId?, tool?}
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
sent_at timestamptz
|
||||||
|
);
|
||||||
|
create unique index mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
|
||||||
|
create index mail_outbound_time_idx on sys.mail_outbound (created_at desc);
|
||||||
|
```
|
||||||
|
|
||||||
|
`meta/_journal.json` 追加 `idx 33 / tag 0033_mail`。`reset-runs` 加 `sys.mail_inbound`、
|
||||||
|
`sys.mail_inbound_delivery`、`sys.mail_outbound`(清跑出来的,留 `sys.mail_account` 这份配置)。
|
||||||
|
|
||||||
|
## 7. 契约变更(`packages/contracts`)
|
||||||
|
|
||||||
|
### 7.1 新增 `packages/contracts/src/mail.ts`(schema,前后端共用)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
|
||||||
|
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), 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), smtpTls: z.boolean().default(true), // true=隐式 TLS(465);false=STARTTLS(587/25)
|
||||||
|
login: z.string().min(1).optional(), // 缺省 = address
|
||||||
|
folder: z.string().min(1).default('INBOX'),
|
||||||
|
});
|
||||||
|
export const wecomSettingsSchema = z.object({ corpId: z.string().min(1), apiBase: z.string().url().default('https://qyapi.weixin.qq.com') });
|
||||||
|
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),
|
||||||
|
pollIntervalSec: z.number().int().min(10).max(3600).default(30), 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.string().email(), ...accountCommon, settings: imapSmtpSettingsSchema, 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() }), // address 由探测回填
|
||||||
|
]);
|
||||||
|
export const mailAccountViewSchema = z.object({ id, kind, name, address, displayName, enabled, isDefault, pollIntervalSec, channelId, backfillFrom, settings /* 非密 */, secretBound: z.boolean(),
|
||||||
|
status: z.object({ lastPolledAt: iso|null, lastPollError: string|null, lastInboundAt: iso|null, cursor: string|null /* 人读摘要 */, polling: z.boolean() }), createdAt, updatedAt });
|
||||||
|
export const probeReportSchema = z.object({ kind, address: z.string().optional(),
|
||||||
|
receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().optional() }),
|
||||||
|
send: z.object({ checked: z.boolean(), ok: z.boolean().optional(), error: z.string().optional(), note: z.string().optional() }) });
|
||||||
|
export const deliveryViewSchema = z.object({ subscriber, state: z.enum(['dispatching','done','failed','skipped']), error: string|null, startedAt, finishedAt: iso|null });
|
||||||
|
export const inboundAttachmentSchema = z.object({ index: int, name, contentType, size: int, ref: string|null, note: string });
|
||||||
|
export const inboundSummarySchema = z.object({ id, accountId, messageId, from: address, to: address[], cc: address[], subject, sentAt: iso|null, receivedAt, textPreview: z.string() /* ≤200 字 */, hasHtml, attachments: inboundAttachmentSchema[], deliveries: deliveryViewSchema[] });
|
||||||
|
export const inboundDetailSchema = inboundSummarySchema.extend({ inReplyTo: string|null, references: string[], text: string|null, html: string|null, size: int, rawAvailable: z.boolean() });
|
||||||
|
export const sendInputSchema = z.object({ accountId: z.string().optional(), to: z.array(z.string().email()).min(1), cc: z.array(z.string().email()).default([]), bcc: z.array(z.string().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'), 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() }) })
|
||||||
|
.refine(v => v.text !== undefined || v.html !== undefined, '正文 text / html 至少一个');
|
||||||
|
export const sendResultSchema = z.object({ outboundId, accountId, messageId, state: z.enum(['sent','failed']), error: z.string().optional(), providerRef: z.string().optional() });
|
||||||
|
export const outboundViewSchema = z.object({ id, accountId, messageId, to: string[], cc, bcc, subject, state: z.enum(['sending','sent','failed']), error: string|null, providerRef: string|null, requestedBy, createdAt, sentAt: iso|null });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 新增路由 `packages/contracts/src/api/mail.ts`(经 `api/index.ts` 转出;写路由由 `/api/gov/*` 中间件自动广播)
|
||||||
|
|
||||||
|
| 方法 | 路径 | access | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/gov/mail/accounts` | `['design','ops']` | `{accounts: MailAccountView[]}` |
|
||||||
|
| POST | `/api/gov/mail/accounts` | `ops` | body `MailAccountInput`;409 名字重复 |
|
||||||
|
| POST | `/api/gov/mail/accounts/probe` | `ops` | **未存**的配置探测:body `MailAccountInput`(带 secret)→ `ProbeReport` |
|
||||||
|
| GET | `/api/gov/mail/accounts/:accountId` | `['design','ops']` | 404 |
|
||||||
|
| PUT | `/api/gov/mail/accounts/:accountId` | `ops` | secret 缺省 = 不改 |
|
||||||
|
| DELETE | `/api/gov/mail/accounts/:accountId` | `ops` | `{ok:true}`;留痕不删 |
|
||||||
|
| POST | `/api/gov/mail/accounts/:accountId/probe` | `ops` | 已存账号探测(用库里凭证)→ `ProbeReport`;凭证空 412 |
|
||||||
|
| POST | `/api/gov/mail/accounts/:accountId/poll` | `ops` | 立即拉一轮 → `PollOutcome`;另一进程占着 409;停用 412 |
|
||||||
|
| POST | `/api/gov/mail/accounts/:accountId/send-test` | `ops` | body `{to, subject, text}` → `SendResult`(失败在 200 的 body 里如实说) |
|
||||||
|
| GET | `/api/gov/mail/inbound?accountId&limit` | `['design','ops']` | `{mails: InboundSummary[]}`,limit 默认 50、上限 200 |
|
||||||
|
| GET | `/api/gov/mail/inbound/:mailId` | `['design','ops']` | `InboundDetail` |
|
||||||
|
| GET | `/api/gov/mail/inbound/:mailId/attachments/:index` | `['design','ops']` | `{name, contentType, dataBase64}`;可带 `?ticket=` 下载(ADR-012 §6);未存(超限)404 |
|
||||||
|
| GET | `/api/gov/mail/inbound/:mailId/raw` | `['design','ops']` | 原件 `{name:'<messageId>.eml', contentType:'message/rfc822', dataBase64}`;可带 `?ticket=`;超限没存 404 |
|
||||||
|
| POST | `/api/gov/mail/inbound/:mailId/redeliver` | `ops` | body `{subscriber?}` → `{deliveries}`;订阅方名不认识 404 |
|
||||||
|
| GET | `/api/gov/mail/outbound?accountId&limit` | `['design','ops']` | `{mails: OutboundView[]}` |
|
||||||
|
|
||||||
|
错误语义按仓内口径:400 `validation-failed`(带 issues)· 404 `not-found` · 409 `conflict` · 412 `precondition-failed`;
|
||||||
|
探测 / 试发 / 拉取的**厂商失败不是接口错误**,在 200 的 body 里带原话(脱敏)。
|
||||||
|
`route-coverage.it.test.ts` 自动兜:少一条契约就红。
|
||||||
|
|
||||||
|
### 7.3 `mail.send` 工具契约升 2.0.0(`seed-mvp.ts` 的 `MAIL_TOOL`;安装恒新增,同版重装 no-op)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
name: 'mail.send', version: '2.0.0', effect: 'write', idempotent: true, connectionKeys: [], // 凭证归 sys.mail_account,不再走工具连接配置
|
||||||
|
input: { type:'object', required:['to','subject'], properties: {
|
||||||
|
to: { type:'array', items:{type:'string'}, minItems:1 }, cc: {…}, bcc: {…}, subject: {type:'string'}, text: {type:'string'}, html: {type:'string'},
|
||||||
|
attachments: { type:'array', items:{ type:'object', required:['uploadRef'], properties:{ uploadRef:{type:'string'}, name:{type:'string'} } } },
|
||||||
|
inReplyTo: {type:'string'}, accountId: {type:'string'} } },
|
||||||
|
output: { type:'object', properties: { outboundId:{type:'string'}, messageId:{type:'string'}, state:{type:'string'} } },
|
||||||
|
errors: [{code:'send-failed', retryable:true, message:'发信失败'}, {code:'no-account', retryable:false, message:'没有可用的邮箱账号'}],
|
||||||
|
```
|
||||||
|
|
||||||
|
**破坏性**:`to` 由 string 变 array、`body` 改名 `text`。唯一消费方是已卸载的 quote-* 活动(2026-08-24),
|
||||||
|
无在途调用;按 §12.4 记入 CONTRACT.md 与本 spec,不走三步弃用。
|
||||||
|
|
||||||
|
### 7.4 路由归属总表
|
||||||
|
|
||||||
|
`docs/API文档-鉴权与人员.md` §6 总表补上 7.2 的 15 行(读标两端、写归 `ops`)。
|
||||||
|
|
||||||
|
## 8. app 层
|
||||||
|
|
||||||
|
- **装配**(`container.ts`):`mail` 在 `toolRuntime` 之前创建(它是 L1,只要 db / secrets / clock / ids);
|
||||||
|
`attachments` 注入件复用 `sys.upload` 的存取(把 `governance-ops.ts` 上传路由里那段 insert/select 抽成
|
||||||
|
`app/upload-store.ts` 两个函数,路由与注入件同用 —— 唯一一处顺手抽取,范围限定在那两段);
|
||||||
|
`notify` → `broadcaster.publish({kind:'system', id:'mail', reason})`。
|
||||||
|
`toolRuntime` 改为 `createToolRuntime(new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler)`
|
||||||
|
—— 处理器在 container 里闭包持 `mail`(`firstPartyToolHandlers` 这个模块级导出随之改成装配时拼出的局部量;仓内没有别处引用它),`first-party.ts` 里的旧 `mailSend` 删除;处理器把 `idempotencyKey`
|
||||||
|
与 `requestedBy:{kind:'tool', taskId?}` 带进 `mail.send`,失败映射为 `{ok:false, error:{code:'send-failed', retryable}}`。
|
||||||
|
channel 的 `mail:` 注入件改为 `{ send: (m) => mail.send({...m 映射, requestedBy:{kind:'channel', taskId}}) 再回 {messageId, threadId: m.threadId ?? messageId} }`
|
||||||
|
(按 `from` 找账号,找不到用默认);`createSimulatedMailTransport` 不再装配(代码与表暂留,记 debt)。
|
||||||
|
channel 创建后:`mail.subscribe('channel', …)`(§8 末)。
|
||||||
|
- **工作者**(`app/workers/mail-poller.ts`):`startMailPoller({container}, tickMs)`,与 `startDispatcher` 同形
|
||||||
|
(`setInterval` + `unref` + `stop()`),每 tick `container.mail.pollDue(now)`;`main.ts` 起、`close()` 停。
|
||||||
|
`config.ts` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000)`。
|
||||||
|
- **路由**(`app/routes/mail.ts`,`registerMailRoutes(app, container)`):按 7.2 逐条,错误映射照 5.1 的注释;
|
||||||
|
附件下载路由认 `?ticket=`(中间件已支持 GET 票据)。
|
||||||
|
- **channel 订阅方**(app 装配处的一个函数 `toInboundMail(detail)`,有测):
|
||||||
|
账号 `channelId` 空 → `{skipped:'账号未绑定通道'}`;否则
|
||||||
|
`channel.ingestMail(channelId, { messageId, threadId: inReplyTo ?? references.at(-1), from: from.address, to: to.map(a=>a.address), cc: …, subject, body: text ?? '', receivedAt: iso, attachments: 只带已存的 [{name, contentType, size, ref}] })`;
|
||||||
|
`ChannelNotFoundError / ChannelDisabledError` 原样抛 → 留痕 `failed` 带原话(通道停用不是信的错,人在运营面看得见)。
|
||||||
|
- **seed**:不种账号(凭证只能人填);`activities:install` 不涉及。
|
||||||
|
|
||||||
|
## 9. 可靠性与安全纪律
|
||||||
|
|
||||||
|
### 9.1 跨进程租约(两个后端连同一个库)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
update sys.mail_account
|
||||||
|
set polling_until = now() + ($2 || ' milliseconds')::interval
|
||||||
|
where id = $1 and enabled
|
||||||
|
and (polling_until is null or polling_until < now())
|
||||||
|
returning id
|
||||||
|
```
|
||||||
|
判与占一条语句(分两句写就是经典竞态);过期 = `max(5min, 3×poll_interval)`,进程崩了不锁死;拉完置空。
|
||||||
|
`pollNow` 抢不到 → 409 `MailPollBusyError`(「另一台正在拉取」)。
|
||||||
|
|
||||||
|
### 9.2 去重与游标
|
||||||
|
|
||||||
|
`(account_id, message_id)` 与 `(account_id, provider_ref)` 两把唯一键:重复拉到(时钟偏、UIDVALIDITY 重锚、
|
||||||
|
wecom 十分钟重叠窗)一律 `inserted:false` 静默跳过、不分发。游标**逐封推进**(IMAP)/ 逐批推进(wecom),
|
||||||
|
中途失败停在最后一封成功处,下一 tick 从那儿接。首次无游标锚在当下,**不回填整箱**;
|
||||||
|
`backfillFrom` 才回填。
|
||||||
|
|
||||||
|
### 9.3 分发语义(诚实版「至少一次」)
|
||||||
|
|
||||||
|
自动路径**只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话)`。不自动重试 ——
|
||||||
|
channel → event 这条链按 `cause.ref` **不幂等**(`event.ingestInbound` 无去重),重投会起两单。
|
||||||
|
失败在运营面看得见,「重新投递」是人按的钮,那一刻起是至少一次(文档写明)。
|
||||||
|
进程在 `dispatching` 期间崩了:行停在 `dispatching`,列表按「投递状态未知(进程中断)」摆出来,同样交人。
|
||||||
|
根治(channel/event 按 `cause.ref` 幂等,自动重投才安全)记 `docs/debt.md`。
|
||||||
|
|
||||||
|
### 9.4 凭证与脱敏
|
||||||
|
|
||||||
|
- 密封:复用 `SecretBox`(AES-256-GCM,`JCAI_SECRET_KEY`,ADR-009);视图只回 `secretBound`;更新时空 = 不改。
|
||||||
|
- 明文只在 usecase 打开、交给 provider 那一段;不进 domain、不进留痕、不进日志。
|
||||||
|
- 所有写进 `last_poll_error / delivery.error / outbound.error` 的厂商原话先过 `redactSecrets`
|
||||||
|
(明文、base64、URL 编码三形);nodemailer 的 `response` 行可能带 AUTH 明文/密文,同样过。
|
||||||
|
- token(wecom)只在 provider 实例内存里,不落库。
|
||||||
|
|
||||||
|
### 9.5 §7.3「两缝」
|
||||||
|
|
||||||
|
`MailProvider` 是新 port,它不经 `ModelGateway` / `ToolInvoker`:**入站是外界事实,持久化即记录**
|
||||||
|
(回放读 `sys.mail_inbound`,永不重拉);**出站**活动侧经 `ToolInvoker`(可录可放),系统侧(channel / ops)
|
||||||
|
留痕 `sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」的解释,
|
||||||
|
随 ADR-013 一并定案。
|
||||||
|
|
||||||
|
### 9.6 界面不撒谎
|
||||||
|
|
||||||
|
探测分收 / 发两栏各说各的,wecom 的发信一栏 `checked:false` 明说「只能真发时验证」;
|
||||||
|
拉取错误摆厂商原话;wecom 的 `address` 探到之前是空串,前端摆「待探测」而不是编一个;
|
||||||
|
`polling` 只在租约未过期时为真。
|
||||||
|
|
||||||
|
## 10. 腾讯侧前置(管理员执行;写进《前端对接说明》)
|
||||||
|
|
||||||
|
**IMAP/SMTP 路**:① 管理员「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP;② 该邮箱在网页端
|
||||||
|
「设置 → 收发信设置」开启 IMAP/SMTP 服务;③ 开了安全登录 / 微信绑定的,「设置 → 邮箱绑定」生成
|
||||||
|
**客户端专用密码**(最多 20 个,短信下发),填进账号配置的密码格;公共邮箱可在企业微信管理端或经 API
|
||||||
|
`create_auth_code:1` 生成(只返回一次)。
|
||||||
|
**企业微信 API 路**:① 企业微信 ≥ 4.0.20;② 建自建应用,记下 corpid 与应用 secret;③ 管理端「协作 → 邮件」
|
||||||
|
给该应用授邮件权限,系统自动分配应用邮箱(前缀=应用名拼音,可改);④ 如企业开了 IP 白名单,把后端出口 IP 加进去
|
||||||
|
(否则 60020)。⑤ 发信配额归企业级共享,腾讯按账号数与信誉浮动,超了 SMTP/API 都会拒 —— 只记文档,不做控制。
|
||||||
|
|
||||||
|
## 11. 连带改动
|
||||||
|
|
||||||
|
| 处 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `tools/module-tiers.json` + `docs/编码规范.md` §4 | tier 1 加 `mail`;表加一行「邮箱账号与收发:两条接入路适配、轮询拉取与留痕、订阅分发、出站留痕」 |
|
||||||
|
| `apps/api/package.json` | `imapflow` `nodemailer` `postal-mime`(+ `@types/nodemailer` dev) |
|
||||||
|
| `apps/api/src/app/tools/first-party.ts` | 删 `mailSend`;`FIRST_PARTY_TOOLS` 去掉 `mail.send` 那行(改由 container 装配) |
|
||||||
|
| `apps/api/src/app/seed-mvp.ts` | `MAIL_TOOL` 升 2.0.0(§7.3) |
|
||||||
|
| `apps/api/src/app/routes/governance-ops.ts` | 上传两段 SQL 抽到 `app/upload-store.ts`,行为不变 |
|
||||||
|
| `apps/api/src/app/reset-runs.ts` | 加三张留痕表 |
|
||||||
|
| `apps/api/src/modules/channel/CONTRACT.md` | 「邮件通道是外界」一条改为:真通道由 `mail` 模块提供(app 注入),模拟件只剩测试用途 |
|
||||||
|
| `docs/adr/ADR-013-mail-gateway.md` | 邮件收发:外界适配两条路、入站先持久后分发、出站留痕、凭证密封、跨进程租约、「两缝」解释 |
|
||||||
|
| `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md` | 本轮产出(§15) |
|
||||||
|
| `docs/API文档-鉴权与人员.md` §6 | 总表加 15 行 |
|
||||||
|
| `docs/debt.md` | 见 §13 |
|
||||||
|
| `CLAUDE.md` 现状快照 | 加一条 |
|
||||||
|
| `api-report/` | `pnpm api:check --update && git add api-report`(新模块契约面 + contracts) |
|
||||||
|
|
||||||
|
## 12. 测试策略
|
||||||
|
|
||||||
|
- **domain 零 mock**:游标两形与摘要、wecom 窗口、到期判定与租约时长、`inboundRecordOf`(缺 Message-ID / 只有 html /
|
||||||
|
附件超限)、`redactSecrets` 三形、`validateSend` 与 `pickAccount`(指定停用 / 无默认 / 多默认)、分发状态机、`viewOf` 不带密文。
|
||||||
|
- **usecase**(假 provider + 内存 store):拉 → 存 → 分发全链;重复拉不重分发;中途失败游标停对位;订阅方 skipped / failed 互不影响;
|
||||||
|
`redeliver`;`send` 的幂等键、失败落行并抛、附件取自 upload;`probe` 两种入参;`updateAccount` 空 secret 不改密文;默认账号唯一。
|
||||||
|
- **adapter**:`postal-mime-parser` 跑夹具(GBK 主题正文、多段、纯 html、附件、无 Message-ID);
|
||||||
|
`wecom-provider` 对假 `fetch`(token 缓存与过期刷新、45009 停、翻页、已知 mail_id 不再 read_mail、compose_send 载荷);
|
||||||
|
`imap-smtp-provider` 的映射逻辑(UID 区间、validity 变化、`cursorAfter`)单测;真信箱 IT 用 env 门控
|
||||||
|
`JCAI_MAIL_IT_IMAP_HOST/USER/PASS`(缺即 `describe.skipIf`)。
|
||||||
|
- **IT(真 PG)**:迁移;三个 store;`claimPolling` 两连接对抢 20 轮每轮恰一个赢、过期后接得上;唯一键冲突回 `inserted:false`。
|
||||||
|
- **app**:`toInboundMail` 映射;`mailSendTool` 错误映射;`route-coverage` 自动覆盖新路由归属。
|
||||||
|
- 门禁:`pnpm gates` 全绿 + api-report 更新。
|
||||||
|
|
||||||
|
## 13. 风险、取舍与欠账(进 `docs/debt.md`)
|
||||||
|
|
||||||
|
1. **channel/event 按 `cause.ref` 不幂等** → 分发只能自动一次;根治后可改自动重投。
|
||||||
|
2. **wecom 发信拿不到 Message-ID** → 线程级归属对 wecom 出信不成立(③级地址窗仍可用)。
|
||||||
|
3. **`mail_data` 编码文档未说** → 实现时探针定档;两档都留兜底直到探明。
|
||||||
|
4. **IMAP IDLE 未做** → 最快 10s 延迟(`pollIntervalSec` 下限)。
|
||||||
|
5. **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留** → 下一轮退役。
|
||||||
|
6. **附件走 `sys.upload` 的 8MB 上限** → 更大的附件只留注;要真存得改上传件的存放策略。
|
||||||
|
7. **订阅方不落库** → 新加订阅方收不到历史信(运营面可手动投)。
|
||||||
|
|
||||||
|
## 14. 实施边界
|
||||||
|
|
||||||
|
改:`apps/api/src/modules/mail/**`(新)· `packages/contracts/src/{mail.ts, api/mail.ts, api/index.ts, index.ts}` ·
|
||||||
|
`db/migrations/0033_mail.sql` + journal · `apps/api/src/app/{container.ts, main.ts, config.ts, reset-runs.ts, seed-mvp.ts, upload-store.ts(新), routes/mail.ts(新), workers/mail-poller.ts(新), tools/first-party.ts}` ·
|
||||||
|
`tools/module-tiers.json` · 文档(§11)。
|
||||||
|
不改:`apps/web/**`、`channel` 的 index/contract/port、任何门禁配置、`ingest` 路由(仍是注入门,e2e 继续用)。
|
||||||
|
|
||||||
|
## 15. 文档产出
|
||||||
|
|
||||||
|
- `docs/API文档-邮件收发.md`:通则 → 每条路由一节(`METHOD /path —— access`)→ 状态码与 body → schema 附录 → curl 实录。
|
||||||
|
- `docs/前端对接说明-邮件收发.md`:一分钟看懂 → 建议落点(运营中心 · 系统区「邮箱」)→ 账号表单按 `kind` 切换的字段清单与默认值 →
|
||||||
|
探测 / 立即拉取 / 试发 / 重新投递四个动作的交互与「界面不撒谎」口径 → 入站 / 出站列表与附件预览 → SSE 失效映射(`kind:'system'` → `['gov','mail',*]`)→
|
||||||
|
腾讯侧前置(§10)→ 接入完成的判据。
|
||||||
Reference in New Issue
Block a user