This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/specs/2026-08-25-mail-design.md
T

44 KiB
Raw Blame History

邮件收发模块(mail)—— 后端设计

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」说成「能发」。
  • 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()),每 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 跨进程租约(两个后端连同一个库)

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)→ 接入完成的判据。