Chapter 13: SAAS Operations: Email and Notifications
A SAAS application cannot operate long-term if it only 'takes in' without 'giving back'. When users perform actions on your platform, the platform must provide feedback in some way. After your application launches and runs, you also need a way to reach your users, whether to notify them of new features or provide help when they encounter problems.
Chapter 13: SAAS Operations: Email and Notifications
A SAAS application cannot operate long-term if it only "takes in" without "giving back." When users perform actions on your platform, the platform must provide feedback in some way. After your application launches and runs, you also need a way to reach your users, whether to notify them of new features or provide help when they encounter problems.
This is the core part of operations: Communication. For Python backend developers, you might be accustomed to using smtplib combined with Jinja2 templates to send emails, or calling Slack's Webhook API to send internal notifications.
In the Next.js full-stack ecosystem, we have more powerful and unified tools. In this chapter, we'll explore how to use React Email to componentize email templates, how to use Resend as our email service provider, and how to integrate notification systems (like Discord) into our Server Actions.
13.1. Transactional Emails (React Email)
First, let's define Transactional Emails. These are automated, one-to-one emails triggered by specific user actions. They are not marketing but a core part of application functionality.
Common examples include:
- Welcome Email (after user registration)
- Password Reset (user clicks "Forgot Password")
- Payment Receipt (user successfully subscribes)
- Usage Alert (user's credits are running low)
In traditional Python stacks, you might maintain a bunch of .html or .txt templates, then use Jinja2 or similar template engines to populate variables at runtime. The biggest pain point with this approach: no type safety, hard to preview, and far removed from your application code (Python).
React Email (react.email) completely changes this. It allows you to write emails using React components.
- WYSIWYG: You write React/TSX, and
react-emailcompiles it to high-performance, cross-client compatible HTML. - Component-Based: You can create reusable email components like
<Button>,<Layout>,<Heading>. - Type Safety: Your templates (e.g.,
WelcomeEmailProps) can inherit types from your Zod or Drizzle schemas, ensuring the data you pass to templates is always correct. - Local Preview: It comes with a dev server that lets you view and debug email templates in real-time in your browser without actually sending an email every time.
13.2. [Code Analysis]: Analyzing src/mail/
Our SAAS template will use React Email to build templates and Resend (see section 13.3) to send them.
Directory Structure
src/
├── actions/
│ └── auth.actions.ts (calls sendWelcomeEmail)
└── mail/
├── sender.ts (low-level function for sending emails, uses Resend)
└── templates/
├── index.ts (exports all templates)
├── Welcome.tsx (welcome email template)
└── ResetPassword.tsx (password reset template)File 1: src/mail/templates/Welcome.tsx
This is a React Email template component. It looks almost identical to a regular React component.
// src/mail/templates/Welcome.tsx
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Text,
} from '@react-email/components';
import * as React from 'react';
interface WelcomeEmailProps {
username: string;
loginUrl: string;
}
export const WelcomeEmail = ({ username, loginUrl }: WelcomeEmailProps) => (
<Html>
<Head />
<Preview>Welcome to Our SAAS!</Preview>
<Body style={{ backgroundColor: '#f6f6f6' }}>
<Container style={{ margin: '20px auto', padding: '20px', backgroundColor: '#ffffff' }}>
<Text style={{ fontSize: '18px' }}>Hi {username},</Text>
<Text>
Welcome to Our SAAS! We're excited to have you on board.
</Text>
<Button
href={loginUrl}
style={{ padding: '12px 20px', backgroundColor: '#000000', color: '#ffffff' }}
>
Get Started
</Button>
</Container>
</Body>
</Html>
);
export default WelcomeEmail;File 2: src/mail/sender.ts
This file encapsulates the actual sending logic. It imports the Resend client and provides a high-level function to render and send emails.
// src/mail/sender.ts
import { Resend } from 'resend';
import * as React from 'react';
// Get API key from .env
const resend = new Resend(process.env.RESEND_API_KEY);
const fromEmail = process.env.FROM_EMAIL || 'noreply@yourdomain.com';
interface EmailPayload<T> {
to: string | string[];
subject: string;
react: React.ReactElement<T>; // Pass in React Email component
}
export async function sendEmail<T>(payload: EmailPayload<T>) {
try {
const { data, error } = await resend.emails.send({
from: fromEmail,
to: payload.to,
subject: payload.subject,
react: payload.react, // Resend natively supports React Email
});
if (error) {
console.error('Failed to send email:', error);
return { error: error.message };
}
return { success: true, data };
} catch (err: any) {
console.error('Email sending exception:', err.message);
return { error: err.message };
}
}File 3: src/actions/auth.actions.ts (Integration)
Now, in our registration Server Action, we can call sendEmail.
// src/actions/auth.actions.ts
'use server';
import { sendEmail } from '@/mail/sender';
import { WelcomeEmail } from '@/mail/templates/Welcome';
// ... other imports ...
export async function registerUser(formData: FormData) {
// ... Drizzle logic for registering user ...
// const newUser = await db.insert(users).values(...).returning();
const email = formData.get('email') as string;
const username = "newUser"; // (obtained from registration logic)
const loginUrl = `${process.env.NEXT_PUBLIC_APP_URL}/login`;
// After successful registration, send welcome email
// This is a "fire-and-forget" operation, no need to await blocking the main flow
sendEmail({
to: email,
subject: 'Welcome to Our SAAS!',
react: React.createElement(WelcomeEmail, {
username: username,
loginUrl: loginUrl,
}),
}).catch(console.error); // Execute asynchronously, don't block registration flow return
// ... return registration success message
return { success: true };
}13.3. Marketing Emails (Resend)
Marketing Emails are different from transactional emails. They are one-to-many, typically sent in bulk, and must include clear "unsubscribe" links.
- Newsletter
- Product Updates
- Special Offers
Resend is not just an email API; it also provides "Audiences" and "Domains" management features.
- Domains: You need to configure your domain (e.g.,
yourdomain.com), set up DKIM and SPF records to ensure your emails aren't flagged as spam. Resend provides very clear guidance. - Audiences: You can create different audience lists, such as "Newsletter Subscribers" or "Pro Users".
13.4. [Code Analysis]: Analyzing src/newsletter/
Our SAAS template will include a simple form for subscribing to the Newsletter. This form will call a Server Action.
File 1: src/components/forms/NewsletterForm.tsx
This is a client component for collecting email addresses.
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { subscribeToNewsletter } from '@/actions/newsletter.actions';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Subscribing...' : 'Subscribe'}</button>;
}
export function NewsletterForm() {
const [message, setMessage] = useState('');
const handleSubmit = async (formData: FormData) => {
const result = await subscribeToNewsletter(formData);
if (result.success) {
setMessage('Thanks for subscribing!');
} else {
setMessage(result.error || 'Something went wrong.');
}
};
return (
<form action={handleSubmit}>
<input type="email" name="email" placeholder="your@email.com" required />
<SubmitButton />
{message && <p>{message}</p>}
</form>
);
}File 2: src/actions/newsletter.actions.ts
This Server Action is responsible for communicating with Resend's Audiences API.
'use server';
import { Resend } from 'resend';
import { z } from 'zod';
const resend = new Resend(process.env.RESEND_API_KEY);
const newsletterAudienceId = process.env.RESEND_NEWSLETTER_AUDIENCE_ID!;
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get('email');
// 1. Zod validation
const validation = emailSchema.safeParse(email);
if (!validation.success) {
return { error: 'Invalid email address.' };
}
const safeEmail = validation.data;
// 2. Call Resend API
try {
const { data, error } = await resend.contacts.create({
email: safeEmail,
audienceId: newsletterAudienceId,
});
if (error) {
// (Handle cases like user already subscribed)
console.error('Resend subscribe error:', error);
return { error: 'Failed to subscribe.' };
}
return { success: true };
} catch (err: any) {
return { error: 'An unexpected error occurred.' };
}
}13.5. Real-Time Notifications: Analyzing src/notification/
The last type of communication is real-time notifications, typically used for internal operations.
- "A new user registered!"
- "A 'Pro' plan was purchased!" (from Stripe Webhook)
- "A Server Action failed 5 times."
While you could email yourself, a more efficient approach is to push to your team collaboration tools, such as Discord, Slack, or Lark (飞书).
These platforms all support Incoming Webhooks—a simple POST request URL.
[Code Analysis]: src/lib/notify.ts
We create a generic notification library that can decide where to send based on environment variables.
// src/lib/notify.ts
const discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL;
interface NotificationPayload {
content: string; // Message content for Discord Webhook
username?: string;
}
export async function sendOpsNotification(payload: NotificationPayload) {
// 1. Send to Discord
if (discordWebhookUrl) {
try {
await fetch(discordWebhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: payload.content,
username: payload.username || 'SAAS Bot',
}),
});
} catch (err) {
console.warn('Failed to send Discord notification', err);
}
}
// 2. (Extension) Send to Lark
// if (feishuWebhookUrl) { ... }
}Integration: Sending Notifications in Stripe Webhooks
Now, when an important business event occurs, we can notify ourselves.
// src/app/api/stripe/webhook/route.ts
// ... (from Chapter 11) ...
import { sendOpsNotification } from '@/lib/notify';
// ...
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
// ... (logic for updating database) ...
// After successfully processing subscription, send an operations notification
const userId = session.metadata?.userId;
sendOpsNotification({
content: `🎉 NEW PRO USER! 🎉\nUserID: ${userId}\nEmail: ${session.customer_details?.email}`,
username: 'Stripe Bot',
}).catch(console.error);
// ... (other logic)
}
// ...Chapter 13 Summary
In this chapter, we built a complete SAAS communication system. We distinguished between transactional and marketing emails, and selected the best tools for each.
- Transactional Emails: We used React Email to create type-safe, reusable, easy-to-preview email templates. This solves the pain point of maintaining traditional HTML templates.
- Email Delivery: We used Resend as our email API service provider. It natively integrates with React Email and provides domain verification and audience management features.
- Marketing Subscriptions: We implemented a Server Action that leverages Resend's Audiences API to securely manage our Newsletter subscription list.
- Real-Time Notifications: We created a lightweight notification library
sendOpsNotificationthat can send real-time alerts to internal tools like Discord or Lark via Webhooks, allowing us to respond immediately to important business events (like new paid users).
Through this layered architecture, our core business logic (like registration, payments) remains decoupled from communication logic, making the system easier to maintain and extend.
Categories
src/mail/templates/Welcome.tsxFile 2: src/mail/sender.tsFile 3: src/actions/auth.actions.ts (Integration)13.3. Marketing Emails (Resend)13.4. [Code Analysis]: Analyzing src/newsletter/File 1: src/components/forms/NewsletterForm.tsxFile 2: src/actions/newsletter.actions.ts13.5. Real-Time Notifications: Analyzing src/notification/[Code Analysis]: src/lib/notify.tsIntegration: Sending Notifications in Stripe WebhooksChapter 13 SummaryMore Posts
Chapter 2: TypeScript - The Architectural Contract for SaaS Projects (vs. Mypy)
In Chapter 1, we explored Node.js's asynchronous philosophy. Now, let's talk about JavaScript's safety belt: TypeScript (TS).
Chapter 4: App Router - Built for SaaS Performance
If the algorithmic thinking you built in section 1.4 is 'computational primitives', then Next.js App Router is the masterwork that 'engineers' these primitives. It's a highly optimized architecture designed to solve SaaS application (especially data-driven and I/O-intensive) performance bottlenecks.
Chapter 3: React - Declarative UI (Goodbye Jinja2)
As a Python backend developer, you're probably most familiar with Jinja2. In Flask or Django, you fetch data from the database, 'inject' it into an HTML template, the server 'renders' an HTML string, and sends it to the browser. This process is imperative - you tell the template engine 'loop here, insert this variable here'...