Archived
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:
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user