/** * Email Service supporting Resend API and Nodemailer SMTP * A generic, reusable email service for the app framework. * Resend is preferred when API key is configured. */ import { EventEmitter } from "events"; import nodemailer from "nodemailer"; import type { Transporter } from "nodemailer"; import { Resend } from "resend"; import { createLogger } from "../core/index.js"; let logger: ReturnType; function ensureLogger() { if (!logger) { logger = createLogger("EmailService"); } return logger; } // ============================================================================ // Types // ============================================================================ export interface EmailOptions { to: string | string[]; subject: string; text?: string; html?: string; attachments?: Array<{ filename: string; content?: string | Buffer; path?: string; }>; } export interface EmailConfig { enabled?: boolean; provider?: "resend" | "smtp"; resend?: { apiKey: string; }; smtp?: { host: string; port: number; secure?: boolean; auth?: { user: string; pass: string; }; }; from?: string; to?: string[]; appName?: string; appTitle?: string; logoUrl?: string; brandColor?: string; footerText?: string; footerLink?: string; } export type NotificationEventType = | "startup" | "shutdown" | "error" | "warning" | "info" | "success" | "custom"; export interface NotificationEvent { type: NotificationEventType; title?: string; data: Record; timestamp: Date; } export interface EmailServiceStatus { enabled: boolean; provider: "Resend" | "SMTP" | "None"; recipients: string[]; } // ============================================================================ // Email Service // ============================================================================ export class EmailService extends EventEmitter { private transporter?: Transporter; private resend?: Resend; private enabled: boolean = false; private useResend: boolean = false; private notificationQueue: NotificationEvent[] = []; private processingInterval?: NodeJS.Timeout; private fromAddress: string = "App "; private defaultRecipients: string[] = []; private config: EmailConfig; // Branding configuration private appName: string = "App"; private appTitle: string = "App"; private logoUrl?: string; private brandColor: string = "#6c5ce7"; private footerText?: string; private footerLink?: string; constructor(config: EmailConfig = {}) { super(); this.config = config; this.enabled = config.enabled ?? false; this.defaultRecipients = config.to || []; this.fromAddress = config.from || this.fromAddress; this.appName = config.appName || this.appName; this.appTitle = config.appTitle || config.appName || this.appTitle; this.logoUrl = config.logoUrl; this.brandColor = config.brandColor || this.brandColor; this.footerText = config.footerText; this.footerLink = config.footerLink; ensureLogger().debug("EmailService created", { enabled: this.enabled, provider: config.provider, }); } /** * Initialize the email service */ async initialize(): Promise { if (!this.enabled) { ensureLogger().info("Email service disabled in configuration"); return; } // Try Resend first (preferred) const resendApiKey = this.config.resend?.apiKey || process.env.RESEND_API_KEY; if (resendApiKey) { try { this.resend = new Resend(resendApiKey); this.useResend = true; ensureLogger().info("Email service initialized with Resend API"); this.startNotificationProcessor(); this.emit("initialized", { provider: "Resend" }); return; } catch (error) { ensureLogger().warn( "Failed to initialize Resend, falling back to SMTP:", error, ); } } // Fall back to SMTP/Nodemailer if (!this.config.smtp) { ensureLogger().warn( "No email provider configured (no Resend API key or SMTP config)", ); this.enabled = false; return; } try { this.transporter = nodemailer.createTransport({ host: this.config.smtp.host, port: this.config.smtp.port, secure: this.config.smtp.secure, auth: this.config.smtp.auth ? { user: this.config.smtp.auth.user, pass: this.config.smtp.auth.pass, } : undefined, }); await this.transporter.verify(); ensureLogger().info("Email service initialized with SMTP"); this.startNotificationProcessor(); this.emit("initialized", { provider: "SMTP" }); } catch (error) { ensureLogger().error("Failed to initialize email service:", error); this.enabled = false; } } /** * Update branding configuration */ setBranding(options: { appName?: string; appTitle?: string; logoUrl?: string; brandColor?: string; footerText?: string; footerLink?: string; }): void { if (options.appName) this.appName = options.appName; if (options.appTitle) this.appTitle = options.appTitle; if (options.logoUrl) this.logoUrl = options.logoUrl; if (options.brandColor) this.brandColor = options.brandColor; if (options.footerText) this.footerText = options.footerText; if (options.footerLink) this.footerLink = options.footerLink; } /** * Send an email */ async sendEmail(options: EmailOptions): Promise { if (!this.enabled) { ensureLogger().debug("Email service not available, skipping email"); return; } const recipients = Array.isArray(options.to) ? options.to : [options.to]; const toAddresses = recipients.length > 0 ? recipients : this.defaultRecipients; if (toAddresses.length === 0) { ensureLogger().warn("No email recipients configured"); return; } try { if (this.useResend && this.resend) { const { data, error } = await this.resend.emails.send({ from: this.fromAddress, to: toAddresses, subject: options.subject, text: options.text || "No content", html: options.html, }); if (error) { throw new Error(error.message); } ensureLogger().info(`Email sent via Resend: ${data?.id}`); this.emit("sent", { id: data?.id, provider: "Resend" }); } else if (this.transporter) { const info = await this.transporter.sendMail({ from: this.fromAddress, to: toAddresses.join(", "), subject: options.subject, text: options.text, html: options.html, attachments: options.attachments, }); ensureLogger().info(`Email sent via SMTP: ${info.messageId}`); this.emit("sent", { id: info.messageId, provider: "SMTP" }); } } catch (error) { ensureLogger().error("Failed to send email:", error); this.emit("error", error); throw error; } } /** * Queue a notification event */ queueNotification(event: NotificationEvent): void { if (!this.enabled) { return; } this.notificationQueue.push(event); ensureLogger().debug(`Queued ${event.type} notification`); this.emit("queued", event); } /** * Send immediate notification (bypasses queue) */ async sendImmediateNotification(event: NotificationEvent): Promise { if (!this.enabled) { return; } try { await this.sendNotificationEmail(event.type, [event]); ensureLogger().info(`Sent immediate ${event.type} notification`); } catch (error) { ensureLogger().error( `Failed to send immediate ${event.type} notification:`, error, ); } } /** * Send a notification with custom type and data */ async notify( type: NotificationEventType, title: string, data: Record, immediate: boolean = true, ): Promise { const event: NotificationEvent = { type, title, data, timestamp: new Date(), }; if (immediate) { await this.sendImmediateNotification(event); } else { this.queueNotification(event); } } /** * Send startup notification */ async notifyStartup( data: Record = {}, ): Promise { const uptime = process.uptime(); const nodeVersion = process.version; const platform = process.platform; await this.sendImmediateNotification({ type: "startup", title: "Application Started", data: { nodeVersion, platform, startupTime: `${uptime.toFixed(2)}s`, timestamp: new Date().toISOString(), ...data, }, timestamp: new Date(), }); } /** * Send error notification */ async notifyError( errorMessage: string, details?: Record, ): Promise { await this.sendImmediateNotification({ type: "error", title: "Error Alert", data: { errorMessage, ...details, }, timestamp: new Date(), }); } /** * Send test email */ async sendTestEmail(): Promise { const provider = this.useResend ? "Resend API" : "SMTP"; await this.sendEmail({ to: this.defaultRecipients, subject: `[${this.appTitle}] Test Email - Configuration Verified`, text: `This is a test email from ${this.appTitle}.\n\nEmail Provider: ${provider}\n\nIf you received this email, your email configuration is working correctly.`, html: this.generateTestEmailHtml(provider), }); } /** * Get service status */ getStatus(): EmailServiceStatus { return { enabled: this.enabled, provider: this.useResend ? "Resend" : this.transporter ? "SMTP" : "None", recipients: this.defaultRecipients, }; } /** * Shutdown the service */ async shutdown(): Promise { if (this.processingInterval) { clearInterval(this.processingInterval); } if (this.notificationQueue.length > 0) { await this.processNotificationQueue(); } if (this.transporter) { this.transporter.close(); } ensureLogger().info("Email service shut down"); this.emit("shutdown"); } // ============================================================================ // Private Methods // ============================================================================ private startNotificationProcessor(): void { // Process queue every 5 minutes this.processingInterval = setInterval(() => { this.processNotificationQueue(); }, 300000); } private async processNotificationQueue(): Promise { if (this.notificationQueue.length === 0) { return; } const grouped = this.groupNotificationsByType(); for (const [type, events] of Object.entries(grouped)) { try { await this.sendNotificationEmail(type as NotificationEventType, events); } catch (error) { ensureLogger().error(`Failed to send ${type} notification:`, error); } } this.notificationQueue = []; } private groupNotificationsByType(): Record { const grouped: Record = {}; for (const event of this.notificationQueue) { if (!grouped[event.type]) { grouped[event.type] = []; } grouped[event.type].push(event); } return grouped; } private async sendNotificationEmail( type: NotificationEventType, events: NotificationEvent[], ): Promise { const subject = this.getNotificationSubject(type, events); const html = this.formatNotificationHtml(type, events); const text = this.formatNotificationText(type, events); await this.sendEmail({ to: this.defaultRecipients, subject, text, html, }); } private getNotificationSubject( type: NotificationEventType, events: NotificationEvent[], ): string { const count = events.length; const customTitle = events[0]?.title; switch (type) { case "startup": return `[${this.appTitle}] Application Started`; case "shutdown": return `[${this.appTitle}] Application Shutdown`; case "error": return `[${this.appTitle}] Error Alert${count > 1 ? ` (${count})` : ""}`; case "warning": return `[${this.appTitle}] Warning${count > 1 ? ` (${count})` : ""}`; case "info": return `[${this.appTitle}] Information`; case "success": return `[${this.appTitle}] Success`; case "custom": return customTitle ? `[${this.appTitle}] ${customTitle}` : `[${this.appTitle}] Notification`; default: return `[${this.appTitle}] Notification`; } } private getNotificationIcon(type: NotificationEventType): string { switch (type) { case "startup": return "🚀"; case "shutdown": return "🛑"; case "error": return "❌"; case "warning": return "âš ī¸"; case "info": return "â„šī¸"; case "success": return "✅"; case "custom": return "📧"; default: return "📧"; } } private getAccentColor(type: NotificationEventType): string { switch (type) { case "error": return "#e74c3c"; case "warning": return "#f39c12"; case "success": return "#27ae60"; case "startup": return "#3498db"; case "shutdown": return "#95a5a6"; default: return this.brandColor; } } private formatDateTime(date: Date): string { const pad = (n: number) => n.toString().padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } private formatEventDataHtml(data: Record): string { if (!data || typeof data !== "object") { return `${data || "No details"}`; } const formatValue = (val: unknown): string => { if (val instanceof Date) return this.formatDateTime(val); if (typeof val === "object") return JSON.stringify(val); return String(val ?? "-"); }; return Object.entries(data) .map(([key, val]) => { const label = key .replace(/([A-Z])/g, " $1") .replace(/^./, (s) => s.toUpperCase()); return `${label}:${formatValue(val)}`; }) .join(""); } private formatNotificationHtml( type: NotificationEventType, events: NotificationEvent[], ): string { const accentColor = this.getAccentColor(type); const icon = this.getNotificationIcon(type); const title = events[0]?.title || this.getNotificationTitle(type); let html = ` ${this.getNotificationSubject(type, events)}
${this.logoUrl ? `${this.appName}` : `${this.appName}`} ${this.appTitle}
${icon}

