diff --git a/package.json b/package.json
index bd323e2..8e8f357 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,32 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7"
},
+ "peerDependenciesMeta": {
+ "nodemailer": {
+ "optional": true
+ },
+ "twilio": {
+ "optional": true
+ },
+ "@aws-sdk/client-sns": {
+ "optional": true
+ },
+ "@vonage/server-sdk": {
+ "optional": true
+ },
+ "firebase-admin": {
+ "optional": true
+ },
+ "mongoose": {
+ "optional": true
+ },
+ "handlebars": {
+ "optional": true
+ },
+ "nanoid": {
+ "optional": true
+ }
+ },
"dependencies": {
"zod": "^3.24.1"
},
diff --git a/src/infra/README.md b/src/infra/README.md
index 6752dbd..db3da1f 100644
--- a/src/infra/README.md
+++ b/src/infra/README.md
@@ -1,5 +1,374 @@
-## Infra layer: external adapters and implementations.
+# Infrastructure Layer
-- May depend on `core/`
-- Must not be imported by consumers directly
-- Expose anything public via `src/index.ts` only
+This directory contains concrete implementations of the core notification interfaces.
+
+## 📁 Structure
+
+```
+infra/
+├── senders/ # Notification channel senders
+│ ├── email/ # Email providers
+│ ├── sms/ # SMS providers
+│ └── push/ # Push notification providers
+├── repositories/ # Data persistence
+│ ├── mongoose/ # MongoDB with Mongoose
+│ └── in-memory/ # In-memory (testing)
+└── providers/ # Utility providers
+ ├── id-generator.provider.ts
+ ├── datetime.provider.ts
+ ├── template.provider.ts
+ └── event-emitter.provider.ts
+```
+
+## 🔌 Email Senders
+
+### Nodemailer (SMTP)
+
+Works with any SMTP provider (Gmail, SendGrid, AWS SES via SMTP, etc.)
+
+```typescript
+import { NodemailerSender } from "@ciscode/notification-kit/infra";
+
+const emailSender = new NodemailerSender({
+ host: "smtp.gmail.com",
+ port: 587,
+ secure: false,
+ auth: {
+ user: "your-email@gmail.com",
+ pass: "your-app-password",
+ },
+ from: "noreply@example.com",
+ fromName: "My App",
+});
+```
+
+**Peer Dependency**: `nodemailer`
+
+## 📱 SMS Senders
+
+### Twilio
+
+```typescript
+import { TwilioSmsSender } from "@ciscode/notification-kit/infra";
+
+const smsSender = new TwilioSmsSender({
+ accountSid: "your-account-sid",
+ authToken: "your-auth-token",
+ fromNumber: "+1234567890",
+});
+```
+
+**Peer Dependency**: `twilio`
+
+### AWS SNS
+
+```typescript
+import { AwsSnsSender } from "@ciscode/notification-kit/infra";
+
+const smsSender = new AwsSnsSender({
+ region: "us-east-1",
+ accessKeyId: "your-access-key",
+ secretAccessKey: "your-secret-key",
+ senderName: "MyApp", // Optional
+});
+```
+
+**Peer Dependency**: `@aws-sdk/client-sns`
+
+### Vonage (Nexmo)
+
+```typescript
+import { VonageSmsSender } from "@ciscode/notification-kit/infra";
+
+const smsSender = new VonageSmsSender({
+ apiKey: "your-api-key",
+ apiSecret: "your-api-secret",
+ from: "MyApp",
+});
+```
+
+**Peer Dependency**: `@vonage/server-sdk`
+
+## 🔔 Push Notification Senders
+
+### Firebase Cloud Messaging
+
+```typescript
+import { FirebasePushSender } from "@ciscode/notification-kit/infra";
+
+const pushSender = new FirebasePushSender({
+ projectId: "your-project-id",
+ privateKey: "your-private-key",
+ clientEmail: "your-client-email",
+});
+```
+
+**Peer Dependency**: `firebase-admin`
+
+### OneSignal
+
+```typescript
+import { OneSignalPushSender } from "@ciscode/notification-kit/infra";
+
+const pushSender = new OneSignalPushSender({
+ appId: "your-app-id",
+ restApiKey: "your-rest-api-key",
+});
+```
+
+**No additional dependencies** (uses fetch API)
+
+### AWS SNS (Push)
+
+```typescript
+import { AwsSnsPushSender } from "@ciscode/notification-kit/infra";
+
+const pushSender = new AwsSnsPushSender({
+ region: "us-east-1",
+ accessKeyId: "your-access-key",
+ secretAccessKey: "your-secret-key",
+ platformApplicationArn: "arn:aws:sns:...",
+});
+```
+
+**Peer Dependency**: `@aws-sdk/client-sns`
+
+## 💾 Repositories
+
+### MongoDB with Mongoose
+
+```typescript
+import mongoose from "mongoose";
+import { MongooseNotificationRepository } from "@ciscode/notification-kit/infra";
+
+const connection = await mongoose.createConnection("mongodb://localhost:27017/mydb");
+
+const repository = new MongooseNotificationRepository(
+ connection,
+ "notifications", // collection name (optional)
+);
+```
+
+**Peer Dependency**: `mongoose`
+
+### In-Memory (Testing)
+
+```typescript
+import { InMemoryNotificationRepository } from "@ciscode/notification-kit/infra";
+
+const repository = new InMemoryNotificationRepository();
+
+// For testing - clear all data
+repository.clear();
+
+// For testing - get all notifications
+const all = repository.getAll();
+```
+
+**No dependencies**
+
+## 🛠️ Utility Providers
+
+### ID Generator
+
+```typescript
+import { UuidGenerator, ObjectIdGenerator, NanoIdGenerator } from "@ciscode/notification-kit/infra";
+
+// UUID v4
+const uuidGen = new UuidGenerator();
+uuidGen.generate(); // "a1b2c3d4-..."
+
+// MongoDB ObjectId format
+const objectIdGen = new ObjectIdGenerator();
+objectIdGen.generate(); // "507f1f77bcf86cd799439011"
+
+// NanoID (requires nanoid package)
+const nanoIdGen = new NanoIdGenerator();
+nanoIdGen.generate(); // "V1StGXR8_Z5jdHi6B-myT"
+```
+
+### DateTime Provider
+
+```typescript
+import { DateTimeProvider } from "@ciscode/notification-kit/infra";
+
+const dateTime = new DateTimeProvider();
+
+dateTime.now(); // "2024-01-15T10:30:00.000Z"
+dateTime.isPast("2024-01-01T00:00:00.000Z"); // true
+dateTime.isFuture("2025-01-01T00:00:00.000Z"); // true
+```
+
+### Template Engine
+
+#### Handlebars
+
+```typescript
+import { HandlebarsTemplateEngine } from "@ciscode/notification-kit/infra";
+
+const templateEngine = new HandlebarsTemplateEngine({
+ templates: {
+ welcome: {
+ title: "Welcome {{name}}!",
+ body: "Hello {{name}}, thanks for joining!",
+ html: "
Welcome {{name}}!
",
+ },
+ },
+});
+
+const result = await templateEngine.render("welcome", { name: "John" });
+// { title: 'Welcome John!', body: 'Hello John, thanks for joining!', html: 'Welcome John!
' }
+```
+
+**Peer Dependency**: `handlebars`
+
+#### Simple Template Engine
+
+```typescript
+import { SimpleTemplateEngine } from "@ciscode/notification-kit/infra";
+
+const templateEngine = new SimpleTemplateEngine({
+ welcome: {
+ title: "Welcome {{name}}!",
+ body: "Hello {{name}}, thanks for joining!",
+ },
+});
+
+const result = await templateEngine.render("welcome", { name: "John" });
+// Uses simple {{variable}} replacement
+```
+
+**No dependencies**
+
+### Event Emitter
+
+#### In-Memory Event Emitter
+
+```typescript
+import { InMemoryEventEmitter } from "@ciscode/notification-kit/infra";
+
+const eventEmitter = new InMemoryEventEmitter();
+
+// Listen to specific events
+eventEmitter.on("notification.sent", (event) => {
+ console.log("Notification sent:", event.notification.id);
+});
+
+// Listen to all events
+eventEmitter.on("*", (event) => {
+ console.log("Event:", event.type);
+});
+```
+
+#### Console Event Emitter
+
+```typescript
+import { ConsoleEventEmitter } from "@ciscode/notification-kit/infra";
+
+const eventEmitter = new ConsoleEventEmitter();
+// Logs all events to console
+```
+
+## 📦 Installation
+
+Install only the peer dependencies you need:
+
+### Email (Nodemailer)
+
+```bash
+npm install nodemailer
+npm install -D @types/nodemailer
+```
+
+### SMS
+
+```bash
+# Twilio
+npm install twilio
+
+# AWS SNS
+npm install @aws-sdk/client-sns
+
+# Vonage
+npm install @vonage/server-sdk
+```
+
+### Push Notifications
+
+```bash
+# Firebase
+npm install firebase-admin
+
+# AWS SNS (same as SMS)
+npm install @aws-sdk/client-sns
+```
+
+### Repository
+
+```bash
+# Mongoose
+npm install mongoose
+```
+
+### Template Engine
+
+```bash
+# Handlebars
+npm install handlebars
+npm install -D @types/handlebars
+```
+
+### ID Generator
+
+```bash
+# NanoID (optional)
+npm install nanoid
+```
+
+## 🎯 Usage with NestJS Module
+
+These implementations will be used when configuring the NotificationKit module:
+
+```typescript
+import { Module } from "@nestjs/common";
+import { NotificationKitModule } from "@ciscode/notification-kit";
+import {
+ NodemailerSender,
+ TwilioSmsSender,
+ FirebasePushSender,
+ MongooseNotificationRepository,
+ UuidGenerator,
+ DateTimeProvider,
+ InMemoryEventEmitter,
+} from "@ciscode/notification-kit/infra";
+
+@Module({
+ imports: [
+ NotificationKitModule.register({
+ senders: [
+ new NodemailerSender({
+ /* config */
+ }),
+ new TwilioSmsSender({
+ /* config */
+ }),
+ new FirebasePushSender({
+ /* config */
+ }),
+ ],
+ repository: new MongooseNotificationRepository(/* mongoose connection */),
+ idGenerator: new UuidGenerator(),
+ dateTimeProvider: new DateTimeProvider(),
+ eventEmitter: new InMemoryEventEmitter(),
+ }),
+ ],
+})
+export class AppModule {}
+```
+
+## 🔒 Architecture Notes
+
+- All implementations use **lazy loading** for peer dependencies
+- External packages are imported dynamically to avoid build-time dependencies
+- TypeScript errors for missing packages are suppressed with `@ts-expect-error`
+- Only install the peer dependencies you actually use
diff --git a/src/infra/index.ts b/src/infra/index.ts
new file mode 100644
index 0000000..a00d201
--- /dev/null
+++ b/src/infra/index.ts
@@ -0,0 +1,21 @@
+/**
+ * Infrastructure Layer
+ *
+ * This layer contains concrete implementations of the core interfaces.
+ * It includes:
+ * - Notification senders (email, SMS, push)
+ * - Repositories (MongoDB, in-memory)
+ * - Utility providers (ID generator, datetime, templates, events)
+ *
+ * These implementations are internal and not exported by default.
+ * They can be used when configuring the NestJS module.
+ */
+
+// Senders
+export * from "./senders";
+
+// Repositories
+export * from "./repositories";
+
+// Providers
+export * from "./providers";
diff --git a/src/infra/providers/datetime.provider.ts b/src/infra/providers/datetime.provider.ts
new file mode 100644
index 0000000..f0ca7fb
--- /dev/null
+++ b/src/infra/providers/datetime.provider.ts
@@ -0,0 +1,28 @@
+import type { IDateTimeProvider } from "../../core";
+
+/**
+ * DateTime provider implementation using native Date
+ */
+export class DateTimeProvider implements IDateTimeProvider {
+ now(): string {
+ return new Date().toISOString();
+ }
+
+ isPast(_datetime: string): boolean {
+ try {
+ const date = new Date(_datetime);
+ return date.getTime() < Date.now();
+ } catch {
+ return false;
+ }
+ }
+
+ isFuture(_datetime: string): boolean {
+ try {
+ const date = new Date(_datetime);
+ return date.getTime() > Date.now();
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/src/infra/providers/event-emitter.provider.ts b/src/infra/providers/event-emitter.provider.ts
new file mode 100644
index 0000000..c70a908
--- /dev/null
+++ b/src/infra/providers/event-emitter.provider.ts
@@ -0,0 +1,62 @@
+import type { INotificationEventEmitter, NotificationEvent } from "../../core";
+
+export type NotificationEventHandler = (event: NotificationEvent) => void | Promise;
+
+/**
+ * Simple in-memory event emitter implementation
+ */
+export class InMemoryEventEmitter implements INotificationEventEmitter {
+ private handlers: Map = new Map();
+
+ async emit(_event: NotificationEvent): Promise {
+ const handlers = this.handlers.get(_event.type) || [];
+ const allHandlers = this.handlers.get("*") || [];
+
+ const allPromises = [...handlers, ...allHandlers].map((handler) => {
+ try {
+ return Promise.resolve(handler(_event));
+ } catch (error) {
+ console.error(`Error in event handler for ${_event.type}:`, error);
+ return Promise.resolve();
+ }
+ });
+
+ await Promise.all(allPromises);
+ }
+
+ /**
+ * Register an event handler
+ */
+ on(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void {
+ const handlers = this.handlers.get(eventType) || [];
+ handlers.push(handler);
+ this.handlers.set(eventType, handlers);
+ }
+
+ /**
+ * Unregister an event handler
+ */
+ off(eventType: NotificationEvent["type"] | "*", handler: NotificationEventHandler): void {
+ const handlers = this.handlers.get(eventType) || [];
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ }
+ }
+
+ /**
+ * Clear all handlers
+ */
+ clear(): void {
+ this.handlers.clear();
+ }
+}
+
+/**
+ * Event emitter that logs events to console
+ */
+export class ConsoleEventEmitter implements INotificationEventEmitter {
+ async emit(_event: NotificationEvent): Promise {
+ console.log(`[NotificationEvent] ${_event.type}`, _event);
+ }
+}
diff --git a/src/infra/providers/id-generator.provider.ts b/src/infra/providers/id-generator.provider.ts
new file mode 100644
index 0000000..f666614
--- /dev/null
+++ b/src/infra/providers/id-generator.provider.ts
@@ -0,0 +1,77 @@
+import type { IIdGenerator } from "../../core";
+
+/**
+ * ID generator using UUID v4
+ */
+export class UuidGenerator implements IIdGenerator {
+ generate(): string {
+ // Simple UUID v4 implementation
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+ }
+}
+
+/**
+ * ID generator using MongoDB ObjectId format
+ */
+export class ObjectIdGenerator implements IIdGenerator {
+ private counter = Math.floor(Math.random() * 0xffffff);
+
+ generate(): string {
+ // Generate MongoDB ObjectId-like string (24 hex characters)
+ const timestamp = Math.floor(Date.now() / 1000)
+ .toString(16)
+ .padStart(8, "0");
+ const machineId = Math.floor(Math.random() * 0xffffff)
+ .toString(16)
+ .padStart(6, "0");
+ const processId = Math.floor(Math.random() * 0xffff)
+ .toString(16)
+ .padStart(4, "0");
+ this.counter = (this.counter + 1) % 0xffffff;
+ const counter = this.counter.toString(16).padStart(6, "0");
+
+ return timestamp + machineId + processId + counter;
+ }
+}
+
+/**
+ * ID generator using NanoID (requires nanoid package)
+ * Note: Returns synchronous string, loads nanoid on first use
+ */
+export class NanoIdGenerator implements IIdGenerator {
+ private nanoid: (() => string) | null = null;
+ private initialized = false;
+
+ generate(): string {
+ if (!this.initialized) {
+ // For first call, use UUID fallback and initialize in background
+ this.initialize();
+ return new UuidGenerator().generate();
+ }
+
+ if (!this.nanoid) {
+ return new UuidGenerator().generate();
+ }
+
+ return this.nanoid();
+ }
+
+ private async initialize(): Promise {
+ if (this.initialized) return;
+
+ try {
+ // @ts-expect-error - nanoid is an optional peer dependency
+ const { nanoid } = await import("nanoid");
+ this.nanoid = nanoid;
+ } catch {
+ // Fallback to UUID if nanoid is not installed
+ this.nanoid = () => new UuidGenerator().generate();
+ }
+
+ this.initialized = true;
+ }
+}
diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts
new file mode 100644
index 0000000..c1c4975
--- /dev/null
+++ b/src/infra/providers/index.ts
@@ -0,0 +1,5 @@
+// Utility providers
+export * from "./id-generator.provider";
+export * from "./datetime.provider";
+export * from "./template.provider";
+export * from "./event-emitter.provider";
diff --git a/src/infra/providers/template.provider.ts b/src/infra/providers/template.provider.ts
new file mode 100644
index 0000000..1c50fab
--- /dev/null
+++ b/src/infra/providers/template.provider.ts
@@ -0,0 +1,124 @@
+import type { ITemplateEngine, TemplateResult } from "../../core";
+
+export interface HandlebarsTemplateConfig {
+ templates: Record;
+}
+
+/**
+ * Template engine implementation using Handlebars
+ */
+export class HandlebarsTemplateEngine implements ITemplateEngine {
+ private handlebars: any = null;
+ private compiledTemplates: Map = new Map();
+
+ constructor(private readonly config: HandlebarsTemplateConfig) {}
+
+ /**
+ * Initialize Handlebars lazily
+ */
+ private async getHandlebars(): Promise {
+ if (this.handlebars) {
+ return this.handlebars;
+ }
+
+ const Handlebars = await import("handlebars");
+ this.handlebars = Handlebars.default || Handlebars;
+
+ return this.handlebars;
+ }
+
+ async render(_templateId: string, _variables: Record): Promise {
+ const template = this.config.templates[_templateId];
+
+ if (!template) {
+ throw new Error(`Template ${_templateId} not found`);
+ }
+
+ const handlebars = await this.getHandlebars();
+
+ // Compile and cache templates
+ if (!this.compiledTemplates.has(_templateId)) {
+ const compiled = {
+ title: handlebars.compile(template.title),
+ body: handlebars.compile(template.body),
+ html: template.html ? handlebars.compile(template.html) : undefined,
+ };
+ this.compiledTemplates.set(_templateId, compiled);
+ }
+
+ const compiled = this.compiledTemplates.get(_templateId)!;
+
+ return {
+ title: compiled.title(_variables),
+ body: compiled.body(_variables),
+ html: compiled.html ? compiled.html(_variables) : undefined,
+ };
+ }
+
+ async hasTemplate(_templateId: string): Promise {
+ return !!this.config.templates[_templateId];
+ }
+
+ async validateVariables(
+ _templateId: string,
+ _variables: Record,
+ ): Promise {
+ try {
+ await this.render(_templateId, _variables);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
+/**
+ * Simple template engine using string replacement
+ */
+export class SimpleTemplateEngine implements ITemplateEngine {
+ constructor(
+ private readonly templates: Record,
+ ) {}
+
+ async render(_templateId: string, _variables: Record): Promise {
+ const template = this.templates[_templateId];
+
+ if (!template) {
+ throw new Error(`Template ${_templateId} not found`);
+ }
+
+ const result: TemplateResult = {
+ title: this.replaceVariables(template.title, _variables),
+ body: this.replaceVariables(template.body, _variables),
+ };
+
+ if (template.html) {
+ result.html = this.replaceVariables(template.html, _variables);
+ }
+
+ return result;
+ }
+
+ async hasTemplate(_templateId: string): Promise {
+ return !!this.templates[_templateId];
+ }
+
+ async validateVariables(
+ _templateId: string,
+ _variables: Record,
+ ): Promise {
+ try {
+ await this.render(_templateId, _variables);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ private replaceVariables(template: string, variables: Record): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
+ const value = variables[key];
+ return value !== undefined ? String(value) : "";
+ });
+ }
+}
diff --git a/src/infra/repositories/in-memory/in-memory.repository.ts b/src/infra/repositories/in-memory/in-memory.repository.ts
new file mode 100644
index 0000000..c98edcf
--- /dev/null
+++ b/src/infra/repositories/in-memory/in-memory.repository.ts
@@ -0,0 +1,178 @@
+import type {
+ INotificationRepository,
+ Notification,
+ NotificationQueryCriteria,
+} from "../../../core";
+
+/**
+ * In-memory repository implementation for testing/simple cases
+ */
+export class InMemoryNotificationRepository implements INotificationRepository {
+ private notifications: Map = new Map();
+ private idCounter = 1;
+
+ async create(
+ _notification: Omit,
+ ): Promise {
+ const now = new Date().toISOString();
+ const id = `notif_${this.idCounter++}`;
+
+ const notification: Notification = {
+ id,
+ ..._notification,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ this.notifications.set(id, notification);
+
+ return notification;
+ }
+
+ async findById(_id: string): Promise {
+ return this.notifications.get(_id) || null;
+ }
+
+ async find(_criteria: NotificationQueryCriteria): Promise {
+ let results = Array.from(this.notifications.values());
+
+ // Apply filters
+ if (_criteria.recipientId) {
+ results = results.filter((n) => n.recipient.id === _criteria.recipientId);
+ }
+
+ if (_criteria.channel) {
+ results = results.filter((n) => n.channel === _criteria.channel);
+ }
+
+ if (_criteria.status) {
+ results = results.filter((n) => n.status === _criteria.status);
+ }
+
+ if (_criteria.priority) {
+ results = results.filter((n) => n.priority === _criteria.priority);
+ }
+
+ if (_criteria.fromDate) {
+ results = results.filter((n) => n.createdAt >= _criteria.fromDate!);
+ }
+
+ if (_criteria.toDate) {
+ results = results.filter((n) => n.createdAt <= _criteria.toDate!);
+ }
+
+ // Sort by createdAt descending
+ results.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1));
+
+ // Apply pagination
+ const offset = _criteria.offset || 0;
+ const limit = _criteria.limit || 10;
+
+ return results.slice(offset, offset + limit);
+ }
+
+ async update(_id: string, _updates: Partial): Promise {
+ const notification = this.notifications.get(_id);
+
+ if (!notification) {
+ throw new Error(`Notification with id ${_id} not found`);
+ }
+
+ const updated: Notification = {
+ ...notification,
+ ..._updates,
+ id: notification.id, // Preserve ID
+ createdAt: notification.createdAt, // Preserve createdAt
+ updatedAt: new Date().toISOString(),
+ };
+
+ this.notifications.set(_id, updated);
+
+ return updated;
+ }
+
+ async delete(_id: string): Promise {
+ return this.notifications.delete(_id);
+ }
+
+ async count(_criteria: NotificationQueryCriteria): Promise {
+ let results = Array.from(this.notifications.values());
+
+ // Apply filters
+ if (_criteria.recipientId) {
+ results = results.filter((n) => n.recipient.id === _criteria.recipientId);
+ }
+
+ if (_criteria.channel) {
+ results = results.filter((n) => n.channel === _criteria.channel);
+ }
+
+ if (_criteria.status) {
+ results = results.filter((n) => n.status === _criteria.status);
+ }
+
+ if (_criteria.priority) {
+ results = results.filter((n) => n.priority === _criteria.priority);
+ }
+
+ if (_criteria.fromDate) {
+ results = results.filter((n) => n.createdAt >= _criteria.fromDate!);
+ }
+
+ if (_criteria.toDate) {
+ results = results.filter((n) => n.createdAt <= _criteria.toDate!);
+ }
+
+ return results.length;
+ }
+
+ async findReadyToSend(_limit: number): Promise {
+ const now = new Date().toISOString();
+ let results = Array.from(this.notifications.values());
+
+ // Find notifications ready to send
+ results = results.filter((n) => {
+ // Pending notifications that are scheduled and ready
+ if (n.status === "pending" && n.scheduledFor && n.scheduledFor <= now) {
+ return true;
+ }
+
+ // Queued notifications (ready to send immediately)
+ if (n.status === "queued") {
+ return true;
+ }
+
+ // Failed notifications that haven't exceeded retry count
+ if (n.status === "failed" && n.retryCount < n.maxRetries) {
+ return true;
+ }
+
+ return false;
+ });
+
+ // Sort by priority (high to low) then by createdAt (oldest first)
+ const priorityOrder: Record = { urgent: 4, high: 3, normal: 2, low: 1 };
+ results.sort((a, b) => {
+ const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
+ if (priorityDiff !== 0) return priorityDiff;
+ return a.createdAt > b.createdAt ? 1 : -1;
+ });
+
+ return results.slice(0, _limit);
+ }
+
+ /**
+ * Clear all notifications (for testing)
+ */
+ clear(): void {
+ this.notifications.clear();
+ this.idCounter = 1;
+ }
+
+ /**
+ * Get all notifications (for testing)
+ */
+ getAll(): Notification[] {
+ return Array.from(this.notifications.values());
+ }
+}
diff --git a/src/infra/repositories/index.ts b/src/infra/repositories/index.ts
new file mode 100644
index 0000000..fab52b3
--- /dev/null
+++ b/src/infra/repositories/index.ts
@@ -0,0 +1,6 @@
+// MongoDB/Mongoose repository
+export * from "./mongoose/notification.schema";
+export * from "./mongoose/mongoose.repository";
+
+// In-memory repository
+export * from "./in-memory/in-memory.repository";
diff --git a/src/infra/repositories/mongoose/mongoose.repository.ts b/src/infra/repositories/mongoose/mongoose.repository.ts
new file mode 100644
index 0000000..7f47825
--- /dev/null
+++ b/src/infra/repositories/mongoose/mongoose.repository.ts
@@ -0,0 +1,260 @@
+import type { Model, Connection } from "mongoose";
+
+import type {
+ INotificationRepository,
+ Notification,
+ NotificationQueryCriteria,
+} from "../../../core";
+
+import type { CreateNotificationInput, NotificationDocument } from "./notification.schema";
+import { notificationSchemaDefinition } from "./notification.schema";
+
+/**
+ * MongoDB repository implementation using Mongoose
+ */
+export class MongooseNotificationRepository implements INotificationRepository {
+ private model: Model | null = null;
+
+ constructor(
+ private readonly connection: Connection,
+ private readonly collectionName: string = "notifications",
+ ) {}
+
+ /**
+ * Get or create the Mongoose model
+ */
+ private getModel(): Model {
+ if (this.model) {
+ return this.model;
+ }
+
+ const mongoose = (this.connection as any).base;
+ const schema = new mongoose.Schema(notificationSchemaDefinition, {
+ collection: this.collectionName,
+ timestamps: false, // We handle timestamps manually
+ });
+
+ // Add indexes
+ schema.index({ "recipient.id": 1, createdAt: -1 });
+ schema.index({ status: 1, scheduledFor: 1 });
+ schema.index({ channel: 1, createdAt: -1 });
+ schema.index({ createdAt: -1 });
+
+ this.model = this.connection.model(
+ "Notification",
+ schema,
+ this.collectionName,
+ );
+
+ return this.model;
+ }
+
+ async create(
+ _notification: Omit,
+ ): Promise {
+ const Model = this.getModel();
+
+ const now = new Date().toISOString();
+ const doc = await Model.create({
+ ..._notification,
+ createdAt: now,
+ updatedAt: now,
+ } as CreateNotificationInput);
+
+ return this.documentToNotification(doc);
+ }
+
+ async findById(_id: string): Promise {
+ const Model = this.getModel();
+ const doc = await Model.findById(_id).exec();
+
+ if (!doc) {
+ return null;
+ }
+
+ return this.documentToNotification(doc);
+ }
+
+ async find(_criteria: NotificationQueryCriteria): Promise {
+ const Model = this.getModel();
+
+ const filter: any = {};
+
+ if (_criteria.recipientId) {
+ filter["recipient.id"] = _criteria.recipientId;
+ }
+
+ if (_criteria.channel) {
+ filter.channel = _criteria.channel;
+ }
+
+ if (_criteria.status) {
+ filter.status = _criteria.status;
+ }
+
+ if (_criteria.priority) {
+ filter.priority = _criteria.priority;
+ }
+
+ if (_criteria.fromDate || _criteria.toDate) {
+ filter.createdAt = {};
+ if (_criteria.fromDate) {
+ filter.createdAt.$gte = _criteria.fromDate;
+ }
+ if (_criteria.toDate) {
+ filter.createdAt.$lte = _criteria.toDate;
+ }
+ }
+
+ const query = Model.find(filter).sort({ createdAt: -1 });
+
+ if (_criteria.limit) {
+ query.limit(_criteria.limit);
+ }
+
+ if (_criteria.offset) {
+ query.skip(_criteria.offset);
+ }
+
+ const docs = await query.exec();
+
+ return docs.map((doc) => this.documentToNotification(doc));
+ }
+
+ async update(_id: string, _updates: Partial): Promise {
+ const Model = this.getModel();
+
+ const updateData: any = { ..._updates };
+ updateData.updatedAt = new Date().toISOString();
+
+ // Remove id and timestamps from updates if present
+ delete updateData.id;
+ delete updateData.createdAt;
+
+ const doc = await Model.findByIdAndUpdate(_id, updateData, { new: true }).exec();
+
+ if (!doc) {
+ throw new Error(`Notification with id ${_id} not found`);
+ }
+
+ return this.documentToNotification(doc);
+ }
+
+ async delete(_id: string): Promise {
+ const Model = this.getModel();
+ const result = await Model.findByIdAndDelete(_id).exec();
+ return !!result;
+ }
+
+ async count(_criteria: NotificationQueryCriteria): Promise {
+ const Model = this.getModel();
+
+ const filter: any = {};
+
+ if (_criteria.recipientId) {
+ filter["recipient.id"] = _criteria.recipientId;
+ }
+
+ if (_criteria.channel) {
+ filter.channel = _criteria.channel;
+ }
+
+ if (_criteria.status) {
+ filter.status = _criteria.status;
+ }
+
+ if (_criteria.priority) {
+ filter.priority = _criteria.priority;
+ }
+
+ if (_criteria.fromDate || _criteria.toDate) {
+ filter.createdAt = {};
+ if (_criteria.fromDate) {
+ filter.createdAt.$gte = _criteria.fromDate;
+ }
+ if (_criteria.toDate) {
+ filter.createdAt.$lte = _criteria.toDate;
+ }
+ }
+
+ return Model.countDocuments(filter).exec();
+ }
+
+ async findReadyToSend(_limit: number): Promise {
+ const Model = this.getModel();
+
+ const now = new Date().toISOString();
+
+ const docs = await Model.find({
+ $or: [
+ // Pending notifications that are scheduled and ready
+ {
+ status: "pending",
+ scheduledFor: { $lte: now },
+ },
+ // Queued notifications (ready to send immediately)
+ {
+ status: "queued",
+ },
+ // Failed notifications that haven't exceeded retry count
+ {
+ status: "failed",
+ $expr: { $lt: ["$retryCount", "$maxRetries"] },
+ },
+ ],
+ })
+ .sort({ priority: -1, createdAt: 1 }) // High priority first, then oldest
+ .limit(_limit)
+ .exec();
+
+ return docs.map((doc) => this.documentToNotification(doc));
+ }
+
+ /**
+ * Convert Mongoose document to Notification entity
+ */
+ private documentToNotification(doc: NotificationDocument): Notification {
+ return {
+ id: doc._id.toString(),
+ channel: doc.channel,
+ status: doc.status,
+ priority: doc.priority,
+ recipient: {
+ id: doc.recipient.id,
+ email: doc.recipient.email,
+ phone: doc.recipient.phone,
+ deviceToken: doc.recipient.deviceToken,
+ metadata: doc.recipient.metadata ? this.mapToRecord(doc.recipient.metadata) : undefined,
+ },
+ content: {
+ title: doc.content.title,
+ body: doc.content.body,
+ html: doc.content.html,
+ data: doc.content.data ? this.mapToRecord(doc.content.data) : undefined,
+ templateId: doc.content.templateId,
+ templateVars: doc.content.templateVars
+ ? this.mapToRecord(doc.content.templateVars)
+ : undefined,
+ },
+ scheduledFor: doc.scheduledFor,
+ sentAt: doc.sentAt,
+ deliveredAt: doc.deliveredAt,
+ error: doc.error,
+ retryCount: doc.retryCount,
+ maxRetries: doc.maxRetries,
+ metadata: doc.metadata ? this.mapToRecord(doc.metadata) : undefined,
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ };
+ }
+
+ /**
+ * Convert Mongoose Map to plain object
+ */
+ private mapToRecord(map: Map | any): Record {
+ if (map instanceof Map) {
+ return Object.fromEntries(map);
+ }
+ return map;
+ }
+}
diff --git a/src/infra/repositories/mongoose/notification.schema.ts b/src/infra/repositories/mongoose/notification.schema.ts
new file mode 100644
index 0000000..0efa32f
--- /dev/null
+++ b/src/infra/repositories/mongoose/notification.schema.ts
@@ -0,0 +1,88 @@
+import type {
+ Notification,
+ NotificationChannel,
+ NotificationContent,
+ NotificationPriority,
+ NotificationRecipient,
+ NotificationStatus,
+} from "../../../core";
+
+// Helper to get Schema type at runtime (for Mongoose schema definitions)
+const getSchemaTypes = () => {
+ try {
+ // @ts-expect-error - mongoose is an optional peer dependency
+ const mongoose = require("mongoose");
+ return mongoose.Schema.Types;
+ } catch {
+ return { Mixed: {} };
+ }
+};
+
+const SchemaTypes = getSchemaTypes();
+
+/**
+ * Mongoose schema definition for Notification
+ */
+export interface NotificationDocument extends Omit {
+ _id: string;
+}
+
+export const notificationSchemaDefinition = {
+ channel: {
+ type: String,
+ required: true,
+ enum: ["email", "sms", "push", "in_app", "webhook"],
+ },
+ status: {
+ type: String,
+ required: true,
+ enum: ["pending", "queued", "sending", "sent", "delivered", "failed", "cancelled"],
+ },
+ priority: {
+ type: String,
+ required: true,
+ enum: ["low", "normal", "high", "urgent"],
+ },
+ recipient: {
+ id: { type: String, required: true },
+ email: { type: String },
+ phone: { type: String },
+ deviceToken: { type: String },
+ metadata: { type: Map, of: SchemaTypes.Mixed },
+ },
+ content: {
+ title: { type: String, required: true },
+ body: { type: String, required: true },
+ html: { type: String },
+ data: { type: Map, of: SchemaTypes.Mixed },
+ templateId: { type: String },
+ templateVars: { type: Map, of: SchemaTypes.Mixed },
+ },
+ scheduledFor: { type: String },
+ sentAt: { type: String },
+ deliveredAt: { type: String },
+ error: { type: String },
+ retryCount: { type: Number, required: true, default: 0 },
+ maxRetries: { type: Number, required: true, default: 3 },
+ metadata: { type: Map, of: SchemaTypes.Mixed },
+ createdAt: { type: String, required: true },
+ updatedAt: { type: String, required: true },
+};
+
+/**
+ * Type helper for creating a new notification
+ */
+export type CreateNotificationInput = {
+ channel: NotificationChannel;
+ status: NotificationStatus;
+ priority: NotificationPriority;
+ recipient: NotificationRecipient;
+ content: NotificationContent;
+ scheduledFor?: string | undefined;
+ sentAt?: string | undefined;
+ deliveredAt?: string | undefined;
+ error?: string | undefined;
+ retryCount: number;
+ maxRetries: number;
+ metadata?: Record | undefined;
+};
diff --git a/src/infra/senders/email/nodemailer.sender.ts b/src/infra/senders/email/nodemailer.sender.ts
new file mode 100644
index 0000000..6855b63
--- /dev/null
+++ b/src/infra/senders/email/nodemailer.sender.ts
@@ -0,0 +1,119 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface NodemailerConfig {
+ host: string;
+ port: number;
+ secure?: boolean | undefined;
+ auth?:
+ | {
+ user: string;
+ pass: string;
+ }
+ | undefined;
+ from: string;
+ fromName?: string | undefined;
+}
+
+/**
+ * Email sender implementation using Nodemailer
+ * Supports any SMTP provider (Gmail, SendGrid, AWS SES, etc.)
+ */
+export class NodemailerSender implements INotificationSender {
+ readonly channel: NotificationChannel = "email" as NotificationChannel;
+ private transporter: any = null;
+
+ constructor(private readonly config: NodemailerConfig) {}
+
+ /**
+ * Initialize the nodemailer transporter lazily
+ */
+ private async getTransporter(): Promise {
+ if (this.transporter) {
+ return this.transporter;
+ }
+
+ // Dynamic import to avoid requiring nodemailer at build time
+ // @ts-expect-error - nodemailer is an optional peer dependency
+ const nodemailer = await import("nodemailer");
+
+ this.transporter = nodemailer.createTransport({
+ host: this.config.host,
+ port: this.config.port,
+ secure: this.config.secure ?? false,
+ auth: this.config.auth,
+ });
+
+ return this.transporter;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.email) {
+ return {
+ success: false,
+ notificationId: "",
+ error: "Recipient email is required",
+ };
+ }
+
+ const transporter = await this.getTransporter();
+
+ const mailOptions = {
+ from: this.config.fromName
+ ? `"${this.config.fromName}" <${this.config.from}>`
+ : this.config.from,
+ to: _recipient.email,
+ subject: _content.title,
+ text: _content.body,
+ html: _content.html,
+ };
+
+ const info = await transporter.sendMail(mailOptions);
+
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: info.messageId,
+ metadata: {
+ accepted: info.accepted,
+ rejected: info.rejected,
+ response: info.response,
+ },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: error instanceof Error ? error.message : "Failed to send email",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const transporter = await this.getTransporter();
+ await transporter.verify();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.email && this.isValidEmail(_recipient.email);
+ }
+
+ private isValidEmail(email: string): boolean {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ }
+}
diff --git a/src/infra/senders/index.ts b/src/infra/senders/index.ts
new file mode 100644
index 0000000..77ceae4
--- /dev/null
+++ b/src/infra/senders/index.ts
@@ -0,0 +1,12 @@
+// Email senders
+export * from "./email/nodemailer.sender";
+
+// SMS senders
+export * from "./sms/twilio.sender";
+export * from "./sms/aws-sns.sender";
+export * from "./sms/vonage.sender";
+
+// Push notification senders
+export * from "./push/firebase.sender";
+export * from "./push/onesignal.sender";
+export * from "./push/aws-sns-push.sender";
diff --git a/src/infra/senders/push/aws-sns-push.sender.ts b/src/infra/senders/push/aws-sns-push.sender.ts
new file mode 100644
index 0000000..55ed0a5
--- /dev/null
+++ b/src/infra/senders/push/aws-sns-push.sender.ts
@@ -0,0 +1,128 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface AwsSnsPushConfig {
+ region: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ platformApplicationArn: string;
+}
+
+/**
+ * Push notification sender implementation using AWS SNS
+ */
+export class AwsSnsPushSender implements INotificationSender {
+ readonly channel: NotificationChannel = "push" as NotificationChannel;
+ private sns: any = null;
+
+ constructor(private readonly config: AwsSnsPushConfig) {}
+
+ /**
+ * Initialize AWS SNS client lazily
+ */
+ private async getClient(): Promise {
+ if (this.sns) {
+ return this.sns;
+ }
+
+ // Dynamic import to avoid requiring @aws-sdk at build time
+ // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency
+ const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns");
+
+ this.sns = {
+ client: new SNSClient({
+ region: this.config.region,
+ credentials: {
+ accessKeyId: this.config.accessKeyId,
+ secretAccessKey: this.config.secretAccessKey,
+ },
+ }),
+ PublishCommand,
+ };
+
+ return this.sns;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.deviceToken) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient device token (endpoint ARN) is required",
+ };
+ }
+
+ const { client, PublishCommand } = await this.getClient();
+
+ // For AWS SNS push, the message format depends on the platform
+ const message = JSON.stringify({
+ default: _content.body,
+ GCM: JSON.stringify({
+ notification: {
+ title: _content.title,
+ body: _content.body,
+ },
+ data: _content.data,
+ }),
+ APNS: JSON.stringify({
+ aps: {
+ alert: {
+ title: _content.title,
+ body: _content.body,
+ },
+ },
+ data: _content.data,
+ }),
+ });
+
+ const params = {
+ Message: message,
+ MessageStructure: "json",
+ TargetArn: _recipient.deviceToken, // This should be the endpoint ARN
+ };
+
+ const command = new PublishCommand(params);
+ const response = await client.send(command);
+
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: response.MessageId,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error:
+ error instanceof Error ? error.message : "Failed to send push notification via AWS SNS",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const { client } = await this.getClient();
+ return !!client;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ // For AWS SNS, deviceToken should be an endpoint ARN
+ return (
+ !!_recipient.deviceToken &&
+ _recipient.deviceToken.startsWith("arn:aws:sns:") &&
+ _recipient.deviceToken.includes(":endpoint/")
+ );
+ }
+}
diff --git a/src/infra/senders/push/firebase.sender.ts b/src/infra/senders/push/firebase.sender.ts
new file mode 100644
index 0000000..18b5a08
--- /dev/null
+++ b/src/infra/senders/push/firebase.sender.ts
@@ -0,0 +1,104 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface FirebaseConfig {
+ projectId: string;
+ privateKey: string;
+ clientEmail: string;
+}
+
+/**
+ * Push notification sender implementation using Firebase Cloud Messaging (FCM)
+ */
+export class FirebasePushSender implements INotificationSender {
+ readonly channel: NotificationChannel = "push" as NotificationChannel;
+ private app: any = null;
+ private messaging: any = null;
+
+ constructor(private readonly config: FirebaseConfig) {}
+
+ /**
+ * Initialize Firebase app lazily
+ */
+ private async getMessaging(): Promise {
+ if (this.messaging) {
+ return this.messaging;
+ }
+
+ // Dynamic import to avoid requiring firebase-admin at build time
+ // @ts-expect-error - firebase-admin is an optional peer dependency
+ const admin = await import("firebase-admin");
+
+ if (!this.app) {
+ this.app = admin.initializeApp({
+ credential: admin.credential.cert({
+ projectId: this.config.projectId,
+ privateKey: this.config.privateKey.replace(/\\n/g, "\n"),
+ clientEmail: this.config.clientEmail,
+ }),
+ });
+ }
+
+ this.messaging = admin.messaging(this.app);
+
+ return this.messaging;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.deviceToken) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient device token is required",
+ };
+ }
+
+ const messaging = await this.getMessaging();
+
+ const message = {
+ token: _recipient.deviceToken,
+ notification: {
+ title: _content.title,
+ body: _content.body,
+ },
+ data: _content.data as Record | undefined,
+ };
+
+ const messageId = await messaging.send(message);
+
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: messageId,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: error instanceof Error ? error.message : "Failed to send push notification via FCM",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const messaging = await this.getMessaging();
+ return !!messaging;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.deviceToken && _recipient.deviceToken.length > 0;
+ }
+}
diff --git a/src/infra/senders/push/onesignal.sender.ts b/src/infra/senders/push/onesignal.sender.ts
new file mode 100644
index 0000000..85a0845
--- /dev/null
+++ b/src/infra/senders/push/onesignal.sender.ts
@@ -0,0 +1,97 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface OneSignalConfig {
+ appId: string;
+ restApiKey: string;
+}
+
+/**
+ * Push notification sender implementation using OneSignal
+ */
+export class OneSignalPushSender implements INotificationSender {
+ readonly channel: NotificationChannel = "push" as NotificationChannel;
+ private readonly baseUrl = "https://onesignal.com/api/v1";
+
+ constructor(private readonly config: OneSignalConfig) {}
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.deviceToken) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient device token is required",
+ };
+ }
+
+ const response = await fetch(`${this.baseUrl}/notifications`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Basic ${this.config.restApiKey}`,
+ },
+ body: JSON.stringify({
+ app_id: this.config.appId,
+ include_player_ids: [_recipient.deviceToken],
+ headings: { en: _content.title },
+ contents: { en: _content.body },
+ data: _content.data,
+ }),
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.id) {
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: result.id,
+ metadata: {
+ recipients: result.recipients,
+ },
+ };
+ } else {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: result.errors?.[0] || "Failed to send push notification via OneSignal",
+ };
+ }
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error:
+ error instanceof Error ? error.message : "Failed to send push notification via OneSignal",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ // Verify API key by fetching app info
+ const response = await fetch(`${this.baseUrl}/apps/${this.config.appId}`, {
+ headers: {
+ Authorization: `Basic ${this.config.restApiKey}`,
+ },
+ });
+
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.deviceToken && _recipient.deviceToken.length > 0;
+ }
+}
diff --git a/src/infra/senders/sms/aws-sns.sender.ts b/src/infra/senders/sms/aws-sns.sender.ts
new file mode 100644
index 0000000..12685a9
--- /dev/null
+++ b/src/infra/senders/sms/aws-sns.sender.ts
@@ -0,0 +1,119 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface AwsSnsConfig {
+ region: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ senderName?: string;
+}
+
+/**
+ * SMS sender implementation using AWS SNS
+ */
+export class AwsSnsSender implements INotificationSender {
+ readonly channel: NotificationChannel = "sms" as NotificationChannel;
+ private sns: any = null;
+
+ constructor(private readonly config: AwsSnsConfig) {}
+
+ /**
+ * Initialize AWS SNS client lazily
+ */
+ private async getClient(): Promise {
+ if (this.sns) {
+ return this.sns;
+ }
+
+ // Dynamic import to avoid requiring @aws-sdk at build time
+ // @ts-expect-error - @aws-sdk/client-sns is an optional peer dependency
+ const { SNSClient, PublishCommand } = await import("@aws-sdk/client-sns");
+
+ this.sns = {
+ client: new SNSClient({
+ region: this.config.region,
+ credentials: {
+ accessKeyId: this.config.accessKeyId,
+ secretAccessKey: this.config.secretAccessKey,
+ },
+ }),
+ PublishCommand,
+ };
+
+ return this.sns;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.phone) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient phone number is required",
+ };
+ }
+
+ const { client, PublishCommand } = await this.getClient();
+
+ const params: any = {
+ Message: _content.body,
+ PhoneNumber: _recipient.phone,
+ };
+
+ if (this.config.senderName) {
+ params.MessageAttributes = {
+ "AWS.SNS.SMS.SenderID": {
+ DataType: "String",
+ StringValue: this.config.senderName,
+ },
+ };
+ }
+
+ const command = new PublishCommand(params);
+ const response = await client.send(command);
+
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: response.MessageId,
+ metadata: {
+ sequenceNumber: response.SequenceNumber,
+ },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: error instanceof Error ? error.message : "Failed to send SMS via AWS SNS",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const { client } = await this.getClient();
+ // Check if client is initialized
+ return !!client;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone);
+ }
+
+ private isValidPhoneNumber(phone: string): boolean {
+ // Basic E.164 format validation
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
+ return phoneRegex.test(phone);
+ }
+}
diff --git a/src/infra/senders/sms/twilio.sender.ts b/src/infra/senders/sms/twilio.sender.ts
new file mode 100644
index 0000000..744ef6b
--- /dev/null
+++ b/src/infra/senders/sms/twilio.sender.ts
@@ -0,0 +1,100 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface TwilioConfig {
+ accountSid: string;
+ authToken: string;
+ fromNumber: string;
+}
+
+/**
+ * SMS sender implementation using Twilio
+ */
+export class TwilioSmsSender implements INotificationSender {
+ readonly channel: NotificationChannel = "sms" as NotificationChannel;
+ private client: any = null;
+
+ constructor(private readonly config: TwilioConfig) {}
+
+ /**
+ * Initialize Twilio client lazily
+ */
+ private async getClient(): Promise {
+ if (this.client) {
+ return this.client;
+ }
+
+ // Dynamic import to avoid requiring twilio at build time
+ // @ts-expect-error - twilio is an optional peer dependency
+ const twilio = await import("twilio");
+ this.client = twilio.default(this.config.accountSid, this.config.authToken);
+
+ return this.client;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.phone) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient phone number is required",
+ };
+ }
+
+ const client = await this.getClient();
+
+ const message = await client.messages.create({
+ body: _content.body,
+ from: this.config.fromNumber,
+ to: _recipient.phone,
+ });
+
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: message.sid,
+ metadata: {
+ status: message.status,
+ price: message.price,
+ priceUnit: message.priceUnit,
+ },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: error instanceof Error ? error.message : "Failed to send SMS via Twilio",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const client = await this.getClient();
+ // Try to fetch account info to verify credentials
+ await client.api.accounts(this.config.accountSid).fetch();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone);
+ }
+
+ private isValidPhoneNumber(phone: string): boolean {
+ // Basic E.164 format validation
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
+ return phoneRegex.test(phone);
+ }
+}
diff --git a/src/infra/senders/sms/vonage.sender.ts b/src/infra/senders/sms/vonage.sender.ts
new file mode 100644
index 0000000..831b297
--- /dev/null
+++ b/src/infra/senders/sms/vonage.sender.ts
@@ -0,0 +1,113 @@
+import type {
+ INotificationSender,
+ NotificationChannel,
+ NotificationContent,
+ NotificationRecipient,
+ NotificationResult,
+} from "../../../core";
+
+export interface VonageConfig {
+ apiKey: string;
+ apiSecret: string;
+ from: string;
+}
+
+/**
+ * SMS sender implementation using Vonage (formerly Nexmo)
+ */
+export class VonageSmsSender implements INotificationSender {
+ readonly channel: NotificationChannel = "sms" as NotificationChannel;
+ private client: any = null;
+
+ constructor(private readonly config: VonageConfig) {}
+
+ /**
+ * Initialize Vonage client lazily
+ */
+ private async getClient(): Promise {
+ if (this.client) {
+ return this.client;
+ }
+
+ // Dynamic import to avoid requiring @vonage/server-sdk at build time
+ // @ts-expect-error - @vonage/server-sdk is an optional peer dependency
+ const { Vonage } = await import("@vonage/server-sdk");
+
+ this.client = new Vonage({
+ apiKey: this.config.apiKey,
+ apiSecret: this.config.apiSecret,
+ });
+
+ return this.client;
+ }
+
+ async send(
+ _recipient: NotificationRecipient,
+ _content: NotificationContent,
+ ): Promise {
+ try {
+ if (!_recipient.phone) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: "Recipient phone number is required",
+ };
+ }
+
+ const client = await this.getClient();
+
+ const response = await client.sms.send({
+ to: _recipient.phone,
+ from: this.config.from,
+ text: _content.body,
+ });
+
+ const message = response.messages[0];
+
+ if (message.status === "0") {
+ return {
+ success: true,
+ notificationId: _recipient.id,
+ providerMessageId: message["message-id"],
+ metadata: {
+ networkCode: message["network-code"],
+ price: message["message-price"],
+ remainingBalance: message["remaining-balance"],
+ },
+ };
+ } else {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: message["error-text"] || "Failed to send SMS via Vonage",
+ };
+ }
+ } catch (error) {
+ return {
+ success: false,
+ notificationId: _recipient.id,
+ error: error instanceof Error ? error.message : "Failed to send SMS via Vonage",
+ };
+ }
+ }
+
+ async isReady(): Promise {
+ try {
+ const client = await this.getClient();
+ // Check if client is initialized
+ return !!client;
+ } catch {
+ return false;
+ }
+ }
+
+ validateRecipient(_recipient: NotificationRecipient): boolean {
+ return !!_recipient.phone && this.isValidPhoneNumber(_recipient.phone);
+ }
+
+ private isValidPhoneNumber(phone: string): boolean {
+ // Basic E.164 format validation
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
+ return phoneRegex.test(phone);
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 63ab110..fbeb6c4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
- "lib": ["ES2022"],
+ "lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,