关于 agent-email-inbox
agent-email-inbox 是用于通过 Resend 构建邮件收件箱的技能,让应用或 AI Agent 接收并回复邮件。它侧重 Webhook 驱动的处理方式,以及针对不可信邮件的多种验证安全方案。
主要功能
- 01由 Webhook 触发的收件处理
- 02通过 Resend 收件箱进行线程回复
- 03五种邮件验证安全级别
- 04Webhook 签名验证指导
- 05域名收件与转发设置
适用场景
- 01将 AI Agent 连接到邮件收件箱
- 02限制仅处理获准发件人的邮件
- 03通过人工审批处理收到的邮件
- 04在现有邮件线程中回复
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ resend/ resend-skills --skill agent-email-inbox安装后在 Claude Code 中输入/agent-email-inbox确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/advanced-patterns.mdsecurity-levels.mdwebhook-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.js | resend | >= 6.9.2 |
| Python | resend | >= 2.21.0 |
| Go | resend-go/v4 | >= 4.0.0 |
| Ruby | resend | >= 1.0.0 |
| PHP | resend/resend-php | >= 1.1.0 |
| Rust | resend-rs | >= 0.20.0 |
| Java | resend-java | >= 4.11.0 |
| .NET | Resend | >= 0.2.1 |
安装 resend npm 软件包:npm install resend(或使用对应语言的等效命令)。如需完整的邮件发送文档,请安装 resend 技能。
快速开始
- 向用户询问邮箱地址。 你需要一个真实邮箱地址来发送测试邮件。询问用户,并在继续操作前等待对方回复。
- 选择安全级别。 在处理任何邮件之前,决定如何验证收到的邮件
- 设置域名。 验证用户的自定义域名以用于邮件发送;除非收件箱使用转发,否则也要验证该域名以用于邮件接收(请参阅“域名设置”部分)
- 创建收件箱。 使用
resend.inboxes.create()创建 Agent 的邮箱地址(请参阅“创建收件箱”部分) - 创建 Webhook 端点。 从一开始就内置安全措施,以处理
inbox.email.received事件。Webhook 端点必须是 POST 路由。 - 设置隧道(本地开发)。 使用 Tailscale Funnel(推荐)或 ngrok。请参阅 references/webhook-setup.md
- 通过 API 创建 Webhook。 使用 Resend Webhook API 以编程方式注册端点。请参阅 references/webhook-setup.md
- 连接到 Agent。 将已验证的邮件传递给 AI Agent,并在邮件会话中回复
开始之前:账户和 API 密钥设置
第一个问题:使用新的 Resend 账户还是现有账户?
询问用户:
- 专门为 Agent 创建新账户? → 设置更简单,使用完全访问权限即可
- 包含其他项目的现有账户? → Agent 需要完全访问权限的密钥,该密钥可以访问所有项目。建议为 Agent 创建新账户(参见“API 密钥权限”)
安全创建 API 密钥
不要在聊天中粘贴 API 密钥!密钥会永远保留在对话历史中。
更安全的方式:
- 环境文件方式: 用户直接创建
.env文件:echo "RESEND_API_KEY=re_xxx" >> .env - 密码管理器/密钥管理器: 用户将密钥存储在 1Password、Vault 等工具中。
- 如果必须在聊天中共享密钥: 用户应在设置完成后立即轮换密钥。
API 密钥权限
Agent 的服务端需要完全访问权限的 API 密钥。“发送权限”密钥只能回复、转发、创建草稿和发送草稿。使用此密钥读取收件箱邮件或注册 Webhook 会返回 401 restricted_api_key。仅限于某个域名的发送权限密钥,在另一个域名的收件箱上会返回 403。
如果用户已有包含其他项目的 Resend 账户,请控制风险范围:
- 尽可能为 Agent 使用专用账户,这样密钥泄露也不会影响其他项目
- 仅在服务端保留密钥。 切勿将其传给 AI 模型或放入提示词中
- 如果密钥曾在聊天中粘贴或提交到代码库,请轮换密钥
域名设置
收件箱地址位于用户已添加到 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 的测试地址:
[email protected]— 模拟成功投递[email protected]— 模拟硬退信
进行安全测试时,请从不在允许列表中的地址发送测试邮件,以验证拒收功能是否正常。
快速验证清单:
- 服务正在运行:
curl http://localhost:3000应返回响应 - 隧道正常工作:
curl https://<your-tunnel-url>应返回相同的响应 - Webhook 已启用:在 Resend 控制台 → Webhooks 中查看状态
- 从允许列表中的地址发送测试邮件,并检查服务器日志
相关技能
- 如需完整的发送、接收和收件箱文档,请安装
resend技能