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.
433 lines (430 loc) • 13.5 kB
JavaScript
import {
MemoryStorage
} from "./chunk-NEZ7NTNQ.mjs";
// lib/core/WaitlistManager.ts
import { EventEmitter } from "events";
import validator from "validator";
var WaitlistManager = class extends 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 (!validator.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;
}
}
};
export {
WaitlistManager
};