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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]>;
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@modules/mail",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./index.ts" }
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user