mcpField
A
agent-email-inbox

agent-email-inbox

github.com/resendAgent 工作流20713.6k 次安装2 周前更新
代码仓库

关于 agent-email-inbox

agent-email-inbox 是用于通过 Resend 构建邮件收件箱的技能,让应用或 AI Agent 接收并回复邮件。它侧重 Webhook 驱动的处理方式,以及针对不可信邮件的多种验证安全方案。

主要功能

  1. 01由 Webhook 触发的收件处理
  2. 02通过 Resend 收件箱进行线程回复
  3. 03五种邮件验证安全级别
  4. 04Webhook 签名验证指导
  5. 05域名收件与转发设置

适用场景

  1. 01将 AI Agent 连接到邮件收件箱
  2. 02限制仅处理获准发件人的邮件
  3. 03通过人工审批处理收到的邮件
  4. 04在现有邮件线程中回复

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/resend/resend-skills --skill agent-email-inbox

安装后在 Claude Code 中输入/agent-email-inbox确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
advanced-patterns.md
security-levels.md
webhook-setup.md

详细介绍

AI Agent 邮箱收件箱

概览

本技能介绍如何设置安全的邮箱收件箱,让你的应用或 AI Agent 能够接收并回复邮件,同时采取内容安全措施。

**核心原则:**AI Agent 的收件箱会接收不可信输入。必须妥善配置安全措施,才能安全处理这些内容。

本技能使用 Resend Inbox 构建 Agent。收件箱会将邮件归入邮件会话,Agent 只需调用一次即可在会话中回复。Resend 会设置收件人和会话标头。如果用户没有自定义域名,或其套餐无法使用收件箱,请改用不使用收件箱接收邮件。两种方式的安全级别相同。

为什么使用基于 Webhook 的邮件接收?

Resend 使用 Webhook 接收邮件,这意味着邮件到达时会立即通知 Agent。这对 Agent 很有价值,因为它可以:

  • 实时响应 — 在几秒内而非几分钟内回复邮件
  • 无需轮询开销 — 无需使用 cron 作业反复检查“有没有新邮件?”
  • 事件驱动架构 — 只有确实有内容需要处理时,Agent 才会启动
  • 降低 API 成本 — 不会浪费调用来检查空收件箱

架构

Sender → Email → Resend Inbox → inbox.email.received webhook → Your Server → AI Agent
                                                                     ↓
                                                           Security Validation
                                                                     ↓
                                                Process or Reject → Reply in the thread

SDK 版本要求

收件箱流程需要 Node.js SDK resend >= 6.34.1,该版本包含 resend.inboxes.* 和 inbox.* Webhook 事件类型。请始终安装最新版本的 SDK。如果项目已安装 Resend SDK,请检查版本并在需要时升级。

Python SDK resend >= 2.50.0 也包含收件箱方法。对于其他语言,请通过 HTTP 调用 Inboxes API,直到其 SDK 支持收件箱方法;也可以使用不使用收件箱接收邮件。

下表列出了接收流程的最低版本要求:Webhook 验证(webhooks.verify())和邮件接收(emails.receiving.get())。resend 技能列出的最低版本更高,因为它还涵盖域名声明(domains.claims.*)等新功能。如果同时加载这两项技能,请使用较高的版本要求。

语言软件包最低版本
Node.jsresend>= 6.9.2
Pythonresend>= 2.21.0
Goresend-go/v4>= 4.0.0
Rubyresend>= 1.0.0
PHPresend/resend-php>= 1.1.0
Rustresend-rs>= 0.20.0
Javaresend-java>= 4.11.0
.NETResend>= 0.2.1

安装 resend npm 软件包:npm install resend(或使用对应语言的等效命令)。如需完整的邮件发送文档,请安装 resend 技能。

