ProductReadyProductReady

邮件发送

使用 React Email 和多个邮件服务商发送邮件 - Resend、Postmark、AWS SES、SendGrid 等

邮件发送

ProductReady 使用两个工作区包来发送邮件:

  • emaillib - 多态邮件库,支持 8+ 个邮件服务商的统一 API
  • transactional - 预构建的 React Email 模板,适用于常见场景

为什么采用这种架构? 使用 React 组件编写一次邮件模板,随时切换服务商而无需更改代码。分离模板允许在 monorepo 中的所有应用之间共享。


通知与邮件触发点

下表列出了 ProductReady 中所有触发通知和事务性邮件的场景:

触发事件事务性邮件应用内通知模板接收者
用户注册✅ 邮箱验证✅ "验证邮件已发送"verify-email.tsx新用户
忘记密码✅ 密码重置✅ "密码重置请求"password-reset.tsx请求重置的用户
组织邀请✅ 邀请邮件✅ "邀请已发送"(发给邀请者)invitation.tsx被邀请者

代码触发位置

事件代码位置Better Auth 钩子
邮箱验证src/lib/auth/email.tsxsendVerificationEmail()emailVerification.sendVerificationEmail
密码重置src/lib/auth/email.tsxsendResetPasswordEmail()emailAndPassword.sendResetPassword
组织邀请src/lib/auth/email.tsxsendOrganizationInvitation()organization.invitations.sendInvitationEmail

双写模式:每封事务性邮件都会同时在 notifications 表中创建一条应用内通知记录。这为用户提供了所有发送邮件的审计追踪,可在仪表板中查看。


快速概览

支持的服务商:

  • Resend - 现代邮件 API(推荐)
  • Postmark - 可靠的事务性邮件
  • AWS SES - 大规模时成本效益高
  • Nodemailer - SMTP 支持(任何服务商)
  • SendGrid - 流行的邮件平台
  • MailerSend - 功能丰富的邮件 API
  • Scaleway - 欧洲云邮件服务
  • Plunk - 简单的事务性邮件 API

主要特性:

  • 🎨 React Email - 使用 React 组件构建邮件
  • 🔌 服务商抽象 - 所有服务商的一致 API
  • 🎯 类型安全 - 完整的 TypeScript 支持
  • 🔐 Better Auth 集成 - 内置邮箱验证和密码重置
  • 📦 共享模板 - 可在所有应用中复用

项目结构

packages/
├── emaillib/            # 邮件客户端抽象
│   └── src/
│       ├── index.ts     # createEmailClient, sendEmail
│       └── types.ts     # EmailProviderConfig, SendEmailOptions

└── transactional/       # React Email 模板
    ├── emails/
    │   ├── _components/ # 共享组件 (Header, Footer, Button)
    │   │   └── styles.ts # 共享内联样式
    │   ├── welcome.tsx
    │   ├── verify-email.tsx
    │   ├── password-reset.tsx
    │   ├── invitation.tsx
    │   └── notification.tsx
    └── package.json     # 直接导入的 exports

apps/productready/
└── src/lib/
    ├── email-client.ts  # 应用特定的邮件配置
    └── auth/
        └── email.tsx    # Better Auth 邮件钩子

安装

两个包都是工作区依赖:

pnpm add emaillib@workspace:* transactional@workspace:*

邮件客户端配置

基本设置

// src/lib/email-client.ts
import { createEmailClient } from "emaillib";
import type { EmailProviderConfig } from "emaillib/types";

const getEmailConfig = (): EmailProviderConfig => {
  const provider = process.env.EMAIL_PROVIDER || "resend";

  switch (provider) {
    case "resend":
      return {
        type: "resend",
        apiKey: process.env.RESEND_API_KEY || "",
      };
    case "postmark":
      return {
        type: "postmark",
        serverToken: process.env.POSTMARK_SERVER_TOKEN || "",
      };
    case "sendgrid":
      return {
        type: "sendgrid",
        apiKey: process.env.SENDGRID_API_KEY || "",
      };
    case "ses":
      return {
        type: "ses",
        region: process.env.AWS_REGION || "us-east-1",
        accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
      };
    default:
      return {
        type: "resend",
        apiKey: process.env.RESEND_API_KEY || "",
      };
  }
};

export const emailClient = createEmailClient(getEmailConfig());

export const EMAIL_FROM = process.env.EMAIL_FROM || "ProductReady <noreply@productready.dev>";

export const emailConfig = {
  appName: "ProductReady",
  homeUrl: process.env.BETTER_AUTH_URL || "https://productready.dev",
  logoUrl: process.env.EMAIL_LOGO_URL,
  supportEmail: "support@productready.dev",
};

环境变量

# 邮件服务商 (resend, postmark, sendgrid, ses)
EMAIL_PROVIDER=resend

# Resend
RESEND_API_KEY=re_xxx

# Postmark
POSTMARK_SERVER_TOKEN=xxx

# SendGrid
SENDGRID_API_KEY=SG.xxx

# AWS SES
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx

# 通用
EMAIL_FROM="ProductReady <noreply@productready.dev>"
EMAIL_LOGO_URL=https://productready.dev/logo.png

