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.
1,086 lines (1,076 loc) • 34.3 kB
JavaScript
;
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/index.ts
var index_exports = {};
__export(index_exports, {
ConsoleMailProvider: () => ConsoleMailProvider,
MemoryStorage: () => MemoryStorage,
MongooseStorage: () => MongooseStorage,
NodemailerProvider: () => NodemailerProvider,
SequelizeStorage: () => SequelizeStorage,
VERSION: () => VERSION,
WaitlistManager: () => WaitlistManager
});
module.exports = __toCommonJS(index_exports);
// lib/core/WaitlistManager.ts
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;
}
}
};
// lib/adapters/storage/MongooseStorage.ts
var MongooseStorage = class {
/**
* Create a new MongooseStorage instance.
* @param config - Configuration object with mongoose model
* @throws {Error} If mongoose is not installed or model is invalid
*/
constructor(config) {
try {
if (!config.model) {
throw new Error("Mongoose model is required. Please provide a valid Mongoose model instance.");
}
if (!config.model.collection) {
throw new Error("Invalid Mongoose model. Make sure you're passing a compiled Mongoose model.");
}
} catch (error) {
if (error.message.includes("Mongoose") || error.message.includes("model")) {
throw error;
}
throw new Error(
"MongooseStorage requires mongoose to be installed. Install it with: npm install mongoose"
);
}
this.model = config.model;
this.normalizeEmail = config.normalizeEmail ?? true;
}
normalizeEmailAddress(email) {
return this.normalizeEmail ? email.toLowerCase() : email;
}
/**
* Add an email to MongoDB.
* @param email - The email to add
* @param data - Optional metadata
* @throws {Error} If the email already exists or database operation fails
*/
async add(email, data) {
const normalized = this.normalizeEmailAddress(email);
const existingDoc = await this.model.findOne({ email: normalized }).exec();
if (existingDoc) {
throw new Error(`Email already exists: ${email}`);
}
try {
await this.model.create({
email: normalized,
metadata: data,
createdAt: /* @__PURE__ */ new Date()
});
} catch (error) {
if (error.code === 11e3) {
throw new Error(`Email already exists: ${email}`);
}
throw error;
}
}
/**
* Remove an email from MongoDB.
* @param email - The email to remove
* @returns true if the email was found and removed
*/
async remove(email) {
const normalized = this.normalizeEmailAddress(email);
const result = await this.model.deleteOne({ email: normalized }).exec();
return result.deletedCount > 0;
}
/**
* Check if an email exists in MongoDB.
* @param email - The email to check
* @returns true if the email exists
*/
async exists(email) {
const normalized = this.normalizeEmailAddress(email);
const count = await this.model.countDocuments({ email: normalized }).exec();
return count > 0;
}
/**
* Retrieve all entries from MongoDB.
* @returns Array of all WaitlistEntry objects
*/
async getAll() {
const docs = await this.model.find().lean().exec();
return docs.map((doc) => ({
email: doc.email,
metadata: doc.metadata,
createdAt: doc.createdAt
}));
}
/**
* Count total entries in MongoDB.
* @returns Number of entries
*/
async count() {
return this.model.countDocuments().exec();
}
/**
* Clear all entries from MongoDB.
*/
async clear() {
await this.model.deleteMany({}).exec();
}
/**
* Search entries by email pattern using MongoDB $regex.
* Delegates filtering to MongoDB for optimal performance.
* @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 escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexOptions = caseInsensitive ? "i" : "";
let query = this.model.find({
email: { $regex: escapedPattern, $options: regexOptions }
});
if (offset > 0) {
query = query.skip(offset);
}
if (limit !== void 0 && limit > 0) {
query = query.limit(limit);
}
const docs = await query.lean().exec();
return docs.map((doc) => ({
email: doc.email,
metadata: doc.metadata,
createdAt: doc.createdAt
}));
}
/**
* Iterate over entries using MongoDB cursor for memory-efficient streaming.
* Uses Mongoose cursor to avoid loading all documents into memory.
* @param batchSize - Number of entries per batch (default: 100)
*/
async *iterate(batchSize = 100) {
const cursor = this.model.find().lean().batchSize(batchSize).cursor();
for await (const doc of cursor) {
yield {
email: doc.email,
metadata: doc.metadata,
createdAt: doc.createdAt
};
}
}
};
// lib/adapters/storage/SequelizeStorage.ts
var SequelizeStorage = class {
/**
* Create a new SequelizeStorage instance.
* @param config - Configuration object with sequelize model
* @throws {Error} If sequelize is not installed or model is invalid
*/
constructor(config) {
try {
if (!config.model) {
throw new Error("Sequelize model is required. Please provide a valid Sequelize model instance.");
}
if (!config.model.sequelize) {
throw new Error("Invalid Sequelize model. Make sure you're passing a compiled Sequelize model.");
}
if (!config.model.findOne || !config.model.create) {
throw new Error("Invalid Sequelize model. Model must have findOne and create methods.");
}
} catch (error) {
if (error.message.includes("Sequelize") || error.message.includes("model")) {
throw error;
}
throw new Error(
"SequelizeStorage requires sequelize to be installed. Install it with: npm install sequelize pg (for PostgreSQL) or npm install sequelize mysql2 (for MySQL) or npm install sequelize better-sqlite3 (for SQLite)"
);
}
this.model = config.model;
this.normalizeEmail = config.normalizeEmail ?? true;
}
normalizeEmailAddress(email) {
return this.normalizeEmail ? email.toLowerCase() : email;
}
/**
* Add an email to the database.
* @param email - The email to add
* @param data - Optional metadata
* @throws {Error} If the email already exists or database operation fails
*/
async add(email, data) {
const normalized = this.normalizeEmailAddress(email);
const existing = await this.model.findOne({ where: { email: normalized } });
if (existing) {
throw new Error(`Email already exists: ${email}`);
}
try {
await this.model.create({
email: normalized,
metadata: data ? JSON.stringify(data) : null,
createdAt: /* @__PURE__ */ new Date()
});
} catch (error) {
if (error.name === "SequelizeUniqueConstraintError") {
throw new Error(`Email already exists: ${email}`);
}
throw error;
}
}
/**
* Remove an email from the database.
* @param email - The email to remove
* @returns true if the email was found and removed
*/
async remove(email) {
const normalized = this.normalizeEmailAddress(email);
const result = await this.model.destroy({ where: { email: normalized } });
return result > 0;
}
/**
* Check if an email exists in the database.
* @param email - The email to check
* @returns true if the email exists
*/
async exists(email) {
const normalized = this.normalizeEmailAddress(email);
const count = await this.model.count({ where: { email: normalized } });
return count > 0;
}
/**
* Retrieve all entries from the database.
* @returns Array of all WaitlistEntry objects
*/
async getAll() {
const records = await this.model.findAll({ raw: true });
return records.map((record) => ({
email: record.email,
metadata: this.safeParseMetadata(record.metadata),
createdAt: record.createdAt
}));
}
/**
* Count total entries in the database.
* @returns Number of entries
*/
async count() {
return this.model.count();
}
/**
* Clear all entries from the database.
*/
async clear() {
await this.model.destroy({ where: {} });
}
/**
* Search entries by email pattern using SQL LIKE/ILIKE.
* Delegates filtering to the database for optimal performance.
* @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 { Op } = this.model.sequelize.Sequelize;
const escapedPattern = pattern.replace(/[%_\\]/g, "\\$&");
const likePattern = `%${escapedPattern}%`;
const dialect = this.model.sequelize.getDialect();
let whereClause;
if (caseInsensitive) {
if (dialect === "postgres") {
whereClause = {
email: { [Op.iLike]: likePattern }
};
} else {
whereClause = this.model.sequelize.where(
this.model.sequelize.fn("LOWER", this.model.sequelize.col("email")),
{ [Op.like]: likePattern.toLowerCase() }
);
}
} else {
whereClause = {
email: { [Op.like]: likePattern }
};
}
const queryOptions = {
where: whereClause,
raw: true
};
if (offset > 0) {
queryOptions.offset = offset;
}
if (limit !== void 0 && limit > 0) {
queryOptions.limit = limit;
}
const records = await this.model.findAll(queryOptions);
return records.map((record) => ({
email: record.email,
metadata: this.safeParseMetadata(record.metadata),
createdAt: record.createdAt
}));
}
/**
* Safely parse metadata JSON with validation.
* Prevents potential issues from malformed or malicious JSON.
* @param metadata - Raw metadata string from database
* @returns Parsed metadata or undefined
*/
safeParseMetadata(metadata) {
if (!metadata) return void 0;
try {
const parsed = JSON.parse(metadata);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
console.warn("Metadata is not a plain object, ignoring");
return void 0;
} catch (error) {
console.error("Failed to parse metadata JSON:", error);
return void 0;
}
}
/**
* Iterate over entries using Sequelize pagination for memory-efficient streaming.
* Uses limit/offset pagination to avoid loading all records into memory.
* @param batchSize - Number of entries per batch (default: 100)
*/
async *iterate(batchSize = 100) {
let offset = 0;
let hasMore = true;
while (hasMore) {
const records = await this.model.findAll({
raw: true,
limit: batchSize,
offset,
order: [["createdAt", "ASC"]]
// Consistent ordering for pagination
});
if (records.length === 0) {
hasMore = false;
} else {
for (const record of records) {
yield {
email: record.email,
metadata: this.safeParseMetadata(record.metadata),
createdAt: record.createdAt
};
}
offset += batchSize;
hasMore = records.length === batchSize;
}
}
}
};
// lib/adapters/mail/NodemailerProvider.ts
var import_nodemailer = __toESM(require("nodemailer"));
var NodemailerProvider = class {
/**
* Create a new NodemailerProvider instance.
* @param config - SMTP configuration
* @throws {Error} If nodemailer is not installed
*/
constructor(config) {
this.config = config;
try {
if (!import_nodemailer.default || !import_nodemailer.default.createTransport) {
throw new Error("nodemailer is not properly initialized");
}
} catch (error) {
throw new Error(
"NodemailerProvider requires nodemailer to be installed. Install it with: npm install nodemailer"
);
}
this.fromEmail = config.from || config.user;
this.fromName = config.name || "Waitlist Manager";
this.transporter = import_nodemailer.default.createTransport({
host: config.host,
port: config.port,
secure: config.secure ?? config.port === 465,
auth: {
user: config.user,
pass: config.pass
}
});
}
/**
* Verify the transporter configuration.
* @returns true if configuration is valid
*/
async verify() {
try {
await this.transporter.verify();
return true;
} catch (error) {
console.error("Nodemailer verification failed:", error);
return false;
}
}
/**
* Send a confirmation email.
* @param email - Recipient email address
* @param context - Template context with variables
* @returns true if the email was sent successfully
*/
async sendConfirmation(email, context) {
try {
const subject = this.buildSubject(context);
const html = this.buildHtml(context);
const mailOptions = {
from: `"${this.fromName}" <${this.fromEmail}>`,
to: email,
subject,
html
};
await this.transporter.sendMail(mailOptions);
return true;
} catch (error) {
console.error(`Failed to send email to ${email}:`, error);
return false;
}
}
/**
* Build the email subject.
* Allows customization through context.
*/
buildSubject(context) {
return context.subject || `Welcome to ${context.companyName || "our platform"}`;
}
/**
* Build the HTML body.
* Provides a default template if customHtml is not provided.
*/
buildHtml(context) {
if (context.customHtml) {
return context.customHtml;
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to ${context.companyName || "Our Platform"}</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>Thank you for joining our waitlist! We're excited to have you on board.</p>
<p>You'll be among the first to know when we launch.</p>
${context.customUrl ? `<p><a href="${context.customUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; display: inline-block;">Learn More</a></p>` : ""}
</div>
<div class="footer">
<p>© 2024 ${context.companyName || "Our Platform"}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
};
// lib/adapters/mail/ConsoleMailProvider.ts
var ConsoleMailProvider = class {
constructor(verbose = true) {
this.verbose = verbose;
}
/**
* Output a confirmation email to console.
* @param email - Recipient email address
* @param context - Email context
* @returns Always returns true (successful "send")
*/
async sendConfirmation(email, context) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const subject = this.buildSubject(context);
const body = this.buildBody(context);
console.log(`
${"=".repeat(60)}`);
console.log(`[EMAIL SENT] ${timestamp}`);
console.log(`To: ${email}`);
console.log(`Subject: ${subject}`);
if (this.verbose) {
console.log(`
Context:`);
console.log(JSON.stringify(context, null, 2));
console.log(`
Body:`);
console.log(body);
}
console.log(`${"=".repeat(60)}
`);
return true;
}
buildSubject(context) {
return context.subject || `Welcome to ${context.companyName || "our platform"}`;
}
buildBody(context) {
return context.customHtml || `
Thank you for joining ${context.companyName || "our platform"}!
Email: ${context.email}
${context.customUrl ? `Link: ${context.customUrl}` : ""}
`.trim();
}
};
// lib/index.ts
var VERSION = "2.1.0";
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ConsoleMailProvider,
MemoryStorage,
MongooseStorage,
NodemailerProvider,
SequelizeStorage,
VERSION,
WaitlistManager
});