Postor-Mailer Specification
Version: 1.0
Library: @eventuras/postor-mailer
Primary Consumer: apps/historia (Next.js/PayloadCMS)
Overview
Postor-mailer is a lightweight TypeScript wrapper around Nodemailer for sending emails. It provides a simple, type-safe interface for email delivery without built-in templating. Template rendering will be handled by separate libraries.
Design Goals
- Simple API: Easy to use, minimal configuration
- Type Safety: Full TypeScript support with clear interfaces
- Transport Flexibility: Support SMTP, SendGrid, and AWS SES
- No Templating: Accepts pre-rendered HTML/text content
- Logging: Integrated with
@eventuras/logger - Testing: Easy to mock and test
Architecture
libs/postor-mailer/├── src/│ ├── index.ts # Public exports│ ├── client.ts # EmailClient class│ ├── transports.ts # Transport factory│ └── types.ts # TypeScript interfaces├── package.json└── tsconfig.json
Core Interfaces
Email Client Configuration
interface EmailClientConfig {transport: SmtpConfig | SendGridConfig | SesConfig | MockConfig;defaults?: {from?: string;replyTo?: string;};}interface SmtpConfig {type: 'smtp';host: string;port: number;secure?: boolean;auth?: {user: string;pass: string;};}interface SendGridConfig {type: 'sendgrid';apiKey: string;}interface SesConfig {type: 'ses';region: string;accessKeyId: string;secretAccessKey: string;}interface MockConfig {type: 'mock';logToConsole?: boolean;saveToFile?: string; // Optional: save emails to JSON file}
Sending Emails
interface SendEmailOptions {to: string | EmailAddress | Array<string | EmailAddress>;subject: string;html?: string;text?: string;from?: string | EmailAddress;replyTo?: string | EmailAddress;cc?: string | EmailAddress | Array<string | EmailAddress>;bcc?: string | EmailAddress | Array<string | EmailAddress>;attachments?: EmailAttachment[];headers?: Record<string, string>;}interface EmailAddress {email: string;name?: string;}interface EmailAttachment {filename: string;content?: Buffer | string;path?: string;contentType?: string;}interface SendEmailResult {messageId: string;accepted: string[];rejected: string[];}
Usage Examples
Basic Setup (Historia)
// apps/historia/src/lib/email.tsimport { EmailClient } from '@eventuras/postor-mailer';export const emailClient = new EmailClient({transport: {type: 'smtp',host: process.env.SMTP_HOST!,port: parseInt(process.env.SMTP_PORT!, 10),auth: {user: process.env.SMTP_USER!,pass: process.env.SMTP_PASS!,},},defaults: {from: 'Historia <[email protected]>',},});
Development Mode Setup
For development, use mock transport to avoid sending real emails:
// apps/historia/src/lib/email.tsimport { EmailClient } from '@eventuras/postor-mailer';const isDevelopment = process.env.NODE_ENV === 'development';export const emailClient = new EmailClient({transport: isDevelopment? {type: 'mock',logToConsole: true,saveToFile: './dev-emails.json', // Optional: save for inspection}: {type: 'smtp',host: process.env.SMTP_HOST!,port: parseInt(process.env.SMTP_PORT!, 10),auth: {user: process.env.SMTP_USER!,pass: process.env.SMTP_PASS!,},},defaults: {from: 'Historia <[email protected]>',},});
Mock transport features:
- Logs email details via
@eventuras/logger - Optional console output for immediate visibility
- Optional JSON file for email inspection
- No network calls, instant response
- Perfect for local development and testing
Alternative: Ethereal Email
For viewing emails in a web UI during development:
// One-time setup to get test credentialsimport nodemailer from 'nodemailer';const testAccount = await nodemailer.createTestAccount();export const emailClient = new EmailClient({transport: {type: 'smtp',host: 'smtp.ethereal.email',port: 587,auth: {user: testAccount.user,pass: testAccount.pass,},},defaults: {from: 'Historia Dev <[email protected]>',},});// After sending, get preview URLconst result = await emailClient.send({...});console.log('Preview:', nodemailer.getTestMessageUrl(result));
Sending a Simple Email
import { emailClient } from '@/lib/email';await emailClient.send({to: '[email protected]',subject: 'Order Confirmation',html: '<h1>Thank you for your order!</h1><p>Your order #12345 has been confirmed.</p>',text: 'Thank you for your order! Your order #12345 has been confirmed.',});
With Structured Recipients
await emailClient.send({to: { name: 'John Doe', email: '[email protected]' },cc: [{ name: 'Support', email: '[email protected]' },],subject: 'Important Update',html: renderedHtml,});
With Attachments
await emailClient.send({to: '[email protected]',subject: 'Your Invoice',html: '<p>Please find your invoice attached.</p>',attachments: [{filename: 'invoice.pdf',path: '/path/to/invoice.pdf',contentType: 'application/pdf',},],});
In Next.js Server Actions
'use server';import { emailClient } from '@/lib/email';import { actionError, actionSuccess } from '@eventuras/core-nextjs/actions';export async function sendOrderConfirmation(orderId: string) {try {// Render email content (from template library)const { html, text } = await renderOrderConfirmationEmail(orderId);await emailClient.send({to: order.customerEmail,subject: 'Order Confirmation',html,text,});return actionSuccess(undefined, 'Confirmation email sent');} catch (error) {return actionError('Failed to send email');}}
Implementation Details
EmailClient Class
export class EmailClient {constructor(config: EmailClientConfig);/*** Send an email* @throws Error if email fails to send*/async send(options: SendEmailOptions): Promise<SendEmailResult>;/*** Verify the transport connection* @returns true if connection is successful*/async verify(): Promise<boolean>;/*** Close the transport connection*/async close(): Promise<void>;}
Transport Factory
function createTransport(config: TransportConfig): Transporter;
Handles creation of Nodemailer transports based on configuration type:
- SMTP: Standard SMTP connection with pooling
- SendGrid: SMTP via SendGrid's relay
- SES: AWS SES transport (implementation pending)
- Mock: In-memory transport that logs without sending
Mock Transport Behavior:
- Returns successful result immediately (no network calls)
- Logs email details via
@eventuras/logger - Optionally logs to console for visibility
- Optionally saves to JSON file for inspection
- Perfect for development and testing
Configuration
Environment Variables (Historia)
# .env# For productionNODE_ENV=productionSMTP_HOST=smtp.example.comSMTP_PORT=587SMTP_PASS=your-passwordSMTP_FROM_NAME=Historia# For development (optional - mock transport used by default)NODE_ENV=development# Mock transport requires no SMTP credentials
SendGrid Alternative
SENDGRID_API_KEY=your-sendgrid-api-key
const emailClient = new EmailClient({transport: {type: 'sendgrid',apiKey: process.env.SENDGRID_API_KEY!,},defaults: {from: 'Historia <[email protected]>',},});
Error Handling
import { Logger } from '@eventuras/logger';const logger = Logger.create({ namespace: 'email' });try {await emailClient.send({to: user.email,subject: 'Welcome',html: welcomeHtml,});} catch (error) {logger.error({ error, userEmail: user.email }, 'Failed to send email');// Handle specific errorsif (error.message.includes('Invalid recipient')) {// Invalid email address} else if (error.message.includes('Connection timeout')) {// SMTP timeout}throw error;}
Testing
Using Mock Transport in Tests
Use the built-in mock transport for unit and integration tests:
import { EmailClient } from '@eventuras/postor-mailer';describe('Email functionality', () => {it('should send order confirmation', async () => {const emailClient = new EmailClient({transport: {type: 'mock',logToConsole: false,},});const result = await emailClient.send({to: '[email protected]',subject: 'Order Confirmation',html: '<p>Test</p>',});expect(result.accepted).toContain('[email protected]');expect(result.messageId).toBeDefined();});});
Mocking with Vitest
For unit tests that need complete control:
import { vi } from 'vitest';vi.mock('@eventuras/postor-mailer', () => ({EmailClient: vi.fn().mockImplementation(() => ({send: vi.fn().mockResolvedValue({messageId: 'test-id',accepted: ['[email protected]'],rejected: [],}),verify: vi.fn().mockResolvedValue(true),close: vi.fn(),})),}));
Integration Testing with Ethereal
// Test account from ethereal.emailconst testAccount = {user: '[email protected]',pass: 'test-password',};const emailClient = new EmailClient({transport: {type: 'smtp',host: 'smtp.ethereal.email',port: 587,auth: testAccount,},});const result = await emailClient.send({to: '[email protected]',subject: 'Test',html: '<p>Test email</p>',});console.log('Preview:', `https://ethereal.email/message/${result.messageId}`);
Dependencies
{"dependencies": {"nodemailer": "^6.9.13","@eventuras/logger": "workspace:*"},"devDependencies": {"@types/nodemailer": "^6.4.15","@eventuras/typescript-config": "workspace:*","@eventuras/vite-config": "workspace:*","vite": "^7.2.6","vitest": "^3.0.0"}}
Integration with Historia
Setup
- Add postor-mailer to Historia dependencies:
{"dependencies": {"@eventuras/postor-mailer": "workspace:*"}}
- Create email client instance:
// apps/historia/src/lib/email.tsimport { EmailClient } from '@eventuras/postor-mailer';import { config } from '@/config.server';export const emailClient = new EmailClient({transport: {type: 'smtp',host: config.smtp.host,port: config.smtp.port,auth: {user: config.smtp.user,pass: config.smtp.pass,},},defaults: {from: `${config.smtp.fromName} <${config.smtp.fromEmail}>`,},});
- Use in server actions or API routes:
import { emailClient } from '@/lib/email';// In server actionexport async function sendNotification(email: string, message: string) {await emailClient.send({to: email,subject: 'Notification',html: message,});}
PayloadCMS Integration
PayloadCMS already uses @payloadcms/email-nodemailer for system emails. Postor-mailer can be used for custom transactional emails outside of PayloadCMS's built-in email system.
Performance Considerations
Connection Pooling
Nodemailer automatically pools SMTP connections (enabled by default in our implementation):
- pool:
true(reuse connections) - maxConnections:
5(max concurrent connections) - maxMessages:
100(messages per connection)
Rate Limiting
For bulk emails, implement rate limiting:
import pLimit from 'p-limit';const limit = pLimit(5); // Max 5 concurrent sendsawait Promise.all(users.map(user =>limit(() =>emailClient.send({to: user.email,subject: 'Newsletter',html: newsletterHtml,}))));
Security Best Practices
- Environment Variables: Never commit SMTP credentials
- TLS/SSL: Use
secure: truefor port 465 - Email Validation: Validate email addresses before sending
- Content Sanitization: Sanitize user-generated content in emails
- Rate Limiting: Prevent abuse with rate limits
Logging
All operations are logged via @eventuras/logger:
// Successlogger.info({ to, messageId }, 'Email sent successfully');// Errorlogger.error({ error, to, subject }, 'Failed to send email');
Future Enhancements
- Queue Support: Integration with Bull/BullMQ for async processing
- Retry Logic: Automatic retry with exponential backoff
- Metrics: Track send rates, failures, and delivery times
- AWS SES Transport: Full implementation for SES
- Template Library: Separate Handlebars-based template rendering
Summary
Postor-mailer provides a simple, type-safe wrapper around Nodemailer specifically designed for Historia and other TypeScript applications in the Eventuras monorepo. It focuses solely on email delivery, leaving template rendering to specialized libraries.
Key Features:
- ✅ Simple, intuitive API
- ✅ Full TypeScript support
- ✅ Multiple transport options
- ✅ No templating (separation of concerns)
- ✅ Easy to test and mock
- ✅ Integrated logging
- ✅ Production-ready
Quick Start:
import { EmailClient } from '@eventuras/postor-mailer';const client = new EmailClient({transport: { type: 'smtp', host: '...', port: 587, auth: {...} },defaults: { from: 'Historia <[email protected]>' }});await client.send({to: '[email protected]',subject: 'Hello',html: '<p>Hello from Historia!</p>'});