# Better Auth
EMAIL_VERIFICATION_REQUIRED=true

Better Auth 集成

ProductReady 将邮件发送与 Better Auth 集成,用于:

  • 注册时的邮箱验证
  • 密码重置
  • 组织邀请

邮件钩子与通知记录

每封发送的邮件都会在数据库中创建对应的通知记录。这样用户可以在仪表板中查看邮件历史,管理员也可以在系统管理后台的用户详情中查看用户的所有通知。

// src/lib/auth/email.tsx
import { VerifyEmail } from "transactional/verify-email";
import { PasswordResetEmail } from "transactional/password-reset";
import { InvitationEmail } from "transactional/invitation";
import { emailClient, EMAIL_FROM, emailConfig } from "~/lib/email-client";
import {
  createEmailVerificationNotification,
  createPasswordResetNotification,
  createOrganizationInvitationNotification,
} from "~/lib/notifications";

export const sendVerificationEmail = async ({
  user,
  url,
}: {
  user: { email: string; name?: string; id?: string };
  url: string;
  token?: string;
}) => {
  const result = await emailClient.send({
    from: EMAIL_FROM,
    to: user.email,
    subject: `验证您的 ${emailConfig.appName} 邮箱`,
    react: VerifyEmail({
      userName: user.name || "您好",
      appName: emailConfig.appName,
      verifyUrl: url,
      logoUrl: emailConfig.logoUrl,
    }),
  });

  if (!result.success) {
    console.error("[Auth Email] 发送验证邮件失败:", result.error);
    throw new Error("发送验证邮件失败");
  }

  // 为用户创建通知记录
  if (user.id) {
    await createEmailVerificationNotification(user.id, user.email);
  }

  return result;
};

export const sendResetPasswordEmail = async ({
  user,
  url,
}: {
  user: { email: string; name?: string; id?: string };
  url: string;
  token?: string;
}) => {
  const result = await emailClient.send({
    from: EMAIL_FROM,
    to: user.email,
    subject: `重置您的 ${emailConfig.appName} 密码`,
    react: PasswordResetEmail({
      userName: user.name || "您好",
      appName: emailConfig.appName,
      resetUrl: url,
      logoUrl: emailConfig.logoUrl,
      expiresIn: "1 小时",
    }),
  });

  if (!result.success) {
    throw new Error("发送密码重置邮件失败");
  }

  // 为用户创建通知记录
  if (user.id) {
    await createPasswordResetNotification(user.id, user.email);
  }

  return result;
};

export const sendOrganizationInvitation = async ({
  email,
  organization,
  inviter,
  role,
  inviteUrl,
}: {
  email: string;
  organization: { name: string; id: string; logo?: string };
  inviter: { name: string; email: string; avatar?: string; id?: string };
  role: string;
  inviteUrl: string;
}) => {
  const result = await emailClient.send({
    from: EMAIL_FROM,
    to: email,
    subject: `${inviter.name} 邀请您加入 ${organization.name}`,
    react: InvitationEmail({
      inviterName: inviter.name,
      inviterEmail: inviter.email,
      inviterAvatar: inviter.avatar,
      organizationName: organization.name,
      organizationLogo: organization.logo,
      role,
      inviteUrl,
      appName: emailConfig.appName,
      logoUrl: emailConfig.logoUrl,
    }),
  });

  if (!result.success) {
    throw new Error("发送邀请邮件失败");
  }

  // 为邀请者创建通知记录
  if (inviter.id) {
    await createOrganizationInvitationNotification(inviter.id, email, organization.name);
  }

  return result;
};

通知服务

通知服务 (src/lib/notifications/index.ts) 提供了创建通知记录的辅助函数:

// src/lib/notifications/index.ts
import { db } from "~/db";
import { notifications } from "~/db/schema";

interface CreateNotificationParams {
  userId: string;
  title: string;
  message: string;
  link?: string;
}

export async function createNotification({
  userId,
  title,
  message,
  link,
}: CreateNotificationParams) {
  const [notification] = await db
    .insert(notifications)
    .values({
      userId,
      title,
      message,
      link,
      read: false,
    })
    .returning();

  return notification;
}

// 常见邮件类型的预置通知创建器
export async function createEmailVerificationNotification(userId: string, userEmail: string) {
  return createNotification({
    userId,
    title: "验证邮件已发送",
    message: `验证邮件已发送至 ${userEmail},请查收。`,
    link: "/account",
  });
}

export async function createPasswordResetNotification(userId: string, userEmail: string) {
  return createNotification({
    userId,
    title: "密码重置请求",
    message: `密码重置邮件已发送至 ${userEmail},链接将在 1 小时后过期。`,
    link: "/account",
  });
}

export async function createOrganizationInvitationNotification(
  userId: string,
  inviteeEmail: string,
  organizationName: string,
) {
  return createNotification({
    userId,
    title: "邀请已发送",
    message: `加入 ${organizationName} 的邀请已发送至 ${inviteeEmail}。`,
  });
}

为什么要创建通知记录? 这为发送给用户的所有邮件提供了审计追踪。用户可以在仪表板中查看通知历史,系统管理员可以在管理后台的用户详情中查看任何用户的所有通知。

