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
+53
View File
@@ -0,0 +1,53 @@
# module: mail
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
SSE(app 经 `ChangeNotifier` 端口接到 broadcaster)
公开出口: index.ts
- 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` /
`MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` /
`MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出
- 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412)
`MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409)
`MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404)
`MailSubscriberTakenError` `MailSendInvalidError`(400)
- 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser`
`SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord`
`OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor`
- 用例工厂:`createMailService(deps: MailServiceDeps): MailService`
- 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow`
`cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、
入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`;
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime
被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块:
channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`;
`mail.send` 工具处理器在 app 里闭包持本服务
不变量(ADR-013):
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
- **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
wecom 的 `address` 探到之前是空串
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变
@@ -0,0 +1,68 @@
/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */
import { describe, expect, it } from 'vitest';
import {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
WECOM_OVERLAP_SEC,
} from '../internal/domain/cursor.ts';
describe('imap 游标', () => {
it('逐封推进只往前不往后', () => {
const c = imapCursor('7', 100);
expect(advanceImap(c, 105).lastUid).toBe(105);
expect(advanceImap(c, 90).lastUid).toBe(100);
});
it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => {
expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false);
expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true);
expect(imapNeedsReanchor(null, '7')).toBe(true);
expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true);
});
it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => {
expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]);
expect(newUids([100], 100)).toEqual([]);
});
});
describe('wecom 游标', () => {
const now = new Date('2026-08-25T10:00:00Z');
it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => {
const w = wecomWindow(wecomCursor(1_000_000), null, now);
expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) });
});
it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => {
const end = Math.floor(now.getTime() / 1000);
expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end });
const since = new Date('2026-08-20T00:00:00Z');
expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000));
});
it('lastTime 往前退不会退成负数', () => {
expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0);
});
});
describe('摘要与解析', () => {
it('人读摘要;没拉过是 null', () => {
expect(cursorSummary(null)).toBeNull();
expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7');
// brief 原字面量 1756116000 对应 2025-08-25T10:00:00.000Z(与期望的 2026 年份相差整 365 天,
// 与本文件其余用例统一锚定的 2026-08-25T10:00:00Z 不符)——已核实为 brief 的数据笔误,
// 修的是输入字面量而非断言期望值,见 task-4-report.md。
expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z');
});
it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => {
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3));
expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5));
expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull();
expect(parseCursor(null)).toBeNull();
expect(parseCursor('garbage')).toBeNull();
});
});
@@ -0,0 +1,33 @@
/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */
import { describe, expect, it } from 'vitest';
import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts';
const now = new Date('2026-08-25T10:00:00Z');
const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null };
describe('isDue', () => {
it('从没拉过 → 到期;停用 → 永不到期', () => {
expect(isDue(base, now)).toBe(true);
expect(isDue({ ...base, enabled: false }, now)).toBe(false);
});
it('上次 + 间隔 <= 现在才到期', () => {
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false);
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true);
});
it('租约还没过期 → 别人在拉,不到期;过期了照常', () => {
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false);
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true);
});
});
describe('leaseMsFor / isPolling', () => {
it('租约 = max(5 分钟, 3 × 间隔)', () => {
expect(leaseMsFor(30)).toBe(MIN_LEASE_MS);
expect(leaseMsFor(600)).toBe(1_800_000);
});
it('polling 只在租约未过期时为真', () => {
expect(isPolling(null, now)).toBe(false);
expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true);
expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
*/
import type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
} from '@contracts';
export type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
};
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './internal/domain/errors.ts';
/**
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
*/
export type InboundHandler = (
mail: MailInboundDetail,
ctx: { readonly accountId: string; readonly channelId: string | undefined },
) => Promise<void | { readonly skipped: string }>;
export type MailService = {
/* ── 账号 ── */
listAccounts(): Promise<MailAccountView[]>;
getAccount(accountId: string): Promise<MailAccountView>;
createAccount(input: MailAccountInput): Promise<MailAccountView>;
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
/** 留痕不删(入站 / 出站表对账号不设外键) */
deleteAccount(accountId: string): Promise<void>;
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
/* ── 收 ── */
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
pollDue(now: Date): Promise<MailPollOutcome[]>;
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
pollNow(accountId: string): Promise<MailPollOutcome>;
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
subscribe(name: string, handler: InboundHandler): () => void;
listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
getInbound(mailId: string): Promise<MailInboundDetail>;
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>;
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
/* ── 发 ── */
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
send(input: MailSendInput): Promise<MailSendResult>;
listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
};
+64
View File
@@ -0,0 +1,64 @@
/**
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
InboundHandler,
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
MailService,
} from './contract.ts';
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './contract.ts';
export type {
AttachmentStore,
ChangeNotifier,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
MimeParser,
OutboundMessage,
OutboundRecord,
OutboundStore,
ParsedMail,
ProviderAccount,
RawMessage,
SecretBox,
} from './port.ts';
export {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
} from './internal/domain/cursor.ts';
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
@@ -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[];
};
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/mail",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+111
View File
@@ -0,0 +1,111 @@
/**
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import type { MailCursor } from './internal/domain/cursor.ts';
import type {
DeliveryRecord,
InboundRecord,
MailAccountRecord,
OutboundMessage,
OutboundRecord,
ParsedMail,
} from './internal/domain/records.ts';
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type MailAccountStore = {
list(): Promise<MailAccountRecord[]>;
get(id: string): Promise<MailAccountRecord | undefined>;
findByName(name: string): Promise<MailAccountRecord | undefined>;
/** upsert;`isDefault:true` 时先把别的置 false(同一事务) */
save(record: MailAccountRecord): Promise<void>;
remove(id: string): Promise<void>;
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
listDue(now: Date): Promise<MailAccountRecord[]>;
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
claimPolling(id: string, leaseMs: number): Promise<boolean>;
releasePolling(id: string): Promise<void>;
/** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */
savePoll(
id: string,
patch: {
cursor?: MailCursor;
polledAt?: Date;
error?: string | null;
lastInboundAt?: Date;
address?: string;
},
): Promise<void>;
};
export type InboundStore = {
/** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */
insert(record: InboundRecord): Promise<{ inserted: boolean }>;
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
list(query: { accountId?: string | undefined; limit: number }): Promise<Array<{ record: InboundRecord; deliveries: DeliveryRecord[] }>>;
get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>;
/** upsert on (mail_id, subscriber) */
setDelivery(delivery: DeliveryRecord): Promise<void>;
};
export type OutboundStore = {
insert(record: OutboundRecord): Promise<void>;
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
list(query: { accountId?: string | undefined; limit: number }): Promise<OutboundRecord[]>;
};
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
export type AttachmentStore = {
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>;
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
};
/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
export type ProviderAccount = {
readonly id: string;
readonly kind: MailAccountKind;
readonly address: string;
readonly displayName: string;
readonly settings: Record<string, unknown>;
readonly secret: Record<string, string>;
};
export type RawMessage = {
readonly providerRef: string;
readonly eml: Uint8Array;
readonly receivedAt: Date;
/** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */
readonly cursorAfter?: MailCursor | undefined;
};
/** 外界:一种账号类型一个适配器(设计稿 §5.5) */
export type MailProvider = {
readonly kind: MailAccountKind;
probe(account: ProviderAccount): Promise<MailProbeReport>;
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
pull(
account: ProviderAccount,
cursor: MailCursor,
opts: {
readonly limit: number;
readonly now: Date;
readonly backfillFrom: Date | null;
/** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */
readonly known: (refs: readonly string[]) => Promise<Set<string>>;
},
): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>;
};
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
export type ChangeNotifier = { changed(reason: string): void };