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.

564 lines (560 loc) 17.9 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lib/core/WaitlistManager.ts var WaitlistManager_exports = {}; __export(WaitlistManager_exports, { WaitlistManager: () => WaitlistManager }); module.exports = __toCommonJS(WaitlistManager_exports); var import_events = require("events"); var import_validator = __toESM(require("validator")); // lib/adapters/storage/MemoryStorage.ts var MemoryStorage = class { constructor() { this.storage = /* @__PURE__ */ new Map(); } /** * Add an email to memory storage. * @param email - The email to add * @param data - Optional metadata * @throws {Error} If the email already exists */ async add(email, data) { const normalized = email.toLowerCase(); if (this.storage.has(normalized)) { throw new Error(`Email already exists: ${email}`); } this.storage.set(normalized, { email, metadata: data, createdAt: /* @__PURE__ */ new Date() }); } /** * Remove an email from memory storage. * @param email - The email to remove * @returns true if the email was found and removed */ async remove(email) { return this.storage.delete(email.toLowerCase()); } /** * Check if an email exists in storage. * @param email - The email to check * @returns true if the email exists */ async exists(email) { return this.storage.has(email.toLowerCase()); } /** * Retrieve all entries from storage. * @returns Array of all WaitlistEntry objects */ async getAll() { return Array.from(this.storage.values()); } /** * Count total entries in storage. * @returns Number of entries */ async count() { return this.storage.size; } /** * Clear all entries from storage. */ async clear() { this.storage.clear(); } /** * Search entries by email pattern. * For MemoryStorage, this filters the in-memory Map. * @param pattern - The pattern to search for * @param options - Optional search options * @returns Matching entries */ async search(pattern, options = {}) { const { limit, offset = 0, caseInsensitive = true } = options; const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern; let results = []; for (const entry of this.storage.values()) { const emailToMatch = caseInsensitive ? entry.email.toLowerCase() : entry.email; if (emailToMatch.includes(searchPattern)) { results.push(entry); } } if (offset > 0) { results = results.slice(offset); } if (limit !== void 0 && limit > 0) { results = results.slice(0, limit); } return results; } /** * Iterate over entries in batches. * For MemoryStorage, yields entries from the in-memory Map. * @param batchSize - Number of entries per batch (default: 100) */ async *iterate(batchSize = 100) { const entries = Array.from(this.storage.values()); for (let i = 0; i < entries.length; i += batchSize) { const batch = entries.slice(i, i + batchSize); for (const entry of batch) { yield entry; } } } }; // lib/core/WaitlistManager.ts var WaitlistManager = class extends import_events.EventEmitter { /** * Create a new WaitlistManager instance. * @param config - Configuration object with storage, optional mailer, etc. */ constructor(config) { super(); this.initialized = false; this.storage = config.storage || new MemoryStorage(); this.mailer = config.mailer; this.companyName = config.companyName || "Our Platform"; 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() { try { if (this.storage.connect) { await this.storage.connect(); this.emit("storage:connected"); } 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() { if (this.initialized) return; return new Promise((resolve) => this.once("initialized", resolve)); } /** * Check if the manager is initialized. * @returns true if initialized */ isInitialized() { 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, metadata) { try { if (!import_validator.default.isEmail(email)) { this.emit("join:validation-error", { email, reason: "invalid-format" }); return { success: false, message: "Invalid email format", email }; } const normalizedEmail = email.toLowerCase(); 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 }; } await this.storage.add(normalizedEmail, metadata); this.emit("join:success", { email: normalizedEmail, metadata }); if (this.mailer) { try { const context = { 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 }); } } catch (error) { console.error("Mail sending error:", error); this.emit("mailer:error", { email: normalizedEmail, error }); } } 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) { 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) { return this.storage.exists(email.toLowerCase()); } /** * Get all waitlist entries. * @returns Array of WaitlistEntry objects */ async getAll() { return this.storage.getAll(); } /** * Get count of waitlist entries. * @returns Number of entries */ async count() { return this.storage.count(); } /** * Clear all entries from the waitlist. * @returns true if successful */ async clear() { 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, context) { if (!this.mailer) { console.warn("No mail provider configured"); return false; } try { const normalizedEmail = email.toLowerCase(); const fullContext = { 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, concurrency = 5) { if (!this.mailer) { console.warn("No mail provider configured"); return { sent: 0, failed: 0, total: 0 }; } if (this.storage.iterate) { return this.sendBulkEmailsWithStreaming(contextBuilder, concurrency); } 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 */ async sendBulkEmailsWithStreaming(contextBuilder, concurrency) { let sent = 0; let failed = 0; let total = 0; const activePromises = []; for await (const entry of this.storage.iterate(100)) { total++; 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) { await promise; } else { const trackedPromise = promise.then(() => { const index = activePromises.indexOf(trackedPromise); if (index > -1) activePromises.splice(index, 1); }); activePromises.push(trackedPromise); } } 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 */ async sendBulkEmailsLegacy(contextBuilder, concurrency) { 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; if (concurrency === 1) { for (const entry of entries) { const success = await this.sendSingleBulkEmail(entry, contextBuilder); if (success) sent++; else failed++; } } else if (concurrency === Infinity) { 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 { const activePromises = []; for (const entry of entries) { if (activePromises.length >= concurrency) { await Promise.race(activePromises); } const promise = this.sendSingleBulkEmail(entry, contextBuilder); activePromises.push(promise); promise.then((success) => { const index = activePromises.indexOf(promise); if (index > -1) { activePromises.splice(index, 1); } if (success) sent++; else failed++; }); } 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 */ async sendSingleBulkEmail(entry, contextBuilder) { try { const context = { 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, options = {}) { if (this.storage.search) { return this.storage.search(pattern, options); } 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); }); if (offset > 0) { results = results.slice(offset); } if (limit !== void 0 && 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() { 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; } } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { WaitlistManager });