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