Sistine Starter

邮件服务

Resend 邮件集成完整指南

邮件服务

Sistine Starter 使用 Resend 作为邮件服务提供商,支持交易邮件(验证邮件、密码重置、购买确认等)和 Newsletter 订阅功能。本文档详细介绍 Resend 的配置步骤、邮件发送方法、模板展示和最佳实践。

Resend 简介

Resend 是一个现代化的开发者邮件平台,专为应用程序设计,具有以下特点:

  • 开发者友好: 简洁的 API 设计,完整的 TypeScript 支持
  • 高送达率: 优化的邮件基础设施,确保邮件到达收件箱
  • 域名验证: 支持自定义域名,提升品牌可信度
  • 免费额度: 每月 3,000 封免费邮件 (足够开发和小规模使用)
  • 测试邮箱: 开发环境可使用 onboarding@resend.dev 无需域名验证

配置步骤

1. 创建 Resend 账号

  1. 访问 Resend 并注册账号
  2. 登录后进入 Dashboard

2. 获取 API Key

  1. 在 Resend Dashboard 中导航到 API Keys
  2. 点击 Create API Key
  3. 输入名称 (如 "Sistine Starter Production")
  4. 选择权限: Sending access (发送邮件)
  5. 复制生成的 API Key (格式: re_xxxxxxxxxxxxx)

3. 配置环境变量

将以下环境变量添加到 .env.local:

# Resend API Key (必需)
RESEND_API_KEY="re_xxxxxxxxxxxxx"

# 开发环境可以不配置发件邮箱,会自动使用测试邮箱
# RESEND_FROM_EMAIL 未设置时,默认使用:
# onboarding@resend.dev (仅开发环境有效)

配置优先级:

  1. 如果设置了 RESEND_FROM_EMAIL,直接使用该地址
  2. 如果是开发环境 (NODE_ENV=development),使用 onboarding@resend.dev
  3. 如果是生产环境,使用 noreply@{RESEND_VERIFIED_DOMAIN}

4. 验证域名 (生产环境必需)

为了在生产环境发送邮件,你需要验证自己的域名。

步骤 A: 添加域名

  1. 在 Resend Dashboard 中导航到 Domains
  2. 点击 Add Domain
  3. 输入你的域名 (如 yourdomain.com)
  4. 选择 Region: 选择离用户最近的区域 (如 us-east-1)

步骤 B: 配置 DNS 记录

Resend 会提供需要添加的 DNS 记录,包括:

SPF 记录 (Sender Policy Framework):

类型: TXT
名称: @
值: v=spf1 include:_spf.resend.com ~all

DKIM 记录 (DomainKeys Identified Mail):

类型: TXT
名称: resend._domainkey
值: (Resend 提供的长字符串)

DMARC 记录 (可选,推荐):

类型: TXT
名称: _dmarc
值: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com

步骤 C: 验证域名

  1. 在你的 DNS 提供商 (如 Cloudflare、AWS Route53、Namecheap) 中添加上述记录
  2. 返回 Resend Dashboard,点击 Verify Domain
  3. 等待验证完成 (通常几分钟内,最多 48 小时)
  4. 验证成功后,状态会显示为 Verified

DNS 配置示例

# 登录 Cloudflare Dashboard
# 选择你的域名 > DNS > Add record

# SPF 记录
Type: TXT
Name: @
Content: v=spf1 include:_spf.resend.com ~all
Proxy status: DNS only (灰色云朵)

# DKIM 记录
Type: TXT
Name: resend._domainkey
Content: (从 Resend 复制)
Proxy status: DNS only

5. 测试邮件发送

配置完成后,使用 Resend Dashboard 的 Send Test Email 功能测试:

  1. 导航到 Emails > Send Test Email
  2. 输入收件地址
  3. 发送并检查是否收到

或在代码中测试:

import { sendEmail } from "@/lib/email";

await sendEmail({
  to: "your-email@example.com",
  subject: "Test Email from Sistine AI",
  html: "<p>Hello! This is a test email.</p>",
});