快速开始

  1. 向用户询问邮箱地址。 你需要一个真实邮箱地址来发送测试邮件。询问用户,并在继续操作前等待对方回复。
  2. 选择安全级别。 在处理任何邮件之前,决定如何验证收到的邮件
  3. 设置域名。 验证用户的自定义域名以用于邮件发送;除非收件箱使用转发,否则也要验证该域名以用于邮件接收(请参阅“域名设置”部分)
  4. 创建收件箱。 使用 resend.inboxes.create() 创建 Agent 的邮箱地址(请参阅“创建收件箱”部分)
  5. 创建 Webhook 端点。 从一开始就内置安全措施,以处理 inbox.email.received 事件。Webhook 端点必须是 POST 路由。
  6. 设置隧道(本地开发)。 使用 Tailscale Funnel(推荐)或 ngrok。请参阅 references/webhook-setup.md
  7. 通过 API 创建 Webhook。 使用 Resend Webhook API 以编程方式注册端点。请参阅 references/webhook-setup.md
  8. 连接到 Agent。 将已验证的邮件传递给 AI Agent,并在邮件会话中回复

开始之前:账户和 API 密钥设置

第一个问题:使用新的 Resend 账户还是现有账户?

询问用户:

  • 专门为 Agent 创建新账户? → 设置更简单,使用完全访问权限即可
  • 包含其他项目的现有账户? → Agent 需要完全访问权限的密钥,该密钥可以访问所有项目。建议为 Agent 创建新账户(参见“API 密钥权限”)
安全创建 API 密钥

不要在聊天中粘贴 API 密钥!密钥会永远保留在对话历史中。

更安全的方式:

  1. 环境文件方式: 用户直接创建 .env 文件:echo "RESEND_API_KEY=re_xxx" >> .env
  2. 密码管理器/密钥管理器: 用户将密钥存储在 1Password、Vault 等工具中。
  3. 如果必须在聊天中共享密钥: 用户应在设置完成后立即轮换密钥。
API 密钥权限

Agent 的服务端需要完全访问权限的 API 密钥。“发送权限”密钥只能回复、转发、创建草稿和发送草稿。使用此密钥读取收件箱邮件或注册 Webhook 会返回 401 restricted_api_key。仅限于某个域名的发送权限密钥,在另一个域名的收件箱上会返回 403。

如果用户已有包含其他项目的 Resend 账户,请控制风险范围:

  1. 尽可能为 Agent 使用专用账户,这样密钥泄露也不会影响其他项目
  2. 仅在服务端保留密钥。 切勿将其传给 AI 模型或放入提示词中
  3. 如果密钥曾在聊天中粘贴或提交到代码库,请轮换密钥

域名设置

收件箱地址位于用户已添加到 Resend 的自定义域名上。该域名必须通过发送验证(DKIM 和 SPF)。如需在该域名上接收邮件,请启用该域名的接收功能,或创建收件箱时设置 forwarding: true。

使用子域名(例如 agent.example.com),以免影响现有邮件服务。

选项 1:在域名上接收(推荐)

用户必须在 Resend 控制面板中启用接收功能:Domains 页面 → 开启“Enable Receiving”。

然后添加一条 MX 记录:

设置值
类型MX
主机你的域名或子域名(例如 agent.example.com)
值Resend 控制面板中提供
优先级10(必须是最低的数字才能优先生效)

提示: 在 dns.email 验证 DNS 传播情况。

DNS 传播:MX 记录更改最多可能需要 48 小时才能在全球生效,不过通常几小时内即可完成。

选项 2:转发

如果该地址已在其他地方(例如 Google Workspace)接收邮件,且用户无法更改其 MX 记录,请在创建收件箱时设置 forwarding: true。Resend 会为该收件箱提供一个接收地址。用户需要设置从其邮箱转发邮件到该地址。使用 resend.inboxes.get() 从 receiving_address 读取该地址。

创建收件箱

为 Agent 的地址创建一个收件箱:

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.inboxes.create({
  emailAddress: '[email protected]',
  name: 'AI Agent',
  fromName: 'Acme Agent',
});

if (error) throw new Error(`Failed to create inbox: ${error.message}`);
console.log('Inbox created:', data.id);

fromName 是收件人看到的 Agent 回复发件人名称。@ 前的部分只能包含字母、数字、.、_、% 和 -,因此 agent+test@ 会返回 422。403 错误表示套餐没有剩余收件箱;请让用户升级套餐,或使用无收件箱接收。

