app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用

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 02:05:50 +08:00
co-authored by Claude Fable 5
parent 58358210e2
commit 0c6e8e2c1b
10 changed files with 449 additions and 48 deletions
+150
View File
@@ -0,0 +1,150 @@
/**
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
* 三段都是纯映射 + 一次调用,单测直接对函数。
*/
import type { InboundMail, MailSendInput } from '@contracts';
import type { MailTransport } from '@modules/channel';
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
import {
bodyTextOf,
MailAccountDisabledError,
MailAccountNotFoundError,
MailNoDefaultAccountError,
MailSecretMissingError,
MailSendInvalidError,
} from '@modules/mail';
import type { ToolHandler } from '@modules/toolbox';
/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */
export function toInboundMail(mail: MailInboundDetail): InboundMail {
const threadId = mail.inReplyTo ?? mail.references.at(-1);
return {
messageId: mail.messageId,
...(threadId === undefined ? {} : { threadId }),
from: mail.from.address,
to: mail.to.map((a) => a.address),
cc: mail.cc.map((a) => a.address),
subject: mail.subject,
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
receivedAt: mail.receivedAt,
attachments: mail.attachments.flatMap((a) =>
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
),
};
}
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
return async (mail, ctx) => {
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
// 显式回 undefined —— 投递成功就是「没话说」;`noImplicitReturns` 不许一条路返值一条路掉出去
return undefined;
};
}
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
return {
async send(input) {
const result = await mail.send({
from: input.from,
to: [...input.to],
cc: [...input.cc],
bcc: [],
subject: input.subject,
text: input.body,
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
references: input.threadId === undefined ? [] : [input.threadId],
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
requestedBy: { kind: 'channel' },
});
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
},
};
}
function recipientsOf(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
return [];
}
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
if (!Array.isArray(value)) return [];
return value.flatMap((v) => {
if (typeof v !== 'object' || v === null) return [];
const a = v as Record<string, unknown>;
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
if (uploadRef === undefined) return [];
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
return [{ name, contentType: 'application/octet-stream', uploadRef }];
});
}
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
return async ({ input, connection, idempotencyKey }) => {
const to = recipientsOf(input['to']);
const cc = recipientsOf(input['cc']);
const bcc = recipientsOf(input['bcc']);
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
// 账号:入参指定 > 连接配置('default' = 默认账号)
const configured = connection['account'];
const accountId =
typeof input['accountId'] === 'string' && input['accountId'] !== ''
? input['accountId']
: configured !== undefined && configured !== '' && configured !== 'default'
? configured
: undefined;
if (input['dryRun'] === true) {
const problems = [
...(to.length === 0 ? ['收件人至少一个'] : []),
...(subject === '' ? ['主题不能为空'] : []),
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
];
return problems.length === 0
? { ok: true, output: { queued: false, dryRun: true } }
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
}
try {
const result = await mail.send({
...(accountId === undefined ? {} : { accountId }),
to,
cc,
bcc,
subject,
...(body === undefined ? {} : { text: body }),
...(html === undefined ? {} : { html }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: inReplyTo === undefined ? [] : [inReplyTo],
attachments: attachmentsOf(input['attachments']),
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
requestedBy: { kind: 'tool', tool: 'mail.send' },
});
if (result.state === 'failed') {
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
}
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
} catch (error) {
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
if (
error instanceof MailNoDefaultAccountError ||
error instanceof MailAccountNotFoundError ||
error instanceof MailAccountDisabledError ||
error instanceof MailSecretMissingError
) {
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
}
throw error;
}
};
}