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
text/typescript
/**
* @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;
}
}
}