diff --git a/packages/mailer/.gitignore b/packages/mailer/.gitignore
new file mode 100644
index 0000000..b04bd51
--- /dev/null
+++ b/packages/mailer/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+coverage
+*.log
+.DS_Store
+.turbo
diff --git a/packages/mailer/.prettierrc b/packages/mailer/.prettierrc
new file mode 100644
index 0000000..6333683
--- /dev/null
+++ b/packages/mailer/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "tabWidth": 4,
+ "useTabs": false,
+ "singleQuote": true,
+ "semi": true
+}
diff --git a/packages/mailer/README.md b/packages/mailer/README.md
new file mode 100644
index 0000000..44fbc30
--- /dev/null
+++ b/packages/mailer/README.md
@@ -0,0 +1,89 @@
+# @orkait/mailer
+
+Type-safe email service with multi-provider support and strategy pattern.
+
+## Features
+
+- Multiple providers (Resend, SendGrid)
+- Strategy pattern (Failover, RoundRobin, Priority)
+- Type-safe templates with Zod validation
+- Cloudflare Workers compatible
+- Comprehensive test coverage
+
+## Quick Start
+
+```typescript
+import { EmailService, ResendProvider, verifyEmailTemplate } from '@orkait/mailer';
+
+const emailService = new EmailService({
+ providers: [new ResendProvider({ apiKey: 'your-key' })],
+ defaultFrom: 'noreply@example.com',
+});
+
+// Send with template
+await emailService.sendWithTemplate(
+ verifyEmailTemplate,
+ {
+ company: 'Orkait',
+ userName: 'John',
+ verificationUrl: 'https://example.com/verify?token=abc',
+ },
+ {
+ to: 'user@example.com',
+ subject: 'Verify your email',
+ }
+);
+
+// Send raw email
+await emailService.send({
+ to: 'user@example.com',
+ subject: 'Hello',
+ html: '
Hello World
',
+});
+```
+
+## Features
+
+- Multiple providers (Resend, SendGrid)
+- Strategy pattern (Failover, RoundRobin, Priority)
+- Type-safe templates with Zod validation
+- Cloudflare Workers compatible
+- Comprehensive test coverage
+
+## Strategies
+
+```typescript
+import { FailoverStrategy, RoundRobinStrategy, PriorityStrategy } from '@orkait/mailer';
+
+// Failover - try next provider if one fails
+new FailoverStrategy()
+
+// Round-robin - distribute load
+new RoundRobinStrategy()
+
+// Priority - respect quota limits
+new PriorityStrategy({ resend: 100, sendgrid: 100 })
+```
+
+## Custom Templates
+
+```typescript
+import { createTemplate } from '@orkait/mailer';
+import { z } from 'zod';
+
+const myTemplate = createTemplate(
+ 'my-template',
+ z.object({ name: z.string() }),
+ (ctx) => ({
+ html: `Hello ${ctx.name}
`,
+ text: `Hello ${ctx.name}`,
+ })
+);
+```
+
+## Testing
+
+```bash
+bun test # Run tests
+bun test:coverage # Generate coverage report
+```
diff --git a/packages/mailer/husky.sh b/packages/mailer/husky.sh
new file mode 100644
index 0000000..d042a79
--- /dev/null
+++ b/packages/mailer/husky.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+# 1. Install dependencies
+echo "📦 Installing Prettier, Husky, and lint-staged..."
+bun add -d prettier husky lint-staged
+
+# 2. Initialize Husky
+echo "🐶 Initializing Husky..."
+bunx husky init
+
+# 3. Create Prettier config (4-space tabs)
+echo "📝 Creating .prettierrc..."
+cat < .prettierrc
+{
+ "tabWidth": 4,
+ "useTabs": false,
+ "singleQuote": true,
+ "semi": true
+}
+EOF
+
+# 4. Update .husky/pre-commit to use bunx lint-staged
+echo "⚓️ Setting up pre-commit hook..."
+echo "bunx lint-staged" > .husky/pre-commit
+
+# 5. Add lint-staged configuration to package.json
+# Note: This uses 'bun --eval' to safely inject JSON into package.json
+echo "⚙️ Adding lint-staged config to package.json..."
+bun --eval "
+ const fs = require('fs');
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg['lint-staged'] = { '**/*': 'prettier --write --ignore-unknown' };
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
+"
+
+echo "✅ Setup complete! Your code will now auto-format to 4 spaces on every commit."
diff --git a/packages/mailer/package.json b/packages/mailer/package.json
new file mode 100644
index 0000000..d3c337a
--- /dev/null
+++ b/packages/mailer/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@orkait/mailer",
+ "version": "2.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "type-check": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
+ },
+ "dependencies": {
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@vitest/coverage-v8": "^1.0.0",
+ "typescript": "^5.4.0",
+ "vitest": "^1.0.0"
+ }
+}
diff --git a/packages/mailer/src/__tests__/email-service.test.ts b/packages/mailer/src/__tests__/email-service.test.ts
new file mode 100644
index 0000000..bba070c
--- /dev/null
+++ b/packages/mailer/src/__tests__/email-service.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect, vi } from 'vitest';
+import { EmailService } from '../email-service';
+import type { BaseEmailProvider } from '../providers';
+import { SingleProviderStrategy } from '../strategies';
+import type { EmailResult } from '../types';
+
+const createMockProvider = (name: string): BaseEmailProvider => ({
+ getName: () => name,
+ send: vi.fn().mockResolvedValue({
+ success: true,
+ messageId: 'msg-123',
+ provider: name,
+ } as EmailResult),
+ verify: vi.fn().mockResolvedValue(true),
+} as any);
+
+describe('EmailService', () => {
+ it('should create service with valid config', () => {
+ const provider = createMockProvider('test');
+ const service = new EmailService({
+ providers: [provider],
+ defaultFrom: 'noreply@example.com',
+ });
+
+ expect(service).toBeDefined();
+ });
+
+ it('should throw error with no providers', () => {
+ expect(() => {
+ new EmailService({ providers: [] });
+ }).toThrow();
+ });
+
+ it('should send email successfully', async () => {
+ const provider = createMockProvider('test');
+ const service = new EmailService({
+ providers: [provider],
+ defaultFrom: 'noreply@example.com',
+ });
+
+ const result = await service.send({
+ to: 'user@example.com',
+ subject: 'Test',
+ html: 'Test
',
+ });
+
+ expect(result.success).toBe(true);
+ expect(provider.send).toHaveBeenCalled();
+ });
+
+ it('should use default from address', async () => {
+ const provider = createMockProvider('test');
+ const service = new EmailService({
+ providers: [provider],
+ defaultFrom: 'noreply@example.com',
+ });
+
+ await service.send({
+ to: 'user@example.com',
+ subject: 'Test',
+ html: 'Test
',
+ });
+
+ expect(provider.send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: 'noreply@example.com',
+ })
+ );
+ });
+
+ it('should add provider dynamically', () => {
+ const provider1 = createMockProvider('provider1');
+ const provider2 = createMockProvider('provider2');
+
+ const service = new EmailService({
+ providers: [provider1],
+ });
+
+ expect(service.getProviders()).toHaveLength(1);
+
+ service.addProvider(provider2);
+
+ expect(service.getProviders()).toHaveLength(2);
+ });
+
+ it('should remove provider by name', () => {
+ const provider1 = createMockProvider('provider1');
+ const provider2 = createMockProvider('provider2');
+
+ const service = new EmailService({
+ providers: [provider1, provider2],
+ });
+
+ service.removeProvider('provider1');
+
+ expect(service.getProviders()).toHaveLength(1);
+ expect(service.getProviders()[0].getName()).toBe('provider2');
+ });
+
+ it('should change strategy', async () => {
+ const provider = createMockProvider('test');
+ const service = new EmailService({
+ providers: [provider],
+ });
+
+ const newStrategy = new SingleProviderStrategy();
+ service.setStrategy(newStrategy);
+
+ const result = await service.send({
+ to: 'user@example.com',
+ from: 'sender@example.com',
+ subject: 'Test',
+ html: 'Test
',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should verify all providers', async () => {
+ const provider1 = createMockProvider('provider1');
+ const provider2 = createMockProvider('provider2');
+
+ const service = new EmailService({
+ providers: [provider1, provider2],
+ });
+
+ const results = await service.verifyProviders();
+
+ expect(results).toEqual({
+ provider1: true,
+ provider2: true,
+ });
+ });
+
+ it('should validate email data', async () => {
+ const provider = createMockProvider('test');
+ const service = new EmailService({
+ providers: [provider],
+ });
+
+ await expect(
+ service.send({
+ to: 'invalid-email',
+ from: 'sender@example.com',
+ subject: 'Test',
+ html: 'Test
',
+ })
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/mailer/src/__tests__/providers/resend.provider.test.ts b/packages/mailer/src/__tests__/providers/resend.provider.test.ts
new file mode 100644
index 0000000..777af65
--- /dev/null
+++ b/packages/mailer/src/__tests__/providers/resend.provider.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ResendProvider } from '../../providers/resend.provider';
+import type { EmailData } from '../../types';
+
+global.fetch = vi.fn();
+
+describe('ResendProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const mockConfig = {
+ apiKey: 'test-api-key',
+ };
+
+ const mockEmailData: EmailData = {
+ from: 'sender@example.com',
+ to: 'recipient@example.com',
+ subject: 'Test Email',
+ html: 'Test
',
+ };
+
+ it('should create provider with valid config', () => {
+ const provider = new ResendProvider(mockConfig);
+ expect(provider.getName()).toBe('resend');
+ });
+
+ it('should send email successfully', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ id: 'msg-123' }),
+ });
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(true);
+ expect(result.messageId).toBe('msg-123');
+ expect(result.provider).toBe('resend');
+ });
+
+ it('should handle API errors', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ text: async () => 'Bad Request',
+ });
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('400');
+ });
+
+ it('should handle network errors', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Network error');
+ });
+
+ it('should handle array of recipients', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ id: 'msg-123' }),
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ to: ['user1@example.com', 'user2@example.com'],
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('user1@example.com'),
+ })
+ );
+ });
+
+ it('should verify API key successfully', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ });
+
+ const result = await provider.verify();
+ expect(result).toBe(true);
+ });
+
+ it('should fail verification with invalid key', async () => {
+ const provider = new ResendProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: false,
+ });
+
+ const result = await provider.verify();
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/mailer/src/__tests__/providers/sendgrid.provider.test.ts b/packages/mailer/src/__tests__/providers/sendgrid.provider.test.ts
new file mode 100644
index 0000000..2038065
--- /dev/null
+++ b/packages/mailer/src/__tests__/providers/sendgrid.provider.test.ts
@@ -0,0 +1,247 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { SendGridProvider } from '../../providers/sendgrid.provider';
+import type { EmailData } from '../../types';
+
+global.fetch = vi.fn();
+
+describe('SendGridProvider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const mockConfig = {
+ apiKey: 'test-api-key',
+ };
+
+ const mockEmailData: EmailData = {
+ from: 'sender@example.com',
+ to: 'recipient@example.com',
+ subject: 'Test Email',
+ html: 'Test
',
+ };
+
+ it('should create provider with valid config', () => {
+ const provider = new SendGridProvider(mockConfig);
+ expect(provider.getName()).toBe('sendgrid');
+ });
+
+ it('should send email successfully', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: (name: string) => name === 'X-Message-Id' ? 'msg-123' : null,
+ },
+ });
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(true);
+ expect(result.messageId).toBe('msg-123');
+ expect(result.provider).toBe('sendgrid');
+ });
+
+ it('should handle API errors', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ text: async () => 'Bad Request',
+ });
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('400');
+ });
+
+ it('should handle network errors', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
+
+ const result = await provider.send(mockEmailData);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Network error');
+ });
+
+ it('should handle array of recipients', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: () => 'msg-123',
+ },
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ to: ['user1@example.com', 'user2@example.com'],
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('user1@example.com'),
+ })
+ );
+ });
+
+ it('should handle CC recipients', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: () => 'msg-123',
+ },
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ cc: 'cc@example.com',
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('cc@example.com'),
+ })
+ );
+ });
+
+ it('should handle BCC recipients', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: () => 'msg-123',
+ },
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ bcc: ['bcc1@example.com', 'bcc2@example.com'],
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('bcc1@example.com'),
+ })
+ );
+ });
+
+ it('should include text content when provided', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: () => 'msg-123',
+ },
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ text: 'Plain text content',
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('Plain text content'),
+ })
+ );
+ });
+
+ it('should include reply-to when provided', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ headers: {
+ get: () => 'msg-123',
+ },
+ });
+
+ const emailData = {
+ ...mockEmailData,
+ replyTo: 'reply@example.com',
+ };
+
+ const result = await provider.send(emailData);
+
+ expect(result.success).toBe(true);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ body: expect.stringContaining('reply@example.com'),
+ })
+ );
+ });
+
+ it('should verify API key successfully', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ });
+
+ const result = await provider.verify();
+ expect(result).toBe(true);
+ });
+
+ it('should fail verification with invalid key', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: false,
+ });
+
+ const result = await provider.verify();
+ expect(result).toBe(false);
+ });
+
+ it('should handle verification network errors', async () => {
+ const provider = new SendGridProvider(mockConfig);
+
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
+
+ const result = await provider.verify();
+ expect(result).toBe(false);
+ });
+
+ it('should use custom API URL when provided', () => {
+ const customConfig = {
+ apiKey: 'test-key',
+ apiUrl: 'https://custom.sendgrid.com',
+ };
+
+ const provider = new SendGridProvider(customConfig);
+ expect(provider).toBeDefined();
+ });
+
+ it('should throw error with invalid config', () => {
+ expect(() => {
+ new SendGridProvider({ apiKey: '' } as any);
+ }).toThrow();
+ });
+});
diff --git a/packages/mailer/src/__tests__/strategies/email.strategy.test.ts b/packages/mailer/src/__tests__/strategies/email.strategy.test.ts
new file mode 100644
index 0000000..9973398
--- /dev/null
+++ b/packages/mailer/src/__tests__/strategies/email.strategy.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+ SingleProviderStrategy,
+ FailoverStrategy,
+ RoundRobinStrategy,
+ PriorityStrategy,
+} from '../../strategies/email.strategy';
+import type { BaseEmailProvider } from '../../providers';
+import type { EmailData, EmailResult } from '../../types';
+
+const mockEmailData: EmailData = {
+ from: 'sender@example.com',
+ to: 'recipient@example.com',
+ subject: 'Test',
+ html: 'Test
',
+};
+
+const createMockProvider = (name: string, shouldSucceed: boolean): BaseEmailProvider => ({
+ getName: () => name,
+ send: vi.fn().mockResolvedValue({
+ success: shouldSucceed,
+ messageId: shouldSucceed ? `msg-${name}` : undefined,
+ error: shouldSucceed ? undefined : `Error from ${name}`,
+ provider: name,
+ } as EmailResult),
+ verify: vi.fn().mockResolvedValue(true),
+} as any);
+
+describe('Email Strategies', () => {
+ describe('SingleProviderStrategy', () => {
+ it('should use first provider', async () => {
+ const strategy = new SingleProviderStrategy();
+ const provider = createMockProvider('provider1', true);
+
+ const result = await strategy.send(mockEmailData, [provider]);
+
+ expect(result.success).toBe(true);
+ expect(result.provider).toBe('provider1');
+ expect(provider.send).toHaveBeenCalledWith(mockEmailData);
+ });
+
+ it('should return error when no providers', async () => {
+ const strategy = new SingleProviderStrategy();
+
+ const result = await strategy.send(mockEmailData, []);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('No email providers');
+ });
+ });
+
+ describe('FailoverStrategy', () => {
+ it('should use first provider if successful', async () => {
+ const strategy = new FailoverStrategy();
+ const provider1 = createMockProvider('provider1', true);
+ const provider2 = createMockProvider('provider2', true);
+
+ const result = await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(result.success).toBe(true);
+ expect(result.provider).toBe('provider1');
+ expect(provider1.send).toHaveBeenCalled();
+ expect(provider2.send).not.toHaveBeenCalled();
+ });
+
+ it('should failover to second provider', async () => {
+ const strategy = new FailoverStrategy();
+ const provider1 = createMockProvider('provider1', false);
+ const provider2 = createMockProvider('provider2', true);
+
+ const result = await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(result.success).toBe(true);
+ expect(result.provider).toBe('provider2');
+ expect(provider1.send).toHaveBeenCalled();
+ expect(provider2.send).toHaveBeenCalled();
+ });
+
+ it('should return error when all providers fail', async () => {
+ const strategy = new FailoverStrategy();
+ const provider1 = createMockProvider('provider1', false);
+ const provider2 = createMockProvider('provider2', false);
+
+ const result = await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('All providers failed');
+ });
+ });
+
+ describe('RoundRobinStrategy', () => {
+ it('should distribute load across providers', async () => {
+ const strategy = new RoundRobinStrategy();
+ const provider1 = createMockProvider('provider1', true);
+ const provider2 = createMockProvider('provider2', true);
+
+ const result1 = await strategy.send(mockEmailData, [provider1, provider2]);
+ expect(result1.provider).toBe('provider1');
+
+ const result2 = await strategy.send(mockEmailData, [provider1, provider2]);
+ expect(result2.provider).toBe('provider2');
+
+ const result3 = await strategy.send(mockEmailData, [provider1, provider2]);
+ expect(result3.provider).toBe('provider1');
+ });
+
+ it('should failover if current provider fails', async () => {
+ const strategy = new RoundRobinStrategy();
+ const provider1 = createMockProvider('provider1', false);
+ const provider2 = createMockProvider('provider2', true);
+
+ const result = await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(result.success).toBe(true);
+ expect(result.provider).toBe('provider2');
+ });
+ });
+
+ describe('PriorityStrategy', () => {
+ it('should respect provider limits', async () => {
+ const strategy = new PriorityStrategy({
+ provider1: 2,
+ provider2: 2,
+ });
+
+ const provider1 = createMockProvider('provider1', true);
+ const provider2 = createMockProvider('provider2', true);
+
+ await strategy.send(mockEmailData, [provider1, provider2]);
+ await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(provider1.send).toHaveBeenCalledTimes(2);
+ expect(provider2.send).not.toHaveBeenCalled();
+
+ await strategy.send(mockEmailData, [provider1, provider2]);
+
+ expect(provider2.send).toHaveBeenCalledTimes(1);
+ });
+
+ it('should track usage correctly', async () => {
+ const strategy = new PriorityStrategy({
+ provider1: 1,
+ });
+
+ const provider1 = createMockProvider('provider1', true);
+
+ await strategy.send(mockEmailData, [provider1]);
+
+ const usage = strategy.getUsage();
+ expect(usage.provider1).toBe(1);
+ });
+
+ it('should reset usage', async () => {
+ const strategy = new PriorityStrategy({
+ provider1: 1,
+ });
+
+ const provider1 = createMockProvider('provider1', true);
+
+ await strategy.send(mockEmailData, [provider1]);
+ strategy.resetUsage();
+
+ const usage = strategy.getUsage();
+ expect(usage.provider1).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/mailer/src/__tests__/templates/base.template.test.ts b/packages/mailer/src/__tests__/templates/base.template.test.ts
new file mode 100644
index 0000000..2f6bc61
--- /dev/null
+++ b/packages/mailer/src/__tests__/templates/base.template.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { z } from 'zod';
+import { createTemplate } from '../../templates/base.template';
+
+describe('createTemplate', () => {
+ it('should create a valid template', () => {
+ const schema = z.object({
+ name: z.string(),
+ url: z.string().url(),
+ });
+
+ const template = createTemplate('test', schema, (ctx) => ({
+ html: `Hello ${ctx.name}
`,
+ text: `Hello ${ctx.name}`,
+ }));
+
+ expect(template.name).toBe('test');
+ expect(template.schema).toBe(schema);
+ });
+
+ it('should validate context before rendering', () => {
+ const schema = z.object({
+ email: z.string().email(),
+ });
+
+ const template = createTemplate('test', schema, (ctx) => ({
+ html: `${ctx.email}
`,
+ text: ctx.email,
+ }));
+
+ expect(() => {
+ template.render({ email: 'invalid-email' } as any);
+ }).toThrow();
+ });
+
+ it('should render with valid context', () => {
+ const schema = z.object({
+ name: z.string(),
+ });
+
+ const template = createTemplate('test', schema, (ctx) => ({
+ html: `${ctx.name}
`,
+ text: ctx.name,
+ }));
+
+ const result = template.render({ name: 'John' });
+
+ expect(result.html).toBe('John
');
+ expect(result.text).toBe('John');
+ });
+
+ it('should handle optional fields', () => {
+ const schema = z.object({
+ name: z.string(),
+ age: z.number().optional(),
+ });
+
+ const template = createTemplate('test', schema, (ctx) => ({
+ html: `${ctx.name} ${ctx.age || 'N/A'}
`,
+ text: `${ctx.name} ${ctx.age || 'N/A'}`,
+ }));
+
+ const result = template.render({ name: 'John' });
+
+ expect(result.html).toBe('John N/A
');
+ });
+});
diff --git a/packages/mailer/src/__tests__/templates/password-reset.template.test.ts b/packages/mailer/src/__tests__/templates/password-reset.template.test.ts
new file mode 100644
index 0000000..7a4df4a
--- /dev/null
+++ b/packages/mailer/src/__tests__/templates/password-reset.template.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from 'vitest';
+import { passwordResetTemplate } from '../../templates/password-reset.template';
+
+describe('passwordResetTemplate', () => {
+ it('should render template with all fields', () => {
+ const context = {
+ company: 'Test Company',
+ userName: 'Jane Doe',
+ resetUrl: 'https://example.com/reset?token=xyz789',
+ expiryHours: 2,
+ };
+
+ const result = passwordResetTemplate.render(context);
+
+ expect(result.html).toContain('Test Company');
+ expect(result.html).toContain('Jane Doe');
+ expect(result.html).toContain('https://example.com/reset?token=xyz789');
+ expect(result.html).toContain('2 hours');
+
+ expect(result.text).toContain('Test Company');
+ expect(result.text).toContain('Jane Doe');
+ });
+
+ it('should render without userName', () => {
+ const context = {
+ company: 'Test Company',
+ resetUrl: 'https://example.com/reset',
+ };
+
+ const result = passwordResetTemplate.render(context);
+
+ expect(result.html).toContain('Hi there,');
+ expect(result.text).toContain('Hi there,');
+ });
+
+ it('should use default expiry hours', () => {
+ const context = {
+ company: 'Test Company',
+ resetUrl: 'https://example.com/reset',
+ };
+
+ const result = passwordResetTemplate.render(context);
+
+ expect(result.html).toContain('1 hour');
+ });
+
+ it('should handle singular hour correctly', () => {
+ const context = {
+ company: 'Test Company',
+ resetUrl: 'https://example.com/reset',
+ expiryHours: 1,
+ };
+
+ const result = passwordResetTemplate.render(context);
+
+ expect(result.html).toContain('1 hour');
+ expect(result.html).not.toContain('1 hours');
+ });
+
+ it('should escape HTML in user input', () => {
+ const context = {
+ company: 'Test
',
+ userName: 'Jane ',
+ resetUrl: 'https://example.com/reset',
+ };
+
+ const result = passwordResetTemplate.render(context);
+
+ expect(result.html).not.toContain('
');
+ expect(result.html).toContain('<');
+ });
+
+ it('should validate required fields', () => {
+ expect(() => {
+ passwordResetTemplate.render({
+ company: 'Test',
+ } as any);
+ }).toThrow();
+ });
+});
diff --git a/packages/mailer/src/__tests__/templates/verify-email.template.test.ts b/packages/mailer/src/__tests__/templates/verify-email.template.test.ts
new file mode 100644
index 0000000..caedf70
--- /dev/null
+++ b/packages/mailer/src/__tests__/templates/verify-email.template.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { verifyEmailTemplate } from '../../templates/verify-email.template';
+
+describe('verifyEmailTemplate', () => {
+ it('should render template with all fields', () => {
+ const context = {
+ company: 'Test Company',
+ userName: 'John Doe',
+ verificationUrl: 'https://example.com/verify?token=abc123',
+ expiryHours: 24,
+ };
+
+ const result = verifyEmailTemplate.render(context);
+
+ expect(result.html).toContain('Test Company');
+ expect(result.html).toContain('John Doe');
+ expect(result.html).toContain('https://example.com/verify?token=abc123');
+ expect(result.html).toContain('24 hours');
+
+ expect(result.text).toContain('Test Company');
+ expect(result.text).toContain('John Doe');
+ expect(result.text).toContain('https://example.com/verify?token=abc123');
+ });
+
+ it('should render without userName', () => {
+ const context = {
+ company: 'Test Company',
+ verificationUrl: 'https://example.com/verify?token=abc123',
+ };
+
+ const result = verifyEmailTemplate.render(context);
+
+ expect(result.html).toContain('Hi there,');
+ expect(result.text).toContain('Hi there,');
+ });
+
+ it('should escape HTML in user input', () => {
+ const context = {
+ company: 'Test ',
+ userName: 'John Doe',
+ verificationUrl: 'https://example.com/verify',
+ };
+
+ const result = verifyEmailTemplate.render(context);
+
+ expect(result.html).not.toContain('')).toBe(
+ '<script>alert("xss")</script>'
+ );
+ });
+
+ it('should escape ampersands', () => {
+ expect(escapeHtml('Tom & Jerry')).toBe('Tom & Jerry');
+ });
+
+ it('should escape quotes', () => {
+ expect(escapeHtml(`He said "hello"`)).toBe('He said "hello"');
+ expect(escapeHtml(`It's working`)).toBe('It's working');
+ });
+
+ it('should handle empty strings', () => {
+ expect(escapeHtml('')).toBe('');
+ });
+
+ it('should handle strings without special characters', () => {
+ expect(escapeHtml('Hello World')).toBe('Hello World');
+ });
+ });
+
+ describe('sanitizeUrl', () => {
+ it('should return valid URLs unchanged', () => {
+ const url = 'https://example.com/path';
+ expect(sanitizeUrl(url)).toBe(url);
+ });
+
+ it('should handle URLs with query params', () => {
+ const url = 'https://example.com?token=abc123';
+ const result = sanitizeUrl(url);
+ expect(result).toContain('example.com');
+ expect(result).toContain('token=abc123');
+ });
+
+ it('should return empty string for invalid URLs', () => {
+ expect(sanitizeUrl('not a url')).toBe('');
+ });
+
+ it('should filter out dangerous protocols', () => {
+ const result = sanitizeUrl('javascript:alert(1)');
+ expect(result).toBe('');
+ });
+
+ it('should handle empty strings', () => {
+ expect(sanitizeUrl('')).toBe('');
+ });
+ });
+});
diff --git a/packages/mailer/src/constants/index.ts b/packages/mailer/src/constants/index.ts
new file mode 100644
index 0000000..4b559fa
--- /dev/null
+++ b/packages/mailer/src/constants/index.ts
@@ -0,0 +1,21 @@
+export const EMAIL_PROVIDERS = {
+ RESEND: 'resend',
+ SENDGRID: 'sendgrid',
+} as const;
+
+export const DEFAULT_API_URLS = {
+ RESEND: 'https://api.resend.com',
+ SENDGRID: 'https://api.sendgrid.com',
+} as const;
+
+export const EMAIL_ERRORS = {
+ NO_PROVIDERS: 'No email providers configured',
+ ALL_PROVIDERS_FAILED: 'All providers failed',
+ ALL_PROVIDERS_EXHAUSTED: 'All providers exhausted or failed',
+ INVALID_CONFIG: 'Invalid configuration',
+} as const;
+
+export const DEFAULT_EXPIRY_HOURS = {
+ EMAIL_VERIFICATION: 24,
+ PASSWORD_RESET: 1,
+} as const;
diff --git a/packages/mailer/src/email-service.ts b/packages/mailer/src/email-service.ts
new file mode 100644
index 0000000..b3b6549
--- /dev/null
+++ b/packages/mailer/src/email-service.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+import type { BaseEmailProvider } from './providers';
+import type { EmailStrategy } from './strategies';
+import { SingleProviderStrategy } from './strategies';
+import type { EmailData } from './types';
+import type { EmailTemplate } from './templates';
+import { BaseEmailSchema } from './types';
+
+export interface EmailServiceConfig {
+ providers: BaseEmailProvider[];
+ strategy?: EmailStrategy;
+ defaultFrom?: string;
+}
+
+const EmailServiceConfigSchema = z.object({
+ providers: z.array(z.any()).min(1, 'At least one provider is required'),
+ strategy: z.any().optional(),
+ defaultFrom: z.string().email().optional(),
+});
+
+export class EmailService {
+ private providers: BaseEmailProvider[];
+ private strategy: EmailStrategy;
+ private defaultFrom?: string;
+
+ constructor(config: EmailServiceConfig) {
+ const validated = EmailServiceConfigSchema.parse(config);
+
+ this.providers = validated.providers;
+ this.strategy = validated.strategy || new SingleProviderStrategy();
+ this.defaultFrom = validated.defaultFrom;
+ }
+
+ async send(emailData: Partial): Promise> {
+ const data = {
+ ...emailData,
+ from: emailData.from || this.defaultFrom,
+ };
+
+ const validated = BaseEmailSchema.parse(data);
+ return this.strategy.send(validated, this.providers);
+ }
+
+ async sendWithTemplate(
+ template: EmailTemplate,
+ context: z.infer,
+ emailData: Omit, 'html' | 'text'>
+ ): Promise> {
+ const { html, text } = template.render(context);
+
+ return this.send({
+ ...emailData,
+ html,
+ text,
+ });
+ }
+
+ addProvider(provider: BaseEmailProvider): void {
+ this.providers.push(provider);
+ }
+
+ removeProvider(providerName: string): void {
+ this.providers = this.providers.filter(p => p.getName() !== providerName);
+ }
+
+ getProviders(): BaseEmailProvider[] {
+ return [...this.providers];
+ }
+
+ setStrategy(strategy: EmailStrategy): void {
+ this.strategy = strategy;
+ }
+
+ async verifyProviders(): Promise> {
+ const results: Record = {};
+
+ for (const provider of this.providers) {
+ results[provider.getName()] = await provider.verify();
+ }
+
+ return results;
+ }
+}
diff --git a/packages/mailer/src/index.ts b/packages/mailer/src/index.ts
new file mode 100644
index 0000000..1e01dfd
--- /dev/null
+++ b/packages/mailer/src/index.ts
@@ -0,0 +1,31 @@
+export { EmailService, type EmailServiceConfig } from './email-service';
+
+export type { EmailData, EmailResult, TemplateContext } from './types';
+
+export {
+ BaseEmailProvider,
+ ResendProvider,
+ SendGridProvider,
+ type EmailProviderConfig,
+ type ResendConfig,
+ type SendGridConfig,
+} from './providers';
+
+export {
+ type EmailStrategy,
+ SingleProviderStrategy,
+ FailoverStrategy,
+ RoundRobinStrategy,
+ PriorityStrategy,
+} from './strategies';
+
+export {
+ type EmailTemplate,
+ createTemplate,
+ verifyEmailTemplate,
+ passwordResetTemplate,
+ type VerifyEmailContext,
+ type PasswordResetContext,
+} from './templates';
+
+export { escapeHtml } from './utils';
diff --git a/packages/mailer/src/providers/base.provider.ts b/packages/mailer/src/providers/base.provider.ts
new file mode 100644
index 0000000..f72c08e
--- /dev/null
+++ b/packages/mailer/src/providers/base.provider.ts
@@ -0,0 +1,23 @@
+import type { EmailData, EmailResult } from '../types';
+
+export interface EmailProviderConfig {
+ apiKey: string;
+ [key: string]: unknown;
+}
+
+export abstract class BaseEmailProvider {
+ protected config: EmailProviderConfig;
+ protected providerName: string;
+
+ constructor(config: EmailProviderConfig, providerName: string) {
+ this.config = config;
+ this.providerName = providerName;
+ }
+
+ abstract send(emailData: EmailData): Promise;
+ abstract verify(): Promise;
+
+ getName(): string {
+ return this.providerName;
+ }
+}
diff --git a/packages/mailer/src/providers/index.ts b/packages/mailer/src/providers/index.ts
new file mode 100644
index 0000000..3b2c22f
--- /dev/null
+++ b/packages/mailer/src/providers/index.ts
@@ -0,0 +1,3 @@
+export { BaseEmailProvider, type EmailProviderConfig } from './base.provider';
+export { ResendProvider, type ResendConfig } from './resend.provider';
+export { SendGridProvider, type SendGridConfig } from './sendgrid.provider';
diff --git a/packages/mailer/src/providers/resend.provider.ts b/packages/mailer/src/providers/resend.provider.ts
new file mode 100644
index 0000000..850b8c7
--- /dev/null
+++ b/packages/mailer/src/providers/resend.provider.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+import { BaseEmailProvider } from './base.provider';
+import type { EmailData, EmailResult } from '../types';
+import { DEFAULT_API_URLS, EMAIL_PROVIDERS } from '../constants';
+import { ensureArray } from '../utils';
+
+const ResendConfigSchema = z.object({
+ apiKey: z.string().min(1),
+ apiUrl: z.string().url().optional().default(DEFAULT_API_URLS.RESEND),
+});
+
+export type ResendConfig = z.infer;
+
+export class ResendProvider extends BaseEmailProvider {
+ private apiUrl: string;
+
+ constructor(config: ResendConfig) {
+ const validated = ResendConfigSchema.parse(config);
+ super(validated, EMAIL_PROVIDERS.RESEND);
+ this.apiUrl = validated.apiUrl;
+ }
+
+ async send(emailData: EmailData): Promise {
+ try {
+ const payload = {
+ from: emailData.from,
+ to: ensureArray(emailData.to),
+ subject: emailData.subject,
+ html: emailData.html,
+ ...(emailData.text && { text: emailData.text }),
+ ...(emailData.replyTo && { reply_to: emailData.replyTo }),
+ ...(emailData.cc && { cc: ensureArray(emailData.cc) }),
+ ...(emailData.bcc && { bcc: ensureArray(emailData.bcc) }),
+ };
+
+ const response = await fetch(`${this.apiUrl}/emails`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${this.config.apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return {
+ success: false,
+ error: `Resend API error: ${response.status} - ${errorText}`,
+ provider: this.providerName,
+ };
+ }
+
+ const result = await response.json() as { id: string };
+
+ return {
+ success: true,
+ messageId: result.id,
+ provider: this.providerName,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ provider: this.providerName,
+ };
+ }
+ }
+
+ async verify(): Promise {
+ try {
+ const response = await fetch(`${this.apiUrl}/api-keys`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${this.config.apiKey}`,
+ },
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/mailer/src/providers/sendgrid.provider.ts b/packages/mailer/src/providers/sendgrid.provider.ts
new file mode 100644
index 0000000..0d8eb1b
--- /dev/null
+++ b/packages/mailer/src/providers/sendgrid.provider.ts
@@ -0,0 +1,93 @@
+import { z } from 'zod';
+import { BaseEmailProvider } from './base.provider';
+import type { EmailData, EmailResult } from '../types';
+import { DEFAULT_API_URLS, EMAIL_PROVIDERS } from '../constants';
+import { ensureArray } from '../utils';
+
+const SendGridConfigSchema = z.object({
+ apiKey: z.string().min(1),
+ apiUrl: z.string().url().optional().default(DEFAULT_API_URLS.SENDGRID),
+});
+
+export type SendGridConfig = z.infer;
+
+export class SendGridProvider extends BaseEmailProvider {
+ private apiUrl: string;
+
+ constructor(config: SendGridConfig) {
+ const validated = SendGridConfigSchema.parse(config);
+ super(validated, EMAIL_PROVIDERS.SENDGRID);
+ this.apiUrl = validated.apiUrl;
+ }
+
+ async send(emailData: EmailData): Promise {
+ try {
+ const payload = {
+ personalizations: [
+ {
+ to: ensureArray(emailData.to).map(email => ({ email })),
+ ...(emailData.cc && {
+ cc: ensureArray(emailData.cc).map(email => ({ email }))
+ }),
+ ...(emailData.bcc && {
+ bcc: ensureArray(emailData.bcc).map(email => ({ email }))
+ }),
+ },
+ ],
+ from: { email: emailData.from },
+ subject: emailData.subject,
+ content: [
+ { type: 'text/html', value: emailData.html },
+ ...(emailData.text ? [{ type: 'text/plain', value: emailData.text }] : []),
+ ],
+ ...(emailData.replyTo && { reply_to: { email: emailData.replyTo } }),
+ };
+
+ const response = await fetch(`${this.apiUrl}/v3/mail/send`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${this.config.apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return {
+ success: false,
+ error: `SendGrid API error: ${response.status} - ${errorText}`,
+ provider: this.providerName,
+ };
+ }
+
+ const messageId = response.headers.get('X-Message-Id') || undefined;
+
+ return {
+ success: true,
+ messageId,
+ provider: this.providerName,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ provider: this.providerName,
+ };
+ }
+ }
+
+ async verify(): Promise {
+ try {
+ const response = await fetch(`${this.apiUrl}/v3/scopes`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${this.config.apiKey}`,
+ },
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/mailer/src/strategies/email.strategy.ts b/packages/mailer/src/strategies/email.strategy.ts
new file mode 100644
index 0000000..3e79e3d
--- /dev/null
+++ b/packages/mailer/src/strategies/email.strategy.ts
@@ -0,0 +1,145 @@
+import type { BaseEmailProvider } from '../providers';
+import type { EmailData, EmailResult } from '../types';
+import { EMAIL_ERRORS } from '../constants';
+import { isNotEmpty } from '../utils';
+
+export interface EmailStrategy {
+ send(emailData: EmailData, providers: BaseEmailProvider[]): Promise;
+}
+
+export class SingleProviderStrategy implements EmailStrategy {
+ async send(emailData: EmailData, providers: BaseEmailProvider[]): Promise {
+ if (!isNotEmpty(providers)) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ const provider = providers[0];
+ if (!provider) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ return provider.send(emailData);
+ }
+}
+
+export class FailoverStrategy implements EmailStrategy {
+ async send(emailData: EmailData, providers: BaseEmailProvider[]): Promise {
+ if (!isNotEmpty(providers)) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ const errors: string[] = [];
+
+ for (const provider of providers) {
+ const result = await provider.send(emailData);
+
+ if (result.success) {
+ return result;
+ }
+
+ errors.push(`${provider.getName()}: ${result.error}`);
+ }
+
+ return {
+ success: false,
+ error: `${EMAIL_ERRORS.ALL_PROVIDERS_FAILED}: ${errors.join('; ')}`,
+ provider: 'failover',
+ };
+ }
+}
+
+export class RoundRobinStrategy implements EmailStrategy {
+ private currentIndex = 0;
+
+ async send(emailData: EmailData, providers: BaseEmailProvider[]): Promise {
+ if (!isNotEmpty(providers)) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ const provider = providers[this.currentIndex];
+ if (!provider) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ this.currentIndex = (this.currentIndex + 1) % providers.length;
+
+ const result = await provider.send(emailData);
+
+ if (!result.success && providers.length > 1) {
+ const nextProvider = providers[this.currentIndex];
+ if (nextProvider) {
+ return nextProvider.send(emailData);
+ }
+ }
+
+ return result;
+ }
+}
+
+export class PriorityStrategy implements EmailStrategy {
+ private providerUsage: Map = new Map();
+ private providerLimits: Map;
+
+ constructor(limits: Record = {}) {
+ this.providerLimits = new Map(Object.entries(limits));
+ }
+
+ async send(emailData: EmailData, providers: BaseEmailProvider[]): Promise {
+ if (!isNotEmpty(providers)) {
+ return {
+ success: false,
+ error: EMAIL_ERRORS.NO_PROVIDERS,
+ provider: 'none',
+ };
+ }
+
+ for (const provider of providers) {
+ const providerName = provider.getName();
+ const usage = this.providerUsage.get(providerName) || 0;
+ const limit = this.providerLimits.get(providerName) || Infinity;
+
+ if (usage < limit) {
+ const result = await provider.send(emailData);
+
+ if (result.success) {
+ this.providerUsage.set(providerName, usage + 1);
+ return result;
+ }
+ }
+ }
+
+ return {
+ success: false,
+ error: EMAIL_ERRORS.ALL_PROVIDERS_EXHAUSTED,
+ provider: 'priority',
+ };
+ }
+
+ resetUsage(): void {
+ this.providerUsage.clear();
+ }
+
+ getUsage(): Record {
+ return Object.fromEntries(this.providerUsage);
+ }
+}
diff --git a/packages/mailer/src/strategies/index.ts b/packages/mailer/src/strategies/index.ts
new file mode 100644
index 0000000..ce31088
--- /dev/null
+++ b/packages/mailer/src/strategies/index.ts
@@ -0,0 +1,7 @@
+export {
+ type EmailStrategy,
+ SingleProviderStrategy,
+ FailoverStrategy,
+ RoundRobinStrategy,
+ PriorityStrategy,
+} from './email.strategy';
diff --git a/packages/mailer/src/templates/base.template.ts b/packages/mailer/src/templates/base.template.ts
new file mode 100644
index 0000000..efb0787
--- /dev/null
+++ b/packages/mailer/src/templates/base.template.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+export interface EmailTemplate {
+ name: string;
+ schema: TContext;
+ render: (context: z.infer) => { html: string; text: string };
+}
+
+export function createTemplate(
+ name: string,
+ schema: TContext,
+ render: (context: z.infer) => { html: string; text: string }
+): EmailTemplate {
+ return {
+ name,
+ schema,
+ render: (context: z.infer) => {
+ const validated = schema.parse(context);
+ return render(validated);
+ },
+ };
+}
diff --git a/packages/mailer/src/templates/index.ts b/packages/mailer/src/templates/index.ts
new file mode 100644
index 0000000..990bbbd
--- /dev/null
+++ b/packages/mailer/src/templates/index.ts
@@ -0,0 +1,3 @@
+export { createTemplate, type EmailTemplate } from './base.template';
+export { verifyEmailTemplate, type VerifyEmailContext } from './verify-email.template';
+export { passwordResetTemplate, type PasswordResetContext } from './password-reset.template';
diff --git a/packages/mailer/src/templates/password-reset.template.ts b/packages/mailer/src/templates/password-reset.template.ts
new file mode 100644
index 0000000..fab886a
--- /dev/null
+++ b/packages/mailer/src/templates/password-reset.template.ts
@@ -0,0 +1,95 @@
+import { z } from 'zod';
+import { createTemplate } from './base.template';
+import { escapeHtml } from '../utils';
+
+const PasswordResetContextSchema = z.object({
+ company: z.string(),
+ userName: z.string().optional(),
+ resetUrl: z.string().url(),
+ expiryHours: z.number().optional().default(1),
+});
+
+export type PasswordResetContext = z.infer;
+
+export const passwordResetTemplate = createTemplate(
+ 'password-reset',
+ PasswordResetContextSchema,
+ (context) => {
+ const greeting = context.userName
+ ? `Hi ${escapeHtml(context.userName)},`
+ : 'Hi there,';
+
+ const html = `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(context.company)}
+ |
+
+
+ |
+
+ ${greeting}
+
+
+ We received a request to reset your password. Click the button below to create a new password:
+
+
+
+ Or copy and paste this link into your browser:
+
+
+ ${escapeHtml(context.resetUrl)}
+
+
+ This link will expire in ${context.expiryHours} hour${context.expiryHours !== 1 ? 's' : ''}. If you didn't request a password reset, please ignore this email or contact support if you have concerns.
+
+ |
+
+
+ |
+
+ © ${new Date().getFullYear()} ${escapeHtml(context.company)}. All rights reserved.
+
+ |
+
+
+ |
+
+
+
+`;
+
+ const text = `${greeting}
+
+We received a request to reset your password for your ${context.company} account.
+
+Click the following link to reset your password:
+${context.resetUrl}
+
+This link will expire in ${context.expiryHours} hour${context.expiryHours !== 1 ? 's' : ''}.
+
+If you didn't request a password reset, please ignore this email or contact support if you have concerns.
+
+© ${new Date().getFullYear()} ${context.company}. All rights reserved.`;
+
+ return { html, text };
+ }
+);
diff --git a/packages/mailer/src/templates/verify-email.template.ts b/packages/mailer/src/templates/verify-email.template.ts
new file mode 100644
index 0000000..c034ac5
--- /dev/null
+++ b/packages/mailer/src/templates/verify-email.template.ts
@@ -0,0 +1,95 @@
+import { z } from 'zod';
+import { createTemplate } from './base.template';
+import { escapeHtml } from '../utils';
+
+const VerifyEmailContextSchema = z.object({
+ company: z.string(),
+ userName: z.string().optional(),
+ verificationUrl: z.string().url(),
+ expiryHours: z.number().optional().default(24),
+});
+
+export type VerifyEmailContext = z.infer;
+
+export const verifyEmailTemplate = createTemplate(
+ 'verify-email',
+ VerifyEmailContextSchema,
+ (context) => {
+ const greeting = context.userName
+ ? `Hi ${escapeHtml(context.userName)},`
+ : 'Hi there,';
+
+ const html = `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(context.company)}
+ |
+
+
+ |
+
+ ${greeting}
+
+
+ Thank you for signing up! Please verify your email address by clicking the button below:
+
+
+
+ Or copy and paste this link into your browser:
+
+
+ ${escapeHtml(context.verificationUrl)}
+
+
+ This link will expire in ${context.expiryHours} hours. If you didn't create an account, you can safely ignore this email.
+
+ |
+
+
+ |
+
+ © ${new Date().getFullYear()} ${escapeHtml(context.company)}. All rights reserved.
+
+ |
+
+
+ |
+
+
+
+`;
+
+ const text = `${greeting}
+
+Thank you for signing up with ${context.company}!
+
+Please verify your email address by clicking the following link:
+${context.verificationUrl}
+
+This link will expire in ${context.expiryHours} hours.
+
+If you didn't create an account, you can safely ignore this email.
+
+© ${new Date().getFullYear()} ${context.company}. All rights reserved.`;
+
+ return { html, text };
+ }
+);
diff --git a/packages/mailer/src/types.ts b/packages/mailer/src/types.ts
new file mode 100644
index 0000000..94ac2ed
--- /dev/null
+++ b/packages/mailer/src/types.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+
+export const EmailAddressSchema = z.string().email();
+
+export const BaseEmailSchema = z.object({
+ to: z.union([EmailAddressSchema, z.array(EmailAddressSchema).min(1)]),
+ from: EmailAddressSchema,
+ subject: z.string().min(1),
+ html: z.string().min(1),
+ text: z.string().optional(),
+ replyTo: EmailAddressSchema.optional(),
+ cc: z.union([EmailAddressSchema, z.array(EmailAddressSchema)]).optional(),
+ bcc: z.union([EmailAddressSchema, z.array(EmailAddressSchema)]).optional(),
+});
+
+export type EmailData = z.infer;
+
+export const EmailResultSchema = z.object({
+ success: z.boolean(),
+ messageId: z.string().optional(),
+ error: z.string().optional(),
+ provider: z.string(),
+});
+
+export type EmailResult = z.infer;
+
+export type TemplateContext = Record;
diff --git a/packages/mailer/src/utils/array.utils.ts b/packages/mailer/src/utils/array.utils.ts
new file mode 100644
index 0000000..25462a2
--- /dev/null
+++ b/packages/mailer/src/utils/array.utils.ts
@@ -0,0 +1,7 @@
+export function ensureArray(value: T | T[]): T[] {
+ return Array.isArray(value) ? value : [value];
+}
+
+export function isNotEmpty(arr: T[]): boolean {
+ return arr.length > 0;
+}
diff --git a/packages/mailer/src/utils/html.utils.ts b/packages/mailer/src/utils/html.utils.ts
new file mode 100644
index 0000000..d22c68e
--- /dev/null
+++ b/packages/mailer/src/utils/html.utils.ts
@@ -0,0 +1,23 @@
+const HTML_ESCAPE_MAP: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+export function escapeHtml(text: string): string {
+ return text.replace(/[&<>"']/g, (m) => HTML_ESCAPE_MAP[m] || m);
+}
+
+export function sanitizeUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return '';
+ }
+ return parsed.href;
+ } catch {
+ return '';
+ }
+}
diff --git a/packages/mailer/src/utils/index.ts b/packages/mailer/src/utils/index.ts
new file mode 100644
index 0000000..196f88b
--- /dev/null
+++ b/packages/mailer/src/utils/index.ts
@@ -0,0 +1,2 @@
+export { escapeHtml, sanitizeUrl } from './html.utils';
+export { ensureArray, isNotEmpty } from './array.utils';
diff --git a/packages/mailer/tsconfig.json b/packages/mailer/tsconfig.json
new file mode 100644
index 0000000..de2b59e
--- /dev/null
+++ b/packages/mailer/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "lib": ["ESNext", "DOM"],
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}
diff --git a/packages/mailer/vitest.config.ts b/packages/mailer/vitest.config.ts
new file mode 100644
index 0000000..fd1c100
--- /dev/null
+++ b/packages/mailer/vitest.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/',
+ 'dist/',
+ '**/*.test.ts',
+ '**/*.config.ts',
+ ],
+ },
+ },
+});