resend-inbound-emails
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWhen to Use This Skill
何时使用该技能
Use when:
- Setting up two-way email communication (send and receive)
- Implementing custom sending domain management
- Building an inbox system with threading
- Adding AI-powered email personalization
- Processing inbound emails via webhooks
适用于以下场景:
- 搭建具备收发功能的双向邮件通信系统
- 实现自定义发件域名管理
- 构建带线程功能的收件箱系统
- 添加AI驱动的邮件个性化功能
- 通过webhook处理入站邮件
Features Overview
功能概述
Core Features (Required)
核心功能(必填)
| Feature | Description |
|---|---|
| Custom Domains | Add, verify, and manage sending domains via Resend API |
| Inbound Webhooks | Receive and process incoming emails with threading |
| Single Send | Send emails individually with RFC 5322 threading |
| User Settings | Notification preferences, sending domain selection |
| Real-time Updates | Polling for instant inbox updates with toast notifications |
| Notifications | Email notifications to team members on replies |
| 功能 | 描述 |
|---|---|
| 自定义域名 | 通过Resend API添加、验证和管理发件域名 |
| 入站Webhook | 接收并处理带线程的入站邮件 |
| 单次发送 | 单独发送邮件,支持RFC 5322线程标准 |
| 用户设置 | 通知偏好设置、发件域名选择 |
| 实时更新 | 轮询实现收件箱即时更新,搭配弹窗通知 |
| 通知功能 | 收到回复时向团队成员发送邮件通知 |
Optional Features
可选功能
| Feature | Description |
|---|---|
| AI Personalization | AI-powered |
| Preview/Review | Review and edit personalized emails before sending |
| Bulk Send | Send emails in batches via pg-boss queue |
| Rich Text Editor | TipTap-based editor with attachments and formatting |
| 功能 | 描述 |
|---|---|
| AI个性化 | 使用Vercel AI SDK实现 |
| 预览/审核 | 发送前预览并编辑个性化邮件 |
| 批量发送 | 通过pg-boss队列批量发送邮件 |
| 富文本编辑器 | 基于TipTap的编辑器,支持附件和格式设置 |
Architecture Diagram
架构图
┌─────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────┘Prerequisites
前置条件
- Resend account with API key
- PostgreSQL database
- pg-boss for bulk email queue
- Vercel AI SDK for personalization
- 拥有Resend账户及API密钥
- PostgreSQL数据库
- 用于批量邮件队列的pg-boss
- 用于个性化功能的Vercel AI SDK
Environment Variables
环境变量
bash
undefinedbash
undefinedResend
Resend
RESEND_API_KEY="re_your_api_key"
RESEND_WEBHOOK_SECRET="whsec_your_webhook_secret"
EMAIL_FROM="onboarding@resend.dev"
RESEND_API_KEY="re_your_api_key"
RESEND_WEBHOOK_SECRET="whsec_your_webhook_secret"
EMAIL_FROM="onboarding@resend.dev"
Your default inbound domain (set up in Resend dashboard)
Your default inbound domain (set up in Resend dashboard)
Format: emails to *@yourdomain.com will be forwarded to your webhook
Format: emails to *@yourdomain.com will be forwarded to your webhook
DEFAULT_EMAIL_DOMAIN="inbox.yourdomain.com"
DEFAULT_EMAIL_DOMAIN="inbox.yourdomain.com"
App URL for notification links
App URL for notification links
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
undefinedNEXT_PUBLIC_APP_URL="https://yourdomain.com"
undefinedDatabase Schema
数据库Schema
Add these models to your Prisma schema:
将以下模型添加到你的Prisma schema中:
Team Domain Management
团队域名管理
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])
}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])
}Inbox Threading
收件箱线程处理
prisma
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
}prisma
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
}Personalization Review
个性化审核
prisma
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
}prisma
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
}Core Patterns
核心模式
1. Custom Domain Management
1. 自定义域名管理
Users can add custom sending domains through Resend API:
typescript
// 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用户可通过Resend API添加自定义发件域名:
typescript
// 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 records2. Building From/Reply-To Addresses
2. 构建发件人/回复地址
typescript
// Always use your inbound domain for reply-to to ensure tracking
const fromAddress = buildFromAddress(userEmail, userName, sendingDomain);
// Result: "Kai <kai@acme.com>"
const replyTo = buildReplyToAddress(userEmail);
// Result: "kai@inbox.yourdomain.com" (your inbound domain)typescript
// Always use your inbound domain for reply-to to ensure tracking
const fromAddress = buildFromAddress(userEmail, userName, sendingDomain);
// Result: "Kai <kai@acme.com>"
const replyTo = buildReplyToAddress(userEmail);
// Result: "kai@inbox.yourdomain.com" (your inbound domain)3. RFC 5322 Email Threading
3. RFC 5322邮件线程处理
Thread continuity is maintained using standard email headers:
typescript
// 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"]),
});通过标准邮件头维持线程连续性:
typescript
// 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 Processing
4. Resend Webhook处理
typescript
// 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;
}Important:
- Read payload as , not
req.text()before verificationreq.json() - Svix headers use lowercase with hyphens: ,
svix-id,svix-timestampsvix-signature - Always deduplicate using before processing
resendEmailId
typescript
// 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-timestampsvix-signature - 处理前务必通过进行重复数据删除
resendEmailId
5. AI Personalization with {{ Tags }} (OPTIONAL)
5. 基于{{ Tags }}的AI个性化(可选)
Note: This feature is optional. Skip if you don't need AI-powered email personalization.
typescript
// 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");
}
}Available tags:
- - Creator's name
{{ name }} - - Day-appropriate greeting
{{ time_based_greeting }} - - AI-generated compliment
{{ compliment }} - - Why collaboration makes sense
{{ content_fit_pitch }} - - Call to action
{{ reply_cta }} - - Sender's name
{{ user_name }}
Required for personalization:
- Vercel AI SDK ()
@ai-sdk/gateway - Team personalization settings UI
- model (for review workflow)
PersonalizedEmailPreview
注意: 该功能为可选功能,若无需AI驱动的邮件个性化可跳过。
typescript
// 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 }} - - AI生成的赞美语
{{ compliment }} - - 合作契合点说明
{{ content_fit_pitch }} - - 行动号召
{{ reply_cta }} - - 发件人姓名
{{ user_name }}
个性化功能要求:
- Vercel AI SDK ()
@ai-sdk/gateway - 团队个性化设置UI
- 模型(用于审核流程)
PersonalizedEmailPreview
6. Bulk Email with pg-boss (OPTIONAL)
6. 基于pg-boss的批量邮件发送(可选)
Note: This feature is optional. Skip if you only need single email sending.
typescript
// 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);Required for bulk send:
- pg-boss setup (see team-saas skill)
- Bulk send API route
- Worker handler for jobs
BULK_SEND_EMAIL
注意: 该功能为可选功能,若仅需单次邮件发送可跳过。
typescript
// 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路由
- 任务的Worker处理器
BULK_SEND_EMAIL
File Structure
文件结构
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 tablesrc/
├── 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 tableAsset Files Included
包含的资源文件
| Asset | Description |
|---|---|
| Resend client + address builders |
| Thread matching logic |
| RFC 5322 utilities |
| Strip quoted content |
| Notification emails |
| Context types (OPTIONAL) |
| Tag detection (OPTIONAL) |
| Domain management API |
| Single send API |
| Webhook handler |
| Domain hooks |
| Inbox hooks |
| Schema models |
| User profile GET/PATCH |
| Get user's available domains |
| Polling endpoint for new messages |
| User profile hooks |
| Available domains hook |
| Inbox polling with toasts |
| Notification toggle UI |
| Domain selector UI |
| AI personalization settings (OPTIONAL) |
| Polling provider wrapper |
| Redis pub/sub client (OPTIONAL) |
| SSE endpoint (OPTIONAL) |
| SSE hook (OPTIONAL) |
| 资源 | 描述 |
|---|---|
| Resend客户端 + 地址构建工具 |
| 线程匹配逻辑 |
| RFC 5322工具类 |
| 移除引用内容 |
| 团队通知工具 |
| 上下文类型(可选) |
| 标签检测(可选) |
| 域名管理API |
| 单次发送API |
| Webhook处理器 |
| 域名管理钩子 |
| 收件箱钩子 |
| Schema模型 |
| 用户信息获取/更新API |
| 获取用户可用域名API |
| 新消息轮询端点 |
| 用户信息钩子 |
| 可用域名钩子 |
| 带弹窗通知的收件箱轮询钩子 |
| 通知开关UI |
| 域名选择器UI |
| AI个性化设置UI(可选) |
| 轮询提供者包装组件 |
| Redis发布/订阅客户端(可选) |
| SSE端点(可选) |
| SSE钩子(可选) |
Setup Instructions
设置说明
1. Configure Resend
1. 配置Resend
- Create account at resend.com
- Get API key from dashboard
- Set up inbound domain:
- Go to Resend Dashboard → Domains
- Add your inbound domain (e.g., )
inbox.yourdomain.com - Configure DNS MX record to point to Resend
- Set up webhook endpoint
- 在resend.com创建账户
- 从控制台获取API密钥
- 设置入站域名:
- 进入Resend控制台 → Domains
- 添加你的入站域名(例如:)
inbox.yourdomain.com - 配置DNS MX记录指向Resend
- 设置webhook端点
2. Configure Webhook
2. 配置Webhook
In Resend Dashboard → Webhooks:
- URL:
https://yourdomain.com/api/webhooks/resend - Events: ,
email.received,email.delivered,email.bouncedemail.sent - Copy the webhook secret to
RESEND_WEBHOOK_SECRET
在Resend控制台 → Webhooks:
- URL:
https://yourdomain.com/api/webhooks/resend - 事件:,
email.received,email.delivered,email.bouncedemail.sent - 将webhook密钥复制到环境变量
RESEND_WEBHOOK_SECRET
3. Install Dependencies
3. 安装依赖
bash
bun add resend
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholderbash
bun add resend
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder4. Add Database Models
4. 添加数据库模型
Copy schema additions from to your schema.
assets/prisma/schema-additions.prisma将中的Schema添加到你的项目Schema中。
assets/prisma/schema-additions.prisma5. Copy Asset Files
5. 复制资源文件
Copy template files from to your project structure.
assets/将中的模板文件复制到你的项目结构中。
assets/Inbound Email Matching Priority
入站邮件匹配优先级
When an email is received:
- Deduplication - Check if already exists (skip if duplicate)
resendEmailId - OUTBOUND Detection - Check if sender is team member sending externally
- RFC 5322 Headers - Match via Message-ID, In-Reply-To, or References
- Creator Email - Match sender against primary creator email
- Creator Contacts - Match sender against associated contact emails
- Domain Match - Match sender domain against creator email domain
- Auto-create - Create new creator if no match found
OUTBOUND Detection: If a team member sends an email from their personal email client (Gmail, Outlook) to an external address, the webhook will receive it as . The handler detects this by checking if the sender is a team member and creates an OUTBOUND message record.
email.received收到邮件时:
- 重复数据删除 - 检查是否已存在(若重复则跳过)
resendEmailId - 出站检测 - 检查发件人是否为团队成员向外发送邮件
- RFC 5322头 - 通过Message-ID、In-Reply-To或References匹配线程
- 创建者邮箱 - 将发件人与主创建者邮箱匹配
- 创建者联系人 - 将发件人与关联联系人邮箱匹配
- 域名匹配 - 将发件人域名与创建者邮箱域名匹配
- 自动创建 - 若未找到匹配项则自动创建新创建者
出站检测说明: 若团队成员从个人邮件客户端(Gmail、Outlook)向外部发送邮件,webhook会以事件接收。处理器会检测发件人是否为团队成员,并创建出站消息记录。
email.receivedUser Domain Selection Flow
用户域名选择流程
- Admin adds domain via Team Settings → Domains
- User configures DNS records (MX, SPF, DKIM)
- Admin verifies domain via "Verify DNS" button
- Admin activates domain (one active per team)
- User selects preferred domain in Personal Settings → Email
- Emails sent use user's selected domain for From address
- Reply-To always uses inbound domain for tracking
- 管理员通过团队设置 → 域名添加域名
- 用户配置DNS记录(MX、SPF、DKIM)
- 管理员点击“验证DNS”按钮验证域名
- 管理员激活域名(每个团队仅可激活一个域名)
- 用户在个人设置 → 邮箱中选择偏好域名
- 发送邮件时使用用户选择的域名作为发件人地址
- 回复地址始终使用入站域名以确保跟踪
User Settings
用户设置
Notification Preferences
通知偏好
Users can toggle email notifications for inbound replies:
typescript
// PATCH /api/users/me
{ notifyInboundEmail: true }
// Only members with notifyInboundEmail: true receive notifications
// If creator has an assignee, only the assignee is notified用户可切换入站回复的邮件通知:
typescript
// PATCH /api/users/me
{ notifyInboundEmail: true }
// Only members with notifyInboundEmail: true receive notifications
// If creator has an assignee, only the assignee is notifiedSending Domain Selection
发件域名选择
Users can select their preferred sending domain from verified team domains:
typescript
// 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用户可从已验证的团队域名中选择偏好的发件域名:
typescript
// 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 teamTeam Settings
团队设置
AI Personalization Configuration (OPTIONAL)
AI个性化配置(可选)
Note: Skip this section if you don't need AI personalization.
Teams can configure AI personalization settings:
| Field | Description |
|---|---|
| Team description for AI context (max 2000 chars) |
| AI model: |
| Custom AI instructions (max 2000 chars) |
| Show preview modal before sending personalized emails |
typescript
// PATCH /api/teams/[teamId]
{
personalizationAboutUs: "We are a marketing agency...",
personalizationModelId: "google/gemini-3-flash",
personalizationInstructions: "Keep tone professional but friendly",
personalizationPreviewEnabled: true
}注意: 若无需AI个性化功能可跳过本节。
团队可配置AI个性化设置:
| 字段 | 描述 |
|---|---|
| 用于AI上下文的团队描述(最多2000字符) |
| AI模型: |
| 自定义AI指令(最多2000字符) |
| 发送个性化邮件前显示预览弹窗 |
typescript
// PATCH /api/teams/[teamId]
{
personalizationAboutUs: "We are a marketing agency...",
personalizationModelId: "google/gemini-3-flash",
personalizationInstructions: "Keep tone professional but friendly",
personalizationPreviewEnabled: true
}Real-time Updates
实时更新
Two approaches are available. Polling is recommended for simplicity and serverless compatibility.
提供两种方案。推荐使用轮询方案,因其实现简单且兼容无服务器架构。
Option 1: Polling (Recommended)
方案1:轮询(推荐)
The system uses polling (10s interval) for reliability with serverless:
typescript
// Wrap dashboard with InboxNotificationProvider
<InboxNotificationProvider>
<DashboardShell>{children}</DashboardShell>
</InboxNotificationProvider>
// Hook configuration
useInboxPolling(teamId, {
interval: 10000, // 10 seconds
enabled: true,
});Features:
- Toast notifications for new messages (max 3, then summary)
- Auto-refetch inbox queries on new messages
- Memory cleanup (keeps last 100 message IDs)
- Click "View" to navigate to message
- No Redis required
系统使用轮询(10秒间隔)确保在无服务器架构下的可靠性:
typescript
// Wrap dashboard with InboxNotificationProvider
<InboxNotificationProvider>
<DashboardShell>{children}</DashboardShell>
</InboxNotificationProvider>
// Hook configuration
useInboxPolling(teamId, {
interval: 10000, // 10 seconds
enabled: true,
});特性:
- 新消息弹窗通知(最多3条,之后显示摘要)
- 收到新消息时自动重新获取收件箱查询
- 内存清理(保留最近100条消息ID)
- 点击“查看”跳转至消息
- 无需Redis
Option 2: SSE with Redis (OPTIONAL)
方案2:基于Redis的SSE(可选)
For true real-time updates, use Server-Sent Events with Redis pub/sub:
typescript
// Hook usage
useInboxRealtime(teamId, {
enabled: true,
onNewMessage: (data) => console.log("New message:", data),
});Requirements:
- Redis instance (e.g., Railway Redis)
- environment variable
REDIS_URL - package
ioredis
Flow:
- Webhook receives email → calls
publishInboxEvent(teamId, event) - Redis publishes to channel
inbox:events:{teamId} - SSE connections subscribed to channel receive event instantly
- Frontend updates via hook
useInboxRealtime
Event types:
- - New inbound email received
new_message - - Thread/message updated
inbox_update - - Delivery status changed (sent, delivered, bounced)
message_status
Advantages: True real-time, more efficient
Disadvantages: Requires Redis, connection limits with serverless
如需真正的实时更新,可使用带Redis发布/订阅的Server-Sent Events:
typescript
// 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连接即时接收事件
- 前端通过钩子更新UI
useInboxRealtime
事件类型:
- - 收到新的入站邮件
new_message - - 线程/消息已更新
inbox_update - - 投递状态变更(已发送、已投递、退回)
message_status
优势: 真正的实时更新,更高效
劣势: 需要Redis,无服务器架构下存在连接限制
Checklist
检查清单
Core Setup (Required)
核心设置(必填)
- Resend API key configured
- Inbound domain set up in Resend
- Webhook endpoint deployed and verified
- Database schema updated (TeamDomain, InboxThread, InboxMessage)
- Resend client utilities added
- Threading utilities added
- Webhook handler implemented
- Domain management API routes
- Single email send API route
- User settings (notification toggle, domain selector)
- Inbox polling with toast notifications
- 已配置Resend API密钥
- 已在Resend中设置入站域名
- Webhook端点已部署并验证
- 已更新数据库Schema(TeamDomain、InboxThread、InboxMessage)
- 已添加Resend客户端工具
- 已添加线程处理工具
- 已实现Webhook处理器
- 已添加域名管理API路由
- 已添加单次邮件发送API路由
- 已实现用户设置(通知开关、域名选择器)
- 已实现带弹窗通知的收件箱轮询
Optional Features
可选功能
- AI Personalization system (lib/personalization/*, team settings UI)
- PersonalizedEmailPreview model + review API routes
- pg-boss worker for bulk send
- TipTap rich text editor
- SSE with Redis for true real-time (lib/redis.ts, inbox/events route)
- AI个性化系统(lib/personalization/*、团队设置UI)
- PersonalizedEmailPreview模型 + 审核API路由
- 用于批量发送的pg-boss worker
- TipTap富文本编辑器
- 基于Redis的SSE实时更新(lib/redis.ts、收件箱事件路由)