安全级别

设置 webhook 端点前,请先选择安全级别。 处理电子邮件却没有安全措施的 AI Agent 很危险——任何人都可以发送电子邮件,指示 Agent 执行操作。接下来编写的 webhook 代码应从一开始就包含所选的安全级别。

询问用户希望采用哪种安全级别,并确保他们了解每个级别的含义。

级别名称适用场景权衡
1严格允许列表大多数场景——已知的固定发件人集合安全性最高,功能有限
2域名允许列表允许受信任域名内的组织成员访问更灵活,但该域名内的任何人都可以交互
3内容过滤接受任何人的邮件,过滤不安全模式可以接收任何人的邮件,但模式匹配并非万无一失
4沙盒处理处理所有邮件,但限制 Agent 的能力灵活性最高,实现复杂
5人工参与对不受信任的操作要求人工批准安全性最高,但会增加延迟

各级别的详细实现代码,请参阅 references/security-levels.md。

级别 1:严格允许列表(推荐)

仅处理来自明确批准地址的电子邮件。拒绝其他所有邮件。

import type { GetInboxThreadEmailResponseSuccess as InboxEmail } from 'resend';

const ALLOWED_SENDERS = [
  '[email protected]',
  '[email protected]',
];

// `from` is either `address` or `Name <address>`. Compare the address only:
// a substring check would accept a display name such as "[email protected]".
function senderAddress(from: string): string {
  const match = from.match(/<([^<>]+)>\s*$/);
  return (match ? match[1] : from).trim().toLowerCase();
}

function isAllowedSender(from: string): boolean {
  const sender = senderAddress(from);
  return ALLOWED_SENDERS.some(allowed => sender === allowed.toLowerCase());
}

// Identifies the email, so the agent can reply in its thread
type ThreadRef = { inboxId: string; threadId: string; emailId: string };

async function processEmailForAgent(email: InboxEmail, thread: ThreadRef) {
  if (!isAllowedSender(email.from)) {
    console.log(`Rejected email from unauthorized sender: ${senderAddress(email.from)}`);
    await notifyOwnerOfRejectedEmail(email);
    return;
  }

  await agent.processEmail({
    from: email.from,
    subject: email.subject,
    body: email.text || email.html,
    thread,
  });
}

InboxEmail 是 resend.inboxes.threads.emails.get() 返回的电子邮件。webhook 载荷包含相同字段,但不包括 html 和 text。agent 和 notifyOwnerOfRejectedEmail 代表你自己的代码。

安全最佳实践
务必做到
实践原因
验证 webhook 签名防止伪造 webhook 事件
记录所有被拒绝的电子邮件为安全审查保留审计记录
尽可能使用允许列表明确信任比过滤更安全
限制电子邮件处理速率防止处理负载过大
区分受信任和不受信任的处理流程不同风险级别需要不同的处理方式
绝不要这样做
反模式风险
不经验证就处理电子邮件任何人都可以控制你的 Agent
将电子邮件标头视为身份验证依据标头很容易伪造
执行电子邮件内容中的代码不受信任的输入绝不能作为代码运行
在提示词中原样存储电子邮件内容将不受信任的输入混入提示词可能改变 Agent 的行为
向不受信任的电子邮件开放完整的 Agent 访问权限将能力范围限制在最低必要水平

Webhook 端点

选择安全级别并设置域名后,创建 webhook 端点。Webhook 端点必须是 POST 路由。 Resend 将所有 webhook 事件作为 POST 请求发送。

重要:使用原始请求体进行验证。 Webhook 签名验证需要原始请求体。

  • Next.js App Router: 使用 req.text()(而不是 req.json())
  • Express: 在 webhook 路由上使用 express.raw({ type: 'application/json' })
