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