GameCraftGameCraft

Email & Transactional Emails

Send emails with React Email and multiple providers - Resend, Postmark, AWS SES, SendGrid, and more

Email & Transactional Emails

ProductReady uses two workspace packages for email:

  • emaillib - A polymorphic email library that supports 8+ email providers with a unified API
  • transactional - Pre-built React Email templates for common use cases

Why this architecture? Write emails once with React components, switch providers anytime without code changes. The separation allows templates to be shared across all apps in the monorepo.


Notification & Email Triggers

The following table shows all places in ProductReady that trigger notifications and transactional emails:

Trigger EventTransactional EmailIn-App NotificationTemplateRecipient
User Signup✅ Email Verification✅ "Email Verification Sent"verify-email.tsxNew user
Forgot Password✅ Password Reset✅ "Password Reset Requested"password-reset.tsxUser requesting reset
Organization Invitation✅ Invitation Email✅ "Invitation Sent" (to inviter)invitation.tsxInvitee

Trigger Locations in Code

EventCode LocationBetter Auth Hook
Email Verificationsrc/lib/auth/email.tsxsendVerificationEmail()emailVerification.sendVerificationEmail
Password Resetsrc/lib/auth/email.tsxsendResetPasswordEmail()emailAndPassword.sendResetPassword
Organization Invitationsrc/lib/auth/email.tsxsendOrganizationInvitation()organization.invitations.sendInvitationEmail

Dual-write pattern: Every transactional email also creates an in-app notification record in the notifications table. This provides users with an audit trail of all emails sent to them, viewable in the dashboard.


Quick Overview

Supported providers:

  • Resend - Modern email API (recommended)
  • Postmark - Reliable transactional email
  • AWS SES - Cost-effective at scale
  • Nodemailer - SMTP support (any provider)
  • SendGrid - Popular email platform
  • MailerSend - Feature-rich email API
  • Scaleway - European cloud email
  • Plunk - Simple transactional email

Key features:

  • 🎨 React Email - Build emails with React components
  • 🔌 Provider abstraction - Consistent API across all providers
  • 🎯 Type-safe - Full TypeScript support
  • 🔐 Better Auth integration - Email verification & password reset built-in
  • 📦 Shared templates - Reusable across all apps

Project Structure

packages/
├── emaillib/            # Email client abstraction
│   └── src/
│       ├── index.ts     # createEmailClient, sendEmail
│       └── types.ts     # EmailProviderConfig, SendEmailOptions

└── transactional/       # React Email templates
    ├── emails/
    │   ├── _components/ # Shared components (Header, Footer, Button)
    │   │   └── styles.ts # Shared inline styles
    │   ├── welcome.tsx
    │   ├── verify-email.tsx
    │   ├── password-reset.tsx
    │   ├── invitation.tsx
    │   └── notification.tsx
    └── package.json     # Exports for direct imports

apps/productready/
└── src/lib/
    ├── email-client.ts  # App-specific email configuration
    └── auth/
        └── email.tsx    # Better Auth email hooks

Installation

Both packages are workspace dependencies:

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

Email Client Configuration

Basic Setup

// 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",
};

Environment Variables

# Email Provider (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

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

# Better Auth
EMAIL_VERIFICATION_REQUIRED=true

Better Auth Integration

ProductReady integrates email sending with Better Auth for:

  • Email verification on signup
  • Password reset
  • Organization invitations

Email Hooks with Notification Records

Every email sent creates a corresponding notification record in the database. This allows users to see their email history in the dashboard, and admins can view user notifications in the System Admin panel.

