Archived
邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof mailAccountKindSchema>;
|
||||
export const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>> = {
|
||||
'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<typeof imapSmtpSettingsSchema>;
|
||||
|
||||
/** 企业微信邮件 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<typeof wecomSettingsSchema>;
|
||||
|
||||
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<typeof mailAccountInputSchema>;
|
||||
|
||||
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<typeof mailAccountViewSchema>;
|
||||
|
||||
/** 探测报告:收 / 发各说各的;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<typeof mailProbeReportSchema>;
|
||||
|
||||
export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const;
|
||||
export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES);
|
||||
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;
|
||||
|
||||
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<typeof mailDeliveryViewSchema>;
|
||||
|
||||
export const mailAddressSchema = z.object({ address: z.string(), name: z.string() });
|
||||
export type MailAddress = z.infer<typeof mailAddressSchema>;
|
||||
|
||||
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<typeof mailInboundAttachmentSchema>;
|
||||
|
||||
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<typeof mailInboundSummarySchema>;
|
||||
|
||||
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<typeof mailInboundDetailSchema>;
|
||||
|
||||
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<typeof mailSendInputSchema>;
|
||||
|
||||
export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const;
|
||||
export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES);
|
||||
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;
|
||||
|
||||
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<typeof mailSendResultSchema>;
|
||||
|
||||
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<typeof mailOutboundViewSchema>;
|
||||
|
||||
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<typeof mailPollOutcomeSchema>;
|
||||
Reference in New Issue
Block a user