diff --git a/packages/contracts/src/__tests__/mail.test.ts b/packages/contracts/src/__tests__/mail.test.ts new file mode 100644 index 0000000..22a4d78 --- /dev/null +++ b/packages/contracts/src/__tests__/mail.test.ts @@ -0,0 +1,64 @@ +/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */ +import { describe, expect, it } from 'vitest'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailSendInputSchema, + MAIL_ACCOUNT_KINDS, +} from '../mail.ts'; + +describe('mailAccountInputSchema', () => { + it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' }); + expect(parsed.kind).toBe('imap-smtp'); + if (parsed.kind !== 'imap-smtp') throw new Error('unreachable'); + expect(parsed.settings).toEqual({ + imapHost: 'imap.exmail.qq.com', + imapPort: 993, + imapTls: true, + smtpHost: 'smtp.exmail.qq.com', + smtpPort: 465, + smtpTls: true, + folder: 'INBOX', + }); + expect(parsed.enabled).toBe(true); + expect(parsed.isDefault).toBe(false); + expect(parsed.pollIntervalSec).toBe(30); + expect(parsed.channelId).toBeNull(); + expect(parsed.backfillFrom).toBeNull(); + expect(parsed.secret).toBeUndefined(); + }); + + it('wecom:corpId 必填,地址不由人填(探测回填)', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } }); + if (parsed.kind !== 'wecom') throw new Error('unreachable'); + expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com'); + expect('address' in parsed).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false); + }); + + it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => { + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false); + expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']); + }); +}); + +describe('mailAccountViewSchema', () => { + it('视图只有 secretBound,没有任何密文 / 密码字段', () => { + const keys = Object.keys(mailAccountViewSchema.options[0]!.shape); + expect(keys).toContain('secretBound'); + expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false); + }); +}); + +describe('mailSendInputSchema', () => { + it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => { + const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data.attachments).toEqual([]); + expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false); + expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8d28055..09c99fe 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,4 +14,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/packages/contracts/src/mail.ts b/packages/contracts/src/mail.ts new file mode 100644 index 0000000..1231f82 --- /dev/null +++ b/packages/contracts/src/mail.ts @@ -0,0 +1,254 @@ +/** + * 邮件收发的契约(设计稿 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'), + /** zod 默认 email 正则要求顶级域至少两个字母(`.c` 会被拒);邮箱地址理应更宽 —— 改用 html5Email 正则 */ + address: z.email({ pattern: z.regexes.html5Email }), + ...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({ pattern: z.regexes.html5Email })).min(1), + cc: z.array(z.email({ pattern: z.regexes.html5Email })).default([]), + bcc: z.array(z.email({ pattern: z.regexes.html5Email })).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;