// 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: `Verify your email for ${emailConfig.appName}`,
    react: VerifyEmail({
      userName: user.name || "there",
      appName: emailConfig.appName,
      verifyUrl: url,
      logoUrl: emailConfig.logoUrl,
    }),
  });

  if (!result.success) {
    console.error("[Auth Email] Failed to send verification email:", result.error);
    throw new Error("Failed to send verification email");
  }

  // Create notification record for the user
  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: `Reset your ${emailConfig.appName} password`,
    react: PasswordResetEmail({
      userName: user.name || "there",
      appName: emailConfig.appName,
      resetUrl: url,
      logoUrl: emailConfig.logoUrl,
      expiresIn: "1 hour",
    }),
  });

  if (!result.success) {
    throw new Error("Failed to send reset password email");
  }

  // Create notification record for the user
  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} invited you to join ${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("Failed to send invitation email");
  }

  // Create notification record for the inviter
  if (inviter.id) {
    await createOrganizationInvitationNotification(inviter.id, email, organization.name);
  }

  return result;
};

Notification Service

The notification service (src/lib/notifications/index.ts) provides helper functions to create notification records:

// 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;
}

// Pre-built notification creators for common email types
export async function createEmailVerificationNotification(userId: string, userEmail: string) {
  return createNotification({
    userId,
    title: "Email Verification Sent",
    message: `A verification email has been sent to ${userEmail}. Please check your inbox.`,
    link: "/account",
  });
}

export async function createPasswordResetNotification(userId: string, userEmail: string) {
  return createNotification({
    userId,
    title: "Password Reset Requested",
    message: `A password reset email has been sent to ${userEmail}. The link will expire in 1 hour.`,
    link: "/account",
  });
}

export async function createOrganizationInvitationNotification(
  userId: string,
  inviteeEmail: string,
  organizationName: string,
) {
  return createNotification({
    userId,
    title: "Invitation Sent",
    message: `An invitation to join ${organizationName} has been sent to ${inviteeEmail}.`,
  });
}

Why create notification records? This provides an audit trail of all emails sent to users. Users can view their notification history in the dashboard, and system admins can see all notifications for any user in the admin panel under User Details.

Better Auth Configuration

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

