Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
44 KiB
邮件收发模块(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
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
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」说成「能发」。
- token 缓存在实例闭包里按账号存(不是模块级,§12.5 #6),提前 5 分钟刷新;40014 / 42001 刷一次重试;
45009 → 本轮停、记 error;
postal-mime-parser.ts:PostalMime.parse(eml)→ParsedMail;地址、Message-ID、References 归一为裸串; 日期解析失败留空。fake-provider.ts:内存假 provider(可喂 EML 夹具、可让 send 失败)—— 邮件通道是外界,替身允许; 它是实现端口的适配器,不是测试里的 mock。
6. 数据模型与迁移 db/migrations/0033_mail.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,前后端共用)
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)
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()),每 tickcontainer.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 跨进程租约(两个后端连同一个库)
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)
- channel/event 按
cause.ref不幂等 → 分发只能自动一次;根治后可改自动重投。 - wecom 发信拿不到 Message-ID → 线程级归属对 wecom 出信不成立(③级地址窗仍可用)。
mail_data编码文档未说 → 实现时探针定档;两档都留兜底直到探明。- IMAP IDLE 未做 → 最快 10s 延迟(
pollIntervalSec下限)。 createSimulatedMailTransport与sys.outbound_mail暂留 → 下一轮退役。- 附件走
sys.upload的 8MB 上限 → 更大的附件只留注;要真存得改上传件的存放策略。 - 订阅方不落库 → 新加订阅方收不到历史信(运营面可手动投)。
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)→ 接入完成的判据。