Overview
All emails flow through the messaging system in src/features/messaging/. System emails use wrapper functions, program emails use sendMessageImmediate or queueMessage. All messages are logged in the Message table.
What it is
Unified messaging system with Postmark for delivery, React Email for templates, and database logging for tracking.
Why we use it
Centralized email handling, delivery tracking, webhook support, and consistent patterns for AI agents.
When to use
Email verification, password reset, applicant notifications, recruitment invites, or program communications.
Key Features
- All emails logged in Message table
- React Email templates for type-safe HTML
- Webhook handling for delivery status
- Wrapper functions for clean DX
Quick Start
System Emails
Use wrapper functions for platform emails.
// System emails (platform-level)
import { sendEmailVerification, sendApplicantApproved } from '@/features/messaging';
// Email verification
const result = await sendEmailVerification({
userId: user.id,
email: user.email,
userName: user.name,
verificationUrl: `${baseUrl}/verify?token=${token}`,
expiresInHours: 24,
});
// Applicant approved
await sendApplicantApproved({
userId: applicant.userId,
email: applicant.email,
applicantName: applicant.name,
recruitmentName: recruitment.name,
programName: program.name,
activationUrl,
});System emails are platform-level (verification, invites, applicant status). Import from @/features/messaging.
Program Emails
Use sendMessageImmediate for program emails.
// Program emails (user-scoped, logged in Message table)
import { sendMessageImmediate, queueMessage } from '@/features/messaging';
// Send immediately
const result = await sendMessageImmediate(userId, {
programId: program.id,
channel: 'EMAIL',
recipientId: member.userId,
subject: 'Your weekly digest',
body: 'Plain text fallback',
html: renderedHtml,
});
// Queue for batch sending
await queueMessage(userId, {
programId: program.id,
channel: 'EMAIL',
recipientId: member.userId,
subject: 'Reminder: Survey due tomorrow',
body: 'Plain text content',
html: renderedHtml,
});Patterns
Creating Templates
React Email templates in system-email-templates/.
// src/features/messaging/system-email-templates/welcome.tsx
/* eslint-disable i18next/no-literal-string */
import {
Body,
Container,
Heading,
Link,
Section,
Text,
} from '@react-email/components';
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
}
export function WelcomeEmailTemplate({ userName, loginUrl }: WelcomeEmailProps) {
return (
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Section>
<Heading>Welcome, {userName}!</Heading>
<Text>
Thanks for joining. Click below to get started.
</Text>
<Link
href={loginUrl}
style={{
backgroundColor: '#0066cc',
color: '#ffffff',
padding: '12px 24px',
borderRadius: '4px',
textDecoration: 'none',
}}
>
Log In Now
</Link>
</Section>
</Container>
</Body>
);
}Adding System Emails
Add wrapper functions to system-emails.ts.
// src/features/messaging/system-emails.ts
import { render } from '@react-email/render';
import { sendMessageImmediate } from './services';
import type { SendResult } from './types';
import { WelcomeEmailTemplate } from './system-email-templates';
interface SendWelcomeEmailParams {
userId: string;
email: string;
userName: string;
loginUrl: string;
}
export async function sendWelcomeEmail({
userId,
email,
userName,
loginUrl,
}: SendWelcomeEmailParams): Promise<SendResult> {
const html = await render(WelcomeEmailTemplate({ userName, loginUrl }));
return sendMessageImmediate(null, {
channel: 'EMAIL',
recipientId: userId,
recipientAddress: email,
subject: 'Welcome!',
body: `Welcome! Log in at: ${loginUrl}`,
html,
metadata: { type: 'welcome' },
});
}Export new functions from src/features/messaging/index.ts after adding them.
Background Jobs
Use Inngest for reliable delivery.
// Background job for reliable delivery
import { defineJob } from '@/lib/jobs';
import { sendApplicantApproved } from '@/features/messaging';
export const sendApplicantStatusEmail = defineJob(
{ id: 'send-applicant-status-email', retries: 3 },
{ event: 'applicant/status.changed' },
async ({ event }) => {
const { applicant, status, recruitment, program } = event.data;
if (status === 'APPROVED') {
const result = await sendApplicantApproved({
userId: applicant.userId,
email: applicant.email,
applicantName: applicant.name,
recruitmentName: recruitment.name,
programName: program.name,
activationUrl: `${baseUrl}/activate?token=${token}`,
});
return { sent: result.success };
}
}
);Watch Out
Calling Postmark directly instead of using messaging system
Don't
// Calling Postmark directly
import { postmark } from '@/lib/postmark';
await postmark.sendEmail({
To: email,
Subject: 'Welcome',
HtmlBody: '<p>Hello!</p>',
});Do
// Use messaging system wrapper
import { sendWelcomeEmail } from '@/features/messaging';
const result = await sendWelcomeEmail({
userId,
email,
userName,
loginUrl,
});- Bypassing the messaging system loses logging and tracking
- Hardcoding email HTML instead of using templates
- Forgetting to export new wrapper functions from index.ts