export const auth = betterAuth({
  // ... other config
  
  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 Email Templates

Available Templates

Import templates directly from the transactional package:

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";

Template Props

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;
}

Preview Templates Locally

cd packages/transactional
pnpm dev

Visit http://localhost:3000 to preview all email templates.


Basic Usage

export function WelcomeEmail({
  name,
  loginUrl = 'https://productready.com/login',
}: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to ProductReady - Let's get started!</Preview>
      <Body style={main}>
        <Container style={container}>
          <Img
            src="https://productready.com/logo.png"
            width="48"
            height="48"
            alt="ProductReady"
            style={logo}
          />
          <Heading style={h1}>Welcome to ProductReady, {name}!</Heading>
          <Text style={text}>
            Thanks for signing up! We're excited to help you ship your AI-powered
            SaaS faster.
          </Text>
          <Section style={buttonContainer}>
            <Button style={button} href={loginUrl}>
              Get Started
            </Button>
          </Section>
          <Text style={text}>
            If you have any questions, feel free to{' '}
            <Link href="https://productready.com/support" style={link}>
              contact our support team
            </Link>
            .
          </Text>
          <Text style={footer}>
            © 2025 ProductReady. All rights reserved.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

// Styles
const main = {
  backgroundColor: '#f6f9fc',
  fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
};

const container = {
  backgroundColor: '#ffffff',
  margin: '0 auto',
  padding: '20px 0 48px',
  marginBottom: '64px',
};

const logo = {
  margin: '0 auto',
};

const h1 = {
  color: '#333',
  fontSize: '24px',
  fontWeight: 'bold',
  margin: '40px 0',
  padding: '0',
  textAlign: 'center' as const,
};

const text = {
  color: '#333',
  fontSize: '16px',
  lineHeight: '26px',
  padding: '0 20px',
};

const button = {
  backgroundColor: '#5469d4',
  borderRadius: '4px',
  color: '#fff',
  fontSize: '16px',
  textDecoration: 'none',
  textAlign: 'center' as const,
  display: 'block',
  width: '200px',
  padding: '12px',
  margin: '0 auto',
};

const buttonContainer = {
  padding: '27px 0',
};

const link = {
  color: '#5469d4',
  textDecoration: 'underline',
};

const footer = {
  color: '#8898aa',
  fontSize: '12px',
  lineHeight: '16px',
  padding: '0 20px',
  marginTop: '32px',
  textAlign: 'center' as const,
};

Preview Emails Locally

Use React Email's dev server:

# Install React Email CLI
pnpm add -D @react-email/cli

# Add script to package.json
{
  "scripts": {
    "email:dev": "email dev"
  }
}

# Start preview server
pnpm email:dev

Visit http://localhost:3000 to preview all email templates.


Provider Configuration

import { createEmailClient } from '@emaillib/core';

const emailClient = createEmailClient({
  type: 'resend',
  apiKey: process.env.RESEND_API_KEY!, // Get from resend.com
});

Environment variables:

RESEND_API_KEY=re_123456789

Why Resend?

  • ✅ Modern API with great DX
  • ✅ React Email built-in support
  • ✅ Generous free tier (3,000 emails/month)
  • ✅ Fast delivery

Postmark

const emailClient = createEmailClient({
  type: 'postmark',
  apiKey: process.env.POSTMARK_API_KEY!,
});

Environment variables:

POSTMARK_API_KEY=your-postmark-server-token

AWS SES

const emailClient = createEmailClient({
  type: 'aws-ses',
  region: 'us-east-1',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
});

Environment variables:

AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_REGION=us-east-1

Nodemailer (SMTP)

const emailClient = createEmailClient({
  type: 'nodemailer',
  host: 'smtp.gmail.com',
  port: 587,
  secure: false,
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASSWORD!,
  },
});

Environment variables:

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password

SendGrid

const emailClient = createEmailClient({
  type: 'sendgrid',
  apiKey: process.env.SENDGRID_API_KEY!,
});

Common Email Templates

Password Reset

// emails/password-reset.tsx
import { Body, Button, Container, Heading, Html, Text } from '@react-email/components';

interface PasswordResetEmailProps {
  resetUrl: string;
  expiresIn?: string;
}

export function PasswordResetEmail({
  resetUrl,
  expiresIn = '1 hour',
}: PasswordResetEmailProps) {
  return (
    <Html>
      <Body>
        <Container>
          <Heading>Reset Your Password</Heading>
          <Text>
            We received a request to reset your password. Click the button below
            to create a new password.
          </Text>
          <Button href={resetUrl}>Reset Password</Button>
          <Text>
            This link will expire in {expiresIn}. If you didn't request a
            password reset, you can safely ignore this email.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Usage:

import { emailClient } from '~/lib/email';
import { PasswordResetEmail } from '~/emails/password-reset';

await emailClient.send({
  from: 'security@productready.com',
  to: user.email,
  subject: 'Reset Your Password',
  react: <PasswordResetEmail resetUrl={`https://productready.com/reset?token=${token}`} />,
});

Email Verification

// emails/verify-email.tsx
export function VerifyEmailEmail({ verifyUrl }: { verifyUrl: string }) {
  return (
    <Html>
      <Body>
        <Container>
          <Heading>Verify Your Email Address</Heading>
          <Text>
            Thanks for signing up! Please verify your email address by clicking
            the button below.
          </Text>
          <Button href={verifyUrl}>Verify Email</Button>
          <Text>
            If you didn't create an account, you can safely ignore this email.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Invoice/Receipt

// emails/invoice.tsx
interface InvoiceEmailProps {
  invoiceNumber: string;
  amount: number;
  currency: string;
  date: Date;
  downloadUrl: string;
}

export function InvoiceEmail({
  invoiceNumber,
  amount,
  currency,
  date,
  downloadUrl,
}: InvoiceEmailProps) {
  return (
    <Html>
      <Body>
        <Container>
          <Heading>Invoice #{invoiceNumber}</Heading>
          <Text>
            Thank you for your payment of {currency.toUpperCase()} {amount.toFixed(2)}
            on {date.toLocaleDateString()}.
          </Text>
          <Button href={downloadUrl}>Download Invoice</Button>
        </Container>
      </Body>
    </Html>
  );
}

Team Invitation

// emails/team-invite.tsx
interface TeamInviteEmailProps {
  inviterName: string;
  teamName: string;
  inviteUrl: string;
}

export function TeamInviteEmail({
  inviterName,
  teamName,
  inviteUrl,
}: TeamInviteEmailProps) {
  return (
    <Html>
      <Body>
        <Container>
          <Heading>{inviterName} invited you to join {teamName}</Heading>
          <Text>
            You've been invited to collaborate on {teamName} on ProductReady.
          </Text>
          <Button href={inviteUrl}>Accept Invitation</Button>
        </Container>
      </Body>
    </Html>
  );
}

Integration with tRPC

Send emails from tRPC mutations:

// src/server/routers/auth.ts
import { createTRPCRouter, publicProcedure } from '../trpc';
import { emailClient } from '~/lib/email';
import { WelcomeEmail } from '~/emails/welcome';
import { z } from 'zod';

export const authRouter = createTRPCRouter({
  signUp: publicProcedure
    .input(z.object({
      email: z.string().email(),
      name: z.string(),
      password: z.string().min(8),
    }))
    .mutation(async ({ input }) => {
      // Create user in database
      const user = await db.insert(users).values({
        email: input.email,
        name: input.name,
        passwordHash: await hashPassword(input.password),
      }).returning();

      // Send welcome email
      await emailClient.send({
        from: 'noreply@productready.com',
        to: user.email,
        subject: 'Welcome to ProductReady!',
        react: <WelcomeEmail name={user.name} />,
      });

      return { success: true, userId: user.id };
    }),
});

Advanced Features

Attachments

await emailClient.send({
  from: 'noreply@productready.com',
  to: 'user@example.com',
  subject: 'Your Invoice',
  react: <InvoiceEmail />,
  attachments: [
    {
      filename: 'invoice.pdf',
      content: pdfBuffer, // Buffer or base64
      contentType: 'application/pdf',
    },
  ],
});

CC and BCC

await emailClient.send({
  from: 'noreply@productready.com',
  to: 'user@example.com',
  cc: ['manager@productready.com'],
  bcc: ['archive@productready.com'],
  subject: 'Team Update',
  react: <TeamUpdateEmail />,
});

Custom Headers

await emailClient.send({
  from: 'noreply@productready.com',
  to: 'user@example.com',
  subject: 'Custom Headers',
  react: <CustomEmail />,
  headers: {
    'X-Custom-Header': 'value',
    'X-Priority': '1',
  },
});

Plain Text Fallback

await emailClient.send({
  from: 'noreply@productready.com',
  to: 'user@example.com',
  subject: 'Welcome',
  react: <WelcomeEmail name="John" />,
  text: 'Welcome to ProductReady, John!', // Fallback for email clients without HTML support
});

Error Handling

Basic Error Handling

const result = await emailClient.send({
  from: 'noreply@productready.com',
  to: 'user@example.com',
  subject: 'Test',
  react: <TestEmail />,
});

if (!result.success) {
  console.error('Failed to send email:', result.error);
  
  // Handle specific error types
  if (result.error.includes('Invalid email')) {
    // Handle invalid email
  } else if (result.error.includes('Rate limit')) {
    // Handle rate limit
  }
}

Retry Logic

async function sendEmailWithRetry(options: SendEmailOptions, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const result = await emailClient.send(options);
    
    if (result.success) {
      return result;
    }
    
    if (i < maxRetries - 1) {
      // Wait before retrying (exponential backoff)
      await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
    }
  }
  
  throw new Error('Failed to send email after retries');
}

Email Queue (Background Jobs)

For better reliability, send emails in background:

Using Inngest

// src/inngest/functions/send-email.ts
import { inngest } from '~/inngest/client';
import { emailClient } from '~/lib/email';

export const sendEmailJob = inngest.createFunction(
  { id: 'send-email' },
  { event: 'email.send' },
  async ({ event }) => {
    await emailClient.send({
      from: event.data.from,
      to: event.data.to,
      subject: event.data.subject,
      html: event.data.html,
    });
  }
);

Trigger from tRPC:

import { inngest } from '~/inngest/client';

// In your mutation
await inngest.send({
  name: 'email.send',
  data: {
    from: 'noreply@productready.com',
    to: user.email,
    subject: 'Welcome!',
    html: await renderEmail(<WelcomeEmail name={user.name} />),
  },
});

Testing

Test in Development

Use a test provider to avoid sending real emails:

// src/lib/email.ts
import { createEmailClient } from '@emaillib/core';

export const emailClient = createEmailClient(
  process.env.NODE_ENV === 'production'
    ? {
        type: 'resend',
        apiKey: process.env.RESEND_API_KEY!,
      }
    : {
        type: 'nodemailer',
        host: 'localhost',
        port: 1025, // MailHog or similar
        secure: false,
      }
);

Unit Tests

// src/server/routers/auth.test.ts
import { vi } from 'vitest';
import { emailClient } from '~/lib/email';

vi.mock('~/lib/email', () => ({
  emailClient: {
    send: vi.fn().mockResolvedValue({ success: true, messageId: 'test-id' }),
  },
}));

test('sends welcome email on signup', async () => {
  const result = await caller.auth.signUp({
    email: 'test@example.com',
    name: 'Test User',
    password: 'password123',
  });

  expect(emailClient.send).toHaveBeenCalledWith(
    expect.objectContaining({
      to: 'test@example.com',
      subject: 'Welcome to ProductReady!',
    })
  );
});

Best Practices

✅ Do's

  • Use React Email for maintainable templates
  • Test emails in development before production
  • Handle errors gracefully with retries
  • Use background jobs for non-critical emails
  • Include plain text fallback
  • Personalize with user data
  • Add unsubscribe links for marketing emails
  • Track email delivery and open rates

❌ Don'ts

  • Don't send from no-reply if you expect replies
  • Don't spam - respect user preferences
  • Don't hardcode email content (use templates)
  • Don't forget to test on mobile devices
  • Don't send without user consent
  • Don't use images for critical content

Email Deliverability Tips

Improve Delivery Rates

  1. Set up SPF, DKIM, and DMARC

    • Add DNS records for your domain
    • Verify domain ownership with provider
  2. Use a dedicated sending domain

    • mail.productready.com instead of productready.com
    • Protects your main domain reputation
  3. Warm up your domain

    • Start with low volume
    • Gradually increase over weeks
  4. Monitor bounce rates

    • Remove invalid emails
    • Keep bounce rate < 2%
  5. Provide unsubscribe

    • Make it easy to opt-out
    • Honor unsubscribe requests immediately

Migration Between Providers

Switch providers without code changes:

// src/lib/email.ts
import { createEmailClient } from '@emaillib/core';

// Before: Resend
const emailClient = createEmailClient({
  type: 'resend',
  apiKey: process.env.RESEND_API_KEY!,
});

// After: Postmark (just change config!)
const emailClient = createEmailClient({
  type: 'postmark',
  apiKey: process.env.POSTMARK_API_KEY!,
});

All email templates and sending code remain unchanged!


Next Steps

  • Set up provider - Choose and configure an email provider
  • Create templates - Build email templates with React Email
  • Integrate tRPC - Send emails from your API
  • Test thoroughly - Preview and test in development
  • Monitor delivery - Track email performance

Start with Resend for the best developer experience. It's free for up to 3,000 emails/month and has excellent documentation.

On this page