邮件发送方法

核心邮件逻辑位于 lib/email.ts

通用邮件发送函数

sendEmail 是所有邮件发送的基础函数:

export interface SendEmailOptions {
  to: string | string[];      // 收件人 (支持单个或多个)
  subject: string;             // 邮件主题
  react?: React.ReactElement;  // React 邮件组件 (推荐)
  html?: string;               // HTML 内容
  text?: string;               // 纯文本内容 (可选)
  from?: string;               // 发件人 (可选,默认使用环境变量)
  replyTo?: string;            // 回复地址 (可选)
}

export async function sendEmail(options: SendEmailOptions) {
  try {
    const data = await resend.emails.send({
      to: options.to,
      subject: options.subject,
      react: options.react,
      html: options.html,
      text: options.text,
      from: options.from || DEFAULT_FROM_EMAIL,
      replyTo: options.replyTo,
    });

    return { success: true, data };
  } catch (error) {
    console.error('Failed to send email:', error);
    return { success: false, error };
  }
}

使用示例:

// 发送 HTML 邮件
await sendEmail({
  to: "user@example.com",
  subject: "Welcome to Sistine AI",
  html: "<h1>Welcome!</h1><p>Thank you for joining us.</p>",
});

// 发送给多个收件人
await sendEmail({
  to: ["user1@example.com", "user2@example.com"],
  subject: "System Maintenance Notice",
  html: "<p>Our system will be under maintenance...</p>",
});

// 自定义发件人和回复地址
await sendEmail({
  to: "support@example.com",
  subject: "Support Request from User",
  html: "<p>User needs help with...</p>",
  from: "Support <support@yourdomain.com>",
  replyTo: "user@example.com",
});

邮件模板展示

Sistine Starter 提供了多种预定义的邮件模板,开箱即用。

1. 验证邮件

用户注册后发送邮箱验证链接。

函数: sendVerificationEmail(email: string, token: string)

使用场景:

  • 用户注册后验证邮箱
  • 修改邮箱地址后重新验证

示例代码:

import { sendVerificationEmail } from "@/lib/email";

// 生成验证 Token
const token = randomUUID();

// 存储 Token 到数据库
await db.insert(verificationToken).values({
  userId,
  token,
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 小时有效
});

// 发送验证邮件
await sendVerificationEmail(user.email, token);

邮件效果:

主题: Verify your email - Sistine AI

内容:
┌─────────────────────────────────────┐
│ Welcome to Sistine AI!              │
│                                     │
│ Please click the link below to      │
│ verify your email address:          │
│                                     │
│ [Verify Email] (黑色按钮)            │
│                                     │
│ Or copy this link to your browser:  │
│ https://yourdomain.com/verify-email?token=xxx
│                                     │
│ If you didn't sign up for Sistine  │
│ AI, you can safely ignore this      │
│ email.                              │
└─────────────────────────────────────┘

模板代码 (可在 lib/email.ts:72-93 自定义):

