邮件发送
使用 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.tsx → sendVerificationEmail() | emailVerification.sendVerificationEmail |
| 密码重置 | src/lib/auth/email.tsx → sendResetPasswordEmail() | emailAndPassword.sendResetPassword |
| 组织邀请 | src/lib/auth/email.tsx → sendOrganizationInvitation() | 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=trueBetter 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 如果期望回复
- 不要发送垃圾邮件 - 尊重用户偏好
- 不要硬编码 邮件内容(使用模板)
- 不要忘记 在移动设备上测试
- 不要在未经同意的情况下发送
邮件投递建议
提高投递率
-
设置 SPF、DKIM 和 DMARC
- 为您的域名添加 DNS 记录
- 与服务商验证域名所有权
-
使用专用发送域名
mail.productready.com而不是productready.com- 保护您的主域名信誉
-
预热您的域名
- 从低发送量开始
- 在数周内逐渐增加
-
监控退信率
- 删除无效邮箱
- 保持退信率 < 2%
下一步
- 设置服务商 - 选择并配置邮件服务商
- 创建模板 - 使用 React Email 构建邮件模板
- 配置 Better Auth - 启用邮箱验证和密码重置
- 彻底测试 - 在开发环境中预览和测试
- 监控投递 - 跟踪邮件性能
从 Resend 开始获得最佳开发者体验。每月免费 3,000 封邮件,文档优秀。