mcpField
R
resend-inbound-emails

resend-inbound-emails

github.com/blink-new生产力53 次安装
代码仓库

关于 resend-inbound-emails

resend-inbound-emails 是使用 Resend 实现邮件收发和收件箱工作流的技能,涵盖域名管理、入站 Webhook 和邮件串联,也支持可选的个性化及批量发送。

主要功能

  1. 01管理并验证发件域名
  2. 02处理入站邮件 Webhook
  3. 03发送串联邮件
  4. 04轮询收件箱更新
  5. 05可选的 AI 个性化与审核
  6. 06可选的队列批量发送

适用场景

  1. 01构建支持邮件串联的团队收件箱
  2. 02通过 Resend Webhook 接收回复
  3. 03审核后发送个性化邮件
  4. 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.ts
domains-activate-route.ts
domains-route.ts
domains-verify-route.ts
inbox-events-sse-route.ts
inbox-send-route.ts
inbox-updates-route.ts
users-me-route.ts
webhook-resend-route.ts
assets/components/
email-settings-section.tsx
inbox-notification-provider.tsx
notifications-section.tsx
team-personalization-section.tsx
assets/config/
env-additions.txt
assets/hooks/
use-available-domains.ts
use-inbox-polling.ts
use-inbox-realtime.ts
use-inbox.ts
use-team-domains.ts
use-user.ts
assets/lib/
redis.ts
resend.ts
assets/lib/inbox/
email-headers.ts
inbound-notification.ts
reply-parser.ts
threading.ts
assets/lib/personalization/
process-liquid-tags.ts
types.ts
assets/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.tsResend 客户端和地址构建器
assets/lib/inbox/threading.ts线程匹配逻辑
assets/lib/inbox/email-headers.tsRFC 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.tsWebhook 处理程序
assets/hooks/use-team-domains.ts域名钩子
assets/hooks/use-inbox.ts收件箱钩子
assets/prisma/schema-additions.prismaSchema 模型
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.tsxAI 个性化设置(可选)
assets/components/inbox-notification-provider.tsx轮询提供程序包装器
assets/lib/redis.tsRedis 发布/订阅客户端(可选)
assets/api/inbox-events-sse-route.tsSSE 端点(可选)
assets/hooks/use-inbox-realtime.tsSSE 钩子(可选)

设置说明

1. 配置 Resend
  1. 在 resend.com 创建账户
  2. 从控制面板获取 API 密钥
  3. 设置入站域名:
    • 前往 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/ 中的模板文件复制到项目结构中。

入站邮件匹配优先级

收到邮件时:

  1. 去重 - 检查 resendEmailId 是否已存在(如果是重复邮件则跳过)
  2. 出站检测 - 检查发件人是否为向外部发送邮件的团队成员
  3. RFC 5322 标头 - 通过 Message-ID、In-Reply-To 或 References 进行匹配
  4. 创作者邮箱 - 将发件人与创作者的主要邮箱进行匹配
  5. 创作者联系人 - 将发件人与关联联系人的邮箱进行匹配
  6. 域名匹配 - 将发件人域名与创作者邮箱域名进行匹配
  7. 自动创建 - 如果没有找到匹配项,则创建新的创作者

出站检测: 如果团队成员通过个人邮件客户端(Gmail、Outlook)向外部地址发送邮件,Webhook 会将其作为 email.received 接收。处理程序会检查发件人是否为团队成员,以此检测这类邮件,并创建一条 OUTBOUND 邮件记录。

用户域名选择流程

  1. 管理员通过 Team Settings → Domains 添加域名
  2. 用户配置 DNS 记录(MX、SPF、DKIM)
  3. 管理员通过“Verify DNS”按钮验证域名
  4. 管理员激活域名(每个团队只能激活一个)
  5. 用户在 Personal Settings → Email 中选择首选域名
  6. 发送的邮件使用用户所选域名作为 From 地址
  7. 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 个字符)
personalizationModelIdAI 模型: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 包

流程:

  1. Webhook 收到邮件 → 调用 publishInboxEvent(teamId, event)
  2. Redis 向频道 inbox:events:{teamId} 发布事件
  3. 订阅该频道的 SSE 连接立即收到事件
  4. 前端通过 useInboxRealtime hook 更新

事件类型:

  • 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 路由)

评论

支持 Markdown