export async function sendVerificationEmail(email: string, token: string) {
  const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${token}`;

  return sendEmail({
    to: email,
    subject: 'Verify your email - Sistine AI',
    html: `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h1 style="color: #333;">Welcome to Sistine AI!</h1>
        <p>Please click the link below to verify your email address:</p>
        <a href="${verificationUrl}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 6px; margin: 20px 0;">
          Verify Email
        </a>
        <p>Or copy this link to your browser:</p>
        <p style="color: #666; word-break: break-all;">${verificationUrl}</p>
        <p style="color: #999; font-size: 14px; margin-top: 30px;">
          If you didn't sign up for Sistine AI, you can safely ignore this email.
        </p>
      </div>
    `,
  });
}

2. 密码重置邮件

用户忘记密码时发送重置链接。

函数: sendPasswordResetEmail(email: string, token: string)

使用场景:

  • 用户点击"忘记密码"
  • 管理员为用户重置密码

示例代码:

import { sendPasswordResetEmail } from "@/lib/email";
import { randomUUID } from "crypto";

// 生成重置 Token
const token = randomUUID();

// 存储到数据库 (1 小时有效)
await db.insert(passwordResetToken).values({
  userId: user.id,
  token,
  expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});

// 发送邮件
await sendPasswordResetEmail(user.email, token);

邮件效果:

主题: Reset your password - Sistine AI

内容:
┌─────────────────────────────────────┐
│ Password Reset Request              │
│                                     │
│ We received a request to reset     │
│ your password. Click the link       │
│ below to reset it:                  │
│                                     │
│ [Reset Password] (黑色按钮)          │
│                                     │
│ Or copy this link to your browser:  │
│ https://yourdomain.com/reset-password?token=xxx
│                                     │
│ This link will expire in 1 hour.   │
│ If you didn't request a password   │
│ reset, you can safely ignore this   │
│ email.                              │
└─────────────────────────────────────┘

3. 欢迎邮件

新用户注册后发送欢迎邮件。

函数: sendWelcomeEmail(email: string, name?: string)

使用场景:

  • 用户完成注册后
  • 邮箱验证成功后

示例代码:

import { sendWelcomeEmail } from "@/lib/email";

// 在用户注册成功后调用
await sendWelcomeEmail(user.email, user.name);

邮件效果:

主题: Welcome to Sistine AI!

内容:
┌─────────────────────────────────────┐
│ Welcome to Sistine AI, John!        │
│                                     │
│ Thank you for joining us! We're    │
│ excited to have you on board.       │
│                                     │
│ Here's what you can do next:       │
│ • Complete your profile             │
│ • Explore our features              │
│ • Try the demo                      │
│ • Check out our documentation       │
│                                     │
│ [Go to Dashboard] (黑色按钮)         │
│                                     │
│ If you have any questions, feel    │
│ free to contact our support team.   │
└─────────────────────────────────────┘

4. 购买确认邮件

用户完成支付后发送购买详情。

函数: sendPurchaseEmail(email: string, orderDetails: any)

使用场景:

  • Creem Webhook 处理支付成功后
  • 手动发放积分时

示例代码:

import { sendPurchaseEmail } from "@/lib/email";

await sendPurchaseEmail(user.email, {
  orderId: "order_123",
  plan: "pro_monthly",
  amount: "$99.00 USD",
  credits: 10000,
  type: "subscription",
});

邮件效果:

主题: Purchase Confirmation - Sistine AI

内容:
┌─────────────────────────────────────┐
│ Purchase Successful!                │
│                                     │
│ Thank you for your purchase. Here  │
│ are your order details:             │
│                                     │
│ ┌─────────────────────────────┐    │
│ │ Order ID: order_123         │    │
│ │ Product: pro_monthly        │    │
│ │ Amount: $99.00 USD          │    │
│ │ Credits Added: 10000        │    │
│ │ Type: Monthly Subscription  │    │
│ └─────────────────────────────┘    │
│                                     │
│ [View Dashboard] (黑色按钮)          │
│                                     │
│ Thank you for choosing Sistine AI!  │
└─────────────────────────────────────┘

订单详情对象:

interface OrderDetails {
  orderId: string;        // 订单 ID
  plan: string;           // 计划名称或积分包 Key
  amount: string;         // 金额 (格式化后的字符串)
  credits: number;        // 发放的积分数
  type: "subscription" | "one_time"; // 类型
}

5. 订阅到期提醒

订阅即将到期时提醒用户续费。

函数: sendSubscriptionExpiryReminder(email: string, daysRemaining: number)

使用场景:

  • 定时任务检查订阅到期时间
  • 提前 7 天、3 天、1 天发送提醒

示例代码:

import { sendSubscriptionExpiryReminder } from "@/lib/email";

// 查询即将到期的订阅
const expiringSoon = await db
  .select()
  .from(subscription)
  .where(
    sql`${subscription.currentPeriodEnd} <= NOW() + INTERVAL '7 days'
        AND ${subscription.currentPeriodEnd} > NOW()`
  );

for (const sub of expiringSoon) {
  const daysRemaining = Math.ceil(
    (sub.currentPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
  );

  await sendSubscriptionExpiryReminder(sub.userEmail, daysRemaining);
}

邮件效果:

主题: Your subscription expires in 7 days - Sistine AI

内容:
┌─────────────────────────────────────┐
│ Subscription Expiry Reminder        │
│                                     │
│ Your Sistine AI subscription will  │
│ expire in 7 days.                   │
│                                     │
│ To continue enjoying uninterrupted │
│ access to our services, please     │
│ renew your subscription.            │
│                                     │
│ [Renew Subscription] (黑色按钮)      │
│                                     │
│ If you have any questions, please  │
│ contact our support team.           │
└─────────────────────────────────────┘

6. 积分不足提醒

用户积分余额过低时发送提醒。

函数: sendLowCreditsNotification(email: string, remainingCredits: number)

使用场景:

  • 用户积分低于阈值 (如 50 积分)
  • 用户尝试使用功能但积分不足

示例代码:

import { sendLowCreditsNotification } from "@/lib/email";
import { getUserCredits } from "@/lib/credits";

// 在 AI API 中检查积分
const credits = await getUserCredits(userId);

if (credits < 50 && credits > 0) {
  // 发送提醒 (可设置节流,避免频繁发送)
  await sendLowCreditsNotification(user.email, credits);
}

邮件效果:

主题: Low Credits Alert - Sistine AI

内容:
┌─────────────────────────────────────┐
│ Low Credits Alert                   │
│                                     │
│ You have only 20 credits remaining │
│ in your account.                    │
│                                     │
│ To continue using our AI services  │
│ without interruption, consider     │
│ purchasing more credits.            │
│                                     │
│ [Buy More Credits] (黑色按钮)        │
│                                     │
│ Need help? Contact our support     │
│ team.                               │
└─────────────────────────────────────┘

Newsletter 订阅功能

Sistine Starter 支持用户订阅 Newsletter,使用 Resend 的 Contacts API。

数据库表结构

Newsletter 订阅依赖 newsletter_subscription 表,建表语句请参考 数据库 文档中的 Newsletter 章节。

订阅流程

前端表单:

// components/newsletter-form.tsx
"use client";

import { useState } from "react";

export function NewsletterForm() {
  const [email, setEmail] = useState("");
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const res = await fetch("/api/newsletter/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });

      const data = await res.json();

      if (res.ok) {
        setMessage("Successfully subscribed! Check your email.");
        setEmail("");
      } else {
        setMessage(data.error || "Failed to subscribe");
      }
    } catch (error) {
      setMessage("Something went wrong");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? "Subscribing..." : "Subscribe"}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

后端 API (app/api/newsletter/subscribe/route.ts):

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { randomUUID } from "crypto";

export async function POST(req: NextRequest) {
  try {
    const { email } = await req.json();

    if (!email || !email.includes("@")) {
      return NextResponse.json(
        { error: "Invalid email" },
        { status: 400 }
      );
    }

    // 检查是否已订阅
    const existing = await db
      .select()
      .from(newsletterSubscription)
      .where(eq(newsletterSubscription.email, email));

    if (existing.length > 0) {
      if (existing[0].status === "active") {
        return NextResponse.json(
          { error: "Already subscribed" },
          { status: 400 }
        );
      } else {
        // 重新激活
        await db
          .update(newsletterSubscription)
          .set({ status: "active", unsubscribedAt: null })
          .where(eq(newsletterSubscription.email, email));

        return NextResponse.json({ message: "Resubscribed successfully" });
      }
    }

    // 插入新订阅
    await db.insert(newsletterSubscription).values({
      id: randomUUID(),
      email,
      status: "active",
    });

    // 发送欢迎邮件
    await sendEmail({
      to: email,
      subject: "Welcome to Sistine AI Newsletter",
      html: `
        <p>Thank you for subscribing to our newsletter!</p>
        <p>You'll receive updates about new features, AI insights, and more.</p>
      `,
    });

    return NextResponse.json({ message: "Subscribed successfully" });
  } catch (error) {
    console.error("Newsletter subscription error:", error);
    return NextResponse.json(
      { error: "Failed to subscribe" },
      { status: 500 }
    );
  }
}

取消订阅

API 端点 (app/api/newsletter/unsubscribe/route.ts):

export async function POST(req: NextRequest) {
  const { email } = await req.json();

  await db
    .update(newsletterSubscription)
    .set({
      status: "unsubscribed",
      unsubscribedAt: new Date(),
    })
    .where(eq(newsletterSubscription.email, email));

  return NextResponse.json({ message: "Unsubscribed successfully" });
}

邮件模板中添加取消订阅链接:

<p style="color: #999; font-size: 12px; margin-top: 40px;">
  You're receiving this email because you subscribed to Sistine AI Newsletter.
  <a href="https://yourdomain.com/unsubscribe?email=${email}">Unsubscribe</a>
</p>

发送 Newsletter

创建管理员接口发送群发邮件:

// app/api/admin/newsletter/send/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { newsletterSubscription } from "@/lib/db/schema";
import { sendEmail } from "@/lib/email";
import { auth } from "@/lib/auth";
import { eq } from "drizzle-orm";

export async function POST(req: NextRequest) {
  // 验证管理员权限
  const session = await auth.api.getSession({ headers: req.headers });
  if (session?.user?.role !== "admin") {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const { subject, html } = await req.json();

  // 获取所有活跃订阅者
  const subscribers = await db
    .select()
    .from(newsletterSubscription)
    .where(eq(newsletterSubscription.status, "active"));

  // 批量发送 (Resend 支持批量发送)
  const emailPromises = subscribers.map(sub =>
    sendEmail({
      to: sub.email,
      subject,
      html,
    })
  );

  await Promise.all(emailPromises);

  return NextResponse.json({
    message: `Sent to ${subscribers.length} subscribers`
  });
}

国际化支持

邮件内容可以根据用户语言偏好进行国际化。

方法 1: 使用条件渲染

export async function sendWelcomeEmail(
  email: string,
  name?: string,
  locale: string = "en"
) {
  const messages = {
    en: {
      subject: "Welcome to Sistine AI!",
      greeting: `Welcome to Sistine AI${name ? ', ' + name : ''}!`,
      body: "Thank you for joining us! We're excited to have you on board.",
    },
    zh: {
      subject: "欢迎加入 Sistine AI!",
      greeting: `欢迎加入 Sistine AI${name ? ', ' + name : ''}!`,
      body: "感谢您的加入!我们很高兴您能成为我们的一员。",
    },
  };

  const msg = messages[locale] || messages.en;

  return sendEmail({
    to: email,
    subject: msg.subject,
    html: `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h1>${msg.greeting}</h1>
        <p>${msg.body}</p>
        <!-- ... -->
      </div>
    `,
  });
}

方法 2: 使用 next-intl

import { getTranslations } from 'next-intl/server';

export async function sendWelcomeEmail(
  email: string,
  locale: string = "en"
) {
  const t = await getTranslations({ locale, namespace: 'email' });

  return sendEmail({
    to: email,
    subject: t('welcome.subject'),
    html: `
      <div>
        <h1>${t('welcome.greeting')}</h1>
        <p>${t('welcome.body')}</p>
      </div>
    `,
  });
}

翻译文件 (messages/en/email.json):

{
  "welcome": {
    "subject": "Welcome to Sistine AI!",
    "greeting": "Welcome to Sistine AI!",
    "body": "Thank you for joining us!"
  }
}

最佳实践

1. 使用 React Email 组件 (推荐)

React Email 提供可重用的 React 组件来构建邮件模板:

安装依赖:

pnpm add @react-email/components

创建邮件组件 (emails/welcome.tsx):

import { Html, Head, Body, Container, Heading, Text, Button } from '@react-email/components';

interface WelcomeEmailProps {
  name?: string;
  dashboardUrl: string;
}

export function WelcomeEmail({ name, dashboardUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: 'sans-serif' }}>
        <Container style={{ maxWidth: '600px', margin: '0 auto' }}>
          <Heading style={{ color: '#333' }}>
            Welcome to Sistine AI{name ? ', ' + name : ''}!
          </Heading>
          <Text>
            Thank you for joining us! We're excited to have you on board.
          </Text>
          <Button
            href={dashboardUrl}
            style={{
              backgroundColor: '#000',
              color: '#fff',
              padding: '12px 24px',
              borderRadius: '6px',
              textDecoration: 'none',
            }}
          >
            Go to Dashboard
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

使用组件:

import { render } from '@react-email/render';
import { WelcomeEmail } from '@/emails/welcome';

await sendEmail({
  to: email,
  subject: 'Welcome to Sistine AI',
  react: <WelcomeEmail name={name} dashboardUrl="https://yourdomain.com/dashboard" />,
});

2. 错误处理和重试

邮件发送可能失败,需要适当处理:

export async function sendEmailWithRetry(
  options: SendEmailOptions,
  maxRetries: number = 3
) {
  let lastError: any;

  for (let i = 0; i < maxRetries; i++) {
    const result = await sendEmail(options);

    if (result.success) {
      return result;
    }

    lastError = result.error;

    // 等待一段时间后重试 (指数退避)
    await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
  }

  console.error('Failed to send email after retries:', lastError);
  return { success: false, error: lastError };
}

3. 邮件发送日志

记录所有邮件发送情况:

import { db } from "@/lib/db";
import { emailLog } from "@/lib/db/schema";
import { randomUUID } from "crypto";

export async function sendEmailWithLogging(options: SendEmailOptions) {
  const logId = randomUUID();

  // 记录发送尝试
  await db.insert(emailLog).values({
    id: logId,
    to: Array.isArray(options.to) ? options.to.join(',') : options.to,
    subject: options.subject,
    status: "pending",
  });

  const result = await sendEmail(options);

  // 更新日志
  await db
    .update(emailLog)
    .set({
      status: result.success ? "sent" : "failed",
      error: result.success ? null : JSON.stringify(result.error),
      sentAt: result.success ? new Date() : null,
    })
    .where(eq(emailLog.id, logId));

  return result;
}

邮件日志表结构

建表语句请参考 数据库 文档中的 email_log 表定义。

4. 避免被标记为垃圾邮件

  • 使用已验证的域名: 确保 SPF、DKIM、DMARC 记录正确配置
  • 添加取消订阅链接: 所有营销邮件必须包含取消订阅功能
  • 避免垃圾词汇: 不要在主题中使用 "Free", "Act Now", "Guaranteed" 等词汇
  • 保持良好的发送频率: 不要短时间内大量发送
  • 提供纯文本版本: 除了 HTML,也提供 text 参数
await sendEmail({
  to: email,
  subject: "Welcome to Sistine AI",
  html: "<h1>Welcome!</h1><p>Thank you for joining us.</p>",
  text: "Welcome!\n\nThank you for joining us.", // 纯文本版本
});

5. 邮件模板测试

在发送给真实用户前,先进行测试:

创建测试端点 (app/api/test/email/route.ts):

import { NextRequest, NextResponse } from "next/server";
import { sendWelcomeEmail } from "@/lib/email";

export async function GET(req: NextRequest) {
  // 仅在开发环境启用
  if (process.env.NODE_ENV !== "development") {
    return NextResponse.json({ error: "Not available" }, { status: 404 });
  }

  const email = req.nextUrl.searchParams.get("email") || "test@example.com";

  await sendWelcomeEmail(email, "Test User");

  return NextResponse.json({ message: `Email sent to ${email}` });
}

访问 http://localhost:3000/api/test/email?email=your-email@example.com 进行测试。

6. 邮件发送限流

避免短时间内向同一用户发送大量邮件:

import { rateLimiter } from "@/lib/rate-limiter";

export async function sendEmailWithRateLimit(
  userId: string,
  options: SendEmailOptions
) {
  // 限制每用户每小时最多 10 封邮件
  const allowed = await rateLimiter.checkLimit(
    `email:${userId}`,
    10,
    60 * 60 * 1000
  );

  if (!allowed) {
    console.warn(`Rate limit exceeded for user ${userId}`);
    return { success: false, error: "Rate limit exceeded" };
  }

  return sendEmail(options);
}

7. 监控邮件送达率

定期检查 Resend Dashboard 中的送达率指标:

  • Delivered: 成功送达的邮件数
  • Bounced: 被退回的邮件 (邮箱不存在等)
  • Opened: 邮件打开率 (需要启用追踪)
  • Clicked: 链接点击率

如果送达率低于 95%,需要检查:

  • DNS 配置是否正确
  • 邮件内容是否触发垃圾邮件过滤器
  • 收件人列表是否包含无效邮箱

常见问题

Q: 开发环境无法发送邮件?

解决方法:

  1. 确认 RESEND_API_KEY 已正确配置
  2. 使用 Resend 的测试邮箱 onboarding@resend.dev (无需域名验证)
  3. 检查 Resend Dashboard 的 Logs 查看错误信息

Q: 生产环境邮件进入垃圾箱?

解决方法:

  1. 确认域名已验证,并正确配置 SPF/DKIM/DMARC 记录
  2. 在邮件中添加取消订阅链接
  3. 避免使用垃圾词汇
  4. 提供纯文本版本
  5. 使用真实的 from 地址 (不要使用 noreply@gmail.com)

Q: 如何批量发送邮件?

Resend 支持批量发送 API:

import { resend } from "@/lib/email";

await resend.batch.send([
  {
    from: "Sistine AI <noreply@yourdomain.com>",
    to: ["user1@example.com"],
    subject: "Hello User 1",
    html: "<p>Hello!</p>",
  },
  {
    from: "Sistine AI <noreply@yourdomain.com>",
    to: ["user2@example.com"],
    subject: "Hello User 2",
    html: "<p>Hello!</p>",
  },
]);

限制: 单次批量最多 100 封邮件。

Q: 如何追踪邮件打开和点击?

Resend 支持自动追踪:

await sendEmail({
  to: email,
  subject: "Newsletter",
  html: "<p>Hello!</p>",
  tags: [
    { name: "category", value: "newsletter" },
  ],
});

在 Resend Dashboard 中查看打开率和点击率。

Q: 如何处理退信 (Bounce)?

监听 Resend 的 Webhook 事件:

配置 Webhook:

  1. 在 Resend Dashboard 中导航到 Webhooks
  2. 添加 Webhook URL: https://yourdomain.com/api/webhooks/resend
  3. 选择事件: email.bounced

处理 Webhook (app/api/webhooks/resend/route.ts):

export async function POST(req: NextRequest) {
  const event = await req.json();

  if (event.type === "email.bounced") {
    const email = event.data.to;

    // 标记邮箱为无效
    await db
      .update(user)
      .set({ emailValid: false })
      .where(eq(user.email, email));

    console.log(`Marked email as invalid: ${email}`);
  }

  return NextResponse.json({ received: true });
}

Q: 如何测试邮件模板?

使用 React Email Preview:

pnpm add @react-email/render --dev

# 启动预览服务器
pnpm email dev

访问 http://localhost:3000 查看所有邮件模板的实时预览。

相关文档

总结

Resend 为 Sistine Starter 提供了可靠的邮件服务,通过:

  • 简单配置: 最少环境变量即可开始使用
  • 预定义模板: 开箱即用的常用邮件模板
  • 国际化支持: 多语言邮件内容
  • 最佳实践: 错误处理、重试机制、送达率优化

遵循本文档的配置步骤和最佳实践,你可以快速为你的 SaaS 应用集成完整的邮件功能。