/** * 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。 * * 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。 * 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱), * 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。 */ import { z } from 'zod'; import { channelIdSchema } from './id.ts'; export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const; export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS); export type MailAccountKind = z.infer; export const MAIL_ACCOUNT_KIND_LABEL: Readonly> = { 'imap-smtp': '标准协议(IMAP/SMTP)', wecom: '企业微信邮件 API(应用邮箱)', }; /** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ export const imapSmtpSettingsSchema = z.object({ imapHost: z.string().min(1).default('imap.exmail.qq.com'), imapPort: z.number().int().min(1).max(65535).default(993), /** true = 隐式 TLS(993);false = 明文(不建议) */ imapTls: z.boolean().default(true), smtpHost: z.string().min(1).default('smtp.exmail.qq.com'), smtpPort: z.number().int().min(1).max(65535).default(465), /** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */ smtpTls: z.boolean().default(true), /** 登录名;缺省 = address */ login: z.string().min(1).optional(), folder: z.string().min(1).default('INBOX'), }); export type ImapSmtpSettings = z.infer; /** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ export const wecomSettingsSchema = z.object({ corpId: z.string().min(1), apiBase: z.url().default('https://qyapi.weixin.qq.com'), }); export type WecomSettings = z.infer; const accountCommon = { name: z.string().min(1).max(64), displayName: z.string().max(64).default(''), enabled: z.boolean().default(true), isDefault: z.boolean().default(false), /** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */ pollIntervalSec: z.number().int().min(10).max(3600).default(30), /** 入站交给哪个通道;空 = channel 订阅方 skipped */ channelId: channelIdSchema.nullable().default(null), /** 从这一天起回填;空 = 首次拉取锚在当下,不回填整箱 */ backfillFrom: z.iso.datetime().nullable().default(null), }; export const mailAccountInputSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('imap-smtp'), address: z.email(), ...accountCommon, settings: imapSmtpSettingsSchema.prefault({}), /** 只写不读:缺省 = 不改(与 LLM 密钥同款) */ secret: z.object({ password: z.string().min(1) }).optional(), }), z.object({ kind: z.literal('wecom'), ...accountCommon, settings: wecomSettingsSchema, secret: z.object({ corpSecret: z.string().min(1) }).optional(), }), ]); export type MailAccountInput = z.infer; export const mailAccountStatusSchema = z.object({ lastPolledAt: z.iso.datetime().nullable(), /** 厂商原话(已脱敏);空 = 上一轮没出错 */ lastPollError: z.string().nullable(), lastInboundAt: z.iso.datetime().nullable(), /** 游标的人读摘要;还没拉过 = null */ cursor: z.string().nullable(), /** 此刻租约在手(某个进程正在拉) */ polling: z.boolean(), }); const viewCommon = { id: z.string(), name: z.string(), /** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */ address: z.string(), displayName: z.string(), enabled: z.boolean(), isDefault: z.boolean(), pollIntervalSec: z.number().int(), channelId: z.string().nullable(), backfillFrom: z.iso.datetime().nullable(), secretBound: z.boolean(), status: mailAccountStatusSchema, createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), }; export const mailAccountViewSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }), z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }), ]); export type MailAccountView = z.infer; /** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ export const mailProbeReportSchema = z.object({ kind: mailAccountKindSchema, address: z.string().optional(), receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }), send: z.object({ checked: z.boolean(), ok: z.boolean().optional(), error: z.string().optional(), note: z.string().optional(), }), }); export type MailProbeReport = z.infer; export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const; export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES); export type MailDeliveryState = z.infer; export const mailDeliveryViewSchema = z.object({ subscriber: z.string(), state: mailDeliveryStateSchema, error: z.string().nullable(), startedAt: z.iso.datetime(), finishedAt: z.iso.datetime().nullable(), }); export type MailDeliveryView = z.infer; export const mailAddressSchema = z.object({ address: z.string(), name: z.string() }); export type MailAddress = z.infer; export const mailInboundAttachmentSchema = z.object({ index: z.number().int(), name: z.string(), contentType: z.string(), size: z.number().int(), /** `sys.upload` 的 id;超限没存 = null,理由在 note */ ref: z.string().nullable(), note: z.string(), }); export type MailInboundAttachment = z.infer; export const mailInboundSummarySchema = z.object({ id: z.string(), accountId: z.string(), messageId: z.string(), from: mailAddressSchema, to: z.array(mailAddressSchema), cc: z.array(mailAddressSchema), subject: z.string(), sentAt: z.iso.datetime().nullable(), receivedAt: z.iso.datetime(), /** 正文前 200 字 */ textPreview: z.string(), hasHtml: z.boolean(), attachments: z.array(mailInboundAttachmentSchema), deliveries: z.array(mailDeliveryViewSchema), }); export type MailInboundSummary = z.infer; export const mailInboundDetailSchema = mailInboundSummarySchema.extend({ inReplyTo: z.string().nullable(), references: z.array(z.string()), text: z.string().nullable(), html: z.string().nullable(), size: z.number().int(), /** 原件 .eml 存下来了(≤16MB) */ rawAvailable: z.boolean(), }); export type MailInboundDetail = z.infer; export const mailSendInputSchema = z.object({ /** 指定账号;缺省用默认账号 */ accountId: z.string().optional(), /** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */ from: z.string().optional(), to: z.array(z.email()).min(1), cc: z.array(z.email()).default([]), bcc: z.array(z.email()).default([]), subject: z.string().min(1), text: z.string().optional(), html: z.string().optional(), inReplyTo: z.string().optional(), references: z.array(z.string()).default([]), attachments: z .array( z.object({ name: z.string().min(1), contentType: z.string().default('application/octet-stream'), /** 二选一:现给字节,或引用 sys.upload 的 id */ dataBase64: z.string().optional(), uploadRef: z.string().optional(), }), ) .default([]), /** 放行落地带的幂等键 —— 同键不发两封 */ idempotencyKey: z.string().optional(), requestedBy: z.object({ kind: z.enum(['tool', 'channel', 'ops']), taskId: z.string().optional(), staffId: z.string().optional(), tool: z.string().optional(), }), }); export type MailSendInput = z.infer; export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const; export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES); export type MailOutboundState = z.infer; export const mailSendResultSchema = z.object({ outboundId: z.string(), accountId: z.string(), messageId: z.string(), state: z.enum(['sent', 'failed']), error: z.string().optional(), providerRef: z.string().optional(), }); export type MailSendResult = z.infer; export const mailOutboundViewSchema = z.object({ id: z.string(), accountId: z.string(), messageId: z.string(), to: z.array(z.string()), cc: z.array(z.string()), bcc: z.array(z.string()), subject: z.string(), state: mailOutboundStateSchema, error: z.string().nullable(), providerRef: z.string().nullable(), requestedBy: z.record(z.string(), z.unknown()), createdAt: z.iso.datetime(), sentAt: z.iso.datetime().nullable(), }); export type MailOutboundView = z.infer; export const mailPollOutcomeSchema = z.object({ accountId: z.string(), /** provider 交出的封数 */ pulled: z.number().int(), /** 真新入库的封数(去重后) */ stored: z.number().int(), dispatched: z.number().int(), /** 预算用尽还有余下,下一 tick 接着 */ more: z.boolean(), error: z.string().optional(), }); export type MailPollOutcome = z.infer;