Archived
mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿
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,62 @@
|
||||
/**
|
||||
* 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[]>;
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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';
|
||||
export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts';
|
||||
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
|
||||
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
|
||||
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
|
||||
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
|
||||
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
|
||||
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
|
||||
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
|
||||
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
|
||||
export { createMailService } from './internal/usecase/mail-service.ts';
|
||||
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
|
||||
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
|
||||
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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