UNPKG

waitlist-mailer

Version:

Modern, modular TypeScript library for managing waitlists with pluggable storage and mail providers. Supports MongoDB, SQL databases, and custom adapters with zero required dependencies for basic usage.

561 lines (503 loc) 17.1 kB
/** * @fileoverview WaitlistManager - The core orchestrator for the waitlist-mailer library. * * This class implements clean architecture principles: * - Dependency Injection: Storage and mail providers are injected * - Single Responsibility: Only orchestrates business logic * - Open/Closed: Extensible through adapters without modification * - Interface Segregation: Depends on interfaces, not concrete implementations */ import { EventEmitter } from 'events'; import validator from 'validator'; import { MemoryStorage } from '../adapters/storage/MemoryStorage'; import { StorageProvider, MailProvider, WaitlistEntry, WaitlistManagerConfig, JoinResult, EmailContext, SearchOptions, } from '../types'; /** * Main WaitlistManager class. * Orchestrates storage and mail providers to manage a waitlist. * * @template T - Metadata type for custom fields * * @example * ```typescript * // Default memory storage * const manager = new WaitlistManager({ companyName: 'My App' }); * * // Custom storage and mailer * const manager = new WaitlistManager({ * storage: new MongooseStorage(...), * mailer: new NodemailerProvider(...) * }); * ``` */ export class WaitlistManager<T = any> extends EventEmitter { private storage: StorageProvider<T>; private mailer?: MailProvider; private companyName: string; private initialized: boolean = false; /** * Create a new WaitlistManager instance. * @param config - Configuration object with storage, optional mailer, etc. */ constructor(config: WaitlistManagerConfig<T>) { super(); // Default to MemoryStorage if no storage is provided this.storage = config.storage || new MemoryStorage<T>(); this.mailer = config.mailer; this.companyName = config.companyName || 'Our Platform'; // Auto-connect if enabled (defaults to true for convenience) if (config.autoConnect !== false) { this.connect().catch(error => { console.error('Failed to auto-connect:', error); this.emit('error', { context: 'autoConnect', error }); }); } } /** * Initialize connections to storage and mail providers. * @returns Promise that resolves when initialization is complete */ async connect(): Promise<void> { try { // Connect storage if it has a connect method if (this.storage.connect) { await this.storage.connect(); this.emit('storage:connected'); } // Verify mailer if it has a verify method if (this.mailer?.verify) { const verified = await this.mailer.verify(); if (verified) { this.emit('mailer:verified'); } else { console.warn('Mail provider verification failed'); this.emit('mailer:verification-failed'); } } this.initialized = true; this.emit('initialized'); } catch (error) { this.emit('error', { context: 'connect', error }); throw error; } } /** * Wait for the manager to be fully initialized. * @returns Promise that resolves when initialized */ async waitForInitialization(): Promise<void> { if (this.initialized) return; return new Promise(resolve => this.once('initialized', resolve)); } /** * Check if the manager is initialized. * @returns true if initialized */ isInitialized(): boolean { return this.initialized; } /** * Add an email to the waitlist. * * Validates the email and checks for duplicates before adding. * Optionally sends a confirmation email. * * @param email - The email address to add * @param metadata - Optional custom metadata * @returns Result object with success status and message * * @example * ```typescript * const result = await manager.join('user@example.com', { * name: 'Alice', * referralSource: 'ProductHunt' * }); * * if (result.success) { * console.log('User added:', result.email); * } * ``` */ async join(email: string, metadata?: T): Promise<JoinResult> { try { // 1. Validate email format if (!validator.isEmail(email)) { this.emit('join:validation-error', { email, reason: 'invalid-format' }); return { success: false, message: 'Invalid email format', email, }; } // 2. Normalize email to lowercase for consistency const normalizedEmail = email.toLowerCase(); // 3. Check if already exists const exists = await this.storage.exists(normalizedEmail); if (exists) { this.emit('join:duplicate', { email: normalizedEmail, metadata }); return { success: false, message: 'Email already in waitlist', email: normalizedEmail, }; } // 4. Add to storage await this.storage.add(normalizedEmail, metadata); this.emit('join:success', { email: normalizedEmail, metadata }); // 5. Send confirmation email if mailer is configured if (this.mailer) { try { const context: EmailContext = { email: normalizedEmail, companyName: this.companyName, ...metadata, }; const sent = await this.mailer.sendConfirmation(normalizedEmail, context); if (sent) { this.emit('mailer:sent', { email: normalizedEmail }); } else { this.emit('mailer:failed', { email: normalizedEmail }); // Note: We don't fail the join - the email was added successfully } } catch (error) { console.error('Mail sending error:', error); this.emit('mailer:error', { email: normalizedEmail, error }); // Continue - don't fail the join if email fails } } return { success: true, message: 'Successfully joined the waitlist', email: normalizedEmail, }; } catch (error) { this.emit('join:error', { email, error }); return { success: false, message: 'Failed to add email to waitlist', email, }; } } /** * Remove an email from the waitlist. * @param email - The email address to remove * @returns true if the email was found and removed */ async leave(email: string): Promise<boolean> { try { const normalizedEmail = email.toLowerCase(); const removed = await this.storage.remove(normalizedEmail); if (removed) { this.emit('leave:success', { email: normalizedEmail }); } else { this.emit('leave:not-found', { email: normalizedEmail }); } return removed; } catch (error) { this.emit('leave:error', { email, error }); return false; } } /** * Check if an email is in the waitlist. * @param email - The email to check * @returns true if the email exists */ async has(email: string): Promise<boolean> { return this.storage.exists(email.toLowerCase()); } /** * Get all waitlist entries. * @returns Array of WaitlistEntry objects */ async getAll(): Promise<WaitlistEntry<T>[]> { return this.storage.getAll(); } /** * Get count of waitlist entries. * @returns Number of entries */ async count(): Promise<number> { return this.storage.count(); } /** * Clear all entries from the waitlist. * @returns true if successful */ async clear(): Promise<boolean> { try { await this.storage.clear(); this.emit('clear:success'); return true; } catch (error) { this.emit('clear:error', { error }); return false; } } /** * Send a custom email to an address. * Useful for sending emails to users who aren't on the waitlist. * * @param email - Recipient email * @param context - Email context with template variables * @returns true if sent successfully */ async sendEmail(email: string, context: EmailContext): Promise<boolean> { if (!this.mailer) { console.warn('No mail provider configured'); return false; } try { const normalizedEmail = email.toLowerCase(); const fullContext: EmailContext = { companyName: this.companyName, ...context, email: normalizedEmail, }; return await this.mailer.sendConfirmation(normalizedEmail, fullContext); } catch (error) { this.emit('email:error', { email, error }); return false; } } /** * Send emails to all waitlist members. * Processes emails concurrently with configurable concurrency limit. * Uses streaming when available to avoid loading all entries into memory. * * @param contextBuilder - Function to build context for each email * @param concurrency - Maximum concurrent emails to send (1 = sequential, Infinity = all at once) * Default: 5 for balance between speed and resource usage * @returns Object with statistics * * @example * ```typescript * // Send with default concurrency (5 at a time) * const result = await manager.sendBulkEmails((email) => ({ * subject: 'Welcome!' * })); * * // Send slowly (1 at a time) * const result = await manager.sendBulkEmails( * (email) => ({ subject: 'Welcome!' }), * 1 * ); * * // Send fast (all at once - use with caution) * const result = await manager.sendBulkEmails( * (email) => ({ subject: 'Welcome!' }), * Infinity * ); * ``` */ async sendBulkEmails( contextBuilder: (email: string, metadata?: T) => EmailContext, concurrency: number = 5 ): Promise<{ sent: number; failed: number; total: number }> { if (!this.mailer) { console.warn('No mail provider configured'); return { sent: 0, failed: 0, total: 0 }; } // Use streaming if available (preferred for large datasets) if (this.storage.iterate) { return this.sendBulkEmailsWithStreaming(contextBuilder, concurrency); } // Fallback to loading all entries (not recommended for large datasets) console.warn( 'Storage provider does not implement iterate(). ' + 'Loading all entries into memory. ' + 'This may cause performance issues with large datasets.' ); return this.sendBulkEmailsLegacy(contextBuilder, concurrency); } /** * Send bulk emails using streaming to avoid memory issues. * Processes entries in batches using the storage iterator. * @private */ private async sendBulkEmailsWithStreaming( contextBuilder: (email: string, metadata?: T) => EmailContext, concurrency: number ): Promise<{ sent: number; failed: number; total: number }> { let sent = 0; let failed = 0; let total = 0; const activePromises: Promise<void>[] = []; // Stream entries from storage for await (const entry of this.storage.iterate!(100)) { total++; // Handle concurrency limit if (concurrency !== Infinity && activePromises.length >= concurrency) { await Promise.race(activePromises); } const promise = (async () => { const success = await this.sendSingleBulkEmail(entry, contextBuilder); if (success) sent++; else failed++; })(); if (concurrency === 1) { // Sequential: wait for each email before proceeding await promise; } else { // Add to active promises and clean up when done const trackedPromise = promise.then(() => { const index = activePromises.indexOf(trackedPromise); if (index > -1) activePromises.splice(index, 1); }); activePromises.push(trackedPromise); } } // Wait for remaining promises to complete await Promise.all(activePromises); const result = { sent, failed, total }; this.emit('bulk:complete', result); return result; } /** * Legacy bulk email method that loads all entries into memory. * Used as fallback when storage doesn't support streaming. * @private */ private async sendBulkEmailsLegacy( contextBuilder: (email: string, metadata?: T) => EmailContext, concurrency: number ): Promise<{ sent: number; failed: number; total: number }> { const entries = await this.storage.getAll(); if (entries.length === 0) { this.emit('bulk:complete', { sent: 0, failed: 0, total: 0 }); return { sent: 0, failed: 0, total: 0 }; } let sent = 0; let failed = 0; // For sequential processing (concurrency = 1) if (concurrency === 1) { for (const entry of entries) { const success = await this.sendSingleBulkEmail(entry, contextBuilder); if (success) sent++; else failed++; } } else if (concurrency === Infinity) { // Send all at once const promises = entries.map(entry => this.sendSingleBulkEmail(entry, contextBuilder) ); const results = await Promise.all(promises); sent = results.filter(s => s).length; failed = results.filter(s => !s).length; } else { // Send with concurrency limit const activePromises: Promise<boolean>[] = []; for (const entry of entries) { // If we've reached the concurrency limit, wait for at least one to complete if (activePromises.length >= concurrency) { await Promise.race(activePromises); } const promise = this.sendSingleBulkEmail(entry, contextBuilder); // Add the promise to active list activePromises.push(promise); // When this promise completes, remove it from active list promise.then((success) => { const index = activePromises.indexOf(promise); if (index > -1) { activePromises.splice(index, 1); } if (success) sent++; else failed++; }); } // Wait for remaining promises to complete await Promise.all(activePromises); } const result = { sent, failed, total: entries.length }; this.emit('bulk:complete', result); return result; } /** * Internal method to send a single bulk email. * @private */ private async sendSingleBulkEmail( entry: WaitlistEntry<T>, contextBuilder: (email: string, metadata?: T) => EmailContext ): Promise<boolean> { try { const context: EmailContext = { companyName: this.companyName, ...contextBuilder(entry.email, entry.metadata), email: entry.email, }; const result = await this.mailer!.sendConfirmation(entry.email, context); if (result) { this.emit('bulk:email-sent', { email: entry.email }); } else { this.emit('bulk:email-failed', { email: entry.email }); } return result; } catch (error) { this.emit('bulk:email-error', { email: entry.email, error }); return false; } } /** * Get entries matching a search pattern. * Delegates to storage provider for efficient database-level filtering when available. * Falls back to in-memory filtering if storage doesn't support search. * * @param pattern - Pattern to search for (partial email match) * @param options - Optional search options (limit, offset, caseInsensitive) * @returns Matching entries * * @example * ```typescript * // Simple search * const gmailUsers = await manager.search('gmail.com'); * * // Paginated search * const page1 = await manager.search('example', { limit: 10, offset: 0 }); * const page2 = await manager.search('example', { limit: 10, offset: 10 }); * ``` */ async search(pattern: string, options: SearchOptions = {}): Promise<WaitlistEntry<T>[]> { // Use storage-level search if available (preferred for performance) if (this.storage.search) { return this.storage.search(pattern, options); } // Fallback to in-memory filtering (not recommended for large datasets) console.warn( 'Storage provider does not implement search(). ' + 'Falling back to in-memory filtering. ' + 'This may cause performance issues with large datasets.' ); const { limit, offset = 0, caseInsensitive = true } = options; const entries = await this.storage.getAll(); const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern; let results = entries.filter(entry => { const emailToMatch = caseInsensitive ? entry.email.toLowerCase() : entry.email; return emailToMatch.includes(searchPattern); }); // Apply pagination if (offset > 0) { results = results.slice(offset); } if (limit !== undefined && limit > 0) { results = results.slice(0, limit); } return results; } /** * Disconnect from storage and mail providers. * Should be called before shutting down the application. */ async disconnect(): Promise<void> { try { if (this.storage.disconnect) { await this.storage.disconnect(); this.emit('storage:disconnected'); } this.initialized = false; this.emit('disconnected'); } catch (error) { this.emit('error', { context: 'disconnect', error }); throw error; } } }