${title}

${type.replace(/_/g, " ")}
`; for (const event of events) { html += `
${this.formatDateTime(event.timestamp)}
${this.formatEventDataHtml(event.data)}
`; } html += `

This is an automated notification from ${this.appTitle}.${this.footerText ? `
${this.footerText}` : ""}${this.footerLink ? `
${this.footerLink}` : ""}

`; return html; } private formatNotificationText( type: NotificationEventType, events: NotificationEvent[], ): string { const timestamp = this.formatDateTime(new Date()); const title = events[0]?.title || this.getNotificationTitle(type); let text = `${this.appTitle} - ${title}\n`; text += `Generated: ${timestamp}\n`; text += "=".repeat(60) + "\n\n"; for (const event of events) { text += `Time: ${this.formatDateTime(event.timestamp)}\n`; text += `Details:\n${this.formatEventDataText(event.data)}\n`; text += "-".repeat(40) + "\n\n"; } text += `This is an automated notification from ${this.appTitle}.\n`; if (this.footerText) text += `${this.footerText}\n`; return text; } private formatEventDataText(data: Record): string { if (!data || typeof data !== "object") { return String(data || "No details"); } const formatValue = (val: unknown): string => { if (val instanceof Date) return this.formatDateTime(val); if (typeof val === "object") return JSON.stringify(val); return String(val ?? "-"); }; return Object.entries(data) .map(([key, val]) => { const label = key .replace(/([A-Z])/g, " $1") .replace(/^./, (s) => s.toUpperCase()); return ` ${label}: ${formatValue(val)}`; }) .join("\n"); } private getNotificationTitle(type: NotificationEventType): string { switch (type) { case "startup": return "Application Started"; case "shutdown": return "Application Shutdown"; case "error": return "Error Alert"; case "warning": return "Warning"; case "info": return "Information"; case "success": return "Success"; case "custom": return "Notification"; default: return "Notification"; } } private generateTestEmailHtml(provider: string): string { return ` Email Configuration Verified
${this.logoUrl ? `${this.appName}` : `${this.appName}`} ${this.appTitle}
✅

Email Configuration Verified

Your email notifications are configured correctly.

Provider ${provider}
App ${this.appTitle}

This is an automated test from ${this.appTitle}.${this.footerText ? `
${this.footerText}` : ""}${this.footerLink ? `
${this.footerLink}` : ""}

`; } } // ============================================================================ // Singleton Pattern // ============================================================================ let emailServiceInstance: EmailService | null = null; /** * Get the email service instance (singleton) */ export function getEmailService(): EmailService | null { return emailServiceInstance; } /** * Create and initialize the email service */ export async function createEmailService( config: EmailConfig, ): Promise { if (emailServiceInstance) { return emailServiceInstance; } emailServiceInstance = new EmailService(config); await emailServiceInstance.initialize(); return emailServiceInstance; } export default EmailService;