Better Auth 配置

// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import {
  sendVerificationEmail,
  sendResetPasswordEmail,
  sendOrganizationInvitation,
} from "./email";

export const auth = betterAuth({
  // ... 其他配置
  
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: process.env.EMAIL_VERIFICATION_REQUIRED === "true",
    sendResetPassword: async ({ user, url, token }, _request) => {
      await sendResetPasswordEmail({ user, url, token });
    },
  },
  
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url, token }, _request) => {
      await sendVerificationEmail({ user, url, token });
    },
  },
  
  plugins: [
    organization({
      sendInvitationEmail: async (data) => {
        await sendOrganizationInvitation({
          email: data.email,
          organization: data.organization,
          inviter: data.inviter,
          role: data.role,
          inviteUrl: data.invitationUrl,
        });
      },
    }),
  ],
});

事务性邮件模板

可用模板

直接从 transactional 包导入模板:

import { WelcomeEmail } from "transactional/welcome";
import { VerifyEmail } from "transactional/verify-email";
import { PasswordResetEmail } from "transactional/password-reset";
import { InvitationEmail } from "transactional/invitation";
import { NotificationEmail } from "transactional/notification";

模板属性

interface WelcomeEmailProps {
  userName: string;
  appName: string;
  dashboardUrl?: string;
  logoUrl?: string;
}
interface VerifyEmailProps {
  userName: string;
  appName: string;
  verifyUrl: string;
  logoUrl?: string;
}
interface PasswordResetEmailProps {
  userName: string;
  appName: string;
  resetUrl: string;
  logoUrl?: string;
  expiresIn?: string;
}
interface InvitationEmailProps {
  inviterName: string;
  inviterEmail: string;
  inviterAvatar?: string;
  organizationName: string;
  organizationLogo?: string;
  role: string;
  inviteUrl: string;
  appName: string;
  logoUrl?: string;
}
interface NotificationEmailProps {
  userName: string;
  appName: string;
  title: string;
  message: string;
  actionUrl?: string;
  actionText?: string;
  logoUrl?: string;
}

本地预览模板

cd packages/transactional
pnpm dev

访问 http://localhost:3000 预览所有邮件模板。


基本用法

发送自定义邮件

import { emailClient, EMAIL_FROM } from "~/lib/email-client";
import { WelcomeEmail } from "transactional/welcome";

const result = await emailClient.send({
  from: EMAIL_FROM,
  to: "user@example.com",
  subject: "欢迎来到 ProductReady!",
  react: WelcomeEmail({
    userName: "张三",
    appName: "ProductReady",
    dashboardUrl: "https://productready.dev/dashboard",
  }),
});

if (result.success) {
  console.log("邮件已发送!", result.messageId);
} else {
  console.error("发送失败:", result.error);
}

与 tRPC 集成

从 tRPC 突变发送邮件:

// src/server/routers/notification.ts
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { emailClient, EMAIL_FROM, emailConfig } from "~/lib/email-client";
import { NotificationEmail } from "transactional/notification";
import { z } from 'zod';

export const notificationRouter = createTRPCRouter({
  sendNotification: protectedProcedure
    .input(z.object({
      title: z.string(),
      message: z.string(),
      actionUrl: z.string().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      await emailClient.send({
        from: EMAIL_FROM,
        to: ctx.user.email,
        subject: input.title,
        react: NotificationEmail({
          userName: ctx.user.name || "您好",
          appName: emailConfig.appName,
          title: input.title,
          message: input.message,
          actionUrl: input.actionUrl,
        }),
      });

      return { success: true };
    }),
});

最佳实践

✅ 应该做的

  • 使用 React Email 编写可维护的模板
  • 在生产前测试 邮件
  • 优雅处理错误 并重试
  • 使用后台作业 发送非关键邮件
  • 包含纯文本 后备内容
  • 个性化 使用用户数据
  • 添加退订链接 用于营销邮件

❌ 不应该做的

  • 不要使用 no-reply 如果期望回复
  • 不要发送垃圾邮件 - 尊重用户偏好
  • 不要硬编码 邮件内容(使用模板)
  • 不要忘记 在移动设备上测试
  • 不要在未经同意的情况下发送

邮件投递建议

提高投递率

  1. 设置 SPF、DKIM 和 DMARC

    • 为您的域名添加 DNS 记录
    • 与服务商验证域名所有权
  2. 使用专用发送域名

    • mail.productready.com 而不是 productready.com
    • 保护您的主域名信誉
  3. 预热您的域名

    • 从低发送量开始
    • 在数周内逐渐增加
  4. 监控退信率

    • 删除无效邮箱
    • 保持退信率 < 2%

下一步

  • 设置服务商 - 选择并配置邮件服务商
  • 创建模板 - 使用 React Email 构建邮件模板
  • 配置 Better Auth - 启用邮箱验证和密码重置
  • 彻底测试 - 在开发环境中预览和测试
  • 监控投递 - 跟踪邮件性能

Resend 开始获得最佳开发者体验。每月免费 3,000 封邮件,文档优秀。

On this page