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

Text
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

typescript
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

typescript
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)

typescript
// apps/historia/src/lib/email.ts
import { 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:

typescript
// apps/historia/src/lib/email.ts
import { 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:

typescript
// One-time setup to get test credentials
import 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 URL
const result = await emailClient.send({...});
console.log('Preview:', nodemailer.getTestMessageUrl(result));

Sending a Simple Email

typescript
import { emailClient } from '@/lib/email';
await emailClient.send({
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

typescript
await emailClient.send({
to: { name: 'John Doe', email: '[email protected]' },
cc: [
{ name: 'Support', email: '[email protected]' },
],
subject: 'Important Update',
html: renderedHtml,
});

With Attachments

typescript
await emailClient.send({
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

typescript
'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

typescript
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

typescript
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)

bash
# .env
# For production
NODE_ENV=production
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_PASS=your-password
SMTP_FROM_NAME=Historia
# For development (optional - mock transport used by default)
NODE_ENV=development
# Mock transport requires no SMTP credentials

SendGrid Alternative

bash
SENDGRID_API_KEY=your-sendgrid-api-key
typescript
const emailClient = new EmailClient({
transport: {
type: 'sendgrid',
apiKey: process.env.SENDGRID_API_KEY!,
},
defaults: {
from: 'Historia <[email protected]>',
},
});

Error Handling

typescript
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 errors
if (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:

typescript
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({
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:

typescript
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

typescript
// Test account from ethereal.email
const testAccount = {
pass: 'test-password',
};
const emailClient = new EmailClient({
transport: {
type: 'smtp',
host: 'smtp.ethereal.email',
port: 587,
auth: testAccount,
},
});
const result = await emailClient.send({
subject: 'Test',
html: '<p>Test email</p>',
});
console.log('Preview:', `https://ethereal.email/message/${result.messageId}`);

Dependencies

json
{
"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

  1. Add postor-mailer to Historia dependencies:
json
{
"dependencies": {
"@eventuras/postor-mailer": "workspace:*"
}
}
  1. Create email client instance:
typescript
// apps/historia/src/lib/email.ts
import { 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}>`,
},
});
  1. Use in server actions or API routes:
typescript
import { emailClient } from '@/lib/email';
// In server action
export 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:

typescript
import pLimit from 'p-limit';
const limit = pLimit(5); // Max 5 concurrent sends
await Promise.all(
users.map(user =>
limit(() =>
emailClient.send({
to: user.email,
subject: 'Newsletter',
html: newsletterHtml,
})
)
)
);

Security Best Practices

  1. Environment Variables: Never commit SMTP credentials
  2. TLS/SSL: Use secure: true for port 465
  3. Email Validation: Validate email addresses before sending
  4. Content Sanitization: Sanitize user-generated content in emails
  5. Rate Limiting: Prevent abuse with rate limits

Logging

All operations are logged via @eventuras/logger:

typescript
// Success
logger.info({ to, messageId }, 'Email sent successfully');
// Error
logger.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:

typescript
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({
subject: 'Hello',
html: '<p>Hello from Historia!</p>'
});