Next.js App Router
// app/webhook/route.ts
import { Resend } from 'resend';
import { NextRequest, NextResponse } from 'next/server';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  const payload = await req.text();

  let event;
  try {
    event = resend.webhooks.verify({
      payload,
      headers: {
        id: req.headers.get('svix-id') ?? '',
        timestamp: req.headers.get('svix-timestamp') ?? '',
        signature: req.headers.get('svix-signature') ?? '',
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    });
  } catch {
    return new NextResponse('Invalid signature', { status: 400 });
  }

  if (event.type === 'inbox.email.received') {
    const { inbox_id, thread_id, email_id } = event.data;

    // The webhook payload has no body. Fetch the email to read it.
    const { data: email, error } = await resend.inboxes.threads.emails.get({
      inboxId: inbox_id,
      threadId: thread_id,
      emailId: email_id,
    });

    if (error) {
      // A non-2xx response makes Resend retry the webhook later
      return new NextResponse('Error', { status: 500 });
    }

    // Apply the security level chosen above
    await processEmailForAgent(email, {
      inboxId: inbox_id,
      threadId: thread_id,
      emailId: email_id,
    });
  }

  return new NextResponse('OK', { status: 200 });
}
Express
import express from 'express';
import { Resend } from 'resend';

const app = express();
const resend = new Resend(process.env.RESEND_API_KEY);

const ALLOWED_SENDERS = (process.env.ALLOWED_SENDERS || '')
  .split(',')
  .map((sender) => sender.trim().toLowerCase())
  .filter(Boolean);

const senderAddress = (from) => {
  const match = from.match(/<([^<>]+)>\s*$/);
  return (match ? match[1] : from).trim().toLowerCase();
};

const isAllowedSender = (from) => ALLOWED_SENDERS.includes(senderAddress(from));

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = resend.webhooks.verify({
      payload: req.body.toString(),
      headers: {
        id: req.headers['svix-id'],
        timestamp: req.headers['svix-timestamp'],
        signature: req.headers['svix-signature'],
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
    });
  } catch {
    res.status(400).send('Invalid signature');
    return;
  }

  if (event.type === 'inbox.email.received') {
    const { inbox_id, thread_id, email_id, email } = event.data;

    // The payload already has the sender, so reject before fetching the body
    if (!email || !isAllowedSender(email.from)) {
      console.log(`Rejected email from unauthorized sender: ${email ? senderAddress(email.from) : 'unknown'}`);
      res.status(200).send('OK'); // Return 200 even for rejected emails
      return;
    }

    const { data: fullEmail, error } = await resend.inboxes.threads.emails.get({
      inboxId: inbox_id,
      threadId: thread_id,
      emailId: email_id,
    });

    if (error) {
      res.status(500).send('Error'); // Resend retries the webhook later
      return;
    }

    await processEmailForAgent(fullEmail, { inboxId: inbox_id, threadId: thread_id, emailId: email_id });
  }

  res.status(200).send('OK');
});

app.get('/', (req, res) => res.send('Agent Email Inbox - Ready'));
app.listen(3000, () => console.log('Webhook server running on :3000'));

Resend 使用与其他所有 webhook 相同的 svix-* 标头为收件箱 webhook 签名。收件箱收到电子邮件时会触发 inbox.email.received。Agent 自己发送的回复则会触发 inbox.email.sent,因此只订阅 inbox.email.received。

有关通过 API 注册 webhook、设置隧道、svix 回退方案和重试行为,请参阅 references/webhook-setup.md。

从 Agent 回复

在该邮件会话中回复。Resend 会发送回复并设置 In-Reply-To 和 References 标头,因此发件人的邮件客户端会将其显示在同一会话中:

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function replyInThread(
  thread: { inboxId: string; threadId: string; emailId: string },
  body: string,
) {
  const { data, error } = await resend.inboxes.threads.emails.reply({
    inboxId: thread.inboxId,
    threadId: thread.threadId,
    emailId: thread.emailId,
    text: body,
  });

  if (error) throw new Error(`Failed to reply: ${error.message}`);
  return data.id;
}

回复会发送到邮件的 Reply-To 地址;如果没有该地址,则发送给发件人。允许列表中的发件人可以将 Reply-To 设置为任意地址,因此在 Agent 回复任何敏感内容前,也要检查 email.reply_to 是否在允许列表中。除非 Agent 需要抄送原邮件的所有收件人,否则不要启用 replyAll。

