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/packages/contracts/src/mail.ts
T

254 lines
9.3 KiB
TypeScript

/**
* 邮件收发的契约(设计稿 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'),
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<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()).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<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>;