Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/mailer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
coverage
*.log
.DS_Store
.turbo
6 changes: 6 additions & 0 deletions packages/mailer/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"useTabs": false,
"singleQuote": true,
"semi": true
}
89 changes: 89 additions & 0 deletions packages/mailer/README.md
Original file line number Diff line number Diff line change
@@ -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: '<p>Hello World</p>',
});
```

## 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: `<h1>Hello ${ctx.name}</h1>`,
text: `Hello ${ctx.name}`,
})
);
```

## Testing

```bash
bun test # Run tests
bun test:coverage # Generate coverage report
```
36 changes: 36 additions & 0 deletions packages/mailer/husky.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF > .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."
23 changes: 23 additions & 0 deletions packages/mailer/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
150 changes: 150 additions & 0 deletions packages/mailer/src/__tests__/email-service.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>Test</p>',
});

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: '<p>Test</p>',
});

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: '<p>Test</p>',
});

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: '<p>Test</p>',
})
).rejects.toThrow();
});
});
Loading