@voilajsx/appkit
Version:
Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development
316 lines (311 loc) • 11.2 kB
JavaScript
/**
* Core email class with automatic strategy selection and ultra-simple API
* @module @voilajsx/appkit/email
* @file src/email/email.ts
*
* @llm-rule WHEN: Building apps that need email sending with automatic provider selection
* @llm-rule AVOID: Using directly - always get instance via emailClass.get()
* @llm-rule NOTE: Auto-detects Resend → SMTP → Console based on environment variables
*/
import { ResendStrategy } from './strategies/resend.js';
import { SmtpStrategy } from './strategies/smtp.js';
import { ConsoleStrategy } from './strategies/console.js';
/**
* Email class with automatic strategy selection and ultra-simple API
*/
export class EmailClass {
config;
strategy;
connected = false;
constructor(config) {
this.config = config;
this.strategy = this.createStrategy();
}
/**
* Creates appropriate strategy based on configuration
* @llm-rule WHEN: Email initialization - selects Resend, SMTP, or Console based on environment
* @llm-rule AVOID: Manual strategy creation - configuration handles strategy selection
*/
createStrategy() {
switch (this.config.strategy) {
case 'resend':
return new ResendStrategy(this.config);
case 'smtp':
return new SmtpStrategy(this.config);
case 'console':
return new ConsoleStrategy(this.config);
default:
throw new Error(`Unknown email strategy: ${this.config.strategy}`);
}
}
/**
* Sends email with automatic strategy handling
* @llm-rule WHEN: Sending any email - this is the main email sending method
* @llm-rule AVOID: Manual strategy calls - this handles all email complexity
* @llm-rule NOTE: Auto-fills FROM address from config if not provided
*/
async send(data) {
try {
// Validate email data
this.validateEmailData(data);
// Auto-fill FROM address if not provided
const emailData = this.prepareEmailData(data);
// Send via strategy
const result = await this.strategy.send(emailData);
// Log success in development
if (this.config.environment.isDevelopment && result.success) {
console.log(`✅ [AppKit] Email sent successfully${result.messageId ? ` (ID: ${result.messageId})` : ''}`);
}
return result;
}
catch (error) {
const errorMessage = error.message;
console.error(`❌ [AppKit] Email send failed:`, errorMessage);
return {
success: false,
error: errorMessage,
};
}
}
/**
* Sends multiple emails efficiently (batch operation)
* @llm-rule WHEN: Sending multiple emails like newsletters or notifications
* @llm-rule AVOID: Multiple individual send() calls - this handles batching efficiently
* @llm-rule NOTE: Processes in batches to avoid overwhelming email providers
*/
async sendBatch(emails, batchSize = 10) {
const results = [];
// Process in batches
for (let i = 0; i < emails.length; i += batchSize) {
const batch = emails.slice(i, i + batchSize);
// Send batch concurrently
const batchPromises = batch.map(email => this.send(email));
const batchResults = await Promise.allSettled(batchPromises);
// Process results
for (const result of batchResults) {
if (result.status === 'fulfilled') {
results.push(result.value);
}
else {
results.push({
success: false,
error: result.reason?.message || 'Unknown error',
});
}
}
// Small delay between batches to be respectful to email providers
if (i + batchSize < emails.length) {
await this.sleep(100);
}
}
return results;
}
/**
* Sends simple text email (convenience method)
* @llm-rule WHEN: Sending simple text-only emails quickly
* @llm-rule AVOID: Complex EmailData object for simple emails - this is more convenient
*/
async sendText(to, subject, text) {
return await this.send({
to,
subject,
text,
});
}
/**
* Sends HTML email (convenience method)
* @llm-rule WHEN: Sending HTML emails with formatting
* @llm-rule AVOID: Manual HTML/text preparation - this handles both formats
*/
async sendHtml(to, subject, html, text) {
return await this.send({
to,
subject,
html,
text: text || this.htmlToText(html),
});
}
/**
* Sends email with template (future extension point)
* @llm-rule WHEN: Sending templated emails with data substitution
* @llm-rule AVOID: Manual template processing - this will handle template rendering
* @llm-rule NOTE: Basic implementation - can be extended with template engines
*/
async sendTemplate(templateName, data) {
// Simple template processing (can be extended)
const template = this.loadTemplate(templateName);
const processedHtml = this.processTemplate(template.html, data);
const processedText = this.processTemplate(template.text, data);
return await this.send({
to: data.to,
subject: this.processTemplate(template.subject, data),
html: processedHtml,
text: processedText,
});
}
/**
* Disconnects email strategy gracefully
* @llm-rule WHEN: App shutdown or email cleanup
* @llm-rule AVOID: Abrupt disconnection - graceful shutdown prevents connection issues
*/
async disconnect() {
if (!this.connected)
return;
try {
await this.strategy.disconnect();
this.connected = false;
if (this.config.environment.isDevelopment) {
console.log(`👋 [AppKit] Email disconnected`);
}
}
catch (error) {
console.error(`⚠️ [AppKit] Email disconnect error:`, error.message);
}
}
/**
* Gets current email strategy name for debugging
* @llm-rule WHEN: Debugging or health checks to see which strategy is active
* @llm-rule AVOID: Using for application logic - email should be transparent
*/
getStrategy() {
return this.config.strategy;
}
/**
* Gets email configuration summary for debugging
* @llm-rule WHEN: Health checks or debugging email configuration
* @llm-rule AVOID: Exposing sensitive details - this only shows safe info
*/
getConfig() {
return {
strategy: this.config.strategy,
fromName: this.config.from.name,
fromEmail: this.config.from.email,
connected: this.connected,
};
}
// Private helper methods
/**
* Validates email data before sending
*/
validateEmailData(data) {
if (!data.to) {
throw new Error('Email "to" field is required');
}
if (!data.subject) {
throw new Error('Email "subject" field is required');
}
if (!data.text && !data.html) {
throw new Error('Email must have either "text" or "html" content');
}
// Validate email addresses
const recipients = Array.isArray(data.to) ? data.to : [data.to];
for (const recipient of recipients) {
const email = typeof recipient === 'string' ? recipient : recipient.email;
if (!this.isValidEmail(email)) {
throw new Error(`Invalid email address: ${email}`);
}
}
// Validate FROM address if provided
if (data.from) {
const fromEmail = typeof data.from === 'string' ? data.from : data.from.email;
if (!this.isValidEmail(fromEmail)) {
throw new Error(`Invalid FROM email address: ${fromEmail}`);
}
}
}
/**
* Prepares email data with defaults
*/
prepareEmailData(data) {
const prepared = { ...data };
// Auto-fill FROM address if not provided
if (!prepared.from) {
prepared.from = {
name: this.config.from.name,
email: this.config.from.email,
};
}
return prepared;
}
/**
* Validates email address format
*/
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Converts HTML to plain text (basic implementation)
*/
htmlToText(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Loads email template (basic implementation)
*/
loadTemplate(templateName) {
// Basic built-in templates
const templates = {
welcome: {
subject: 'Welcome to {{appName}}!',
html: `
<h1>Welcome {{name}}!</h1>
<p>Thanks for joining {{appName}}. We're excited to have you!</p>
<p>Best regards,<br>The {{appName}} Team</p>
`,
text: `
Welcome {{name}}!
Thanks for joining {{appName}}. We're excited to have you!
Best regards,
The {{appName}} Team
`,
},
reset: {
subject: 'Reset your {{appName}} password',
html: `
<h1>Reset your password</h1>
<p>Hi {{name}},</p>
<p>Click the link below to reset your password:</p>
<a href="{{resetUrl}}">Reset Password</a>
<p>If you didn't request this, please ignore this email.</p>
`,
text: `
Reset your password
Hi {{name}},
Click the link below to reset your password:
{{resetUrl}}
If you didn't request this, please ignore this email.
`,
},
};
const template = templates[templateName];
if (!template) {
throw new Error(`Template not found: ${templateName}`);
}
return template;
}
/**
* Processes template with data substitution
*/
processTemplate(template, data) {
let processed = template;
// Simple {{key}} substitution
for (const [key, value] of Object.entries(data)) {
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
processed = processed.replace(regex, String(value));
}
return processed.trim();
}
/**
* Sleep for specified milliseconds
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
//# sourceMappingURL=email.js.map