diff --git a/api-report/apps/api/src/modules/mail/contract.d.ts b/api-report/apps/api/src/modules/mail/contract.d.ts new file mode 100644 index 0000000..f62c5ba --- /dev/null +++ b/api-report/apps/api/src/modules/mail/contract.d.ts @@ -0,0 +1,62 @@ +/** + * module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。 + */ +import type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult } from '@contracts'; +export type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, }; +export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './internal/domain/errors.ts'; +/** + * 订阅方回调 —— 「注册回调读到邮件内容」的落点。 + * 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。 + * **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。 + */ +export type InboundHandler = (mail: MailInboundDetail, ctx: { + readonly accountId: string; + readonly channelId: string | undefined; +}) => Promise; +export type MailService = { + listAccounts(): Promise; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ + updateAccount(accountId: string, input: MailAccountInput): Promise; + /** 留痕不删(入站 / 出站表对账号不设外键) */ + deleteAccount(accountId: string): Promise; + /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */ + probe(target: { + accountId: string; + } | { + input: MailAccountInput; + accountId?: string | undefined; + }): Promise; + /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */ + pollDue(now: Date): Promise; + /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */ + pollNow(accountId: string): Promise; + /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */ + subscribe(name: string, handler: InboundHandler): () => void; + listInbound(query: { + accountId?: string | undefined; + limit?: number | undefined; + }): Promise; + getInbound(mailId: string): Promise; + attachment(mailId: string, index: number): Promise<{ + name: string; + contentType: string; + bytes: Uint8Array; + }>; + /** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */ + raw(mailId: string): Promise<{ + name: string; + bytes: Uint8Array; + }>; + /** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */ + redeliver(mailId: string, subscriber?: string): Promise; + /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */ + send(input: MailSendInput): Promise; + listOutbound(query: { + accountId?: string | undefined; + limit?: number | undefined; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/mail/index.d.ts b/api-report/apps/api/src/modules/mail/index.d.ts new file mode 100644 index 0000000..dc8dbd6 --- /dev/null +++ b/api-report/apps/api/src/modules/mail/index.d.ts @@ -0,0 +1,21 @@ +/** + * module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { InboundHandler, MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, MailService, } from './contract.ts'; +export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './contract.ts'; +export type { AttachmentStore, ChangeNotifier, Clock, DeliveryRecord, IdGenerator, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, MailCursor, MailProvider, MimeParser, OutboundMessage, OutboundRecord, OutboundStore, ParsedMail, ProviderAccount, RawMessage, SecretBox, } from './port.ts'; +export { advanceImap, cursorSummary, imapCursor, imapNeedsReanchor, newUids, parseCursor, wecomCursor, wecomWindow, } from './internal/domain/cursor.ts'; +export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; +export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts'; +export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; +export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; +export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; +export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; +export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; +export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; +export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; +export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; +export { createMailService } from './internal/usecase/mail-service.ts'; +export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; +export type { FetchLike } from './internal/adapter/wecom-provider.ts'; +export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts'; diff --git a/api-report/apps/api/src/modules/mail/port.d.ts b/api-report/apps/api/src/modules/mail/port.d.ts new file mode 100644 index 0000000..3beaee0 --- /dev/null +++ b/api-report/apps/api/src/modules/mail/port.d.ts @@ -0,0 +1,132 @@ +/** + * module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import type { MailCursor } from './internal/domain/cursor.ts'; +import type { DeliveryRecord, InboundRecord, MailAccountRecord, OutboundMessage, OutboundRecord, ParsedMail } from './internal/domain/records.ts'; +export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail }; +/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */ +export type SecretBox = { + seal(plaintext: string): string; + open(cipher: string): string; +}; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +export type MailAccountStore = { + list(): Promise; + get(id: string): Promise; + findByName(name: string): Promise; + /** upsert;`isDefault:true` 时先把别的置 false(同一事务) */ + save(record: MailAccountRecord): Promise; + remove(id: string): Promise; + /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */ + listDue(now: Date): Promise; + /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */ + claimPolling(id: string, leaseMs: number): Promise; + releasePolling(id: string): Promise; + /** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */ + savePoll(id: string, patch: { + cursor?: MailCursor; + polledAt?: Date; + error?: string | null; + lastInboundAt?: Date; + address?: string; + }): Promise; +}; +export type InboundStore = { + /** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */ + insert(record: InboundRecord): Promise<{ + inserted: boolean; + }>; + knownProviderRefs(accountId: string, refs: readonly string[]): Promise>; + list(query: { + accountId?: string | undefined; + limit: number; + }): Promise>; + get(mailId: string): Promise<{ + record: InboundRecord; + deliveries: DeliveryRecord[]; + } | undefined>; + /** upsert on (mail_id, subscriber) */ + setDelivery(delivery: DeliveryRecord): Promise; +}; +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { + error?: string; + providerRef?: string; + sentAt?: Date; + }): Promise; + list(query: { + accountId?: string | undefined; + limit: number; + }): Promise; +}; +/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */ +export type AttachmentStore = { + put(file: { + name: string; + contentType: string; + bytes: Uint8Array; + note: string; + }): Promise<{ + ref: string; + }>; + get(ref: string): Promise<{ + name: string; + contentType: string; + bytes: Uint8Array; + } | undefined>; +}; +/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */ +export type ProviderAccount = { + readonly id: string; + readonly kind: MailAccountKind; + readonly address: string; + readonly displayName: string; + readonly settings: Record; + readonly secret: Record; +}; +export type RawMessage = { + readonly providerRef: string; + readonly eml: Uint8Array; + readonly receivedAt: Date; + /** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */ + readonly cursorAfter?: MailCursor | undefined; +}; +/** 外界:一种账号类型一个适配器(设计稿 §5.5) */ +export type MailProvider = { + readonly kind: MailAccountKind; + probe(account: ProviderAccount): Promise; + /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise; + pull(account: ProviderAccount, cursor: MailCursor, opts: { + readonly limit: number; + readonly now: Date; + readonly backfillFrom: Date | null; + /** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */ + readonly known: (refs: readonly string[]) => Promise>; + }): Promise<{ + messages: RawMessage[]; + cursor: MailCursor; + more: boolean; + }>; + send(account: ProviderAccount, message: OutboundMessage): Promise<{ + providerRef?: string | undefined; + }>; +}; +export type MimeParser = { + parse(eml: Uint8Array): Promise; +}; +/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */ +export type ChangeNotifier = { + changed(reason: string): void; +}; diff --git a/api-report/packages/contracts/src/__tests__/mail.test.d.ts b/api-report/packages/contracts/src/__tests__/mail.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/api-report/packages/contracts/src/__tests__/mail.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/api-report/packages/contracts/src/api/index.d.ts b/api-report/packages/contracts/src/api/index.d.ts index 69dfaef..297cfdd 100644 --- a/api-report/packages/contracts/src/api/index.d.ts +++ b/api-report/packages/contracts/src/api/index.d.ts @@ -2,3 +2,4 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; export * from './ingest.ts'; +export * from './mail.ts'; diff --git a/api-report/packages/contracts/src/api/mail.d.ts b/api-report/packages/contracts/src/api/mail.d.ts new file mode 100644 index 0000000..be53e80 --- /dev/null +++ b/api-report/packages/contracts/src/api/mail.d.ts @@ -0,0 +1,690 @@ +/** + * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 + * + * 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方; + * 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +export declare const listMailAccountsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/accounts"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + accounts: z.ZodArray; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>; +}; +export declare const createMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts"; + readonly access: "ops"; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ +export declare const probeMailDraftRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/probe"; + readonly access: "ops"; + /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + }, z.core.$strip>; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +export declare const getMailAccountRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ +export declare const updateMailAccountRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +export declare const deleteMailAccountRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 已存账号探测(用库里凭证);凭证空 412 */ +export declare const probeMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/probe"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +/** 立即拉一轮;另一进程占着租约 409;停用 412 */ +export declare const pollMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/poll"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + accountId: z.ZodString; + pulled: z.ZodNumber; + stored: z.ZodNumber; + dispatched: z.ZodNumber; + more: z.ZodBoolean; + error: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const sendTestMailRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/send-test"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + to: z.ZodEmail; + subject: z.ZodString; + text: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + outboundId: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sent: "sent"; + }>; + error: z.ZodOptional; + providerRef: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const listMailInboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound"; + readonly access: "ops"; + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + limit: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + mails: z.ZodArray; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getMailInboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + inReplyTo: z.ZodNullable; + references: z.ZodArray; + text: z.ZodNullable; + html: z.ZodNullable; + size: z.ZodNumber; + rawAvailable: z.ZodBoolean; + }, z.core.$strip>; +}; +/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ +export declare const mailAttachmentRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId/attachments/:index"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + index: z.ZodCoercedNumber; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodString; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** 原件 .eml;超限没存 404 */ +export declare const mailRawRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId/raw"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodLiteral<"message/rfc822">; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ +export declare const redeliverMailInboundRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/inbound/:mailId/redeliver"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodDefault; + }, z.core.$strip>>; + readonly output: z.ZodObject<{ + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const listMailOutboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/outbound"; + readonly access: "ops"; + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + limit: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + mails: z.ZodArray; + cc: z.ZodArray; + bcc: z.ZodArray; + subject: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; + }>; + error: z.ZodNullable; + providerRef: z.ZodNullable; + requestedBy: z.ZodRecord; + createdAt: z.ZodISODateTime; + sentAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 全部 15 条 —— 文档总表与覆盖测试按它数 */ +export declare const MAIL_ROUTES: readonly RouteContract[]; diff --git a/api-report/packages/contracts/src/index.d.ts b/api-report/packages/contracts/src/index.d.ts index 6cafdfc..9ef3862 100644 --- a/api-report/packages/contracts/src/index.d.ts +++ b/api-report/packages/contracts/src/index.d.ts @@ -13,4 +13,5 @@ export * from './ledger.ts'; export * from './process-package.ts'; export * from './manifest.ts'; export * from './org.ts'; +export * from './mail.ts'; export * from './api/index.ts'; diff --git a/api-report/packages/contracts/src/mail.d.ts b/api-report/packages/contracts/src/mail.d.ts new file mode 100644 index 0000000..8833d17 --- /dev/null +++ b/api-report/packages/contracts/src/mail.d.ts @@ -0,0 +1,364 @@ +/** + * 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。 + * + * 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。 + * 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱), + * 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。 + */ +import { z } from 'zod'; +export declare const MAIL_ACCOUNT_KINDS: readonly ["imap-smtp", "wecom"]; +export declare const mailAccountKindSchema: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; +}>; +export type MailAccountKind = z.infer; +export declare const MAIL_ACCOUNT_KIND_LABEL: Readonly>; +/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ +export declare const imapSmtpSettingsSchema: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; +}, z.core.$strip>; +export type ImapSmtpSettings = z.infer; +/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ +export declare const wecomSettingsSchema: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; +}, z.core.$strip>; +export type WecomSettings = z.infer; +export declare const mailAccountInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; +}, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; +}, z.core.$strip>], "kind">; +export type MailAccountInput = z.infer; +export declare const mailAccountStatusSchema: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; +}, z.core.$strip>; +export declare const mailAccountViewSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; +}, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; +}, z.core.$strip>], "kind">; +export type MailAccountView = z.infer; +/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ +export declare const mailProbeReportSchema: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; +export type MailProbeReport = z.infer; +export declare const MAIL_DELIVERY_STATES: readonly ["dispatching", "done", "failed", "skipped"]; +export declare const mailDeliveryStateSchema: z.ZodEnum<{ + failed: "failed"; + done: "done"; + dispatching: "dispatching"; + skipped: "skipped"; +}>; +export type MailDeliveryState = z.infer; +export declare const mailDeliveryViewSchema: z.ZodObject<{ + subscriber: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + done: "done"; + dispatching: "dispatching"; + skipped: "skipped"; + }>; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; +}, z.core.$strip>; +export type MailDeliveryView = z.infer; +export declare const mailAddressSchema: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; +}, z.core.$strip>; +export type MailAddress = z.infer; +export declare const mailInboundAttachmentSchema: z.ZodObject<{ + index: z.ZodNumber; + name: z.ZodString; + contentType: z.ZodString; + size: z.ZodNumber; + ref: z.ZodNullable; + note: z.ZodString; +}, z.core.$strip>; +export type MailInboundAttachment = z.infer; +export declare const mailInboundSummarySchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; +}, z.core.$strip>; +export type MailInboundSummary = z.infer; +export declare const mailInboundDetailSchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + inReplyTo: z.ZodNullable; + references: z.ZodArray; + text: z.ZodNullable; + html: z.ZodNullable; + size: z.ZodNumber; + rawAvailable: z.ZodBoolean; +}, z.core.$strip>; +export type MailInboundDetail = z.infer; +export declare const mailSendInputSchema: z.ZodObject<{ + accountId: z.ZodOptional; + from: z.ZodOptional; + to: z.ZodArray; + cc: z.ZodDefault>; + bcc: z.ZodDefault>; + subject: z.ZodString; + text: z.ZodOptional; + html: z.ZodOptional; + inReplyTo: z.ZodOptional; + references: z.ZodDefault>; + attachments: z.ZodDefault; + dataBase64: z.ZodOptional; + uploadRef: z.ZodOptional; + }, z.core.$strip>>>; + idempotencyKey: z.ZodOptional; + requestedBy: z.ZodObject<{ + kind: z.ZodEnum<{ + channel: "channel"; + tool: "tool"; + ops: "ops"; + }>; + taskId: z.ZodOptional; + staffId: z.ZodOptional; + tool: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; +export type MailSendInput = z.infer; +export declare const MAIL_OUTBOUND_STATES: readonly ["sending", "sent", "failed"]; +export declare const mailOutboundStateSchema: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; +}>; +export type MailOutboundState = z.infer; +export declare const mailSendResultSchema: z.ZodObject<{ + outboundId: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sent: "sent"; + }>; + error: z.ZodOptional; + providerRef: z.ZodOptional; +}, z.core.$strip>; +export type MailSendResult = z.infer; +export declare const mailOutboundViewSchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + to: z.ZodArray; + cc: z.ZodArray; + bcc: z.ZodArray; + subject: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; + }>; + error: z.ZodNullable; + providerRef: z.ZodNullable; + requestedBy: z.ZodRecord; + createdAt: z.ZodISODateTime; + sentAt: z.ZodNullable; +}, z.core.$strip>; +export type MailOutboundView = z.infer; +export declare const mailPollOutcomeSchema: z.ZodObject<{ + accountId: z.ZodString; + pulled: z.ZodNumber; + stored: z.ZodNumber; + dispatched: z.ZodNumber; + more: z.ZodBoolean; + error: z.ZodOptional; +}, z.core.$strip>; +export type MailPollOutcome = z.infer; diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts index c790103..e0b8b55 100644 --- a/apps/api/src/app/seed-mvp.ts +++ b/apps/api/src/app/seed-mvp.ts @@ -48,21 +48,60 @@ const HISTORY_TOOL = { connectionKeys: ['endpoint'], }; -/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */ +/** + * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。 + * + * 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。 + * 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。 + * `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。 + * `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。 + */ const MAIL_TOOL = { ...HISTORY_TOOL, name: 'mail.send', displayName: '发送邮件', - description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界', + version: '2.0.0', + description: + '从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' + + 'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。', input: { type: 'object', required: ['to', 'subject'], - properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } }, + properties: { + to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' }, + cc: { type: 'array', items: { type: 'string' }, description: '抄送' }, + bcc: { type: 'array', items: { type: 'string' }, description: '密送' }, + subject: { type: 'string' }, + body: { type: 'string', description: '纯文本正文(与 html 至少一个)' }, + html: { type: 'string', description: 'HTML 正文' }, + attachments: { + type: 'array', + items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } }, + description: '附件:上传件的 ref', + }, + inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' }, + accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' }, + dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' }, + }, + }, + output: { + type: 'object', + properties: { + queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' }, + outboundId: { type: 'string' }, + messageId: { type: 'string' }, + state: { type: 'string' }, + dryRun: { type: 'boolean' }, + }, }, - output: { type: 'object', properties: { queued: { type: 'boolean' } } }, effect: 'write' as const, + errors: [ + { code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' }, + { code: 'no-account', retryable: false, message: '没有可用的邮箱账号' }, + { code: 'invalid', retryable: false, message: '入参不合法' }, + ], idempotent: true, - connectionKeys: ['endpoint'], + connectionKeys: ['account'], }; /** @@ -297,8 +336,10 @@ export async function seedMvp(container: Container): Promise { // 样例集一份喂四件工具:并集覆盖各家必填 input: { pn: 'LM358DR', - to: 'x@y.com', + to: ['x@y.com'], subject: '询价', + body: '样例正文', + dryRun: true, history_days: 30, market_stable: true, lifecycle: '在产', @@ -306,8 +347,10 @@ export async function seedMvp(container: Container): Promise { expectShape: { type: 'object' }, }, ], - // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点 - ...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: 'replay://crm' } }), + // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」 + ...(contract.connectionKeys.length === 0 + ? {} + : { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }), }); if (!verdict.accepted) { log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; ')); diff --git a/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts b/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts index ed6feaf..7d6b995 100644 --- a/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts +++ b/apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts @@ -138,3 +138,20 @@ describe('staticAudit —— 注册两审之静态审', () => { expect(auditPassed(findings)).toBe(true); }); }); + +describe('mail.send 2.0.0 的演进', () => { + it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => { + const v1 = { + name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)', + input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } }, + output: { type: 'object', properties: { queued: { type: 'boolean' } } }, + effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'], + }; + const v2 = { + ...v1, version: '2.0.0', connectionKeys: ['account'], + input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } }, + output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } }, + }; + expect(checkEvolution(v1, v2)).toEqual([]); + }); +});