Flue 集成 Resend 入站邮件:Webhook 校验、事件分发与 Agent 工具化实战
Flue 集成 Resend 入站邮件Webhook 校验、事件分发与 Agent 工具化实战【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue本指南以仓库 examples/resend-channel 示例项目为骨架完整讲解如何在 Flue 沙箱 Agent 框架中接入 Resend 邮件服务接收并校验email.receivedWebhook 事件将邮件信封元数据分发到按邮件绑定的 Agent 实例并通过受控工具按需拉取完整邮件正文。读完本文你将掌握createResendChannel的配置与挂载方式、dispatch的事件驱动建模、以及窄工具 按需获取的 Agent 设计模式并了解底层 Webhook 验签、限流与响应约定的实现细节。示例项目概览examples/resend-channel是一个可直接运行的 Flue 示例它在POST /channels/resend/webhook接收经过校验的 Resend Webhook 事件并演示了五件事导出一个第一方first-partychannel即由项目自身在应用代码中创建并挂载的渠道导出项目自有的官方 Resendclient封装在createResendClient中供渠道校验签名与查询邮件使用将email.received元数据通过dispatch分发到按邮件实例化的 Agentmessage-scoped agent暴露一个窄工具narrow tool用于获取已绑定到该 Agent 的完整入站邮件通过 fake Fetch 在 Node 与 workerd 两种运行时下测试真实 client 的行为。项目的文件结构如下examples/resend-channel/ ├── src/ │ ├── agents/assistant.ts # 按邮件绑定的 Agent 定义 │ ├── channels/resend.ts # 第一方 channel 与客户端、工具导出 │ ├── app.ts # Hono 应用与路由挂载 │ └── resend-client.ts # 官方 Resend 客户端的工厂封装 ├── package.json ├── tsconfig.json ├── vite.config.ts # 本地开发构建配置 ├── vite.config.cloudflare.ts # Cloudflare Workers 部署配置 └── wrangler.jsonc # Workers 运行时配置必填环境变量与 Webhook 注册运行该示例需要两个环境变量见 examples/resend-channel/README.mdRESEND_API_KEYre_... RESEND_WEBHOOK_SECRETwhsec_...RESEND_API_KEY用于实例化官方Resend客户端既承担 Webhook 签名校验也用于调用client.emails.receiving.get()拉取完整邮件RESEND_WEBHOOK_SECRETWebhook 签名密钥配合 Svix 签名头完成事件真实性校验。在 Resend 控制台中为email.received事件配置 Webhook回调地址必须填写完整部署路由即https://your-domain/channels/resend/webhook示例中 channel 挂载在/channels/resend其内部固定路由为/webhook。需要特别强调的是渠道的职责边界接收域名、MX 记录、Webhook 注册、凭据管理、事件去重、附件存储与回复策略仍然由应用自身负责。createResendChannel是一个无状态渠道只负责验签与事件转发不做去重与重排因此这些与业务强相关的状态逻辑需要应用层自己实现官方交付头svix-id即为此预留。创建 Resend 客户端示例在 src/resend-client.ts 中提供了一个极简的客户端工厂import { Resend } from resend; export function createResendClient(apiKey: string, options: { baseUrl?: string } {}): Resend { return new Resend(apiKey, options); }createResendChannel对传入的 client 有最小鸭子类型校验在 packages/resend/src/index.ts 的validateOptions中会检查client.webhooks?.verify是否为函数isResendClient因此任何实现了官方webhooks.verify接口的对象包括测试中的 fake 实现都能通过校验。这也为在测试中用 fake Fetch 驱动真实 client留下了空间。定义第一方 Channel校验、分发与窄工具核心逻辑集中在 src/channels/resend.ts。它一次性导出了三样东西client供渠道与工具复用、channel挂载到 Hono 应用以及retrieveReceivedEmail供 Agent 使用。import { createResendChannel } from flue/resend; import { defineTool, dispatch, type JsonValue } from flue/runtime; const EMAIL_INSTANCE_PREFIX resend-email:; export const client createResendClient(requiredEnv(RESEND_API_KEY)); export const channel createResendChannel({ client, webhookSecret: requiredEnv(RESEND_WEBHOOK_SECRET), // Path: /channels/resend/webhook async webhook({ event, delivery }) { switch (event.type) { case email.received: { await dispatch(Assistant, { id: emailInstanceId(event.data.email_id), initialData: { emailId: event.data.email_id, from: event.data.from, subject: event.data.subject, receivedAt: new Date(event.data.created_at).toISOString(), }, message: { kind: signal, type: resend.email.received, body: event.data.subject, attributes: { deliveryId: delivery.id, messageId: event.data.message_id, to: event.data.to.join(, ), ...(event.data.cc.length 0 ? {} : { cc: event.data.cc.join(, ) }), ...(event.data.attachments.length 0 ? {} : { attachmentCount: String(event.data.attachments.length) }), }, }, }); return; } default: return; } }, });webhook 处理器按事件类型分发webhook回调收到的event是provider-native供应商原生格式官方resend包导出的WebhookEventPayload联合类型字段保留原始的snake_case命名与嵌套结构渠道不会把它改造成 Flue 自有形态见 packages/resend/src/index.ts 的类型注释。因此你可以直接用switch (event.type)对每个建模事件做窄化分支当前示例只关心email.received其余事件类型一律忽略default: return。由于官方验签器client.webhooks.verify()对任何通过认证的投递都返回解析后的 body并不会限制必须是已建模的事件名所以即使未来 Resend 新增了事件类型运行时也能到达你的 handler——只是它的静态类型仍是当前安装版本的官方联合类型。处理此类比你安装的resend版本更新的事件只需检查event.type即可。dispatch把邮件绑定到独立 Agent 实例对email.received的处理核心是dispatch(Assistant, {...})id使用emailInstanceId(event.data.email_id)生成格式为resend-email:urlencoded email_id。emailInstanceId会对空 id 抛出TypeError并用encodeURIComponent编码保证实例键稳定、可预测initialData记录邮件元数据emailId、from、subject、receivedAt只在事件首次创建该实例时写入一次之后投递的重复事件会被忽略message是一个signal类型消息type为resend.email.receivedbody直接取event.data.subject并把deliveryId、messageId、收件人列表to、可选的cc与附件数放入attributes。注意message中携带的只是信封级envelope数据完整邮件文本并不随事件下发——这正是下一步窄工具的用武之地。窄工具按需获取完整邮件export function retrieveReceivedEmail(emailId: string) { return defineTool({ name: retrieve_resend_email, description: Retrieve the complete inbound email already bound to this agent., async run() { const result await client.emails.receiving.get(emailId); if (result.error) throw new Error(result.error.message); return { output: result.data as unknown as JsonValue }; }, }); }retrieveReceivedEmail(emailId)返回一个通过defineTool定义的、只绑定到当前邮件 id的窄工具描述明确限定为获取已绑定到本 Agent 的完整入站邮件run()内部调用client.emails.receiving.get(emailId)拉取完整邮件出错即抛出、成功则返回JsonValue形式的输出。这种设计让 Agent 的权限面最小化——它无法查询任意邮件只能读取自己实例所绑定的那一封。Agent 端接收 initialData 并按需使用工具src/agents/assistant.ts 以use agent声明展示了消息级 Agent 的完整形态use agent; import { useInitialData, useModel, useTool } from flue/runtime; import * as v from valibot; import { retrieveReceivedEmail } from ../channels/resend.ts; const initialDataSchema v.object({ emailId: v.string(), from: v.string(), subject: v.string(), receivedAt: v.pipe(v.string(), v.isoTimestamp()), }); export function Assistant() { useModel(anthropic/claude-haiku-4-5); const data useInitialDatav.InferOutputtypeof initialDataSchema(); if (!data) throw new Error(This agent is created by the Resend channel dispatch.); useTool(retrieveReceivedEmail(data.emailId)); return Review the inbound support email, handling an email from ${data.from} about ${data.subject} received at ${data.receivedAt}. Retrieve the complete email when its body or headers are needed.; } Assistant.initialData initialDataSchema;要点拆解模型选择useModel(anthropic/claude-haiku-4-5)指定模型与邮件摘要/初审这类轻量任务匹配initialData 校验Assistant.initialData initialDataSchema把 valibot 模式挂到 Agent 上useInitialData...()返回类型安全的初值由于该 Agent 只会由 Resend channel 的dispatch创建若拿不到initialData直接抛错避免在错误上下文中运行工具绑定useTool(retrieveReceivedEmail(data.emailId))把上面定义的窄工具注册进当前 Agent 会话——注意工具是在拿到data.emailId之后才创建的因此工具与实例严格绑定系统提示返回字符串作为提示词明确告诉模型这是来自from、关于subject、于receivedAt收到的入站支持邮件需要正文或头信息时去取完整邮件。这让模型只在必要时发起工具调用而不是无条件拉取节省 API 开销。路由装配agent 路由与 channel 路由src/app.ts 使用 Hono 把两条路由挂到同一个应用上import { createAgentRouter } from flue/runtime/routing; import { Hono } from hono; import { Assistant } from ./agents/assistant.ts; import { channel } from ./channels/resend.ts; const app new Hono(); app.route(/agents/assistant, createAgentRouter(Assistant)); app.route(/channels/resend, channel.route()); export default app;createAgentRouter(Assistant)为 Agent 生成/agents/assistant下的运行时路由channel.route()返回渠道子应用app.route(/channels/resend, channel.route())把它挂载到/channels/resend于是 Webhook 的完整地址即为POST /channels/resend/webhook——与 README 中说明的完全一致。源码级原理Webhook 处理链路createResendChannel内部实现位于 packages/resend/src/index.ts它校验完选项后注册唯一一条固定路由POST /webhook并交给 packages/resend/src/webhook.ts 的createResendWebhookHandler处理。请求处理链路依次是Content-Type 检查要求application/json否则返回415大小限制bodyLimit默认1024 * 10241 MiB见DEFAULT_BODY_LIMIT通过content-length头快速拒绝413再对流式读取做累计校验超限即终止读取并返回413bodyLimit必须是正整数否则在创建 handler 时直接抛TypeError交付头解析从svix-id与svix-timestamp提取delivery时间戳必须是合法正整数字符串缺失或非法返回400签名校验调用官方client.webhooks.verify({ payload, headers: { id, timestamp, signature }, webhookSecret })校验 Svix 签名与时间窗口校验失败捕获后返回400最小结构验证验签通过后只要求 payload 是对象且type为非空字符串即转发给应用的webhook回调——渠道不重新校验官方类型已描述的事件族形状响应约定回调返回undefined或 JSON 时以200应答返回Response则原样透传。Resend 会对任何非200状态码重试投递因此要避免对合法但不需要处理的事件返回错误码导致无谓重试。delivery.id即 Svix 投递 idREADME 明确建议用它做应用层去重——结合上面第 4 步可见去重能力已被刻意留给应用渠道保持无状态。本地开发与 Cloudflare 部署示例同时提供两套 Vite 配置vite.config.ts本地开发构建仅启用flue()插件vite.config.cloudflare.ts部署到 Cloudflare Workers组合flue()与cloudflare({ config: flueWorkerConfig() })插件。对应的 npm 脚本见 examples/resend-channel/package.jsonpnpm build # 本地构建 pnpm build:cloudflare # 使用 Cloudflare 配置构建 pnpm check:types # 类型检查wrangler.jsonc 声明了 Workers 运行环境{ name: resend-channel-example, compatibility_date: 2026-06-01, compatibility_flags: [nodejs_compat], migrations: [{ tag: v1, new_sqlite_classes: [FlueAssistantAgent] }] }nodejs_compat兼容标志确保官方resend客户端在 Workers 运行时可用new_sqlite_classes: [FlueAssistantAgent]是 Flue 的 SQLite 持久化迁移声明为 Agent 会话与事件派发提供存储。依赖方面示例直接引用工作区包flue/resend与flue/runtimeworkspace:*并固定了hono版本resend客户端则使用*便于测试时用 fake 实现替换。测试策略fake Fetch 驱动真实客户端README 特别强调了一项测试能力在 Node 与 workerd 两种运行时下通过 fake Fetch 测试真实 client。这是该项目客户端可注入 鸭子类型校验设计的直接收益——createResendChannel只要求 client 具备webhooks.verify方法isResendClient测试中既可以用打桩的假 client也可以用 fakefetch让真实Resend客户端走完 Webhook 验签链路从而在不依赖真实 Resend 服务与真实密钥的前提下覆盖验签失败返回 400合法投递触发 dispatch等关键路径。应用层仍需承担的责任清单最后汇总 README 与源码共同划定的边界——以下事项不在 channel 职责内需要应用自行实现责任说明接收域名与 MX 记录让 Resend 能收到寄往你域名的邮件Webhook 注册在 Resend 控制台配置email.received事件与完整回调地址凭据管理RESEND_API_KEY、RESEND_WEBHOOK_SECRET的安全存储与注入事件去重利用delivery.idsvix-id做幂等处理channel 本身不去重附件存储事件载荷只携带信封数据附件需自行持久化回复策略入站邮件的自动回复或人工转发流程由应用定义理解这条边界是把这个示例改造成生产级邮件 Agent 的第一步channel 负责安全地把可信事件送到你的代码而业务状态与策略完全由你掌控。输出文章【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考