如需开始新的会话,请使用收件箱地址调用 resend.emails.send() 发送邮件。有关发送功能的完整文档,请安装 resend skill。

没有收件箱时接收邮件

当用户没有自定义域名,或其套餐没有可用收件箱时,请直接使用 Resend Receiving。上述安全级别保持不变。不同之处如下:

  • **地址:**使用自动生成的 <anything>@<your-id>.resend.app 地址(Dashboard → Emails → Receiving → "Receiving address"),或使用已启用接收功能的自定义域名。不会创建收件箱。
  • **Webhook 事件:**订阅 email.received,而不是 inbox.email.received。负载包含 email_id、from、to 和 subject,但不包含邮件正文。
  • **读取邮件:**使用 resend.emails.receiving.get(emailId) 获取邮件。
  • **回复:**使用 resend.emails.send() 发送,并自行设置 In-Reply-To 标头。这里没有邮件会话。
if (event.type === 'email.received') {
  if (!isAllowedSender(event.data.from)) {
    return new NextResponse('OK', { status: 200 });
  }

  const { data: email, error } = await resend.emails.receiving.get(event.data.email_id);
  if (error) {
    return new NextResponse('Error', { status: 500 });
  }

  await agent.processEmail({ from: email.from, subject: email.subject, body: email.text || email.html });
}

Webhook 验证方式与收件箱示例相同。

环境变量

# Required
RESEND_API_KEY=re_xxxxxxxxx
RESEND_WEBHOOK_SECRET=whsec_xxxxxxxxx

# Security Configuration
SECURITY_LEVEL=strict                    # strict | domain | filtered | sandboxed
[email protected],[email protected]
ALLOWED_DOMAINS=example.com
[email protected]               # For security notifications

常见错误

错误修复方法
未验证发件人处理邮件前始终验证发件人身份
信任邮件标头使用 Webhook 验证,而不是使用邮件标头进行身份验证
对所有邮件一视同仁区分可信与不可信的发件人
错误消息过于详细使用通用错误响应,避免泄露内部逻辑
未进行速率限制按发件人实施速率限制。参见 references/advanced-patterns.md
直接处理 HTML移除 HTML 或仅使用纯文本,以降低复杂性和风险
未记录拒绝事件记录所有安全事件,以便审计
使用临时隧道 URL使用持久 URL(Tailscale Funnel、付费版 ngrok),或部署到生产环境
在 Webhook 路由上使用 express.json()使用 express.raw({ type: 'application/json' }) — JSON 解析会破坏签名验证
对拒收的邮件返回非 200 状态码始终返回 200 以确认收到 — 否则 Resend 会重试
Resend SDK 版本过旧resend.inboxes.* 需要 resend >= 6.34.1。emails.receiving.get() 和 webhooks.verify() 也需要较新的版本。参见 SDK 版本要求
将 from 作为完整字符串比较from 可能是 Name <address>。先提取地址,再与允许列表进行比较
未检查 Reply-To 就回复可信发件人可以将 Reply-To 指向任意地址。发送敏感回复前,请检查它是否在允许列表中
使用 "Sending access" API 密钥它只能用于回复、转发、创建草稿和发送草稿。读取收件箱及设置 Webhook 需要完整访问权限的密钥
将 secret 或 svix-* 密钥传给 webhooks.verify()传入 webhookSecret,并将 headers 设置为 { id, timestamp, signature }

测试

开发时请使用 Resend 的测试地址:

进行安全测试时,请从不在允许列表中的地址发送测试邮件,以验证拒收功能是否正常。

快速验证清单:

  1. 服务正在运行:curl http://localhost:3000 应返回响应
  2. 隧道正常工作:curl https://<your-tunnel-url> 应返回相同的响应
  3. Webhook 已启用:在 Resend 控制台 → Webhooks 中查看状态
  4. 从允许列表中的地址发送测试邮件,并检查服务器日志

相关技能

  • 如需完整的发送、接收和收件箱文档,请安装 resend 技能

评论

支持 Markdown