/** * 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, threadIdOf, } from '@modules/mail'; import type { ToolHandler } from '@modules/toolbox'; /** * mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。 * * **线程口径只有一处**:`threadIdOf`(mail 模块的 domain,取这一串的根;缘由与反例见那里)。 * 这里曾经内联过一份同义实现,而模块里那一份当时取的是最近一跳 —— 同一个模块两套相反的语义, * 下一个人照导出的那个接线就会接反。现在调它。 */ export function toInboundMail(mail: MailInboundDetail): InboundMail { const threadId = threadIdOf(mail); 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 }): 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): 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; const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined; // 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎 if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]); const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef; return [{ name, contentType: 'application/octet-stream', uploadRef }]; }); } export function mailSendTool(mail: Pick): 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; } }; }