关于 resend-inbound-emails
resend-inbound-emails 是使用 Resend 实现邮件收发和收件箱工作流的技能,涵盖域名管理、入站 Webhook 和邮件串联,也支持可选的个性化及批量发送。
主要功能
- 01管理并验证发件域名
- 02处理入站邮件 Webhook
- 03发送串联邮件
- 04轮询收件箱更新
- 05可选的 AI 个性化与审核
- 06可选的队列批量发送
适用场景
- 01构建支持邮件串联的团队收件箱
- 02通过 Resend Webhook 接收回复
- 03审核后发送个性化邮件
- 04将批量邮件加入队列发送
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ blink-new/ claude --skill resend-inbound-emails安装后在 Claude Code 中输入/resend-inbound-emails确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/api/available-domains-route.tsdomains-activate-route.tsdomains-route.tsdomains-verify-route.tsinbox-events-sse-route.tsinbox-send-route.tsinbox-updates-route.tsusers-me-route.tswebhook-resend-route.tsassets/components/email-settings-section.tsxinbox-notification-provider.tsxnotifications-section.tsxteam-personalization-section.tsxassets/config/env-additions.txtassets/hooks/use-available-domains.tsuse-inbox-polling.tsuse-inbox-realtime.tsuse-inbox.tsuse-team-domains.tsuse-user.tsassets/lib/redis.tsresend.tsassets/lib/inbox/email-headers.tsinbound-notification.tsreply-parser.tsthreading.tsassets/lib/personalization/process-liquid-tags.tstypes.tsassets/prisma/schema-additions.prisma详细介绍
使用此技能的时机
适用于:
- 设置双向电子邮件通信(发送和接收)
- 实现自定义发件域名管理
- 构建支持邮件串接的收件箱系统
- 添加 AI 驱动的电子邮件个性化功能
- 通过 Webhook 处理收到的电子邮件
功能概览
核心功能(必需)
| 功能 | 描述 |
|---|---|
| 自定义域名 | 通过 Resend API 添加、验证和管理发件域名 |
| 入站 Webhook | 接收和处理收到的电子邮件,并支持邮件串接 |
| 单封发送 | 使用 RFC 5322 邮件串接逐封发送电子邮件 |
| 用户设置 | 通知偏好设置、发件域名选择 |
| 实时更新 | 通过轮询即时更新收件箱,并显示 Toast 通知 |
| 通知 | 收到回复时向团队成员发送电子邮件通知 |
可选功能
| 功能 | 描述 |
|---|---|
| AI 个性化 | 使用 Vercel AI SDK,通过 AI 驱动的 {{ tag }} 替换实现个性化 |
| 预览/审核 | 发送前审核并编辑个性化电子邮件 |
| 批量发送 | 通过 pg-boss 队列分批发送电子邮件 |
| 富文本编辑器 | 基于 TipTap 的编辑器,支持附件和格式设置 |
架构图
┌─────────────────────────────────────────────────────────────────────┐
│ OUTBOUND FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ Composer → Detect {{tags}} → Generate Previews → Review Modal │
│ ↓ │
│ Send via Resend → Store InboxMessage → Update Thread │
├─────────────────────────────────────────────────────────────────────┤
│ INBOUND FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ Resend Webhook → Verify Signature → Parse Headers │
│ ↓ │
│ Match Thread (RFC 5322) → Resolve Creator → Store Message │
│ ↓ │
│ Notify Team → Publish Event → Update UI via Polling/SSE │
└─────────────────────────────────────────────────────────────────────┘
前提条件
- Resend 账号及 API 密钥
- PostgreSQL 数据库
- 用于批量电子邮件队列的 pg-boss
- 用于个性化的 Vercel AI SDK
环境变量
# Resend
RESEND_API_KEY="re_your_api_key"
RESEND_WEBHOOK_SECRET="whsec_your_webhook_secret"
EMAIL_FROM="[email protected]"
# Your default inbound domain (set up in Resend dashboard)
# Format: emails to *@yourdomain.com will be forwarded to your webhook
DEFAULT_EMAIL_DOMAIN="inbox.yourdomain.com"
# App URL for notification links
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
数据库架构
将以下模型添加到 Prisma 架构中:
团队域名管理
model TeamDomain {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
resendDomainId String @unique // Resend's domain ID
domain String // e.g., "acme.com"
status String @default("not_started") // "not_started" | "pending" | "verified" | "invalid"
isActive Boolean @default(false) // Only one domain can be active per team
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([teamId, domain])
@@index([teamId])
}
收件箱邮件串接
model InboxThread {
id String @id @default(cuid())
teamId String
creatorId String? // Link to your entity (creator, contact, etc.)
creatorEmail String // Primary email for this thread
primaryEmail String? // Original email the thread was created with
isRead Boolean @default(false)
isArchived Boolean @default(false)
lastMessageAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Track all emails that have participated
participantEmails String[] @default([])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
messages InboxMessage[]
@@index([teamId, lastMessageAt])
@@index([teamId, creatorId])
}
model InboxMessage {
id String @id @default(cuid())
threadId String
direction InboxDirection // INBOUND or OUTBOUND
from String
to String
subject String?
textBody String?
htmlBody String?
resendEmailId String?
messageId String? // RFC 5322 Message-ID
inReplyTo String? // RFC 5322 In-Reply-To
references String[] // RFC 5322 References chain
sentAt DateTime?
receivedAt DateTime?
deliveryStatus InboxDeliveryStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
thread InboxThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
attachments InboxAttachment[]
@@index([resendEmailId])
@@index([messageId])
}
model InboxAttachment {
id String @id @default(cuid())
messageId String
filename String
url String
size Int?
contentType String?
message InboxMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
}
enum InboxDirection {
INBOUND
OUTBOUND
}
enum InboxDeliveryStatus {
PENDING
SENT
DELIVERED
BOUNCED
FAILED
}
个性化审核
model PersonalizedEmailPreview {
id String @id @default(cuid())
teamId String
creatorId String?
createdById String
originalSubject String
originalBody String @db.Text
personalizedSubject String
personalizedBody String @db.Text
explanation String? @db.Text
recipientEmail String
recipientName String?
status EmailReviewStatus @default(PENDING)
editedSubject String?
editedBody String? @db.Text
wasEdited Boolean @default(false)
sentMessageId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime // 7 days from creation
@@index([teamId, status])
}
enum EmailReviewStatus {
PENDING
SENT
EXPIRED
}
核心模式
1. 自定义域名管理
用户可以通过 Resend API 添加自定义发件域名:
// Add domain
const { data } = await resend.domains.create({ name: "acme.com" });
// Verify DNS records
await resend.domains.verify(domainId);
// Get DNS records for user to configure
const { data: details } = await resend.domains.get(domainId);
// details.records contains MX, TXT, DKIM records
2. 构建发件人/回复地址
// Always use your inbound domain for reply-to to ensure tracking
const fromAddress = buildFromAddress(userEmail, userName, sendingDomain);
// Result: "Kai <[email protected]>"
const replyTo = buildReplyToAddress(userEmail);
// Result: "[email protected]" (your inbound domain)
3. RFC 5322 电子邮件串接
通过标准电子邮件标头保持邮件线程的连续性:
// On send: build headers from previous messages
const lastMessage = await getLastThreadMessage(threadId);
const headers = {
"Message-ID": `<${uuid()}@${EMAIL_DOMAIN}>`,
"In-Reply-To": formatMessageIdHeader(lastMessage?.messageId),
"References": buildReferencesHeader(
mergeReferences(lastMessage?.references ?? [], lastMessage?.messageId)
),
};
// On receive: match thread by headers
const match = await findThreadByHeaders({
teamId,
messageId: parseMessageIdHeader(headers["message-id"]),
inReplyTo: parseMessageIdHeader(headers["in-reply-to"]),
references: parseReferencesHeader(headers["references"]),
});
4. Resend Webhook 处理
// Verify webhook signature using Svix headers
const payload = await req.text(); // Must read as text, not json
const 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 ?? "",
});
// Handle event types
switch (event.type) {
case "email.received":
// Fetch full email content
const { data: email } = await resend.emails.receiving.get(event.data.email_id);
// email.html, email.text, email.headers, email.attachments
break;
case "email.delivered":
case "email.bounced":
// Update delivery status
break;
}
重要:
- 验证前使用
req.text()读取负载,而不是req.json() - Svix 标头使用小写字母和连字符:
svix-id、svix-timestamp、svix-signature - 处理前始终使用
resendEmailId进行去重
5. 使用 {{ Tags }} 实现 AI 个性化(可选)
**注意:**此功能为可选项。如果不需要 AI 驱动的电子邮件个性化,可以跳过。
// Detect if personalization is needed
if (hasLiquidTags(subject) || hasLiquidTags(body)) {
const result = await generatePersonalizedEmail({
subject,
body,
context: buildPersonalizationContext({ creator, team, sender }),
});
// Verify all tags were replaced
if (hasLiquidTags(result.subject) || hasLiquidTags(result.message)) {
throw new Error("Personalization failed");
}
}
可用标签:
{{ name }}- 创作者姓名{{ time_based_greeting }}- 符合时段的问候语{{ compliment }}- AI 生成的赞美语{{ content_fit_pitch }}- 合作为何合适{{ reply_cta }}- 行动号召{{ user_name }}- 发件人姓名
个性化所需条件:
- Vercel AI SDK(
@ai-sdk/gateway) - 团队个性化设置界面
PersonalizedEmailPreview模型(用于审核工作流)
6. 使用 pg-boss 批量发送电子邮件(可选)
**注意:**此功能为可选项。如果只需要发送单封电子邮件,可以跳过。
// Queue bulk send job
await boss.send(QUEUES.BULK_SEND_EMAIL, {
teamId,
recipients: [{ creatorId, email, name }],
subject,
body,
fromEmail: session.user.email,
}, DEFAULT_JOB_OPTIONS);
// Worker processes in batches of 100 via Resend batch API
const { data } = await resend.batch.send(emailObjects);
批量发送所需条件:
- pg-boss 配置(参见 team-saas 技能)
- 批量发送 API 路由
- 用于处理
BULK_SEND_EMAIL作业的工作进程处理程序
文件结构
src/
├── app/api/
│ ├── teams/[teamId]/
│ │ ├── domains/
│ │ │ ├── route.ts # List/add domains
│ │ │ └── [domainId]/
│ │ │ ├── route.ts # Get/delete domain
│ │ │ ├── verify/route.ts # Verify DNS
│ │ │ └── activate/route.ts # Activate domain
│ │ ├── inbox/
│ │ │ ├── send/route.ts # Single email send
│ │ │ ├── bulk-send/route.ts # Bulk send (queued)
│ │ │ ├── threads/route.ts # List threads
│ │ │ ├── personalization/
│ │ │ │ └── preview/route.ts # Generate previews
│ │ │ └── reviews/ # Review management
│ │ └── ...
│ └── webhooks/
│ └── resend/route.ts # Webhook handler
├── lib/
│ ├── resend.ts # Client + address builders
│ ├── inbox/
│ │ ├── threading.ts # Thread matching
│ │ ├── email-headers.ts # RFC 5322 utilities
│ │ ├── reply-parser.ts # Strip quoted content
│ │ ├── resend-webhook.ts # Webhook helpers
│ │ └── inbound-notification.ts # Team notifications
│ ├── personalization/
│ │ ├── types.ts # Context types
│ │ ├── build-context.ts # Build AI context
│ │ ├── generate-personalized-email.ts
│ │ └── process-liquid-tags.ts # Tag detection
│ └── jobs/handlers/
│ └── bulk-email-handler.ts # Bulk send worker
├── hooks/
│ ├── use-inbox.ts # Thread/message hooks
│ ├── use-inbox-polling.ts # Real-time updates
│ ├── use-team-domains.ts # Domain management
│ └── use-email-reviews.ts # Review hooks
└── components/
├── inbox/
│ ├── inbox-editor.tsx # TipTap rich editor
│ ├── inbox-compose-dialog.tsx # Compose modal
│ ├── inbox-message-bubble.tsx # Message display
│ ├── personalization-button.tsx# Tag insertion
│ └── personalization-preview-modal.tsx
└── settings/
├── team-domains-section.tsx # Domain UI
└── domain-dns-records.tsx # DNS records table
包含的资产文件
| 资产 | 说明 |
|---|---|
assets/lib/resend.ts | Resend 客户端和地址构建器 |
assets/lib/inbox/threading.ts | 线程匹配逻辑 |
assets/lib/inbox/email-headers.ts | RFC 5322 工具 |
assets/lib/inbox/reply-parser.ts | 移除引用内容 |
assets/lib/inbox/inbound-notification.ts | 通知邮件 |
assets/lib/personalization/types.ts | 上下文类型(可选) |
assets/lib/personalization/process-liquid-tags.ts | 标签检测(可选) |
assets/api/domains-route.ts | 域名管理 API |
assets/api/inbox-send-route.ts | 单封邮件发送 API |
assets/api/webhook-resend-route.ts | Webhook 处理程序 |
assets/hooks/use-team-domains.ts | 域名钩子 |
assets/hooks/use-inbox.ts | 收件箱钩子 |
assets/prisma/schema-additions.prisma | Schema 模型 |
assets/api/users-me-route.ts | 用户资料 GET/PATCH |
assets/api/available-domains-route.ts | 获取用户可用的域名 |
assets/api/inbox-updates-route.ts | 新消息轮询端点 |
assets/hooks/use-user.ts | 用户资料钩子 |
assets/hooks/use-available-domains.ts | 可用域名钩子 |
assets/hooks/use-inbox-polling.ts | 带提示通知的收件箱轮询 |
assets/components/notifications-section.tsx | 通知开关界面 |
assets/components/email-settings-section.tsx | 域名选择器界面 |
assets/components/team-personalization-section.tsx | AI 个性化设置(可选) |
assets/components/inbox-notification-provider.tsx | 轮询提供程序包装器 |
assets/lib/redis.ts | Redis 发布/订阅客户端(可选) |
assets/api/inbox-events-sse-route.ts | SSE 端点(可选) |
assets/hooks/use-inbox-realtime.ts | SSE 钩子(可选) |
设置说明
1. 配置 Resend
- 在 resend.com 创建账户
- 从控制面板获取 API 密钥
- 设置入站域名:
- 前往 Resend 控制面板 → Domains
- 添加入站域名(例如,
inbox.yourdomain.com) - 配置 DNS MX 记录,使其指向 Resend
- 设置 Webhook 端点
2. 配置 Webhook
在 Resend 控制面板 → Webhooks 中:
- URL:
https://yourdomain.com/api/webhooks/resend - 事件:
email.received、email.delivered、email.bounced、email.sent - 将 Webhook 密钥复制到
RESEND_WEBHOOK_SECRET
3. 安装依赖项
bun add resend
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder
4. 添加数据库模型
将 assets/prisma/schema-additions.prisma 中的 Schema 补充内容复制到你的 Schema 中。
5. 复制资产文件
将 assets/ 中的模板文件复制到项目结构中。
入站邮件匹配优先级
收到邮件时:
- 去重 - 检查
resendEmailId是否已存在(如果是重复邮件则跳过) - 出站检测 - 检查发件人是否为向外部发送邮件的团队成员
- RFC 5322 标头 - 通过 Message-ID、In-Reply-To 或 References 进行匹配
- 创作者邮箱 - 将发件人与创作者的主要邮箱进行匹配
- 创作者联系人 - 将发件人与关联联系人的邮箱进行匹配
- 域名匹配 - 将发件人域名与创作者邮箱域名进行匹配
- 自动创建 - 如果没有找到匹配项,则创建新的创作者
出站检测: 如果团队成员通过个人邮件客户端(Gmail、Outlook)向外部地址发送邮件,Webhook 会将其作为 email.received 接收。处理程序会检查发件人是否为团队成员,以此检测这类邮件,并创建一条 OUTBOUND 邮件记录。
用户域名选择流程
- 管理员通过 Team Settings → Domains 添加域名
- 用户配置 DNS 记录(MX、SPF、DKIM)
- 管理员通过“Verify DNS”按钮验证域名
- 管理员激活域名(每个团队只能激活一个)
- 用户在 Personal Settings → Email 中选择首选域名
- 发送的邮件使用用户所选域名作为 From 地址
- Reply-To 始终使用入站域名进行追踪
用户设置
通知首选项
用户可以切换是否接收有关入站回复的邮件通知:
// PATCH /api/users/me
{ notifyInboundEmail: true }
// Only members with notifyInboundEmail: true receive notifications
// If creator has an assignee, only the assignee is notified
发件域名选择
用户可以从已验证的团队域名中选择首选发件域名:
// GET /api/users/me/available-domains
// Returns all verified domains from user's teams
// PATCH /api/users/me
{ sendFromDomain: "acme.com" } // or null for default
// Validated: domain must be verified and belong to user's team
团队设置
AI 个性化配置(可选)
注意: 如果不需要 AI 个性化,请跳过此部分。
团队可以配置 AI 个性化设置:
| 字段 | 描述 |
|---|---|
personalizationAboutUs | 用于 AI 上下文的团队描述(最多 2000 个字符) |
personalizationModelId | AI 模型:google/gemini-3-flash、anthropic/claude-sonnet-4.5 等 |
personalizationInstructions | 自定义 AI 指令(最多 2000 个字符) |
personalizationPreviewEnabled | 发送个性化邮件前显示预览弹窗 |
// PATCH /api/teams/[teamId]
{
personalizationAboutUs: "We are a marketing agency...",
personalizationModelId: "google/gemini-3-flash",
personalizationInstructions: "Keep tone professional but friendly",
personalizationPreviewEnabled: true
}
实时更新
有两种可用方案。为简化实现并兼容无服务器环境,推荐使用轮询。
选项 1:轮询(推荐)
系统使用轮询(间隔 10 秒),以确保在无服务器环境中可靠运行:
// Wrap dashboard with InboxNotificationProvider
<InboxNotificationProvider>
<DashboardShell>{children}</DashboardShell>
</InboxNotificationProvider>
// Hook configuration
useInboxPolling(teamId, {
interval: 10000, // 10 seconds
enabled: true,
});
功能:
- 收到新消息时显示 Toast 通知(最多显示 3 条,之后显示摘要)
- 收到新消息时自动重新获取收件箱查询数据
- 清理内存(仅保留最近 100 个消息 ID)
- 点击“查看”以跳转到消息
- 无需 Redis
选项 2:使用 Redis 的 SSE(可选)
如需真正的实时更新,请使用 Server-Sent Events 和 Redis 发布/订阅:
// Hook usage
useInboxRealtime(teamId, {
enabled: true,
onNewMessage: (data) => console.log("New message:", data),
});
要求:
- Redis 实例(例如 Railway Redis)
REDIS_URL环境变量ioredis包
流程:
- Webhook 收到邮件 → 调用
publishInboxEvent(teamId, event) - Redis 向频道
inbox:events:{teamId}发布事件 - 订阅该频道的 SSE 连接立即收到事件
- 前端通过
useInboxRealtimehook 更新
事件类型:
new_message- 收到新的入站邮件inbox_update- 会话/消息已更新message_status- 投递状态已更改(已发送、已送达、退信)
优点: 真正的实时更新,效率更高
缺点: 需要 Redis;在无服务器环境中存在连接数限制
检查清单
核心设置(必需)
- 已配置 Resend API 密钥
- 已在 Resend 中设置入站域名
- Webhook 端点已部署并验证
- 数据库架构已更新(TeamDomain、InboxThread、InboxMessage)
- 已添加 Resend 客户端工具
- 已添加会话处理工具
- 已实现 Webhook 处理程序
- 域名管理 API 路由
- 单封邮件发送 API 路由
- 用户设置(通知开关、域名选择器)
- 收件箱轮询及 Toast 通知
可选功能
- AI 个性化系统(lib/personalization/*、团队设置界面)
- PersonalizedEmailPreview 模型 + 审核 API 路由
- 用于批量发送的 pg-boss worker
- TipTap 富文本编辑器
- 使用 Redis 的 SSE,实现真正的实时更新(lib/redis.ts、inbox/events 路由)