EmailService.ts

Provider-agnostic email service with template support and delivery tracking

EmailService.tsv1.0.041.9 KB
EmailService.ts(typescript)
1/**
2 * Email Service - Sanitized for UCM Publishing
3 * 
4 * This service provides comprehensive email functionality with pluggable provider support,
5 * template abstraction, and multi-environment email delivery capabilities.
6 * 
7 * @author UCM Publishing System
8 * @version 1.0.0
9 * @technology email-abstraction
10 * @category implementations
11 * @dependencies none (provider-agnostic)
12 * 
13 * REQUIRED DEPENDENCIES:
14 * None - This service is provider-agnostic and has no external dependencies
15 * 
16 * Note: You will need to implement IEmailProvider with your chosen email provider
17 * (e.g., Resend, SendGrid, Mailpit, etc.) which may have their own dependencies
18 */
19
20/**
21 * NOTE: The following interfaces and classes (BaseService, ServiceMetadata, ILogger, 
22 * ConsoleLogger, IEmailService, IEmailProvider, and related types) are embedded from the 
23 * micro-block architecture framework for portability.
24 * 
25 * RECOMMENDATION: When integrating this service into your project:
26 * - Move these interfaces to shared/reusable files
27 * - Modify to match your existing service architecture
28 * - Integrate with your project's base classes and patterns
29 * - Create concrete email providers (Mailpit, Resend, SendGrid, etc.)
30 * - Implement your own email template system if needed
31 * - Consider using external template engines like Handlebars or Mustache
32 */
33
34// ===== EMBEDDED INTERFACES FROM MICRO-BLOCK ARCHITECTURE =====
35
36/**
37 * Logger interface for structured logging throughout the application
38 */
39export interface ILogger {
40  info(message: string, context?: Record<string, any>): void;
41  warn(message: string, context?: Record<string, any>): void;
42  error(message: string, error?: Error | unknown, context?: Record<string, any>): void;
43  debug(message: string, context?: Record<string, any>): void;
44}
45
46/**
47 * Command-specific logger interface for tracking command execution
48 */
49export interface ICommandLogger extends ILogger {
50  commandStart(commandName: string, input?: Record<string, any>): () => void;
51  commandSuccess(commandName: string, metrics?: Record<string, any>): void;
52  commandFailure(commandName: string, error: Error | unknown, metrics?: Record<string, any>): void;
53}
54
55/**
56 * Basic console logger implementation
57 */
58export class ConsoleLogger implements ICommandLogger {
59  private readonly context: string;
60
61  constructor(context: string = 'App') {
62    this.context = context;
63  }
64
65  info(message: string, context?: Record<string, any>): void {
66    const timestamp = new Date().toISOString();
67    console.log(`[${timestamp}] [${this.context}] INFO: ${message}`, context ? JSON.stringify(context, null, 2) : '');
68  }
69
70  warn(message: string, context?: Record<string, any>): void {
71    const timestamp = new Date().toISOString();
72    console.warn(`[${timestamp}] [${this.context}] WARN: ${message}`, context ? JSON.stringify(context, null, 2) : '');
73  }
74
75  error(message: string, error?: Error | unknown, context?: Record<string, any>): void {
76    const timestamp = new Date().toISOString();
77    console.error(`[${timestamp}] [${this.context}] ERROR: ${message}`);
78    if (error) {
79      console.error('Error details:', error);
80    }
81    if (context) {
82      console.error('Context:', JSON.stringify(context, null, 2));
83    }
84  }
85
86  debug(message: string, context?: Record<string, any>): void {
87    if (process.env.NODE_ENV === 'development') {
88      const timestamp = new Date().toISOString();
89      console.debug(`[${timestamp}] [${this.context}] DEBUG: ${message}`, context ? JSON.stringify(context, null, 2) : '');
90    }
91  }
92
93  commandStart(commandName: string, input?: Record<string, any>): () => void {
94    const startTime = Date.now();
95    this.info(`Command started: ${commandName}`, { input });
96    
97    return () => {
98      const duration = Date.now() - startTime;
99      this.debug(`Command timer stopped: ${commandName}`, { duration });
100    };
101  }
102
103  commandSuccess(commandName: string, metrics?: Record<string, any>): void {
104    this.info(`Command succeeded: ${commandName}`, metrics);
105  }
106
107  commandFailure(commandName: string, error: Error | unknown, metrics?: Record<string, any>): void {
108    this.error(`Command failed: ${commandName}`, error, metrics);
109  }
110}
111
112/**
113 * Validation rules for operation parameters
114 */
115export interface ParameterValidation {
116  min?: number;                    // Minimum value (numbers) or length (strings/arrays)
117  max?: number;                    // Maximum value (numbers) or length (strings/arrays)
118  options?: string[];              // Valid options for dropdown/select
119  pattern?: string;                // Regex pattern for string validation
120  step?: number;                   // Step increment for numbers
121}
122
123/**
124 * Definition of a parameter for a service operation
125 */
126export interface OperationParameter {
127  name: string;                    // Parameter name (matches method signature)
128  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
129  required: boolean;               // Whether parameter is required
130  defaultValue?: any;              // Default value to use in UI
131  description?: string;            // Human-readable description
132  displayName?: string;            // UI-friendly name (defaults to name)
133  validation?: ParameterValidation; // Validation rules
134  group?: string;                  // Group parameters in UI (e.g., 'Performance', 'Security')
135  sensitive?: boolean;             // Mark as sensitive (password field, etc.)
136}
137
138/**
139 * Definition of a service operation that can be executed via admin interface
140 */
141export interface ServiceOperation {
142  name: string;                    // Method name to call (e.g., 'start', 'stop')
143  displayName: string;             // Human-friendly name for UI
144  description: string;             // What this operation does
145  parameters: OperationParameter[]; // Parameters this operation accepts
146  category?: string;               // Group operations (e.g., 'control', 'configuration', 'maintenance')
147  permissions?: string[];          // Required permissions to execute
148  dangerous?: boolean;             // Requires confirmation dialog
149  confirmationMessage?: string;    // Custom confirmation message for dangerous operations
150  icon?: string;                   // UI icon name/class
151  buttonStyle?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; // UI button styling
152}
153
154/**
155 * Result of executing an operation
156 */
157export interface OperationResult<T = any> {
158  success: boolean;
159  data?: T;
160  message?: string;
161  error?: string;
162  metadata?: Record<string, any>;
163}
164
165/**
166 * Service metadata interface for comprehensive service discovery and selection
167 */
168export interface ServiceMetadata {
169  // === Identity & Basic Info ===
170  name: string;                    // Unique service identifier
171  displayName: string;             // Human-friendly name  
172  description: string;             // What this service does
173  contract: string;                // Interface it implements (e.g., 'ICacheService')
174  implementation: string;          // Implementation type (e.g., 'InMemory', 'Redis')
175  version: string;                 // Service implementation version
176  contractVersion: string;         // Interface contract version
177  
178  // === Capabilities & Features ===
179  features: string[];              // Key features this implementation provides
180  limitations?: string[];          // Known limitations or constraints
181  
182  // === Requirements ===
183  requirements: {
184    services?: string[];           // Other service dependencies (e.g., ['IDatabaseService'])
185    runtime?: string[];            // External dependencies (e.g., ['Redis 6.0+', 'Node.js 18+'])
186    configuration: {
187      required: string[];          // Required config keys (e.g., ['REDIS_URL'])
188      optional?: string[];         // Optional config keys (e.g., ['REDIS_POOL_SIZE'])
189    };
190  };
191  
192  // === Use Case Recommendations ===
193  recommendations: {
194    idealFor: string[];            // Perfect use cases
195    acceptableFor?: string[];      // Works but not optimal
196    notRecommendedFor?: string[];  // Poor fit scenarios
197  };
198  
199  // === Configuration Schema (optional) ===
200  configurationSchema?: {
201    [key: string]: {
202      type: 'string' | 'number' | 'boolean' | 'array' | 'object';
203      description: string;
204      default?: any;
205      validation?: string;         // Validation rule or regex
206      example?: any;
207    };
208  };
209}
210
211/**
212 * Abstract base class for all services in the micro-block architecture.
213 * Enforces metadata requirements for service discovery and selection.
214 */
215export abstract class BaseService {
216  /**
217   * Static metadata that describes this service implementation.
218   * Must be accessible before instantiation for discovery purposes.
219   */
220  static readonly metadata: ServiceMetadata;
221  
222  /**
223   * Get the metadata for this service.
224   * Returns the static metadata property.
225   */
226  abstract getMetadata(): ServiceMetadata;
227  
228  /**
229   * Initialize the service with optional configuration.
230   * Override this method to implement service-specific initialization.
231   */
232  abstract initialize(config?: Record<string, any>): Promise<void> | void;
233  
234  /**
235   * Health check for the service.
236   * Override this method to implement service-specific health checks.
237   */
238  abstract isHealthy(): Promise<boolean> | boolean;
239  
240  /**
241   * Graceful shutdown of the service.
242   * Override this method to implement service-specific cleanup.
243   */
244  abstract destroy(): Promise<void> | void;
245  
246  /**
247   * Get service-specific status information.
248   * Override this method to provide custom status details.
249   */
250  getStatus(): Record<string, any> {
251    return {
252      healthy: this.isHealthy(),
253      metadata: this.getMetadata()
254    };
255  }
256  
257  /**
258   * Get available operations for this service.
259   * Override this method to expose service-specific operations for admin interface.
260   * Can be async to allow services to determine available operations based on current state.
261   */
262  getOperations(): ServiceOperation[] | Promise<ServiceOperation[]> {
263    return [];
264  }
265
266  /**
267   * Execute a service-specific operation with enhanced error handling and result formatting.
268   * This provides a generic way for services to expose custom operations
269   * while maintaining type safety through service-specific interfaces.
270   */
271  async executeOperation<T = any>(
272    operation: string, 
273    params?: Record<string, any>
274  ): Promise<OperationResult<T>> {
275    try {
276      // Validate that the operation exists - handle both sync and async getOperations
277      const operationsResult = this.getOperations();
278      const availableOperations = await Promise.resolve(operationsResult);
279      const operationDef = availableOperations.find((op: ServiceOperation) => op.name === operation);
280      
281      if (!operationDef) {
282        return {
283          success: false,
284          error: `Operation '${operation}' is not supported by service '${this.getMetadata().name}'`,
285          metadata: { availableOperations: availableOperations.map((op: ServiceOperation) => op.name) }
286        };
287      }
288
289      // Validate parameters
290      const validationResult = this.validateOperationParameters(operationDef, params || {});
291      if (!validationResult.valid) {
292        return {
293          success: false,
294          error: `Parameter validation failed: ${validationResult.errors.join(', ')}`,
295          metadata: { validationErrors: validationResult.errors }
296        };
297      }
298
299      // Execute the operation
300      const result = await this.executeOperationInternal(operation, params || {});
301      
302      return {
303        success: true,
304        data: result,
305        message: `Operation '${operation}' completed successfully`,
306        metadata: { operation: operationDef.name, executedAt: new Date().toISOString() }
307      };
308
309    } catch (error) {
310      return {
311        success: false,
312        error: error instanceof Error ? error.message : String(error),
313        metadata: { operation, executedAt: new Date().toISOString() }
314      };
315    }
316  }
317
318  /**
319   * Internal operation execution - override this in derived classes.
320   * This method should handle the actual operation logic.
321   */
322  protected async executeOperationInternal<T = any>(
323    operation: string, 
324    params: Record<string, any>
325  ): Promise<T> {
326    throw new Error(
327      `Operation '${operation}' is not implemented by service '${this.getMetadata().name}'`
328    );
329  }
330  
331  /**
332   * Validate operation parameters against their definitions.
333   */
334  protected validateOperationParameters(
335    operation: ServiceOperation, 
336    params: Record<string, any>
337  ): { valid: boolean; errors: string[] } {
338    const errors: string[] = [];
339
340    // Check required parameters
341    for (const param of operation.parameters) {
342      if (param.required && (params[param.name] === undefined || params[param.name] === null)) {
343        errors.push(`Required parameter '${param.name}' is missing`);
344        continue;
345      }
346
347      const value = params[param.name];
348      if (value !== undefined && value !== null) {
349        // Type validation
350        const actualType = Array.isArray(value) ? 'array' : typeof value;
351        if (actualType !== param.type) {
352          errors.push(`Parameter '${param.name}' must be of type '${param.type}', got '${actualType}'`);
353          continue;
354        }
355
356        // Validation rules
357        if (param.validation) {
358          const validationErrors = this.validateParameterValue(param.name, value, param.validation);
359          errors.push(...validationErrors);
360        }
361      }
362    }
363
364    // Check for unknown parameters
365    const allowedParams = new Set(operation.parameters.map(p => p.name));
366    for (const paramName of Object.keys(params)) {
367      if (!allowedParams.has(paramName)) {
368        errors.push(`Unknown parameter '${paramName}'`);
369      }
370    }
371
372    return { valid: errors.length === 0, errors };
373  }
374
375  /**
376   * Validate a single parameter value against validation rules.
377   */
378  private validateParameterValue(name: string, value: any, validation: ParameterValidation): string[] {
379    const errors: string[] = [];
380
381    // Min/Max validation
382    if (typeof value === 'number') {
383      if (validation.min !== undefined && value < validation.min) {
384        errors.push(`Parameter '${name}' must be at least ${validation.min}`);
385      }
386      if (validation.max !== undefined && value > validation.max) {
387        errors.push(`Parameter '${name}' must be at most ${validation.max}`);
388      }
389    }
390
391    // String/Array length validation
392    if (typeof value === 'string' || Array.isArray(value)) {
393      if (validation.min !== undefined && value.length < validation.min) {
394        errors.push(`Parameter '${name}' must have at least ${validation.min} characters/items`);
395      }
396      if (validation.max !== undefined && value.length > validation.max) {
397        errors.push(`Parameter '${name}' must have at most ${validation.max} characters/items`);
398      }
399    }
400
401    // Options validation (enum-like)
402    if (validation.options && !validation.options.includes(String(value))) {
403      errors.push(`Parameter '${name}' must be one of: ${validation.options.join(', ')}`);
404    }
405
406    // Pattern validation (regex)
407    if (validation.pattern && typeof value === 'string') {
408      const regex = new RegExp(validation.pattern);
409      if (!regex.test(value)) {
410        errors.push(`Parameter '${name}' does not match required pattern`);
411      }
412    }
413
414    return errors;
415  }
416
417  /**
418   * Validate configuration against the service's requirements.
419   * Uses the metadata to check required and optional configuration keys.
420   */
421  protected validateConfiguration(config: Record<string, any>): void {
422    const metadata = this.getMetadata();
423    const required = metadata.requirements.configuration.required || [];
424    
425    // Check required configuration
426    for (const key of required) {
427      if (config[key] === undefined || config[key] === null) {
428        throw new Error(
429          `Required configuration key '${key}' is missing for service '${metadata.name}'`
430        );
431      }
432    }
433    
434    // Validate configuration against schema if provided
435    if (metadata.configurationSchema) {
436      for (const [key, value] of Object.entries(config)) {
437        const schema = metadata.configurationSchema[key];
438        if (schema && !this.validateConfigValue(value, schema)) {
439          throw new Error(
440            `Configuration key '${key}' is invalid for service '${metadata.name}': ${schema.validation || 'Type mismatch'}`
441          );
442        }
443      }
444    }
445  }
446  
447  /**
448   * Validate a single configuration value against its schema.
449   */
450  private validateConfigValue(value: any, schema: NonNullable<ServiceMetadata['configurationSchema']>[string]): boolean {
451    // Basic type checking
452    const actualType = Array.isArray(value) ? 'array' : typeof value;
453    if (actualType !== schema.type) {
454      return false;
455    }
456    
457    // Additional validation rules can be added here
458    if (schema.validation) {
459      // For now, just basic validation - could be extended with regex, range checks, etc.
460      if (schema.type === 'string' && schema.validation.includes('Must be')) {
461        // Could implement more sophisticated validation
462        return value.length > 0;
463      }
464    }
465    
466    return true;
467  }
468}
469
470/**
471 * Email service interface and supporting types
472 */
473export interface EmailOptions {
474  to: string;
475  subject: string;
476  html: string;
477  text?: string;
478}
479
480export interface IEmailService {
481  /**
482   * Send an email
483   * @param options - Email configuration including recipient, subject, and content
484   * @returns Promise resolving to true if email was sent successfully
485   */
486  sendEmail(options: EmailOptions): Promise<boolean>;
487
488  /**
489   * Send verification email to new users
490   * @param email - Recipient email address
491   * @param name - Recipient name
492   * @param token - Verification token
493   * @returns Promise resolving to true if email was sent successfully
494   */
495  sendVerificationEmail(email: string, name: string, token: string): Promise<boolean>;
496
497  /**
498   * Send password reset email
499   * @param email - Recipient email address
500   * @param name - Recipient name
501   * @param token - Password reset token
502   * @returns Promise resolving to true if email was sent successfully
503   */
504  sendPasswordResetEmail(email: string, name: string, token: string): Promise<boolean>;
505}
506
507/**
508 * Email provider interfaces
509 */
510export interface EmailAttachment {
511  filename: string;
512  content: Buffer | string;
513  contentType?: string;
514  cid?: string;           // Content ID for inline attachments
515}
516
517export interface EmailOptions_Provider {
518  to: string | string[];
519  from?: string;          // Optional - use provider default if not specified
520  subject: string;
521  text?: string;          // Plain text version
522  html?: string;          // HTML version  
523  replyTo?: string;
524  cc?: string | string[];
525  bcc?: string | string[];
526  attachments?: EmailAttachment[];
527  headers?: Record<string, string>;
528}
529
530export interface EmailResult {
531  success: boolean;
532  messageId?: string;     // Provider-specific message ID
533  providerId?: string;    // Provider identifier
534  sentAt: Date;
535  error?: string;
536}
537
538export interface EmailProviderMetadata {
539  name: string;           // 'mailpit' | 'resend'
540  displayName: string;    // 'Mailpit (Development)' | 'Resend'
541  version: string;
542  features: string[];     // ['html', 'attachments', 'templates']
543  limitations: string[];  // ['development-only'] | ['domain-verification-required']
544}
545
546export interface IEmailProvider {
547  /**
548   * Send an email using this provider
549   * @param options - Email configuration and content
550   * @returns Promise resolving to email result with success status
551   */
552  sendEmail(options: EmailOptions_Provider): Promise<EmailResult>;
553  
554  /**
555   * Validate that the provider is properly configured
556   * @returns Promise resolving to true if configuration is valid
557   */
558  validateConfiguration(): Promise<boolean>;
559  
560  /**
561   * Get provider metadata for discovery and capabilities
562   * @returns Provider metadata including features and limitations
563   */
564  getMetadata(): EmailProviderMetadata;
565  
566  /**
567   * Check if the provider is healthy and ready to send emails
568   * @returns Promise resolving to true if provider is operational
569   */
570  isHealthy(): Promise<boolean>;
571}
572
573// ===== END EMBEDDED INTERFACES =====
574
575/**
576 * Email configuration interface
577 */
578export interface EmailConfig {
579  provider: string;
580  from: {
581    email: string;
582    name: string;
583  };
584  replyTo?: {
585    email: string;
586    name?: string;
587  };
588  // Provider-specific configurations would go here
589  // e.g., mailpit: { host: string, port: number }
590  // e.g., resend: { apiKey: string }
591}
592
593/**
594 * Application configuration interface
595 */
596export interface AppConfig {
597  baseUrl: string;
598  appName?: string;
599  supportEmail?: string;
600}
601
602/**
603 * Email template interface for extensibility
604 */
605export interface EmailTemplate {
606  subject: string;
607  html: string;
608  text: string;
609}
610
611/**
612 * Email template data for verification emails
613 */
614export interface VerificationEmailData {
615  name: string;
616  verificationUrl: string;
617  appName: string;
618  expirationHours: number;
619}
620
621/**
622 * Email template data for password reset emails
623 */
624export interface PasswordResetEmailData {
625  name: string;
626  resetUrl: string;
627  appName: string;
628  expirationHours: number;
629}
630
631/**
632 * Abstract email template generator
633 * 
634 * This interface allows for different template implementations:
635 * - Built-in templates (default)
636 * - External template files
637 * - Template engines (Handlebars, Mustache, etc.)
638 * - Database-stored templates
639 */
640export interface IEmailTemplateGenerator {
641  generateVerificationEmail(data: VerificationEmailData): EmailTemplate;
642  generatePasswordResetEmail(data: PasswordResetEmailData): EmailTemplate;
643  generateGenericEmail(templateName: string, data: Record<string, any>): EmailTemplate;
644}
645
646/**
647 * Built-in email template generator
648 * 
649 * This is a simple implementation that provides basic HTML and text templates.
650 * For production use, consider implementing a more sophisticated template system.
651 */
652export class DefaultEmailTemplateGenerator implements IEmailTemplateGenerator {
653  private logger: ILogger;
654
655  constructor(logger?: ILogger) {
656    this.logger = logger || new ConsoleLogger('EmailTemplateGenerator');
657  }
658
659  generateVerificationEmail(data: VerificationEmailData): EmailTemplate {
660    return {
661      subject: `Verify Your ${data.appName} Account`,
662      html: this.generateVerificationEmailHtml(data),
663      text: this.generateVerificationEmailText(data)
664    };
665  }
666
667  generatePasswordResetEmail(data: PasswordResetEmailData): EmailTemplate {
668    return {
669      subject: `Reset Your ${data.appName} Password`,
670      html: this.generatePasswordResetEmailHtml(data),
671      text: this.generatePasswordResetEmailText(data)
672    };
673  }
674
675  generateGenericEmail(templateName: string, data: Record<string, any>): EmailTemplate {
676    this.logger.warn(`Generic email template '${templateName}' not implemented`, { templateName, data });
677    return {
678      subject: `${data.appName || 'Application'} Notification`,
679      html: `<p>Hello,</p><p>This is a generic email notification.</p><p>Best regards,<br>The ${data.appName || 'Application'} Team</p>`,
680      text: `Hello,\n\nThis is a generic email notification.\n\nBest regards,\nThe ${data.appName || 'Application'} Team`
681    };
682  }
683
684  private generateVerificationEmailHtml(data: VerificationEmailData): string {
685    return `
686<!DOCTYPE html>
687<html>
688<head>
689    <meta charset="utf-8">
690    <title>Verify Your Email</title>
691    <style>
692        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
693        .header { background: #007cba; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
694        .content { background: #f9f9f9; padding: 20px; border-radius: 0 0 5px 5px; }
695        .button { display: inline-block; background: #007cba; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
696        .footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 14px; }
697    </style>
698</head>
699<body>
700    <div class="header">
701        <h1>Welcome to ${data.appName}!</h1>
702    </div>
703    <div class="content">
704        <p>Hi ${data.name},</p>
705        <p>Thank you for signing up! Please verify your email address to complete your registration.</p>
706        <p>
707            <a href="${data.verificationUrl}" class="button">Verify Email Address</a>
708        </p>
709        <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
710        <p><a href="${data.verificationUrl}">${data.verificationUrl}</a></p>
711        <p>This verification link will expire in ${data.expirationHours} hours.</p>
712        <p>If you didn't create an account, you can safely ignore this email.</p>
713    </div>
714    <div class="footer">
715        <p>Best regards,<br>The ${data.appName} Team</p>
716    </div>
717</body>
718</html>
719    `.trim();
720  }
721
722  private generateVerificationEmailText(data: VerificationEmailData): string {
723    return `
724Welcome to ${data.appName}!
725
726Hi ${data.name},
727
728Thank you for signing up! Please verify your email address to complete your registration.
729
730Verify your email by clicking this link:
731${data.verificationUrl}
732
733This verification link will expire in ${data.expirationHours} hours.
734
735If you didn't create an account, you can safely ignore this email.
736
737Best regards,
738The ${data.appName} Team
739    `.trim();
740  }
741
742  private generatePasswordResetEmailHtml(data: PasswordResetEmailData): string {
743    return `
744<!DOCTYPE html>
745<html>
746<head>
747    <meta charset="utf-8">
748    <title>Reset Your Password</title>
749    <style>
750        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
751        .header { background: #dc3545; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
752        .content { background: #f9f9f9; padding: 20px; border-radius: 0 0 5px 5px; }
753        .button { display: inline-block; background: #dc3545; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
754        .footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 14px; }
755        .warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 15px 0; }
756    </style>
757</head>
758<body>
759    <div class="header">
760        <h1>Password Reset Request</h1>
761    </div>
762    <div class="content">
763        <p>Hi ${data.name},</p>
764        <p>We received a request to reset your password for your ${data.appName} account.</p>
765        <p>
766            <a href="${data.resetUrl}" class="button">Reset Password</a>
767        </p>
768        <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
769        <p><a href="${data.resetUrl}">${data.resetUrl}</a></p>
770        <div class="warning">
771            <strong>Security Notice:</strong> This password reset link will expire in ${data.expirationHours} hour(s) for your security.
772        </div>
773        <p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
774    </div>
775    <div class="footer">
776        <p>Best regards,<br>The ${data.appName} Team</p>
777    </div>
778</body>
779</html>
780    `.trim();
781  }
782
783  private generatePasswordResetEmailText(data: PasswordResetEmailData): string {
784    return `
785Password Reset Request
786
787Hi ${data.name},
788
789We received a request to reset your password for your ${data.appName} account.
790
791Reset your password by clicking this link:
792${data.resetUrl}
793
794SECURITY NOTICE: This password reset link will expire in ${data.expirationHours} hour(s) for your security.
795
796If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
797
798Best regards,
799The ${data.appName} Team
800    `.trim();
801  }
802}
803
804/**
805 * EmailService - Main email service that uses pluggable providers
806 * 
807 * This service provides a comprehensive email solution with:
808 * - Pluggable provider support for different email backends
809 * - Template abstraction for consistent email formatting
810 * - Multi-environment support (development/production)
811 * - Business logic separation from delivery mechanism
812 * - Health monitoring and configuration validation
813 * 
814 * Features:
815 * - Provider-agnostic email sending
816 * - Built-in templates with customization support
817 * - HTML and text email generation
818 * - Template data validation
819 * - Configuration validation with nested object support
820 * - Error handling and logging
821 * - Health monitoring
822 * 
823 * Usage:
824 * ```typescript
825 * const config: EmailConfig = {
826 *   provider: 'resend',
827 *   from: { email: 'noreply@example.com', name: 'My App' },
828 *   replyTo: { email: 'support@example.com', name: 'Support' }
829 * };
830 * 
831 * const appConfig: AppConfig = {
832 *   baseUrl: 'https://myapp.com',
833 *   appName: 'My Application'
834 * };
835 * 
836 * const emailProvider = new ResendEmailProvider(providerConfig);
837 * const service = new EmailService(config, emailProvider, appConfig);
838 * 
839 * await service.sendVerificationEmail('user@example.com', 'John Doe', 'verification-token');
840 * ```
841 */
842export class EmailService extends BaseService implements IEmailService {
843  static readonly metadata: ServiceMetadata = {
844    name: 'EmailService',
845    displayName: 'Email Service',
846    description: 'Generic email service with pluggable provider support for development and production email delivery',
847    contract: 'IEmailService',
848    implementation: 'Generic',
849    version: '1.0.0',
850    contractVersion: '1.0.0',
851    features: [
852      'Pluggable email providers',
853      'HTML and text email support',
854      'Email templates with abstraction',
855      'Provider health monitoring',
856      'Configuration validation',
857      'Template data validation'
858    ],
859    limitations: [
860      'Provider capabilities dependent on implementation',
861      'Built-in templates are basic HTML/text only'
862    ],
863    requirements: {
864      services: ['IEmailProvider'],
865      runtime: ['Node.js 18+'],
866      configuration: {
867        required: ['provider', 'from.email', 'from.name', 'baseUrl'],
868        optional: ['replyTo.email', 'replyTo.name', 'appName', 'supportEmail']
869      }
870    },
871    recommendations: {
872      idealFor: [
873        'Multi-environment email delivery',
874        'Provider-agnostic email sending',
875        'Development and production email handling',
876        'Template-based transactional emails'
877      ],
878      acceptableFor: [
879        'Simple transactional emails',
880        'Basic email templates',
881        'Provider abstraction'
882      ],
883      notRecommendedFor: [
884        'High-volume marketing emails',
885        'Complex email campaigns',
886        'Advanced template engines'
887      ]
888    }
889  };
890
891  private emailProvider: IEmailProvider;
892  private config: EmailConfig;
893  private appConfig: AppConfig;
894  private templateGenerator: IEmailTemplateGenerator;
895  private logger: ILogger;
896
897  constructor(
898    config: EmailConfig, 
899    emailProvider: IEmailProvider, 
900    appConfig: AppConfig,
901    templateGenerator?: IEmailTemplateGenerator,
902    logger?: ILogger
903  ) {
904    super();
905    this.config = config;
906    this.emailProvider = emailProvider;
907    this.appConfig = appConfig;
908    this.templateGenerator = templateGenerator || new DefaultEmailTemplateGenerator(logger);
909    this.logger = logger || new ConsoleLogger('EmailService');
910    this.validateConfiguration({ ...config, ...appConfig });
911  }
912
913  /**
914   * Override base class configuration validation to handle nested config structure
915   */
916  protected validateConfiguration(config: Record<string, any>): void {
917    const metadata = this.getMetadata();
918    const required = metadata.requirements.configuration.required || [];
919    
920    // Check required configuration with nested object support
921    for (const key of required) {
922      if (key.includes('.')) {
923        // Handle nested keys like 'from.email'
924        const parts = key.split('.');
925        let value: any = config;
926        
927        for (const part of parts) {
928          value = value?.[part];
929          if (value === undefined || value === null) {
930            throw new Error(
931              `Required configuration key '${key}' is missing for service '${metadata.name}'`
932            );
933          }
934        }
935      } else {
936        // Handle flat keys normally
937        if (config[key] === undefined || config[key] === null) {
938          throw new Error(
939            `Required configuration key '${key}' is missing for service '${metadata.name}'`
940          );
941        }
942      }
943    }
944    
945    // Validate configuration against schema if provided
946    if (metadata.configurationSchema) {
947      for (const [key, value] of Object.entries(config)) {
948        const schema = metadata.configurationSchema[key];
949        if (schema && !this.validateConfigValueLocal(value, schema)) {
950          throw new Error(
951            `Configuration key '${key}' is invalid for service '${metadata.name}': ${schema.validation || 'Type mismatch'}`
952          );
953        }
954      }
955    }
956  }
957
958  /**
959   * Validate a single configuration value against its schema (copied from base class)
960   */
961  private validateConfigValueLocal(value: any, schema: NonNullable<ServiceMetadata['configurationSchema']>[string]): boolean {
962    // Basic type checking
963    const actualType = Array.isArray(value) ? 'array' : typeof value;
964    if (actualType !== schema.type) {
965      return false;
966    }
967    
968    // Additional validation rules can be added here
969    if (schema.validation) {
970      // For now, just basic validation - could be extended with regex, range checks, etc.
971      if (schema.type === 'string' && schema.validation.includes('Must be')) {
972        // Could implement more sophisticated validation
973        return value.length > 0;
974      }
975    }
976    
977    return true;
978  }
979
980  async initialize(): Promise<void> {
981    // Validate provider configuration
982    const isValidConfig = await this.emailProvider.validateConfiguration();
983    if (!isValidConfig) {
984      throw new Error(`Email provider '${this.emailProvider.getMetadata().name}' configuration is invalid`);
985    }
986    
987    this.logger.info('Email service initialized successfully', {
988      provider: this.emailProvider.getMetadata().name,
989      fromEmail: this.config.from.email,
990      appName: this.appConfig.appName
991    });
992  }
993
994  getMetadata(): ServiceMetadata {
995    return EmailService.metadata;
996  }
997
998  async isHealthy(): Promise<boolean> {
999    try {
1000      return await this.emailProvider.isHealthy();
1001    } catch (error) {
1002      this.logger.error('Email service health check failed', error);
1003      return false;
1004    }
1005  }
1006
1007  async destroy(): Promise<void> {
1008    this.logger.info('Email service destroyed');
1009  }
1010
1011  /**
1012   * Send a generic email using the configured provider
1013   */
1014  async sendEmail(options: EmailOptions): Promise<boolean> {
1015    try {
1016      // Convert IEmailService.EmailOptions to IEmailProvider.EmailOptions
1017      const providerOptions: EmailOptions_Provider = {
1018        to: options.to,
1019        from: this.getFromAddress(),
1020        subject: options.subject,
1021        html: options.html,
1022        text: options.text,
1023        replyTo: this.getReplyToAddress()
1024      };
1025
1026      const result: EmailResult = await this.emailProvider.sendEmail(providerOptions);
1027      
1028      if (result.success) {
1029        this.logger.info('Email sent successfully', {
1030          to: options.to,
1031          subject: options.subject,
1032          messageId: result.messageId,
1033          provider: result.providerId
1034        });
1035      } else {
1036        this.logger.error('Email sending failed', { 
1037          to: options.to, 
1038          subject: options.subject, 
1039          error: result.error 
1040        });
1041      }
1042      
1043      return result.success;
1044    } catch (error) {
1045      this.logger.error('EmailService.sendEmail failed', error, { 
1046        to: options.to, 
1047        subject: options.subject 
1048      });
1049      return false;
1050    }
1051  }
1052
1053  /**
1054   * Send verification email to new users
1055   */
1056  async sendVerificationEmail(email: string, name: string, token: string): Promise<boolean> {
1057    try {
1058      const verificationUrl = `${this.appConfig.baseUrl}/auth/verify?token=${token}&email=${encodeURIComponent(email)}`;
1059      
1060      const templateData: VerificationEmailData = {
1061        name,
1062        verificationUrl,
1063        appName: this.appConfig.appName || 'Application',
1064        expirationHours: 24
1065      };
1066
1067      const template = this.templateGenerator.generateVerificationEmail(templateData);
1068
1069      const providerOptions: EmailOptions_Provider = {
1070        to: email,
1071        from: this.getFromAddress(),
1072        subject: template.subject,
1073        html: template.html,
1074        text: template.text,
1075        replyTo: this.getReplyToAddress()
1076      };
1077
1078      const result: EmailResult = await this.emailProvider.sendEmail(providerOptions);
1079      
1080      if (result.success) {
1081        this.logger.info('Verification email sent successfully', {
1082          to: email,
1083          messageId: result.messageId,
1084          provider: result.providerId
1085        });
1086      } else {
1087        this.logger.error('Verification email sending failed', { 
1088          to: email, 
1089          error: result.error 
1090        });
1091      }
1092      
1093      return result.success;
1094    } catch (error) {
1095      this.logger.error('EmailService.sendVerificationEmail failed', error, { 
1096        to: email, 
1097        name 
1098      });
1099      return false;
1100    }
1101  }
1102
1103  /**
1104   * Send password reset email
1105   */
1106  async sendPasswordResetEmail(email: string, name: string, token: string): Promise<boolean> {
1107    try {
1108      const resetUrl = `${this.appConfig.baseUrl}/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
1109      
1110      const templateData: PasswordResetEmailData = {
1111        name,
1112        resetUrl,
1113        appName: this.appConfig.appName || 'Application',
1114        expirationHours: 1
1115      };
1116
1117      const template = this.templateGenerator.generatePasswordResetEmail(templateData);
1118
1119      const providerOptions: EmailOptions_Provider = {
1120        to: email,
1121        from: this.getFromAddress(),
1122        subject: template.subject,
1123        html: template.html,
1124        text: template.text,
1125        replyTo: this.getReplyToAddress()
1126      };
1127
1128      const result: EmailResult = await this.emailProvider.sendEmail(providerOptions);
1129      
1130      if (result.success) {
1131        this.logger.info('Password reset email sent successfully', {
1132          to: email,
1133          messageId: result.messageId,
1134          provider: result.providerId
1135        });
1136      } else {
1137        this.logger.error('Password reset email sending failed', { 
1138          to: email, 
1139          error: result.error 
1140        });
1141      }
1142      
1143      return result.success;
1144    } catch (error) {
1145      this.logger.error('EmailService.sendPasswordResetEmail failed', error, { 
1146        to: email, 
1147        name 
1148      });
1149      return false;
1150    }
1151  }
1152
1153  /**
1154   * Administrative operations
1155   */
1156  async getOperations(): Promise<ServiceOperation[]> {
1157    return [
1158      {
1159        name: 'sendTestEmail',
1160        displayName: 'Send Test Email',
1161        description: 'Send a test email to verify email service configuration',
1162        category: 'testing',
1163        icon: 'mail',
1164        buttonStyle: 'primary',
1165        parameters: [
1166          {
1167            name: 'to',
1168            type: 'string',
1169            required: true,
1170            displayName: 'Recipient Email',
1171            description: 'Email address to send test email to',
1172            group: 'Configuration',
1173            validation: {
1174              pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$'
1175            }
1176          },
1177          {
1178            name: 'testType',
1179            type: 'string',
1180            required: false,
1181            displayName: 'Test Type',
1182            description: 'Type of test email to send',
1183            group: 'Configuration',
1184            defaultValue: 'basic',
1185            validation: {
1186              options: ['basic', 'verification', 'password-reset']
1187            }
1188          }
1189        ]
1190      },
1191      {
1192        name: 'getProviderInfo',
1193        displayName: 'Get Provider Information',
1194        description: 'Get information about the configured email provider',
1195        category: 'monitoring',
1196        icon: 'info',
1197        buttonStyle: 'secondary',
1198        parameters: []
1199      }
1200    ];
1201  }
1202
1203  protected async executeOperationInternal<T = any>(
1204    operation: string,
1205    params: Record<string, any>
1206  ): Promise<T> {
1207    this.logger.info('Executing email operation', { operation, params });
1208    
1209    switch (operation) {
1210      case 'sendTestEmail':
1211        const testResult = await this.sendTestEmail(params.to, params.testType || 'basic');
1212        return {
1213          message: testResult ? 'Test email sent successfully' : 'Test email failed to send',
1214          data: { success: testResult, recipient: params.to, testType: params.testType },
1215          metadata: {
1216            operation: 'sendTestEmail',
1217            executedAt: new Date().toISOString()
1218          }
1219        } as T;
1220        
1221      case 'getProviderInfo':
1222        const providerInfo = this.emailProvider.getMetadata();
1223        return {
1224          message: 'Provider information retrieved successfully',
1225          data: providerInfo,
1226          metadata: {
1227            operation: 'getProviderInfo',
1228            executedAt: new Date().toISOString()
1229          }
1230        } as T;
1231        
1232      default:
1233        throw new Error(`Operation '${operation}' is not implemented`);
1234    }
1235  }
1236
1237  private async sendTestEmail(to: string, testType: string): Promise<boolean> {
1238    try {
1239      switch (testType) {
1240        case 'verification':
1241          return await this.sendVerificationEmail(to, 'Test User', 'test-verification-token');
1242        case 'password-reset':
1243          return await this.sendPasswordResetEmail(to, 'Test User', 'test-reset-token');
1244        default:
1245          return await this.sendEmail({
1246            to,
1247            subject: `Test Email from ${this.appConfig.appName || 'Application'}`,
1248            html: `<h1>Test Email</h1><p>This is a test email sent from ${this.appConfig.appName || 'Application'} email service.</p><p>If you received this email, the email service is working correctly.</p>`,
1249            text: `Test Email\n\nThis is a test email sent from ${this.appConfig.appName || 'Application'} email service.\n\nIf you received this email, the email service is working correctly.`
1250          });
1251      }
1252    } catch (error) {
1253      this.logger.error('Test email failed', error, { to, testType });
1254      return false;
1255    }
1256  }
1257
1258  /**
1259   * Get the configured from address
1260   */
1261  private getFromAddress(): string {
1262    return `${this.config.from.name} <${this.config.from.email}>`;
1263  }
1264
1265  /**
1266   * Get the configured reply-to address
1267   */
1268  private getReplyToAddress(): string | undefined {
1269    if (this.config.replyTo?.email) {
1270      return `${this.config.replyTo.name || this.config.from.name} <${this.config.replyTo.email}>`;
1271    }
1272    return undefined;
1273  }
1274}

Metadata

Path
utaba/main/services/communication/EmailService.ts
Namespace
utaba/main/services/communication
Author
utaba
Category
services
Technology
typescript
Contract Version
1.0.0
MIME Type
application/typescript
Published
18-Jul-2025
Last Updated
18-Jul-2025