From 671a7233180ee35409c341f663cd15a3c54b6cdf Mon Sep 17 00:00:00 2001 From: KailasMahavarkar Date: Wed, 11 Feb 2026 18:35:28 +0530 Subject: [PATCH] added packages to send email --- packages/mailer/.gitignore | 6 + packages/mailer/.prettierrc | 6 + packages/mailer/README.md | 89 +++++++ packages/mailer/husky.sh | 36 +++ packages/mailer/package.json | 23 ++ .../src/__tests__/email-service.test.ts | 150 +++++++++++ .../providers/resend.provider.test.ts | 114 ++++++++ .../providers/sendgrid.provider.test.ts | 247 ++++++++++++++++++ .../strategies/email.strategy.test.ts | 167 ++++++++++++ .../__tests__/templates/base.template.test.ts | 67 +++++ .../templates/password-reset.template.test.ts | 81 ++++++ .../templates/verify-email.template.test.ts | 67 +++++ .../src/__tests__/utils/array.utils.test.ts | 32 +++ .../src/__tests__/utils/html.utils.test.ts | 56 ++++ packages/mailer/src/constants/index.ts | 21 ++ packages/mailer/src/email-service.ts | 83 ++++++ packages/mailer/src/index.ts | 31 +++ .../mailer/src/providers/base.provider.ts | 23 ++ packages/mailer/src/providers/index.ts | 3 + .../mailer/src/providers/resend.provider.ts | 83 ++++++ .../mailer/src/providers/sendgrid.provider.ts | 93 +++++++ .../mailer/src/strategies/email.strategy.ts | 145 ++++++++++ packages/mailer/src/strategies/index.ts | 7 + .../mailer/src/templates/base.template.ts | 22 ++ packages/mailer/src/templates/index.ts | 3 + .../src/templates/password-reset.template.ts | 95 +++++++ .../src/templates/verify-email.template.ts | 95 +++++++ packages/mailer/src/types.ts | 27 ++ packages/mailer/src/utils/array.utils.ts | 7 + packages/mailer/src/utils/html.utils.ts | 23 ++ packages/mailer/src/utils/index.ts | 2 + packages/mailer/tsconfig.json | 11 + packages/mailer/vitest.config.ts | 18 ++ 33 files changed, 1933 insertions(+) create mode 100644 packages/mailer/.gitignore create mode 100644 packages/mailer/.prettierrc create mode 100644 packages/mailer/README.md create mode 100644 packages/mailer/husky.sh create mode 100644 packages/mailer/package.json create mode 100644 packages/mailer/src/__tests__/email-service.test.ts create mode 100644 packages/mailer/src/__tests__/providers/resend.provider.test.ts create mode 100644 packages/mailer/src/__tests__/providers/sendgrid.provider.test.ts create mode 100644 packages/mailer/src/__tests__/strategies/email.strategy.test.ts create mode 100644 packages/mailer/src/__tests__/templates/base.template.test.ts create mode 100644 packages/mailer/src/__tests__/templates/password-reset.template.test.ts create mode 100644 packages/mailer/src/__tests__/templates/verify-email.template.test.ts create mode 100644 packages/mailer/src/__tests__/utils/array.utils.test.ts create mode 100644 packages/mailer/src/__tests__/utils/html.utils.test.ts create mode 100644 packages/mailer/src/constants/index.ts create mode 100644 packages/mailer/src/email-service.ts create mode 100644 packages/mailer/src/index.ts create mode 100644 packages/mailer/src/providers/base.provider.ts create mode 100644 packages/mailer/src/providers/index.ts create mode 100644 packages/mailer/src/providers/resend.provider.ts create mode 100644 packages/mailer/src/providers/sendgrid.provider.ts create mode 100644 packages/mailer/src/strategies/email.strategy.ts create mode 100644 packages/mailer/src/strategies/index.ts create mode 100644 packages/mailer/src/templates/base.template.ts create mode 100644 packages/mailer/src/templates/index.ts create mode 100644 packages/mailer/src/templates/password-reset.template.ts create mode 100644 packages/mailer/src/templates/verify-email.template.ts create mode 100644 packages/mailer/src/types.ts create mode 100644 packages/mailer/src/utils/array.utils.ts create mode 100644 packages/mailer/src/utils/html.utils.ts create mode 100644 packages/mailer/src/utils/index.ts create mode 100644 packages/mailer/tsconfig.json create mode 100644 packages/mailer/vitest.config.ts 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: +

+ + + + +
+ + Reset 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: +

+ + + + +
+ + Verify Email Address + +
+

+ 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', + ], + }, + }, +});