mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 00:11:14 +08:00
co-authored by Claude Fable 5
parent 7c6e399b57
commit 83f65b2785
11 changed files with 700 additions and 0 deletions
@@ -0,0 +1,65 @@
/**
* 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。
* IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。
*/
export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number };
export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number };
export type MailCursor = ImapCursor | WecomCursor;
/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */
export const WECOM_OVERLAP_SEC = 600;
export function imapCursor(uidValidity: string, lastUid: number): ImapCursor {
return { kind: 'imap', uidValidity, lastUid };
}
export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor {
return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor;
}
export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean {
return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity;
}
/**
* IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)——
* 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
*/
export function newUids(uids: readonly number[], lastUid: number): number[] {
return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b);
}
export function wecomCursor(lastTime: number): WecomCursor {
return { kind: 'wecom', lastTime };
}
export function wecomWindow(
cursor: MailCursor | null,
backfillFrom: Date | null,
now: Date,
): { beginTime: number; endTime: number } {
const endTime = Math.floor(now.getTime() / 1000);
if (cursor !== null && cursor.kind === 'wecom') {
return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime };
}
if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime };
// 无游标不回填:窗口空,这一轮只是把游标锚在当下
return { beginTime: endTime, endTime };
}
export function cursorSummary(cursor: MailCursor | null): string | null {
if (cursor === null) return null;
if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`;
return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`;
}
/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */
export function parseCursor(raw: unknown): MailCursor | null {
if (typeof raw !== 'object' || raw === null) return null;
const c = raw as Record<string, unknown>;
if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && typeof c['lastUid'] === 'number') {
return imapCursor(c['uidValidity'], c['lastUid']);
}
if (c['kind'] === 'wecom' && typeof c['lastTime'] === 'number') return wecomCursor(c['lastTime']);
return null;
}
@@ -0,0 +1,21 @@
/** 到期判定与租约时长(设计稿 §9.1) */
export const MIN_LEASE_MS = 5 * 60_000;
/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */
export function leaseMsFor(pollIntervalSec: number): number {
return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000);
}
export function isPolling(pollingUntil: Date | null, now: Date): boolean {
return pollingUntil !== null && pollingUntil.getTime() > now.getTime();
}
export function isDue(
account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null },
now: Date,
): boolean {
if (!account.enabled) return false;
if (isPolling(account.pollingUntil, now)) return false;
if (account.lastPolledAt === null) return true;
return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime();
}
@@ -0,0 +1,67 @@
/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */
export class MailAccountNotFoundError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号不存在:${accountId}`);
this.name = 'MailAccountNotFoundError';
}
}
export class MailAccountNameTakenError extends Error {
constructor(readonly accountName: string) {
super(`邮箱账号名已被占用:${accountName}`);
this.name = 'MailAccountNameTakenError';
}
}
export class MailAccountDisabledError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号已停用:${accountId}`);
this.name = 'MailAccountDisabledError';
}
}
export class MailNoDefaultAccountError extends Error {
constructor() {
super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认');
this.name = 'MailNoDefaultAccountError';
}
}
export class MailSecretMissingError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`);
this.name = 'MailSecretMissingError';
}
}
export class MailPollBusyError extends Error {
constructor(readonly accountId: string) {
super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`);
this.name = 'MailPollBusyError';
}
}
export class MailInboundNotFoundError extends Error {
constructor(readonly mailId: string) {
super(`这封信不在了:${mailId}`);
this.name = 'MailInboundNotFoundError';
}
}
export class MailAttachmentNotFoundError extends Error {
constructor(readonly mailId: string, readonly index: number) {
super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`);
this.name = 'MailAttachmentNotFoundError';
}
}
export class MailSubscriberUnknownError extends Error {
constructor(readonly subscriber: string) {
super(`没有这个订阅方:${subscriber}`);
this.name = 'MailSubscriberUnknownError';
}
}
export class MailSubscriberTakenError extends Error {
constructor(readonly subscriber: string) {
super(`订阅方已注册,不许重名:${subscriber}`);
this.name = 'MailSubscriberTakenError';
}
}
export class MailSendInvalidError extends Error {
constructor(readonly problems: readonly string[]) {
super(`发信入参不合法:${problems.join('; ')}`);
this.name = 'MailSendInvalidError';
}
}
@@ -0,0 +1,128 @@
/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */
import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts';
import type { MailCursor } from './cursor.ts';
export type MailAccountRecord = {
readonly id: string;
readonly kind: MailAccountKind;
readonly name: string;
readonly address: string;
readonly displayName: string;
readonly enabled: boolean;
readonly isDefault: boolean;
readonly pollIntervalSec: number;
readonly channelId: string | null;
readonly backfillFrom: Date | null;
/** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */
readonly settings: Record<string, unknown>;
/** 密封的 JSON:{password} | {corpSecret};永不出接口 */
readonly secretCipher: string | null;
readonly cursor: MailCursor | null;
readonly lastPolledAt: Date | null;
readonly lastPollError: string | null;
readonly lastInboundAt: Date | null;
readonly pollingUntil: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
};
export type InboundAttachmentRecord = {
readonly index: number;
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
readonly note: string;
};
export type InboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly providerRef: string;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly from: MailAddress;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly sentAt: Date | null;
readonly receivedAt: Date;
readonly attachments: readonly InboundAttachmentRecord[];
readonly raw: Uint8Array | null;
readonly rawNote: string;
readonly size: number;
readonly createdAt: Date;
};
export type DeliveryRecord = {
readonly mailId: string;
readonly subscriber: string;
readonly state: MailDeliveryState;
readonly error: string | null;
readonly startedAt: Date;
readonly finishedAt: Date | null;
};
export type OutboundAttachmentRecord = {
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
};
export type OutboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly idempotencyKey: string | null;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly attachments: readonly OutboundAttachmentRecord[];
readonly state: MailOutboundState;
readonly error: string | null;
readonly providerRef: string | null;
readonly requestedBy: Record<string, unknown>;
readonly createdAt: Date;
readonly sentAt: Date | null;
};
export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array };
/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */
export type ParsedMail = {
readonly messageId?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly from?: MailAddress | undefined;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly date?: Date | undefined;
readonly attachments: readonly ParsedAttachment[];
};
/** 交给 provider 的出信 —— 附件已是字节 */
export type OutboundMessage = {
readonly messageId: string;
readonly from: MailAddress;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly attachments: readonly ParsedAttachment[];
};