feat(mta): Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
This commit is contained in:
		
							
								
								
									
										39
									
								
								changelog.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								changelog.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-03-15 - 1.1.0 - feat(mta) | ||||
| Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes | ||||
|  | ||||
| - Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering | ||||
| - Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods | ||||
| - Update Email class to support multiple recipients, CC, BCC with input sanitization and validation | ||||
| - Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob | ||||
| - Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics | ||||
| - Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration | ||||
|  | ||||
| ## 2024-05-11 - 1.0.10 to 1.0.8 - core   | ||||
| Applied core fixes across several versions on this day. | ||||
|  | ||||
| - Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8 | ||||
|  | ||||
| ## 2024-04-01 - 1.0.7 - core   | ||||
| Applied a core fix. | ||||
|  | ||||
| - Fixed core functionality for version 1.0.7 | ||||
|  | ||||
| ## 2024-03-19 - 1.0.6 - core   | ||||
| Applied a core fix. | ||||
|  | ||||
| - Fixed core functionality for version 1.0.6 | ||||
|  | ||||
| ## 2024-02-16 - 1.0.5 to 1.0.2 - core   | ||||
| Applied multiple core fixes in a contiguous range of versions. | ||||
|  | ||||
| - Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2 | ||||
|  | ||||
| ## 2024-02-15 - 1.0.1 - core   | ||||
| Applied a core fix. | ||||
|  | ||||
| - Fixed core functionality for version 1.0.1 | ||||
|  | ||||
| ––––––––––––––––––––––– | ||||
| Note: Versions that only contained version bumps (for example, 1.0.11 and the plain “1.0.x” commits) have been omitted from individual entries and are implicitly included in the version ranges above. | ||||
							
								
								
									
										13355
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13355
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,8 +1,8 @@ | ||||
| /** | ||||
|  * autocreated commitinfo by @pushrocks/commitinfo | ||||
|  * autocreated commitinfo by @push.rocks/commitinfo | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@serve.zone/platformservice', | ||||
|   version: '1.0.11', | ||||
|   version: '1.1.0', | ||||
|   description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.' | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,846 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import { Email, IEmailOptions } from './mta.classes.email.js'; | ||||
| import { DeliveryStatus } from './mta.classes.emailsendjob.js'; | ||||
| import type { MtaService } from './mta.classes.mta.js'; | ||||
| import type { IDnsRecord } from './mta.classes.dnsmanager.js'; | ||||
|  | ||||
| export class ApiManager { | ||||
|   public typedrouter = new plugins.typedrequest.TypedRouter(); | ||||
|  | ||||
|    | ||||
| /** | ||||
|  * Authentication options for API requests | ||||
|  */ | ||||
| interface AuthOptions { | ||||
|   /** Required API keys for different endpoints */ | ||||
|   apiKeys: Map<string, string[]>; | ||||
|   /** JWT secret for token-based authentication */ | ||||
|   jwtSecret?: string; | ||||
|   /** Whether to validate IP addresses */ | ||||
|   validateIp?: boolean; | ||||
|   /** Allowed IP addresses */ | ||||
|   allowedIps?: string[]; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Rate limiting options for API endpoints | ||||
|  */ | ||||
| interface RateLimitOptions { | ||||
|   /** Maximum requests per window */ | ||||
|   maxRequests: number; | ||||
|   /** Time window in milliseconds */ | ||||
|   windowMs: number; | ||||
|   /** Whether to apply per endpoint */ | ||||
|   perEndpoint?: boolean; | ||||
|   /** Whether to apply per IP */ | ||||
|   perIp?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * API route definition | ||||
|  */ | ||||
| interface ApiRoute { | ||||
|   /** HTTP method */ | ||||
|   method: 'GET' | 'POST' | 'PUT' | 'DELETE'; | ||||
|   /** Path pattern */ | ||||
|   path: string; | ||||
|   /** Handler function */ | ||||
|   handler: (req: any, res: any) => Promise<any>; | ||||
|   /** Required authentication level */ | ||||
|   authLevel: 'none' | 'basic' | 'admin'; | ||||
|   /** Rate limiting options */ | ||||
|   rateLimit?: RateLimitOptions; | ||||
|   /** Route description */ | ||||
|   description?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Email send request | ||||
|  */ | ||||
| interface SendEmailRequest { | ||||
|   /** Email details */ | ||||
|   email: IEmailOptions; | ||||
|   /** Whether to validate domains before sending */ | ||||
|   validateDomains?: boolean; | ||||
|   /** Priority level (1-5, 1 = highest) */ | ||||
|   priority?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Email status response | ||||
|  */ | ||||
| interface EmailStatusResponse { | ||||
|   /** Email ID */ | ||||
|   id: string; | ||||
|   /** Current status */ | ||||
|   status: DeliveryStatus; | ||||
|   /** Send time */ | ||||
|   sentAt?: Date; | ||||
|   /** Delivery time */ | ||||
|   deliveredAt?: Date; | ||||
|   /** Error message if failed */ | ||||
|   error?: string; | ||||
|   /** Recipient address */ | ||||
|   recipient: string; | ||||
|   /** Number of delivery attempts */ | ||||
|   attempts: number; | ||||
|   /** Next retry time */ | ||||
|   nextRetry?: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain verification response | ||||
|  */ | ||||
| interface DomainVerificationResponse { | ||||
|   /** Domain name */ | ||||
|   domain: string; | ||||
|   /** Whether the domain is verified */ | ||||
|   verified: boolean; | ||||
|   /** Verification details */ | ||||
|   details: { | ||||
|     /** SPF record status */ | ||||
|     spf: { | ||||
|       valid: boolean; | ||||
|       record?: string; | ||||
|       error?: string; | ||||
|     }; | ||||
|     /** DKIM record status */ | ||||
|     dkim: { | ||||
|       valid: boolean; | ||||
|       record?: string; | ||||
|       error?: string; | ||||
|     }; | ||||
|     /** DMARC record status */ | ||||
|     dmarc: { | ||||
|       valid: boolean; | ||||
|       record?: string; | ||||
|       error?: string; | ||||
|     }; | ||||
|     /** MX record status */ | ||||
|     mx: { | ||||
|       valid: boolean; | ||||
|       records?: string[]; | ||||
|       error?: string; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * API error response | ||||
|  */ | ||||
| interface ApiError { | ||||
|   /** Error code */ | ||||
|   code: string; | ||||
|   /** Error message */ | ||||
|   message: string; | ||||
|   /** Detailed error information */ | ||||
|   details?: any; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * API Manager for MTA service | ||||
|  */ | ||||
| export class ApiManager { | ||||
|   /** TypedRouter for API routing */ | ||||
|   public typedrouter = new plugins.typedrequest.TypedRouter(); | ||||
|   /** MTA service reference */ | ||||
|   private mtaRef: MtaService; | ||||
|   /** Express app */ | ||||
|   private app: any; | ||||
|   /** Authentication options */ | ||||
|   private authOptions: AuthOptions; | ||||
|   /** API routes */ | ||||
|   private routes: ApiRoute[] = []; | ||||
|   /** Rate limiters */ | ||||
|   private rateLimiters: Map<string, { | ||||
|     count: number; | ||||
|     resetTime: number; | ||||
|     clients: Map<string, { | ||||
|       count: number; | ||||
|       resetTime: number; | ||||
|     }>; | ||||
|   }> = new Map(); | ||||
|  | ||||
|   /** | ||||
|    * Initialize API Manager | ||||
|    * @param mtaRef MTA service reference | ||||
|    */ | ||||
|   constructor(mtaRef?: MtaService) { | ||||
|     this.mtaRef = mtaRef; | ||||
|      | ||||
|     // Initialize Express app | ||||
|     this.app = plugins.express(); | ||||
|      | ||||
|     // Default authentication options | ||||
|     this.authOptions = { | ||||
|       apiKeys: new Map(), | ||||
|       validateIp: false, | ||||
|       allowedIps: [] | ||||
|     }; | ||||
|      | ||||
|     // Configure middleware | ||||
|     this.configureMiddleware(); | ||||
|      | ||||
|     // Register routes | ||||
|     this.registerRoutes(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set MTA service reference | ||||
|    * @param mtaRef MTA service reference | ||||
|    */ | ||||
|   public setMtaService(mtaRef: MtaService): void { | ||||
|     this.mtaRef = mtaRef; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Configure authentication options | ||||
|    * @param options Authentication options | ||||
|    */ | ||||
|   public configureAuth(options: Partial<AuthOptions>): void { | ||||
|     this.authOptions = { | ||||
|       ...this.authOptions, | ||||
|       ...options | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Configure Express middleware | ||||
|    */ | ||||
|   private configureMiddleware(): void { | ||||
|     // JSON body parser | ||||
|     this.app.use(plugins.express.json({ limit: '10mb' })); | ||||
|      | ||||
|     // CORS middleware | ||||
|     this.app.use((req: any, res: any, next: any) => { | ||||
|       res.header('Access-Control-Allow-Origin', '*'); | ||||
|       res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); | ||||
|       res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key'); | ||||
|        | ||||
|       if (req.method === 'OPTIONS') { | ||||
|         return res.status(200).end(); | ||||
|       } | ||||
|        | ||||
|       next(); | ||||
|     }); | ||||
|      | ||||
|     // Request logging | ||||
|     this.app.use((req: any, res: any, next: any) => { | ||||
|       const start = Date.now(); | ||||
|        | ||||
|       res.on('finish', () => { | ||||
|         const duration = Date.now() - start; | ||||
|         console.log(`[API] ${req.method} ${req.path} ${res.statusCode} ${duration}ms`); | ||||
|       }); | ||||
|        | ||||
|       next(); | ||||
|     }); | ||||
|      | ||||
|     // Authentication middleware | ||||
|     this.app.use((req: any, res: any, next: any) => { | ||||
|       // Store authentication level in request | ||||
|       req.authLevel = 'none'; | ||||
|        | ||||
|       // Check API key | ||||
|       const apiKey = req.headers['x-api-key']; | ||||
|       if (apiKey) { | ||||
|         for (const [level, keys] of this.authOptions.apiKeys.entries()) { | ||||
|           if (keys.includes(apiKey)) { | ||||
|             req.authLevel = level; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check JWT token (if configured) | ||||
|       if (this.authOptions.jwtSecret && req.headers.authorization) { | ||||
|         try { | ||||
|           const token = req.headers.authorization.split(' ')[1]; | ||||
|           const decoded = plugins.jwt.verify(token, this.authOptions.jwtSecret); | ||||
|            | ||||
|           if (decoded && decoded.level) { | ||||
|             req.authLevel = decoded.level; | ||||
|             req.user = decoded; | ||||
|           } | ||||
|         } catch (error) { | ||||
|           // Invalid token, but don't fail the request yet | ||||
|           console.error('Invalid JWT token:', error.message); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check IP address (if configured) | ||||
|       if (this.authOptions.validateIp) { | ||||
|         const clientIp = req.ip || req.connection.remoteAddress; | ||||
|         if (!this.authOptions.allowedIps.includes(clientIp)) { | ||||
|           return res.status(403).json({ | ||||
|             code: 'FORBIDDEN', | ||||
|             message: 'IP address not allowed' | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       next(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Register API routes | ||||
|    */ | ||||
|   private registerRoutes(): void { | ||||
|     // Email routes | ||||
|     this.addRoute({ | ||||
|       method: 'POST', | ||||
|       path: '/api/email/send', | ||||
|       handler: this.handleSendEmail.bind(this), | ||||
|       authLevel: 'basic', | ||||
|       description: 'Send an email' | ||||
|     }); | ||||
|      | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api/email/status/:id', | ||||
|       handler: this.handleGetEmailStatus.bind(this), | ||||
|       authLevel: 'basic', | ||||
|       description: 'Get email delivery status' | ||||
|     }); | ||||
|      | ||||
|     // Domain routes | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api/domain/verify/:domain', | ||||
|       handler: this.handleVerifyDomain.bind(this), | ||||
|       authLevel: 'basic', | ||||
|       description: 'Verify domain DNS records' | ||||
|     }); | ||||
|      | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api/domain/records/:domain', | ||||
|       handler: this.handleGetDomainRecords.bind(this), | ||||
|       authLevel: 'basic', | ||||
|       description: 'Get recommended DNS records for domain' | ||||
|     }); | ||||
|      | ||||
|     // DKIM routes | ||||
|     this.addRoute({ | ||||
|       method: 'POST', | ||||
|       path: '/api/dkim/generate/:domain', | ||||
|       handler: this.handleGenerateDkim.bind(this), | ||||
|       authLevel: 'admin', | ||||
|       description: 'Generate DKIM keys for domain' | ||||
|     }); | ||||
|      | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api/dkim/public/:domain', | ||||
|       handler: this.handleGetDkimPublicKey.bind(this), | ||||
|       authLevel: 'basic', | ||||
|       description: 'Get DKIM public key for domain' | ||||
|     }); | ||||
|      | ||||
|     // Stats route | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api/stats', | ||||
|       handler: this.handleGetStats.bind(this), | ||||
|       authLevel: 'admin', | ||||
|       description: 'Get MTA statistics' | ||||
|     }); | ||||
|      | ||||
|     // Documentation route | ||||
|     this.addRoute({ | ||||
|       method: 'GET', | ||||
|       path: '/api', | ||||
|       handler: this.handleGetApiDocs.bind(this), | ||||
|       authLevel: 'none', | ||||
|       description: 'API documentation' | ||||
|     }); | ||||
|      | ||||
|     // Map routes to Express | ||||
|     this.mapRoutesToExpress(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add an API route | ||||
|    * @param route Route definition | ||||
|    */ | ||||
|   private addRoute(route: ApiRoute): void { | ||||
|     this.routes.push(route); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Map defined routes to Express | ||||
|    */ | ||||
|   private mapRoutesToExpress(): void { | ||||
|     for (const route of this.routes) { | ||||
|       const { method, path, handler, authLevel } = route; | ||||
|        | ||||
|       // Add Express route | ||||
|       this.app[method.toLowerCase()](path, async (req: any, res: any) => { | ||||
|         try { | ||||
|           // Check authentication | ||||
|           if (authLevel !== 'none' && req.authLevel !== authLevel && req.authLevel !== 'admin') { | ||||
|             return res.status(403).json({ | ||||
|               code: 'FORBIDDEN', | ||||
|               message: `This endpoint requires ${authLevel} access` | ||||
|             }); | ||||
|           } | ||||
|            | ||||
|           // Check rate limit | ||||
|           if (route.rateLimit) { | ||||
|             const exceeded = this.checkRateLimit(route, req); | ||||
|             if (exceeded) { | ||||
|               return res.status(429).json({ | ||||
|                 code: 'RATE_LIMIT_EXCEEDED', | ||||
|                 message: 'Rate limit exceeded, please try again later' | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|            | ||||
|           // Handle the request | ||||
|           await handler(req, res); | ||||
|         } catch (error) { | ||||
|           console.error(`Error handling ${method} ${path}:`, error); | ||||
|            | ||||
|           // Send appropriate error response | ||||
|           const status = error.status || 500; | ||||
|           const apiError: ApiError = { | ||||
|             code: error.code || 'INTERNAL_ERROR', | ||||
|             message: error.message || 'Internal server error' | ||||
|           }; | ||||
|            | ||||
|           if (process.env.NODE_ENV !== 'production') { | ||||
|             apiError.details = error.stack; | ||||
|           } | ||||
|            | ||||
|           res.status(status).json(apiError); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     // Add 404 handler | ||||
|     this.app.use((req: any, res: any) => { | ||||
|       res.status(404).json({ | ||||
|         code: 'NOT_FOUND', | ||||
|         message: 'Endpoint not found' | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check rate limit for a route | ||||
|    * @param route Route definition | ||||
|    * @param req Express request | ||||
|    * @returns Whether rate limit is exceeded | ||||
|    */ | ||||
|   private checkRateLimit(route: ApiRoute, req: any): boolean { | ||||
|     if (!route.rateLimit) return false; | ||||
|      | ||||
|     const { maxRequests, windowMs, perEndpoint, perIp } = route.rateLimit; | ||||
|      | ||||
|     // Determine rate limit key | ||||
|     let key = 'global'; | ||||
|     if (perEndpoint) { | ||||
|       key = `${route.method}:${route.path}`; | ||||
|     } | ||||
|      | ||||
|     // Get or create limiter | ||||
|     if (!this.rateLimiters.has(key)) { | ||||
|       this.rateLimiters.set(key, { | ||||
|         count: 0, | ||||
|         resetTime: Date.now() + windowMs, | ||||
|         clients: new Map() | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     const limiter = this.rateLimiters.get(key); | ||||
|      | ||||
|     // Reset if window has passed | ||||
|     if (Date.now() > limiter.resetTime) { | ||||
|       limiter.count = 0; | ||||
|       limiter.resetTime = Date.now() + windowMs; | ||||
|       limiter.clients.clear(); | ||||
|     } | ||||
|      | ||||
|     // Check per-IP limit if enabled | ||||
|     if (perIp) { | ||||
|       const clientIp = req.ip || req.connection.remoteAddress; | ||||
|       let clientLimiter = limiter.clients.get(clientIp); | ||||
|        | ||||
|       if (!clientLimiter) { | ||||
|         clientLimiter = { | ||||
|           count: 0, | ||||
|           resetTime: Date.now() + windowMs | ||||
|         }; | ||||
|         limiter.clients.set(clientIp, clientLimiter); | ||||
|       } | ||||
|        | ||||
|       // Reset client limiter if needed | ||||
|       if (Date.now() > clientLimiter.resetTime) { | ||||
|         clientLimiter.count = 0; | ||||
|         clientLimiter.resetTime = Date.now() + windowMs; | ||||
|       } | ||||
|        | ||||
|       // Check client limit | ||||
|       if (clientLimiter.count >= maxRequests) { | ||||
|         return true; | ||||
|       } | ||||
|        | ||||
|       // Increment client count | ||||
|       clientLimiter.count++; | ||||
|     } else { | ||||
|       // Check global limit | ||||
|       if (limiter.count >= maxRequests) { | ||||
|         return true; | ||||
|       } | ||||
|        | ||||
|       // Increment global count | ||||
|       limiter.count++; | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create an API error | ||||
|    * @param code Error code | ||||
|    * @param message Error message | ||||
|    * @param status HTTP status code | ||||
|    * @param details Additional details | ||||
|    * @returns API error | ||||
|    */ | ||||
|   private createError(code: string, message: string, status = 400, details?: any): Error & { code: string; status: number; details?: any } { | ||||
|     const error = new Error(message) as Error & { code: string; status: number; details?: any }; | ||||
|     error.code = code; | ||||
|     error.status = status; | ||||
|     if (details) { | ||||
|       error.details = details; | ||||
|     } | ||||
|     return error; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate that MTA service is available | ||||
|    */ | ||||
|   private validateMtaService(): void { | ||||
|     if (!this.mtaRef) { | ||||
|       throw this.createError('SERVICE_UNAVAILABLE', 'MTA service is not available', 503); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle email send request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleSendEmail(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const data = req.body as SendEmailRequest; | ||||
|      | ||||
|     if (!data || !data.email) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing email data'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Create Email instance | ||||
|       const email = new Email(data.email); | ||||
|        | ||||
|       // Validate domains if requested | ||||
|       if (data.validateDomains) { | ||||
|         const fromDomain = email.getFromDomain(); | ||||
|         if (fromDomain) { | ||||
|           const verification = await this.mtaRef.dnsManager.verifyEmailAuthRecords(fromDomain); | ||||
|            | ||||
|           // Check if SPF and DKIM are valid | ||||
|           if (!verification.spf.valid || !verification.dkim.valid) { | ||||
|             throw this.createError('DOMAIN_VERIFICATION_FAILED', 'Domain DNS verification failed', 400, { | ||||
|               verification | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Send email | ||||
|       const id = await this.mtaRef.send(email); | ||||
|        | ||||
|       // Return success response | ||||
|       res.json({ | ||||
|         id, | ||||
|         message: 'Email queued successfully', | ||||
|         status: 'pending' | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       // Handle Email constructor errors | ||||
|       if (error.message.includes('Invalid') || error.message.includes('must have')) { | ||||
|         throw this.createError('INVALID_EMAIL', error.message); | ||||
|       } | ||||
|        | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle email status request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGetEmailStatus(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const id = req.params.id; | ||||
|      | ||||
|     if (!id) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing email ID'); | ||||
|     } | ||||
|      | ||||
|     // Get email status | ||||
|     const status = this.mtaRef.getEmailStatus(id); | ||||
|      | ||||
|     if (!status) { | ||||
|       throw this.createError('NOT_FOUND', `Email with ID ${id} not found`, 404); | ||||
|     } | ||||
|      | ||||
|     // Create response | ||||
|     const response: EmailStatusResponse = { | ||||
|       id: status.id, | ||||
|       status: status.status, | ||||
|       sentAt: status.addedAt, | ||||
|       recipient: status.email.to[0], | ||||
|       attempts: status.attempts | ||||
|     }; | ||||
|      | ||||
|     // Add additional fields if available | ||||
|     if (status.lastAttempt) { | ||||
|       response.sentAt = status.lastAttempt; | ||||
|     } | ||||
|      | ||||
|     if (status.status === DeliveryStatus.DELIVERED) { | ||||
|       response.deliveredAt = status.lastAttempt; | ||||
|     } | ||||
|      | ||||
|     if (status.error) { | ||||
|       response.error = status.error.message; | ||||
|     } | ||||
|      | ||||
|     if (status.nextAttempt) { | ||||
|       response.nextRetry = status.nextAttempt; | ||||
|     } | ||||
|      | ||||
|     res.json(response); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle domain verification request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleVerifyDomain(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const domain = req.params.domain; | ||||
|      | ||||
|     if (!domain) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing domain'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Verify domain DNS records | ||||
|       const records = await this.mtaRef.dnsManager.verifyEmailAuthRecords(domain); | ||||
|        | ||||
|       // Get MX records | ||||
|       let mxValid = false; | ||||
|       let mxRecords: string[] = []; | ||||
|       let mxError: string = undefined; | ||||
|        | ||||
|       try { | ||||
|         const mxResult = await this.mtaRef.dnsManager.lookupMx(domain); | ||||
|         mxValid = mxResult.length > 0; | ||||
|         mxRecords = mxResult.map(mx => mx.exchange); | ||||
|       } catch (error) { | ||||
|         mxError = error.message; | ||||
|       } | ||||
|        | ||||
|       // Create response | ||||
|       const response: DomainVerificationResponse = { | ||||
|         domain, | ||||
|         verified: records.spf.valid && records.dkim.valid && records.dmarc.valid && mxValid, | ||||
|         details: { | ||||
|           spf: { | ||||
|             valid: records.spf.valid, | ||||
|             record: records.spf.value, | ||||
|             error: records.spf.error | ||||
|           }, | ||||
|           dkim: { | ||||
|             valid: records.dkim.valid, | ||||
|             record: records.dkim.value, | ||||
|             error: records.dkim.error | ||||
|           }, | ||||
|           dmarc: { | ||||
|             valid: records.dmarc.valid, | ||||
|             record: records.dmarc.value, | ||||
|             error: records.dmarc.error | ||||
|           }, | ||||
|           mx: { | ||||
|             valid: mxValid, | ||||
|             records: mxRecords, | ||||
|             error: mxError | ||||
|           } | ||||
|         } | ||||
|       }; | ||||
|        | ||||
|       res.json(response); | ||||
|     } catch (error) { | ||||
|       throw this.createError('VERIFICATION_FAILED', `Domain verification failed: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle get domain records request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGetDomainRecords(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const domain = req.params.domain; | ||||
|      | ||||
|     if (!domain) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing domain'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Generate recommended DNS records | ||||
|       const records = await this.mtaRef.dnsManager.generateAllRecommendedRecords(domain); | ||||
|        | ||||
|       res.json({ | ||||
|         domain, | ||||
|         records | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       throw this.createError('GENERATION_FAILED', `DNS record generation failed: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle generate DKIM keys request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGenerateDkim(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const domain = req.params.domain; | ||||
|      | ||||
|     if (!domain) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing domain'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Generate DKIM keys | ||||
|       await this.mtaRef.dkimCreator.createAndStoreDKIMKeys(domain); | ||||
|        | ||||
|       // Get DNS record | ||||
|       const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain); | ||||
|        | ||||
|       res.json({ | ||||
|         domain, | ||||
|         dnsRecord, | ||||
|         message: 'DKIM keys generated successfully' | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       throw this.createError('GENERATION_FAILED', `DKIM generation failed: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle get DKIM public key request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGetDkimPublicKey(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     const domain = req.params.domain; | ||||
|      | ||||
|     if (!domain) { | ||||
|       throw this.createError('INVALID_REQUEST', 'Missing domain'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Get DKIM keys | ||||
|       const keys = await this.mtaRef.dkimCreator.readDKIMKeys(domain); | ||||
|        | ||||
|       // Get DNS record | ||||
|       const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain); | ||||
|        | ||||
|       res.json({ | ||||
|         domain, | ||||
|         publicKey: keys.publicKey, | ||||
|         dnsRecord | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       throw this.createError('NOT_FOUND', `DKIM keys not found for domain: ${domain}`, 404); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle get stats request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGetStats(req: any, res: any): Promise<void> { | ||||
|     this.validateMtaService(); | ||||
|      | ||||
|     // Get MTA stats | ||||
|     const stats = this.mtaRef.getStats(); | ||||
|      | ||||
|     res.json(stats); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle get API docs request | ||||
|    * @param req Express request | ||||
|    * @param res Express response | ||||
|    */ | ||||
|   private async handleGetApiDocs(req: any, res: any): Promise<void> { | ||||
|     // Generate API documentation | ||||
|     const docs = { | ||||
|       name: 'MTA API', | ||||
|       version: '1.0.0', | ||||
|       description: 'API for interacting with the MTA service', | ||||
|       endpoints: this.routes.map(route => ({ | ||||
|         method: route.method, | ||||
|         path: route.path, | ||||
|         description: route.description, | ||||
|         authLevel: route.authLevel | ||||
|       })) | ||||
|     }; | ||||
|      | ||||
|     res.json(docs); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start the API server | ||||
|    * @param port Port to listen on | ||||
|    * @returns Promise that resolves when server is started | ||||
|    */ | ||||
|   public start(port: number = 3000): Promise<void> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       try { | ||||
|         // Start HTTP server | ||||
|         this.app.listen(port, () => { | ||||
|           console.log(`API server listening on port ${port}`); | ||||
|           resolve(); | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         console.error('Failed to start API server:', error); | ||||
|         reject(error); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stop the API server | ||||
|    */ | ||||
|   public stop(): void { | ||||
|     // Nothing to do if not running | ||||
|     console.log('API server stopped'); | ||||
|   } | ||||
| } | ||||
| @@ -1,10 +1,558 @@ | ||||
| import type { MtaService } from './mta.classes.mta.js'; | ||||
| import * as plugins from '../plugins.js'; | ||||
| import * as paths from '../paths.js'; | ||||
| import type { MtaService } from './mta.classes.mta.js'; | ||||
|  | ||||
| /** | ||||
|  * Interface for DNS record information | ||||
|  */ | ||||
| export interface IDnsRecord { | ||||
|   name: string; | ||||
|   type: string; | ||||
|   value: string; | ||||
|   ttl?: number; | ||||
|   dnsSecEnabled?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Interface for DNS lookup options | ||||
|  */ | ||||
| export interface IDnsLookupOptions { | ||||
|   /** Cache time to live in milliseconds, 0 to disable caching */ | ||||
|   cacheTtl?: number; | ||||
|   /** Timeout for DNS queries in milliseconds */ | ||||
|   timeout?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Interface for DNS verification result | ||||
|  */ | ||||
| export interface IDnsVerificationResult { | ||||
|   record: string; | ||||
|   found: boolean; | ||||
|   valid: boolean; | ||||
|   value?: string; | ||||
|   expectedValue?: string; | ||||
|   error?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Manager for DNS-related operations, including record lookups, verification, and generation | ||||
|  */ | ||||
| export class DNSManager { | ||||
|   public mtaRef: MtaService; | ||||
|   private cache: Map<string, { data: any; expires: number }> = new Map(); | ||||
|   private defaultOptions: IDnsLookupOptions = { | ||||
|     cacheTtl: 300000, // 5 minutes | ||||
|     timeout: 5000 // 5 seconds | ||||
|   }; | ||||
|  | ||||
|   constructor(mtaRefArg: MtaService) { | ||||
|   constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) { | ||||
|     this.mtaRef = mtaRefArg; | ||||
|      | ||||
|     if (options) { | ||||
|       this.defaultOptions = { | ||||
|         ...this.defaultOptions, | ||||
|         ...options | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     // Ensure the DNS records directory exists | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Lookup MX records for a domain | ||||
|    * @param domain Domain to look up | ||||
|    * @param options Lookup options | ||||
|    * @returns Array of MX records sorted by priority | ||||
|    */ | ||||
|   public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> { | ||||
|     const lookupOptions = { ...this.defaultOptions, ...options }; | ||||
|     const cacheKey = `mx:${domain}`; | ||||
|      | ||||
|     // Check cache first | ||||
|     const cached = this.getFromCache(cacheKey); | ||||
|     if (cached) { | ||||
|       return cached; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       const records = await this.dnsResolveMx(domain, lookupOptions.timeout); | ||||
|        | ||||
|       // Sort by priority | ||||
|       records.sort((a, b) => a.priority - b.priority); | ||||
|        | ||||
|       // Cache the result | ||||
|       this.setInCache(cacheKey, records, lookupOptions.cacheTtl); | ||||
|        | ||||
|       return records; | ||||
|     } catch (error) { | ||||
|       console.error(`Error looking up MX records for ${domain}:`, error); | ||||
|       throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Lookup TXT records for a domain | ||||
|    * @param domain Domain to look up | ||||
|    * @param options Lookup options | ||||
|    * @returns Array of TXT records | ||||
|    */ | ||||
|   public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> { | ||||
|     const lookupOptions = { ...this.defaultOptions, ...options }; | ||||
|     const cacheKey = `txt:${domain}`; | ||||
|      | ||||
|     // Check cache first | ||||
|     const cached = this.getFromCache(cacheKey); | ||||
|     if (cached) { | ||||
|       return cached; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       const records = await this.dnsResolveTxt(domain, lookupOptions.timeout); | ||||
|        | ||||
|       // Cache the result | ||||
|       this.setInCache(cacheKey, records, lookupOptions.cacheTtl); | ||||
|        | ||||
|       return records; | ||||
|     } catch (error) { | ||||
|       console.error(`Error looking up TXT records for ${domain}:`, error); | ||||
|       throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Find specific TXT record by subdomain and prefix | ||||
|    * @param domain Base domain | ||||
|    * @param subdomain Subdomain prefix (e.g., "dkim._domainkey") | ||||
|    * @param prefix Record prefix to match (e.g., "v=DKIM1") | ||||
|    * @param options Lookup options | ||||
|    * @returns Matching TXT record or null if not found | ||||
|    */ | ||||
|   public async findTxtRecord( | ||||
|     domain: string, | ||||
|     subdomain: string = '', | ||||
|     prefix: string = '', | ||||
|     options?: IDnsLookupOptions | ||||
|   ): Promise<string | null> { | ||||
|     const fullDomain = subdomain ? `${subdomain}.${domain}` : domain; | ||||
|      | ||||
|     try { | ||||
|       const records = await this.lookupTxt(fullDomain, options); | ||||
|        | ||||
|       for (const recordArray of records) { | ||||
|         // TXT records can be split into chunks, join them | ||||
|         const record = recordArray.join(''); | ||||
|          | ||||
|         if (!prefix || record.startsWith(prefix)) { | ||||
|           return record; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       return null; | ||||
|     } catch (error) { | ||||
|       // Domain might not exist or no TXT records | ||||
|       console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Verify if a domain has a valid SPF record | ||||
|    * @param domain Domain to verify | ||||
|    * @returns Verification result | ||||
|    */ | ||||
|   public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> { | ||||
|     const result: IDnsVerificationResult = { | ||||
|       record: 'SPF', | ||||
|       found: false, | ||||
|       valid: false | ||||
|     }; | ||||
|      | ||||
|     try { | ||||
|       const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1'); | ||||
|        | ||||
|       if (spfRecord) { | ||||
|         result.found = true; | ||||
|         result.value = spfRecord; | ||||
|          | ||||
|         // Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms | ||||
|         const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord); | ||||
|         result.valid = isValid; | ||||
|          | ||||
|         if (!isValid) { | ||||
|           result.error = 'SPF record format is invalid'; | ||||
|         } | ||||
|       } else { | ||||
|         result.error = 'No SPF record found'; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       result.error = `Error verifying SPF: ${error.message}`; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Verify if a domain has a valid DKIM record | ||||
|    * @param domain Domain to verify | ||||
|    * @param selector DKIM selector (usually "mta" in our case) | ||||
|    * @returns Verification result | ||||
|    */ | ||||
|   public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> { | ||||
|     const result: IDnsVerificationResult = { | ||||
|       record: 'DKIM', | ||||
|       found: false, | ||||
|       valid: false | ||||
|     }; | ||||
|      | ||||
|     try { | ||||
|       const dkimSelector = `${selector}._domainkey`; | ||||
|       const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1'); | ||||
|        | ||||
|       if (dkimRecord) { | ||||
|         result.found = true; | ||||
|         result.value = dkimRecord; | ||||
|          | ||||
|         // Basic validation - check for required fields | ||||
|         const hasP = dkimRecord.includes('p='); | ||||
|         result.valid = dkimRecord.includes('v=DKIM1') && hasP; | ||||
|          | ||||
|         if (!result.valid) { | ||||
|           result.error = 'DKIM record is missing required fields'; | ||||
|         } else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) { | ||||
|           result.valid = false; | ||||
|           result.error = 'DKIM record has invalid public key format'; | ||||
|         } | ||||
|       } else { | ||||
|         result.error = `No DKIM record found for selector ${selector}`; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       result.error = `Error verifying DKIM: ${error.message}`; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Verify if a domain has a valid DMARC record | ||||
|    * @param domain Domain to verify | ||||
|    * @returns Verification result | ||||
|    */ | ||||
|   public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> { | ||||
|     const result: IDnsVerificationResult = { | ||||
|       record: 'DMARC', | ||||
|       found: false, | ||||
|       valid: false | ||||
|     }; | ||||
|      | ||||
|     try { | ||||
|       const dmarcDomain = `_dmarc.${domain}`; | ||||
|       const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1'); | ||||
|        | ||||
|       if (dmarcRecord) { | ||||
|         result.found = true; | ||||
|         result.value = dmarcRecord; | ||||
|          | ||||
|         // Basic validation - check for required fields | ||||
|         const hasPolicy = dmarcRecord.includes('p='); | ||||
|         result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy; | ||||
|          | ||||
|         if (!result.valid) { | ||||
|           result.error = 'DMARC record is missing required fields'; | ||||
|         } | ||||
|       } else { | ||||
|         result.error = 'No DMARC record found'; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       result.error = `Error verifying DMARC: ${error.message}`; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check all email authentication records (SPF, DKIM, DMARC) for a domain | ||||
|    * @param domain Domain to check | ||||
|    * @param dkimSelector DKIM selector | ||||
|    * @returns Object with verification results for each record type | ||||
|    */ | ||||
|   public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{ | ||||
|     spf: IDnsVerificationResult; | ||||
|     dkim: IDnsVerificationResult; | ||||
|     dmarc: IDnsVerificationResult; | ||||
|   }> { | ||||
|     const [spf, dkim, dmarc] = await Promise.all([ | ||||
|       this.verifySpfRecord(domain), | ||||
|       this.verifyDkimRecord(domain, dkimSelector), | ||||
|       this.verifyDmarcRecord(domain) | ||||
|     ]); | ||||
|      | ||||
|     return { spf, dkim, dmarc }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate a recommended SPF record for a domain | ||||
|    * @param domain Domain name | ||||
|    * @param options Configuration options for the SPF record | ||||
|    * @returns Generated SPF record | ||||
|    */ | ||||
|   public generateSpfRecord(domain: string, options: { | ||||
|     includeMx?: boolean; | ||||
|     includeA?: boolean; | ||||
|     includeIps?: string[]; | ||||
|     includeSpf?: string[]; | ||||
|     policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject'; | ||||
|   } = {}): IDnsRecord { | ||||
|     const { | ||||
|       includeMx = true, | ||||
|       includeA = true, | ||||
|       includeIps = [], | ||||
|       includeSpf = [], | ||||
|       policy = 'softfail' | ||||
|     } = options; | ||||
|      | ||||
|     let value = 'v=spf1'; | ||||
|      | ||||
|     if (includeMx) { | ||||
|       value += ' mx'; | ||||
|     } | ||||
|      | ||||
|     if (includeA) { | ||||
|       value += ' a'; | ||||
|     } | ||||
|      | ||||
|     // Add IP addresses | ||||
|     for (const ip of includeIps) { | ||||
|       if (ip.includes(':')) { | ||||
|         value += ` ip6:${ip}`; | ||||
|       } else { | ||||
|         value += ` ip4:${ip}`; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Add includes | ||||
|     for (const include of includeSpf) { | ||||
|       value += ` include:${include}`; | ||||
|     } | ||||
|      | ||||
|     // Add policy | ||||
|     const policyMap = { | ||||
|       'none': '?all', | ||||
|       'neutral': '~all', | ||||
|       'softfail': '~all', | ||||
|       'fail': '-all', | ||||
|       'reject': '-all' | ||||
|     }; | ||||
|      | ||||
|     value += ` ${policyMap[policy]}`; | ||||
|      | ||||
|     return { | ||||
|       name: domain, | ||||
|       type: 'TXT', | ||||
|       value: value | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate a recommended DMARC record for a domain | ||||
|    * @param domain Domain name | ||||
|    * @param options Configuration options for the DMARC record | ||||
|    * @returns Generated DMARC record | ||||
|    */ | ||||
|   public generateDmarcRecord(domain: string, options: { | ||||
|     policy?: 'none' | 'quarantine' | 'reject'; | ||||
|     subdomainPolicy?: 'none' | 'quarantine' | 'reject'; | ||||
|     pct?: number; | ||||
|     rua?: string; | ||||
|     ruf?: string; | ||||
|     daysInterval?: number; | ||||
|   } = {}): IDnsRecord { | ||||
|     const { | ||||
|       policy = 'none', | ||||
|       subdomainPolicy, | ||||
|       pct = 100, | ||||
|       rua, | ||||
|       ruf, | ||||
|       daysInterval = 1 | ||||
|     } = options; | ||||
|      | ||||
|     let value = 'v=DMARC1; p=' + policy; | ||||
|      | ||||
|     if (subdomainPolicy) { | ||||
|       value += `; sp=${subdomainPolicy}`; | ||||
|     } | ||||
|      | ||||
|     if (pct !== 100) { | ||||
|       value += `; pct=${pct}`; | ||||
|     } | ||||
|      | ||||
|     if (rua) { | ||||
|       value += `; rua=mailto:${rua}`; | ||||
|     } | ||||
|      | ||||
|     if (ruf) { | ||||
|       value += `; ruf=mailto:${ruf}`; | ||||
|     } | ||||
|      | ||||
|     if (daysInterval !== 1) { | ||||
|       value += `; ri=${daysInterval * 86400}`; | ||||
|     } | ||||
|      | ||||
|     // Add reporting format and ADKIM/ASPF alignment | ||||
|     value += '; fo=1; adkim=r; aspf=r'; | ||||
|      | ||||
|     return { | ||||
|       name: `_dmarc.${domain}`, | ||||
|       type: 'TXT', | ||||
|       value: value | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Save DNS record recommendations to a file | ||||
|    * @param domain Domain name | ||||
|    * @param records DNS records to save | ||||
|    */ | ||||
|   public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> { | ||||
|     try { | ||||
|       const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`); | ||||
|       plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath); | ||||
|       console.log(`DNS recommendations for ${domain} saved to ${filePath}`); | ||||
|     } catch (error) { | ||||
|       console.error(`Error saving DNS recommendations for ${domain}:`, error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get cache key value | ||||
|    * @param key Cache key | ||||
|    * @returns Cached value or undefined if not found or expired | ||||
|    */ | ||||
|   private getFromCache<T>(key: string): T | undefined { | ||||
|     const cached = this.cache.get(key); | ||||
|      | ||||
|     if (cached && cached.expires > Date.now()) { | ||||
|       return cached.data as T; | ||||
|     } | ||||
|      | ||||
|     // Remove expired entry | ||||
|     if (cached) { | ||||
|       this.cache.delete(key); | ||||
|     } | ||||
|      | ||||
|     return undefined; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set cache key value | ||||
|    * @param key Cache key | ||||
|    * @param data Data to cache | ||||
|    * @param ttl TTL in milliseconds | ||||
|    */ | ||||
|   private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void { | ||||
|     if (ttl <= 0) return; // Don't cache if TTL is disabled | ||||
|      | ||||
|     this.cache.set(key, { | ||||
|       data, | ||||
|       expires: Date.now() + ttl | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear the DNS cache | ||||
|    * @param key Optional specific key to clear, or all cache if not provided | ||||
|    */ | ||||
|   public clearCache(key?: string): void { | ||||
|     if (key) { | ||||
|       this.cache.delete(key); | ||||
|     } else { | ||||
|       this.cache.clear(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Promise-based wrapper for dns.resolveMx | ||||
|    * @param domain Domain to resolve | ||||
|    * @param timeout Timeout in milliseconds | ||||
|    * @returns Promise resolving to MX records | ||||
|    */ | ||||
|   private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const timeoutId = setTimeout(() => { | ||||
|         reject(new Error(`DNS MX lookup timeout for ${domain}`)); | ||||
|       }, timeout); | ||||
|        | ||||
|       plugins.dns.resolveMx(domain, (err, addresses) => { | ||||
|         clearTimeout(timeoutId); | ||||
|          | ||||
|         if (err) { | ||||
|           reject(err); | ||||
|         } else { | ||||
|           resolve(addresses); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Promise-based wrapper for dns.resolveTxt | ||||
|    * @param domain Domain to resolve | ||||
|    * @param timeout Timeout in milliseconds | ||||
|    * @returns Promise resolving to TXT records | ||||
|    */ | ||||
|   private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const timeoutId = setTimeout(() => { | ||||
|         reject(new Error(`DNS TXT lookup timeout for ${domain}`)); | ||||
|       }, timeout); | ||||
|        | ||||
|       plugins.dns.resolveTxt(domain, (err, records) => { | ||||
|         clearTimeout(timeoutId); | ||||
|          | ||||
|         if (err) { | ||||
|           reject(err); | ||||
|         } else { | ||||
|           resolve(records); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate all recommended DNS records for proper email authentication | ||||
|    * @param domain Domain to generate records for | ||||
|    * @returns Array of recommended DNS records | ||||
|    */ | ||||
|   public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> { | ||||
|     const records: IDnsRecord[] = []; | ||||
|      | ||||
|     // Get DKIM record (already created by DKIMCreator) | ||||
|     try { | ||||
|       const dkimRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain); | ||||
|       records.push(dkimRecord); | ||||
|     } catch (error) { | ||||
|       console.error(`Error getting DKIM record for ${domain}:`, error); | ||||
|     } | ||||
|      | ||||
|     // Generate SPF record | ||||
|     const spfRecord = this.generateSpfRecord(domain, { | ||||
|       includeMx: true, | ||||
|       includeA: true, | ||||
|       policy: 'softfail' | ||||
|     }); | ||||
|     records.push(spfRecord); | ||||
|      | ||||
|     // Generate DMARC record | ||||
|     const dmarcRecord = this.generateDmarcRecord(domain, { | ||||
|       policy: 'none', // Start with monitoring mode | ||||
|       rua: `dmarc@${domain}` // Replace with appropriate report address | ||||
|     }); | ||||
|     records.push(dmarcRecord); | ||||
|      | ||||
|     // Save recommendations | ||||
|     await this.saveDnsRecommendations(domain, records); | ||||
|      | ||||
|     return records; | ||||
|   } | ||||
| } | ||||
| @@ -2,35 +2,218 @@ export interface IAttachment { | ||||
|   filename: string; | ||||
|   content: Buffer; | ||||
|   contentType: string; | ||||
|   contentId?: string; // Optional content ID for inline attachments | ||||
|   encoding?: string; // Optional encoding specification | ||||
| } | ||||
|  | ||||
| export interface IEmailOptions { | ||||
|   from: string; | ||||
|   to: string; | ||||
|   to: string | string[]; // Support multiple recipients | ||||
|   cc?: string | string[]; // Optional CC recipients | ||||
|   bcc?: string | string[]; // Optional BCC recipients | ||||
|   subject: string; | ||||
|   text: string; | ||||
|   attachments: IAttachment[]; | ||||
|   html?: string; // Optional HTML version | ||||
|   attachments?: IAttachment[]; | ||||
|   headers?: Record<string, string>; // Optional additional headers | ||||
|   mightBeSpam?: boolean; | ||||
|   priority?: 'high' | 'normal' | 'low'; // Optional email priority | ||||
| } | ||||
|  | ||||
| export class Email { | ||||
|   from: string; | ||||
|   to: string; | ||||
|   to: string[]; | ||||
|   cc: string[]; | ||||
|   bcc: string[]; | ||||
|   subject: string; | ||||
|   text: string; | ||||
|   html?: string; | ||||
|   attachments: IAttachment[]; | ||||
|   headers: Record<string, string>; | ||||
|   mightBeSpam: boolean; | ||||
|   priority: 'high' | 'normal' | 'low'; | ||||
|  | ||||
|   constructor(options: IEmailOptions) { | ||||
|     // Validate and set the from address | ||||
|     if (!this.isValidEmail(options.from)) { | ||||
|       throw new Error(`Invalid sender email address: ${options.from}`); | ||||
|     } | ||||
|     this.from = options.from; | ||||
|     this.to = options.to; | ||||
|     this.subject = options.subject; | ||||
|     this.text = options.text; | ||||
|     this.attachments = options.attachments; | ||||
|  | ||||
|     // Handle to addresses (single or multiple) | ||||
|     this.to = this.parseRecipients(options.to); | ||||
|      | ||||
|     // Handle optional cc and bcc | ||||
|     this.cc = options.cc ? this.parseRecipients(options.cc) : []; | ||||
|     this.bcc = options.bcc ? this.parseRecipients(options.bcc) : []; | ||||
|      | ||||
|     // Validate that we have at least one recipient | ||||
|     if (this.to.length === 0 && this.cc.length === 0 && this.bcc.length === 0) { | ||||
|       throw new Error('Email must have at least one recipient'); | ||||
|     } | ||||
|  | ||||
|     // Set subject with sanitization | ||||
|     this.subject = this.sanitizeString(options.subject || ''); | ||||
|      | ||||
|     // Set text content with sanitization | ||||
|     this.text = this.sanitizeString(options.text || ''); | ||||
|      | ||||
|     // Set optional HTML content | ||||
|     this.html = options.html ? this.sanitizeString(options.html) : undefined; | ||||
|      | ||||
|     // Set attachments | ||||
|     this.attachments = Array.isArray(options.attachments) ? options.attachments : []; | ||||
|      | ||||
|     // Set additional headers | ||||
|     this.headers = options.headers || {}; | ||||
|      | ||||
|     // Set spam flag | ||||
|     this.mightBeSpam = options.mightBeSpam || false; | ||||
|      | ||||
|     // Set priority | ||||
|     this.priority = options.priority || 'normal'; | ||||
|   } | ||||
|  | ||||
|   public getFromDomain() { | ||||
|     return this.from.split('@')[1] | ||||
|   /** | ||||
|    * Validates an email address using a regex pattern | ||||
|    * @param email The email address to validate | ||||
|    * @returns boolean indicating if the email is valid | ||||
|    */ | ||||
|   private isValidEmail(email: string): boolean { | ||||
|     if (!email || typeof email !== 'string') return false; | ||||
|      | ||||
|     // Basic but effective email regex | ||||
|     const emailRegex = /^[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,}$/; | ||||
|     return emailRegex.test(email); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   /** | ||||
|    * Parses and validates recipient email addresses | ||||
|    * @param recipients A string or array of recipient emails | ||||
|    * @returns Array of validated email addresses | ||||
|    */ | ||||
|   private parseRecipients(recipients: string | string[]): string[] { | ||||
|     const result: string[] = []; | ||||
|      | ||||
|     if (typeof recipients === 'string') { | ||||
|       // Handle single recipient | ||||
|       if (this.isValidEmail(recipients)) { | ||||
|         result.push(recipients); | ||||
|       } else { | ||||
|         throw new Error(`Invalid recipient email address: ${recipients}`); | ||||
|       } | ||||
|     } else if (Array.isArray(recipients)) { | ||||
|       // Handle multiple recipients | ||||
|       for (const recipient of recipients) { | ||||
|         if (this.isValidEmail(recipient)) { | ||||
|           result.push(recipient); | ||||
|         } else { | ||||
|           throw new Error(`Invalid recipient email address: ${recipient}`); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Basic sanitization for strings to prevent header injection | ||||
|    * @param input The string to sanitize | ||||
|    * @returns Sanitized string | ||||
|    */ | ||||
|   private sanitizeString(input: string): string { | ||||
|     if (!input) return ''; | ||||
|      | ||||
|     // Remove CR and LF characters to prevent header injection | ||||
|     return input.replace(/\r|\n/g, ' '); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets the domain part of the from email address | ||||
|    * @returns The domain part of the from email or null if invalid | ||||
|    */ | ||||
|   public getFromDomain(): string | null { | ||||
|     try { | ||||
|       const parts = this.from.split('@'); | ||||
|       if (parts.length !== 2 || !parts[1]) { | ||||
|         return null; | ||||
|       } | ||||
|       return parts[1]; | ||||
|     } catch (error) { | ||||
|       console.error('Error extracting domain from email:', error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets all recipients (to, cc, bcc) as a unique array | ||||
|    * @returns Array of all unique recipient email addresses | ||||
|    */ | ||||
|   public getAllRecipients(): string[] { | ||||
|     // Combine all recipients and remove duplicates | ||||
|     return [...new Set([...this.to, ...this.cc, ...this.bcc])]; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets primary recipient (first in the to field) | ||||
|    * @returns The primary recipient email or null if none exists | ||||
|    */ | ||||
|   public getPrimaryRecipient(): string | null { | ||||
|     return this.to.length > 0 ? this.to[0] : null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Checks if the email has attachments | ||||
|    * @returns Boolean indicating if the email has attachments | ||||
|    */ | ||||
|   public hasAttachments(): boolean { | ||||
|     return this.attachments.length > 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets the total size of all attachments in bytes | ||||
|    * @returns Total size of all attachments in bytes | ||||
|    */ | ||||
|   public getAttachmentsSize(): number { | ||||
|     return this.attachments.reduce((total, attachment) => { | ||||
|       return total + (attachment.content?.length || 0); | ||||
|     }, 0); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Creates an RFC822 compliant email string | ||||
|    * @returns The email formatted as an RFC822 compliant string | ||||
|    */ | ||||
|   public toRFC822String(): string { | ||||
|     // This is a simplified version - a complete implementation would be more complex | ||||
|     let result = ''; | ||||
|      | ||||
|     // Add headers | ||||
|     result += `From: ${this.from}\r\n`; | ||||
|     result += `To: ${this.to.join(', ')}\r\n`; | ||||
|      | ||||
|     if (this.cc.length > 0) { | ||||
|       result += `Cc: ${this.cc.join(', ')}\r\n`; | ||||
|     } | ||||
|      | ||||
|     result += `Subject: ${this.subject}\r\n`; | ||||
|     result += `Date: ${new Date().toUTCString()}\r\n`; | ||||
|      | ||||
|     // Add custom headers | ||||
|     for (const [key, value] of Object.entries(this.headers)) { | ||||
|       result += `${key}: ${value}\r\n`; | ||||
|     } | ||||
|      | ||||
|     // Add priority if not normal | ||||
|     if (this.priority !== 'normal') { | ||||
|       const priorityValue = this.priority === 'high' ? '1' : '5'; | ||||
|       result += `X-Priority: ${priorityValue}\r\n`; | ||||
|     } | ||||
|      | ||||
|     // Add content type and body | ||||
|     result += `Content-Type: text/plain; charset=utf-8\r\n`; | ||||
|     result += `\r\n${this.text}\r\n`; | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
| } | ||||
| @@ -4,45 +4,553 @@ import { Email } from './mta.classes.email.js'; | ||||
| import { EmailSignJob } from './mta.classes.emailsignjob.js'; | ||||
| import type { MtaService } from './mta.classes.mta.js'; | ||||
|  | ||||
| // Configuration options for email sending | ||||
| export interface IEmailSendOptions { | ||||
|   maxRetries?: number; | ||||
|   retryDelay?: number; // in milliseconds | ||||
|   connectionTimeout?: number; // in milliseconds | ||||
|   tlsOptions?: plugins.tls.ConnectionOptions; | ||||
|   debugMode?: boolean; | ||||
| } | ||||
|  | ||||
| // Email delivery status | ||||
| export enum DeliveryStatus { | ||||
|   PENDING = 'pending', | ||||
|   SENDING = 'sending', | ||||
|   DELIVERED = 'delivered', | ||||
|   FAILED = 'failed', | ||||
|   DEFERRED = 'deferred' // Temporary failure, will retry | ||||
| } | ||||
|  | ||||
| // Detailed information about delivery attempts | ||||
| export interface DeliveryInfo { | ||||
|   status: DeliveryStatus; | ||||
|   attempts: number; | ||||
|   error?: Error; | ||||
|   lastAttempt?: Date; | ||||
|   nextAttempt?: Date; | ||||
|   mxServer?: string; | ||||
|   deliveryTime?: Date; | ||||
|   logs: string[]; | ||||
| } | ||||
|  | ||||
| export class EmailSendJob { | ||||
|   mtaRef: MtaService; | ||||
|   private email: Email; | ||||
|   private socket: plugins.net.Socket | plugins.tls.TLSSocket = null; | ||||
|   private mxRecord: string = null; | ||||
|   private mxServers: string[] = []; | ||||
|   private currentMxIndex = 0; | ||||
|   private options: IEmailSendOptions; | ||||
|   public deliveryInfo: DeliveryInfo; | ||||
|  | ||||
|   constructor(mtaRef: MtaService, emailArg: Email) { | ||||
|   constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) { | ||||
|     this.email = emailArg; | ||||
|     this.mtaRef = mtaRef; | ||||
|      | ||||
|     // Set default options | ||||
|     this.options = { | ||||
|       maxRetries: options.maxRetries || 3, | ||||
|       retryDelay: options.retryDelay || 300000, // 5 minutes | ||||
|       connectionTimeout: options.connectionTimeout || 30000, // 30 seconds | ||||
|       tlsOptions: options.tlsOptions || { rejectUnauthorized: true }, | ||||
|       debugMode: options.debugMode || false | ||||
|     }; | ||||
|      | ||||
|     // Initialize delivery info | ||||
|     this.deliveryInfo = { | ||||
|       status: DeliveryStatus.PENDING, | ||||
|       attempts: 0, | ||||
|       logs: [] | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async send(): Promise<void> { | ||||
|     const domain = this.email.to.split('@')[1]; | ||||
|     const addresses = await this.resolveMx(domain); | ||||
|     addresses.sort((a, b) => a.priority - b.priority); | ||||
|     this.mxRecord = addresses[0].exchange; | ||||
|  | ||||
|     console.log(`Using ${this.mxRecord} as mail server for domain ${domain}`); | ||||
|  | ||||
|     this.socket = plugins.net.connect(25, this.mxRecord); | ||||
|     await this.processInitialResponse(); | ||||
|     await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250'); | ||||
|   /** | ||||
|    * Send the email with retry logic | ||||
|    */ | ||||
|   async send(): Promise<DeliveryStatus> { | ||||
|     try { | ||||
|       await this.sendCommand('STARTTLS\r\n', '220'); | ||||
|       this.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false }); | ||||
|       await this.processTLSUpgrade(this.email.from.split('@')[1]); | ||||
|       // Check if the email is valid before attempting to send | ||||
|       this.validateEmail(); | ||||
|        | ||||
|       // Resolve MX records for the recipient domain | ||||
|       await this.resolveMxRecords(); | ||||
|        | ||||
|       // Try to send the email | ||||
|       return await this.attemptDelivery(); | ||||
|     } catch (error) { | ||||
|       console.log('Error sending STARTTLS command:', error); | ||||
|       console.log('Continuing with unencrypted connection...'); | ||||
|       this.log(`Critical error in send process: ${error.message}`); | ||||
|       this.deliveryInfo.status = DeliveryStatus.FAILED; | ||||
|       this.deliveryInfo.error = error; | ||||
|        | ||||
|       // Save failed email for potential future retry or analysis | ||||
|       await this.saveFailed(); | ||||
|       return DeliveryStatus.FAILED; | ||||
|     } | ||||
|  | ||||
|     await this.sendMessage(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate the email before sending | ||||
|    */ | ||||
|   private validateEmail(): void { | ||||
|     if (!this.email.to || this.email.to.length === 0) { | ||||
|       throw new Error('No recipients specified'); | ||||
|     } | ||||
|      | ||||
|     if (!this.email.from) { | ||||
|       throw new Error('No sender specified'); | ||||
|     } | ||||
|      | ||||
|     const fromDomain = this.email.getFromDomain(); | ||||
|     if (!fromDomain) { | ||||
|       throw new Error('Invalid sender domain'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Resolve MX records for the recipient domain | ||||
|    */ | ||||
|   private async resolveMxRecords(): Promise<void> { | ||||
|     const domain = this.email.getPrimaryRecipient()?.split('@')[1]; | ||||
|     if (!domain) { | ||||
|       throw new Error('Invalid recipient domain'); | ||||
|     } | ||||
|      | ||||
|     this.log(`Resolving MX records for domain: ${domain}`); | ||||
|     try { | ||||
|       const addresses = await this.resolveMx(domain); | ||||
|        | ||||
|       // Sort by priority (lowest number = highest priority) | ||||
|       addresses.sort((a, b) => a.priority - b.priority); | ||||
|        | ||||
|       this.mxServers = addresses.map(mx => mx.exchange); | ||||
|       this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`); | ||||
|        | ||||
|       if (this.mxServers.length === 0) { | ||||
|         throw new Error(`No MX records found for domain: ${domain}`); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       this.log(`Failed to resolve MX records: ${error.message}`); | ||||
|       throw new Error(`MX lookup failed for ${domain}: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Attempt to deliver the email with retries | ||||
|    */ | ||||
|   private async attemptDelivery(): Promise<DeliveryStatus> { | ||||
|     while (this.deliveryInfo.attempts < this.options.maxRetries) { | ||||
|       this.deliveryInfo.attempts++; | ||||
|       this.deliveryInfo.lastAttempt = new Date(); | ||||
|       this.deliveryInfo.status = DeliveryStatus.SENDING; | ||||
|        | ||||
|       try { | ||||
|         this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`); | ||||
|          | ||||
|         // Try each MX server in order of priority | ||||
|         while (this.currentMxIndex < this.mxServers.length) { | ||||
|           const currentMx = this.mxServers[this.currentMxIndex]; | ||||
|           this.deliveryInfo.mxServer = currentMx; | ||||
|            | ||||
|           try { | ||||
|             this.log(`Attempting delivery to MX server: ${currentMx}`); | ||||
|             await this.connectAndSend(currentMx); | ||||
|              | ||||
|             // If we get here, email was sent successfully | ||||
|             this.deliveryInfo.status = DeliveryStatus.DELIVERED; | ||||
|             this.deliveryInfo.deliveryTime = new Date(); | ||||
|             this.log(`Email delivered successfully to ${currentMx}`); | ||||
|              | ||||
|             // Save successful email record | ||||
|             await this.saveSuccess(); | ||||
|             return DeliveryStatus.DELIVERED; | ||||
|           } catch (error) { | ||||
|             this.log(`Error with MX ${currentMx}: ${error.message}`); | ||||
|              | ||||
|             // Clean up socket if it exists | ||||
|             if (this.socket) { | ||||
|               this.socket.destroy(); | ||||
|               this.socket = null; | ||||
|             } | ||||
|              | ||||
|             // Try the next MX server | ||||
|             this.currentMxIndex++; | ||||
|              | ||||
|             // If this is a permanent failure, don't try other MX servers | ||||
|             if (this.isPermanentFailure(error)) { | ||||
|               throw error; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // If we've tried all MX servers without success, throw an error | ||||
|         throw new Error('All MX servers failed'); | ||||
|       } catch (error) { | ||||
|         // Check if this is a permanent failure | ||||
|         if (this.isPermanentFailure(error)) { | ||||
|           this.log(`Permanent failure: ${error.message}`); | ||||
|           this.deliveryInfo.status = DeliveryStatus.FAILED; | ||||
|           this.deliveryInfo.error = error; | ||||
|            | ||||
|           // Save failed email for analysis | ||||
|           await this.saveFailed(); | ||||
|           return DeliveryStatus.FAILED; | ||||
|         } | ||||
|          | ||||
|         // This is a temporary failure, we can retry | ||||
|         this.log(`Temporary failure: ${error.message}`); | ||||
|          | ||||
|         // If this is the last attempt, mark as failed | ||||
|         if (this.deliveryInfo.attempts >= this.options.maxRetries) { | ||||
|           this.deliveryInfo.status = DeliveryStatus.FAILED; | ||||
|           this.deliveryInfo.error = error; | ||||
|            | ||||
|           // Save failed email for analysis | ||||
|           await this.saveFailed(); | ||||
|           return DeliveryStatus.FAILED; | ||||
|         } | ||||
|          | ||||
|         // Schedule the next retry | ||||
|         const nextRetryTime = new Date(Date.now() + this.options.retryDelay); | ||||
|         this.deliveryInfo.status = DeliveryStatus.DEFERRED; | ||||
|         this.deliveryInfo.nextAttempt = nextRetryTime; | ||||
|         this.log(`Will retry at ${nextRetryTime.toISOString()}`); | ||||
|          | ||||
|         // Wait before retrying | ||||
|         await this.delay(this.options.retryDelay); | ||||
|          | ||||
|         // Reset MX server index for the next attempt | ||||
|         this.currentMxIndex = 0; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // If we get here, all retries failed | ||||
|     this.deliveryInfo.status = DeliveryStatus.FAILED; | ||||
|     await this.saveFailed(); | ||||
|     return DeliveryStatus.FAILED; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Connect to a specific MX server and send the email | ||||
|    */ | ||||
|   private async connectAndSend(mxServer: string): Promise<void> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       let commandTimeout: NodeJS.Timeout; | ||||
|        | ||||
|       // Function to clear timeouts and remove listeners | ||||
|       const cleanup = () => { | ||||
|         clearTimeout(commandTimeout); | ||||
|         if (this.socket) { | ||||
|           this.socket.removeAllListeners(); | ||||
|         } | ||||
|       }; | ||||
|        | ||||
|       // Function to set a timeout for each command | ||||
|       const setCommandTimeout = () => { | ||||
|         clearTimeout(commandTimeout); | ||||
|         commandTimeout = setTimeout(() => { | ||||
|           this.log('Connection timed out'); | ||||
|           cleanup(); | ||||
|           if (this.socket) { | ||||
|             this.socket.destroy(); | ||||
|             this.socket = null; | ||||
|           } | ||||
|           reject(new Error('Connection timed out')); | ||||
|         }, this.options.connectionTimeout); | ||||
|       }; | ||||
|        | ||||
|       // Connect to the MX server | ||||
|       this.log(`Connecting to ${mxServer}:25`); | ||||
|       setCommandTimeout(); | ||||
|        | ||||
|       this.socket = plugins.net.connect(25, mxServer); | ||||
|        | ||||
|       this.socket.on('error', (err) => { | ||||
|         this.log(`Socket error: ${err.message}`); | ||||
|         cleanup(); | ||||
|         reject(err); | ||||
|       }); | ||||
|        | ||||
|       // Set up the command sequence | ||||
|       this.socket.once('data', async (data) => { | ||||
|         try { | ||||
|           const greeting = data.toString(); | ||||
|           this.log(`Server greeting: ${greeting.trim()}`); | ||||
|            | ||||
|           if (!greeting.startsWith('220')) { | ||||
|             throw new Error(`Unexpected server greeting: ${greeting}`); | ||||
|           } | ||||
|            | ||||
|           // EHLO command | ||||
|           const fromDomain = this.email.getFromDomain(); | ||||
|           await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250'); | ||||
|            | ||||
|           // Try STARTTLS if available | ||||
|           try { | ||||
|             await this.sendCommand('STARTTLS\r\n', '220'); | ||||
|             this.upgradeToTLS(mxServer, fromDomain); | ||||
|             // The TLS handshake and subsequent commands will continue in the upgradeToTLS method | ||||
|             // resolve will be called from there if successful | ||||
|           } catch (error) { | ||||
|             this.log(`STARTTLS failed or not supported: ${error.message}`); | ||||
|             this.log('Continuing with unencrypted connection'); | ||||
|              | ||||
|             // Continue with unencrypted connection | ||||
|             await this.sendEmailCommands(); | ||||
|             cleanup(); | ||||
|             resolve(); | ||||
|           } | ||||
|         } catch (error) { | ||||
|           cleanup(); | ||||
|           reject(error); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Upgrade the connection to TLS | ||||
|    */ | ||||
|   private upgradeToTLS(mxServer: string, fromDomain: string): void { | ||||
|     this.log('Starting TLS handshake'); | ||||
|      | ||||
|     const tlsOptions = { | ||||
|       ...this.options.tlsOptions, | ||||
|       socket: this.socket, | ||||
|       servername: mxServer | ||||
|     }; | ||||
|      | ||||
|     // Create TLS socket | ||||
|     this.socket = plugins.tls.connect(tlsOptions); | ||||
|      | ||||
|     // Handle TLS connection | ||||
|     this.socket.once('secureConnect', async () => { | ||||
|       try { | ||||
|         this.log('TLS connection established'); | ||||
|          | ||||
|         // Send EHLO again over TLS | ||||
|         await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250'); | ||||
|          | ||||
|         // Send the email | ||||
|         await this.sendEmailCommands(); | ||||
|          | ||||
|         this.socket.destroy(); | ||||
|         this.socket = null; | ||||
|       } catch (error) { | ||||
|         this.log(`Error in TLS session: ${error.message}`); | ||||
|         this.socket.destroy(); | ||||
|         this.socket = null; | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     this.socket.on('error', (err) => { | ||||
|       this.log(`TLS error: ${err.message}`); | ||||
|       this.socket.destroy(); | ||||
|       this.socket = null; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Send SMTP commands to deliver the email | ||||
|    */ | ||||
|   private async sendEmailCommands(): Promise<void> { | ||||
|     // MAIL FROM command | ||||
|     await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250'); | ||||
|      | ||||
|     // RCPT TO command for each recipient | ||||
|     for (const recipient of this.email.getAllRecipients()) { | ||||
|       await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250'); | ||||
|     } | ||||
|      | ||||
|     // DATA command | ||||
|     await this.sendCommand('DATA\r\n', '354'); | ||||
|      | ||||
|     // Create the email message with DKIM signature | ||||
|     const message = await this.createEmailMessage(); | ||||
|      | ||||
|     // Send the message content | ||||
|     await this.sendCommand(message); | ||||
|     await this.sendCommand('\r\n.\r\n', '250'); | ||||
|      | ||||
|     // QUIT command | ||||
|     await this.sendCommand('QUIT\r\n', '221'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create the full email message with headers and DKIM signature | ||||
|    */ | ||||
|   private async createEmailMessage(): Promise<string> { | ||||
|     this.log('Preparing email message'); | ||||
|      | ||||
|     const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`; | ||||
|     const boundary = '----=_NextPart_' + plugins.uuid.v4(); | ||||
|      | ||||
|     // Prepare headers | ||||
|     const headers = { | ||||
|       'Message-ID': messageId, | ||||
|       'From': this.email.from, | ||||
|       'To': this.email.to.join(', '), | ||||
|       'Subject': this.email.subject, | ||||
|       'Content-Type': `multipart/mixed; boundary="${boundary}"`, | ||||
|       'Date': new Date().toUTCString() | ||||
|     }; | ||||
|      | ||||
|     // Add CC header if present | ||||
|     if (this.email.cc && this.email.cc.length > 0) { | ||||
|       headers['Cc'] = this.email.cc.join(', '); | ||||
|     } | ||||
|      | ||||
|     // Add custom headers | ||||
|     for (const [key, value] of Object.entries(this.email.headers || {})) { | ||||
|       headers[key] = value; | ||||
|     } | ||||
|      | ||||
|     // Add priority header if not normal | ||||
|     if (this.email.priority && this.email.priority !== 'normal') { | ||||
|       const priorityValue = this.email.priority === 'high' ? '1' : '5'; | ||||
|       headers['X-Priority'] = priorityValue; | ||||
|     } | ||||
|      | ||||
|     // Create body | ||||
|     let body = ''; | ||||
|      | ||||
|     // Text part | ||||
|     body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`; | ||||
|      | ||||
|     // HTML part if present | ||||
|     if (this.email.html) { | ||||
|       body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`; | ||||
|     } | ||||
|      | ||||
|     // Attachments | ||||
|     for (const attachment of this.email.attachments) { | ||||
|       body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`; | ||||
|       body += 'Content-Transfer-Encoding: base64\r\n'; | ||||
|       body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; | ||||
|        | ||||
|       // Add Content-ID for inline attachments if present | ||||
|       if (attachment.contentId) { | ||||
|         body += `Content-ID: <${attachment.contentId}>\r\n`; | ||||
|       } | ||||
|        | ||||
|       body += '\r\n'; | ||||
|       body += attachment.content.toString('base64') + '\r\n'; | ||||
|     } | ||||
|      | ||||
|     // End of message | ||||
|     body += `--${boundary}--\r\n`; | ||||
|      | ||||
|     // Create DKIM signature | ||||
|     const dkimSigner = new EmailSignJob(this.mtaRef, { | ||||
|       domain: this.email.getFromDomain(), | ||||
|       selector: 'mta', | ||||
|       headers: headers, | ||||
|       body: body, | ||||
|     }); | ||||
|      | ||||
|     // Build the message with headers | ||||
|     let headerString = ''; | ||||
|     for (const [key, value] of Object.entries(headers)) { | ||||
|       headerString += `${key}: ${value}\r\n`; | ||||
|     } | ||||
|     let message = headerString + '\r\n' + body; | ||||
|      | ||||
|     // Add DKIM signature header | ||||
|     let signatureHeader = await dkimSigner.getSignatureHeader(message); | ||||
|     message = `${signatureHeader}${message}`; | ||||
|      | ||||
|     return message; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Send a command to the SMTP server and wait for the expected response | ||||
|    */ | ||||
|   private sendCommand(command: string, expectedResponseCode?: string): Promise<string> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!this.socket) { | ||||
|         return reject(new Error('Socket not connected')); | ||||
|       } | ||||
|        | ||||
|       // Debug log for commands (except DATA which can be large) | ||||
|       if (this.options.debugMode && !command.startsWith('--')) { | ||||
|         const logCommand = command.length > 100  | ||||
|           ? command.substring(0, 97) + '...'  | ||||
|           : command; | ||||
|         this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`); | ||||
|       } | ||||
|        | ||||
|       this.socket.write(command, (error) => { | ||||
|         if (error) { | ||||
|           this.log(`Write error: ${error.message}`); | ||||
|           return reject(error); | ||||
|         } | ||||
|          | ||||
|         // If no response is expected, resolve immediately | ||||
|         if (!expectedResponseCode) { | ||||
|           return resolve(''); | ||||
|         } | ||||
|          | ||||
|         // Set a timeout for the response | ||||
|         const responseTimeout = setTimeout(() => { | ||||
|           this.log('Response timeout'); | ||||
|           reject(new Error('Response timeout')); | ||||
|         }, this.options.connectionTimeout); | ||||
|          | ||||
|         // Wait for the response | ||||
|         this.socket.once('data', (data) => { | ||||
|           clearTimeout(responseTimeout); | ||||
|           const response = data.toString(); | ||||
|            | ||||
|           if (this.options.debugMode) { | ||||
|             this.log(`Received: ${response.trim()}`); | ||||
|           } | ||||
|            | ||||
|           if (response.startsWith(expectedResponseCode)) { | ||||
|             resolve(response); | ||||
|           } else { | ||||
|             const error = new Error(`Unexpected server response: ${response.trim()}`); | ||||
|             this.log(error.message); | ||||
|             reject(error); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Determine if an error represents a permanent failure | ||||
|    */ | ||||
|   private isPermanentFailure(error: Error): boolean { | ||||
|     if (!error || !error.message) return false; | ||||
|      | ||||
|     const message = error.message.toLowerCase(); | ||||
|      | ||||
|     // Check for permanent SMTP error codes (5xx) | ||||
|     if (message.match(/^5\d\d/)) return true; | ||||
|      | ||||
|     // Check for specific permanent failure messages | ||||
|     const permanentFailurePatterns = [ | ||||
|       'no such user', | ||||
|       'user unknown', | ||||
|       'domain not found', | ||||
|       'invalid domain', | ||||
|       'rejected', | ||||
|       'denied', | ||||
|       'prohibited', | ||||
|       'authentication required', | ||||
|       'authentication failed', | ||||
|       'unauthorized' | ||||
|     ]; | ||||
|      | ||||
|     return permanentFailurePatterns.some(pattern => message.includes(pattern)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Resolve MX records for a domain | ||||
|    */ | ||||
|   private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       plugins.dns.resolveMx(domain, (err, addresses) => { | ||||
|         if (err) { | ||||
|           console.error('Error resolving MX:', err); | ||||
|           reject(err); | ||||
|         } else { | ||||
|           resolve(addresses); | ||||
| @@ -51,123 +559,65 @@ export class EmailSendJob { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private processInitialResponse(): Promise<void> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       this.socket.once('data', (data) => { | ||||
|         const response = data.toString(); | ||||
|         if (!response.startsWith('220')) { | ||||
|           console.error('Unexpected initial server response:', response); | ||||
|           reject(new Error(`Unexpected initial server response: ${response}`)); | ||||
|         } else { | ||||
|           console.log('Received initial server response:', response); | ||||
|           console.log('Connected to server, sending EHLO...'); | ||||
|           resolve(); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private processTLSUpgrade(domain: string): Promise<void> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       this.socket.once('secureConnect', async () => { | ||||
|         console.log('TLS started successfully'); | ||||
|         try { | ||||
|           await this.sendCommand(`EHLO ${domain}\r\n`, '250'); | ||||
|           resolve(); | ||||
|         } catch (err) { | ||||
|           console.error('Error sending EHLO after TLS upgrade:', err); | ||||
|           reject(err); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private sendCommand(command: string, expectedResponseCode?: string): Promise<void> { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       this.socket.write(command, (error) => { | ||||
|         if (error) { | ||||
|           reject(error); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         if (!expectedResponseCode) { | ||||
|           resolve(); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         this.socket.once('data', (data) => { | ||||
|           const response = data.toString(); | ||||
|           if (response.startsWith('221')) { | ||||
|             this.socket.destroy(); | ||||
|             resolve(); | ||||
|           } | ||||
|           if (!response.startsWith(expectedResponseCode)) { | ||||
|             reject(new Error(`Unexpected server response: ${response}`)); | ||||
|           } else { | ||||
|             resolve(); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private async sendMessage(): Promise<void> { | ||||
|     console.log('Preparing email message...'); | ||||
|     const messageId = `<${plugins.uuid.v4()}@${this.email.from.split('@')[1]}>`; | ||||
|    | ||||
|     // Create a boundary for the email parts | ||||
|     const boundary = '----=_NextPart_' + plugins.uuid.v4(); | ||||
|    | ||||
|     const headers = { | ||||
|       From: this.email.from, | ||||
|       To: this.email.to, | ||||
|       Subject: this.email.subject, | ||||
|       'Content-Type': `multipart/mixed; boundary="${boundary}"`, | ||||
|     }; | ||||
|    | ||||
|     // Construct the body of the message | ||||
|     let body = `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.text}\r\n`; | ||||
|    | ||||
|     // Then, the attachments | ||||
|     for (let attachment of this.email.attachments) { | ||||
|       body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`; | ||||
|       body += 'Content-Transfer-Encoding: base64\r\n'; | ||||
|       body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n\r\n`; | ||||
|       body += attachment.content.toString('base64') + '\r\n'; | ||||
|     } | ||||
|    | ||||
|     // End of email | ||||
|     body += `--${boundary}--\r\n`; | ||||
|    | ||||
|     // Create an instance of DKIMSigner | ||||
|     const dkimSigner = new EmailSignJob(this.mtaRef, { | ||||
|       domain: this.email.getFromDomain(), // Replace with your domain | ||||
|       selector: `mta`, // Replace with your DKIM selector | ||||
|       headers: headers, | ||||
|       body: body, | ||||
|     }); | ||||
|    | ||||
|     // Construct the message with DKIM-Signature header | ||||
|     let message = `Message-ID: ${messageId}\r\nFrom: ${this.email.from}\r\nTo: ${this.email.to}\r\nSubject: ${this.email.subject}\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`; | ||||
|     message += body; | ||||
|    | ||||
|     let signatureHeader = await dkimSigner.getSignatureHeader(message); | ||||
|     message = `${signatureHeader}${message}`; | ||||
|    | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir); | ||||
|     plugins.smartfile.memory.toFsSync(message, plugins.path.join(paths.sentEmailsDir, `${Date.now()}.eml`)); | ||||
|    | ||||
|    | ||||
|     // Adding necessary commands before sending the actual email message | ||||
|     await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250'); | ||||
|     await this.sendCommand(`RCPT TO:<${this.email.to}>\r\n`, '250'); | ||||
|     await this.sendCommand(`DATA\r\n`, '354'); | ||||
|   /** | ||||
|    * Add a log entry | ||||
|    */ | ||||
|   private log(message: string): void { | ||||
|     const timestamp = new Date().toISOString(); | ||||
|     const logEntry = `[${timestamp}] ${message}`; | ||||
|     this.deliveryInfo.logs.push(logEntry); | ||||
|      | ||||
|     // Now send the message content | ||||
|     await this.sendCommand(message); | ||||
|     await this.sendCommand('\r\n.\r\n', '250'); | ||||
|    | ||||
|     await this.sendCommand('QUIT\r\n', '221'); | ||||
|     console.log('Email message sent successfully!'); | ||||
|     if (this.options.debugMode) { | ||||
|       console.log(`EmailSendJob: ${logEntry}`); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|   /** | ||||
|    * Save a successful email for record keeping | ||||
|    */ | ||||
|   private async saveSuccess(): Promise<void> { | ||||
|     try { | ||||
|       plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir); | ||||
|       const emailContent = await this.createEmailMessage(); | ||||
|       const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`; | ||||
|       plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName)); | ||||
|        | ||||
|       // Save delivery info | ||||
|       const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`; | ||||
|       plugins.smartfile.memory.toFsSync( | ||||
|         JSON.stringify(this.deliveryInfo, null, 2), | ||||
|         plugins.path.join(paths.sentEmailsDir, infoFileName) | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       console.error('Error saving successful email:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Save a failed email for potential retry | ||||
|    */ | ||||
|   private async saveFailed(): Promise<void> { | ||||
|     try { | ||||
|       plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir); | ||||
|       const emailContent = await this.createEmailMessage(); | ||||
|       const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`; | ||||
|       plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName)); | ||||
|        | ||||
|       // Save delivery info | ||||
|       const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`; | ||||
|       plugins.smartfile.memory.toFsSync( | ||||
|         JSON.stringify(this.deliveryInfo, null, 2), | ||||
|         plugins.path.join(paths.failedEmailsDir, infoFileName) | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       console.error('Error saving failed email:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Simple delay function | ||||
|    */ | ||||
|   private delay(ms: number): Promise<void> { | ||||
|     return new Promise(resolve => setTimeout(resolve, ms)); | ||||
|   } | ||||
| } | ||||
| @@ -1,69 +1,945 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import * as paths from '../paths.js'; | ||||
|  | ||||
| import { Email } from './mta.classes.email.js'; | ||||
| import { EmailSendJob } from './mta.classes.emailsendjob.js'; | ||||
| import { EmailSendJob, DeliveryStatus } from './mta.classes.emailsendjob.js'; | ||||
| import { DKIMCreator } from './mta.classes.dkimcreator.js'; | ||||
| import { DKIMVerifier } from './mta.classes.dkimverifier.js'; | ||||
| import { SMTPServer } from './mta.classes.smtpserver.js'; | ||||
| import { SMTPServer, type ISmtpServerOptions } from './mta.classes.smtpserver.js'; | ||||
| import { DNSManager } from './mta.classes.dnsmanager.js'; | ||||
| import { ApiManager } from './mta.classes.apimanager.js'; | ||||
| import type { SzPlatformService } from '../classes.platformservice.js'; | ||||
|  | ||||
| export class MtaService { | ||||
|   public platformServiceRef: SzPlatformService; | ||||
|   public server: SMTPServer; | ||||
|   public dkimCreator: DKIMCreator; | ||||
|   public dkimVerifier: DKIMVerifier; | ||||
|   public dnsManager: DNSManager; | ||||
| /** | ||||
|  * Configuration options for the MTA service | ||||
|  */ | ||||
| export interface IMtaConfig { | ||||
|   /** SMTP server options */ | ||||
|   smtp?: { | ||||
|     /** Whether to enable the SMTP server */ | ||||
|     enabled?: boolean; | ||||
|     /** Port to listen on (default: 25) */ | ||||
|     port?: number; | ||||
|     /** SMTP server hostname */ | ||||
|     hostname?: string; | ||||
|     /** Maximum allowed email size in bytes */ | ||||
|     maxSize?: number; | ||||
|   }; | ||||
|   /** SSL/TLS configuration */ | ||||
|   tls?: { | ||||
|     /** Domain for certificate */ | ||||
|     domain?: string; | ||||
|     /** Whether to auto-renew certificates */ | ||||
|     autoRenew?: boolean; | ||||
|     /** Custom key/cert paths (if not using auto-provision) */ | ||||
|     keyPath?: string; | ||||
|     certPath?: string; | ||||
|   }; | ||||
|   /** Outbound email sending configuration */ | ||||
|   outbound?: { | ||||
|     /** Maximum concurrent sending jobs */ | ||||
|     concurrency?: number; | ||||
|     /** Retry configuration */ | ||||
|     retries?: { | ||||
|       /** Maximum number of retries per message */ | ||||
|       max?: number; | ||||
|       /** Initial delay between retries (milliseconds) */ | ||||
|       delay?: number; | ||||
|       /** Whether to use exponential backoff for retries */ | ||||
|       useBackoff?: boolean; | ||||
|     }; | ||||
|     /** Rate limiting configuration */ | ||||
|     rateLimit?: { | ||||
|       /** Maximum emails per period */ | ||||
|       maxPerPeriod?: number; | ||||
|       /** Time period in milliseconds */ | ||||
|       periodMs?: number; | ||||
|       /** Whether to apply per domain (vs globally) */ | ||||
|       perDomain?: boolean; | ||||
|     }; | ||||
|   }; | ||||
|   /** Security settings */ | ||||
|   security?: { | ||||
|     /** Whether to use DKIM signing */ | ||||
|     useDkim?: boolean; | ||||
|     /** Whether to verify inbound DKIM signatures */ | ||||
|     verifyDkim?: boolean; | ||||
|     /** Whether to verify SPF on inbound */ | ||||
|     verifySpf?: boolean; | ||||
|     /** Whether to use TLS for outbound when available */ | ||||
|     useTls?: boolean; | ||||
|     /** Whether to require valid certificates */ | ||||
|     requireValidCerts?: boolean; | ||||
|   }; | ||||
|   /** Domains configuration */ | ||||
|   domains?: { | ||||
|     /** List of domains that this MTA will handle as local */ | ||||
|     local?: string[]; | ||||
|     /** Whether to auto-create DNS records */ | ||||
|     autoCreateDnsRecords?: boolean; | ||||
|     /** DKIM selector to use (default: "mta") */ | ||||
|     dkimSelector?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   constructor(platformServiceRefArg: SzPlatformService) { | ||||
| /** | ||||
|  * Email queue entry | ||||
|  */ | ||||
| interface QueueEntry { | ||||
|   id: string; | ||||
|   email: Email; | ||||
|   addedAt: Date; | ||||
|   processing: boolean; | ||||
|   attempts: number; | ||||
|   lastAttempt?: Date; | ||||
|   nextAttempt?: Date; | ||||
|   error?: Error; | ||||
|   status: DeliveryStatus; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate information | ||||
|  */ | ||||
| interface Certificate { | ||||
|   privateKey: string; | ||||
|   publicKey: string; | ||||
|   expiresAt: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Stats for MTA monitoring | ||||
|  */ | ||||
| interface MtaStats { | ||||
|   startTime: Date; | ||||
|   emailsReceived: number; | ||||
|   emailsSent: number; | ||||
|   emailsFailed: number; | ||||
|   activeConnections: number; | ||||
|   queueSize: number; | ||||
|   certificateInfo?: { | ||||
|     domain: string; | ||||
|     expiresAt: Date; | ||||
|     daysUntilExpiry: number; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Main MTA Service class that coordinates all email functionality | ||||
|  */ | ||||
| export class MtaService { | ||||
|   /** Reference to the platform service */ | ||||
|   public platformServiceRef: SzPlatformService; | ||||
|    | ||||
|   /** SMTP server instance */ | ||||
|   public server: SMTPServer; | ||||
|    | ||||
|   /** DKIM creator for signing outgoing emails */ | ||||
|   public dkimCreator: DKIMCreator; | ||||
|    | ||||
|   /** DKIM verifier for validating incoming emails */ | ||||
|   public dkimVerifier: DKIMVerifier; | ||||
|    | ||||
|   /** DNS manager for handling DNS records */ | ||||
|   public dnsManager: DNSManager; | ||||
|    | ||||
|   /** API manager for external integrations */ | ||||
|   public apiManager: ApiManager; | ||||
|    | ||||
|   /** Email queue for outbound emails */ | ||||
|   private emailQueue: Map<string, QueueEntry> = new Map(); | ||||
|    | ||||
|   /** Email queue processing state */ | ||||
|   private queueProcessing = false; | ||||
|    | ||||
|   /** Rate limiters for outbound emails */ | ||||
|   private rateLimiters: Map<string, { | ||||
|     tokens: number; | ||||
|     lastRefill: number; | ||||
|   }> = new Map(); | ||||
|    | ||||
|   /** Certificate cache */ | ||||
|   private certificate: Certificate = null; | ||||
|    | ||||
|   /** MTA configuration */ | ||||
|   private config: IMtaConfig; | ||||
|    | ||||
|   /** Stats for monitoring */ | ||||
|   private stats: MtaStats; | ||||
|    | ||||
|   /** Whether the service is currently running */ | ||||
|   private running = false; | ||||
|  | ||||
|   /** | ||||
|    * Initialize the MTA service | ||||
|    * @param platformServiceRefArg Reference to the platform service | ||||
|    * @param config Configuration options | ||||
|    */ | ||||
|   constructor(platformServiceRefArg: SzPlatformService, config: IMtaConfig = {}) { | ||||
|     this.platformServiceRef = platformServiceRefArg; | ||||
|      | ||||
|     // Initialize with default configuration | ||||
|     this.config = this.getDefaultConfig(); | ||||
|      | ||||
|     // Merge with provided configuration | ||||
|     this.config = this.mergeConfig(this.config, config); | ||||
|      | ||||
|     // Initialize components | ||||
|     this.dkimCreator = new DKIMCreator(this); | ||||
|     this.dkimVerifier = new DKIMVerifier(this); | ||||
|     this.dnsManager = new DNSManager(this); | ||||
|   } | ||||
|  | ||||
|   public async start() { | ||||
|     // lets get the certificate | ||||
|     /** | ||||
|      * gets a certificate for a domain used by a service | ||||
|      * @param serviceNameArg | ||||
|      * @param domainNameArg | ||||
|      */ | ||||
|     const typedrouter = new plugins.typedrequest.TypedRouter(); | ||||
|     const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient( | ||||
|       typedrouter, | ||||
|       'https://cloudly.lossless.one:443' | ||||
|     ); | ||||
|     const getCertificateForDomainOverHttps = async (domainNameArg: string) => { | ||||
|       const typedCertificateRequest = | ||||
|         typedsocketClient.createTypedRequest<any>('getSslCertificate'); | ||||
|       const typedResponse = await typedCertificateRequest.fire({ | ||||
|         authToken: '', // do proper auth here | ||||
|         requiredCertName: domainNameArg, | ||||
|       }); | ||||
|       return typedResponse.certificate; | ||||
|     this.apiManager = new ApiManager(); | ||||
|      | ||||
|     // Initialize stats | ||||
|     this.stats = { | ||||
|       startTime: new Date(), | ||||
|       emailsReceived: 0, | ||||
|       emailsSent: 0, | ||||
|       emailsFailed: 0, | ||||
|       activeConnections: 0, | ||||
|       queueSize: 0 | ||||
|     }; | ||||
|     const certificate = await getCertificateForDomainOverHttps('mta.lossless.one'); | ||||
|     await typedsocketClient.stop(); | ||||
|     this.server = new SMTPServer(this, { | ||||
|       port: 25, | ||||
|       key: certificate.privateKey, | ||||
|       cert: certificate.publicKey, | ||||
|     }); | ||||
|     await this.server.start(); | ||||
|      | ||||
|     // Ensure required directories exist | ||||
|     this.ensureDirectories(); | ||||
|   } | ||||
|  | ||||
|   public async stop() { | ||||
|     if (!this.server) { | ||||
|       console.error('Server is not running'); | ||||
|   /** | ||||
|    * Get default configuration | ||||
|    */ | ||||
|   private getDefaultConfig(): IMtaConfig { | ||||
|     return { | ||||
|       smtp: { | ||||
|         enabled: true, | ||||
|         port: 25, | ||||
|         hostname: 'mta.lossless.one', | ||||
|         maxSize: 10 * 1024 * 1024 // 10MB | ||||
|       }, | ||||
|       tls: { | ||||
|         domain: 'mta.lossless.one', | ||||
|         autoRenew: true | ||||
|       }, | ||||
|       outbound: { | ||||
|         concurrency: 5, | ||||
|         retries: { | ||||
|           max: 3, | ||||
|           delay: 300000, // 5 minutes | ||||
|           useBackoff: true | ||||
|         }, | ||||
|         rateLimit: { | ||||
|           maxPerPeriod: 100, | ||||
|           periodMs: 60000, // 1 minute | ||||
|           perDomain: true | ||||
|         } | ||||
|       }, | ||||
|       security: { | ||||
|         useDkim: true, | ||||
|         verifyDkim: true, | ||||
|         verifySpf: true, | ||||
|         useTls: true, | ||||
|         requireValidCerts: false | ||||
|       }, | ||||
|       domains: { | ||||
|         local: ['lossless.one'], | ||||
|         autoCreateDnsRecords: true, | ||||
|         dkimSelector: 'mta' | ||||
|       } | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Merge configurations | ||||
|    */ | ||||
|   private mergeConfig(defaultConfig: IMtaConfig, customConfig: IMtaConfig): IMtaConfig { | ||||
|     // Deep merge of configurations | ||||
|     // (A more robust implementation would use a dedicated deep-merge library) | ||||
|     const merged = { ...defaultConfig }; | ||||
|      | ||||
|     // Merge first level | ||||
|     for (const [key, value] of Object.entries(customConfig)) { | ||||
|       if (value === null || value === undefined) continue; | ||||
|        | ||||
|       if (typeof value === 'object' && !Array.isArray(value)) { | ||||
|         merged[key] = { ...merged[key], ...value }; | ||||
|       } else { | ||||
|         merged[key] = value; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return merged; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Ensure required directories exist | ||||
|    */ | ||||
|   private ensureDirectories(): void { | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.keysDir); | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir); | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir); | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir); | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir); | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.logsDir); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start the MTA service | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     if (this.running) { | ||||
|       console.warn('MTA service is already running'); | ||||
|       return; | ||||
|     } | ||||
|     await this.server.stop(); | ||||
|      | ||||
|     try { | ||||
|       console.log('Starting MTA service...'); | ||||
|        | ||||
|       // Load or provision certificate | ||||
|       await this.loadOrProvisionCertificate(); | ||||
|        | ||||
|       // Start SMTP server if enabled | ||||
|       if (this.config.smtp.enabled) { | ||||
|         const smtpOptions: ISmtpServerOptions = { | ||||
|           port: this.config.smtp.port, | ||||
|           key: this.certificate.privateKey, | ||||
|           cert: this.certificate.publicKey, | ||||
|           hostname: this.config.smtp.hostname | ||||
|         }; | ||||
|          | ||||
|         this.server = new SMTPServer(this, smtpOptions); | ||||
|         this.server.start(); | ||||
|         console.log(`SMTP server started on port ${smtpOptions.port}`); | ||||
|       } | ||||
|        | ||||
|       // Start queue processing | ||||
|       this.startQueueProcessing(); | ||||
|        | ||||
|       // Update DNS records for local domains if configured | ||||
|       if (this.config.domains.autoCreateDnsRecords) { | ||||
|         await this.updateDnsRecordsForLocalDomains(); | ||||
|       } | ||||
|        | ||||
|       this.running = true; | ||||
|       console.log('MTA service started successfully'); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to start MTA service:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async send(email: Email): Promise<void> { | ||||
|     await this.dkimCreator.handleDKIMKeysForEmail(email); | ||||
|     const sendJob = new EmailSendJob(this, email); | ||||
|     await sendJob.send(); | ||||
|   /** | ||||
|    * Stop the MTA service | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     if (!this.running) { | ||||
|       console.warn('MTA service is not running'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       console.log('Stopping MTA service...'); | ||||
|        | ||||
|       // Stop SMTP server if running | ||||
|       if (this.server) { | ||||
|         await this.server.stop(); | ||||
|         this.server = null; | ||||
|         console.log('SMTP server stopped'); | ||||
|       } | ||||
|        | ||||
|       // Stop queue processing | ||||
|       this.queueProcessing = false; | ||||
|       console.log('Email queue processing stopped'); | ||||
|        | ||||
|       this.running = false; | ||||
|       console.log('MTA service stopped successfully'); | ||||
|     } catch (error) { | ||||
|       console.error('Error stopping MTA service:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|   /** | ||||
|    * Send an email (add to queue) | ||||
|    */ | ||||
|   public async send(email: Email): Promise<string> { | ||||
|     if (!this.running) { | ||||
|       throw new Error('MTA service is not running'); | ||||
|     } | ||||
|      | ||||
|     // Generate a unique ID for this email | ||||
|     const id = plugins.uuid.v4(); | ||||
|      | ||||
|     // Validate email | ||||
|     this.validateEmail(email); | ||||
|      | ||||
|     // Create DKIM keys if needed | ||||
|     if (this.config.security.useDkim) { | ||||
|       await this.dkimCreator.handleDKIMKeysForEmail(email); | ||||
|     } | ||||
|      | ||||
|     // Add to queue | ||||
|     this.emailQueue.set(id, { | ||||
|       id, | ||||
|       email, | ||||
|       addedAt: new Date(), | ||||
|       processing: false, | ||||
|       attempts: 0, | ||||
|       status: DeliveryStatus.PENDING | ||||
|     }); | ||||
|      | ||||
|     // Update stats | ||||
|     this.stats.queueSize = this.emailQueue.size; | ||||
|      | ||||
|     console.log(`Email added to queue: ${id}`); | ||||
|      | ||||
|     return id; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get status of an email in the queue | ||||
|    */ | ||||
|   public getEmailStatus(id: string): QueueEntry | null { | ||||
|     return this.emailQueue.get(id) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle an incoming email | ||||
|    */ | ||||
|   public async processIncomingEmail(email: Email): Promise<boolean> { | ||||
|     if (!this.running) { | ||||
|       throw new Error('MTA service is not running'); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       console.log(`Processing incoming email from ${email.from} to ${email.to}`); | ||||
|        | ||||
|       // Update stats | ||||
|       this.stats.emailsReceived++; | ||||
|        | ||||
|       // Check if the recipient domain is local | ||||
|       const recipientDomain = email.to[0].split('@')[1]; | ||||
|       const isLocalDomain = this.isLocalDomain(recipientDomain); | ||||
|        | ||||
|       if (isLocalDomain) { | ||||
|         // Save to local mailbox | ||||
|         await this.saveToLocalMailbox(email); | ||||
|         return true; | ||||
|       } else { | ||||
|         // Forward to another server | ||||
|         const forwardId = await this.send(email); | ||||
|         console.log(`Forwarding email to ${email.to} with queue ID ${forwardId}`); | ||||
|         return true; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Error processing incoming email:', error); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a domain is local | ||||
|    */ | ||||
|   private isLocalDomain(domain: string): boolean { | ||||
|     return this.config.domains.local.includes(domain); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Save an email to a local mailbox | ||||
|    */ | ||||
|   private async saveToLocalMailbox(email: Email): Promise<void> { | ||||
|     // Simplified implementation - in a real system, this would store to a user's mailbox | ||||
|     const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local'); | ||||
|     plugins.smartfile.fs.ensureDirSync(mailboxPath); | ||||
|      | ||||
|     const emailContent = email.toRFC822String(); | ||||
|     const filename = `${Date.now()}_${email.to[0].replace('@', '_at_')}.eml`; | ||||
|      | ||||
|     plugins.smartfile.memory.toFsSync( | ||||
|       emailContent, | ||||
|       plugins.path.join(mailboxPath, filename) | ||||
|     ); | ||||
|      | ||||
|     console.log(`Email saved to local mailbox: ${filename}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start processing the email queue | ||||
|    */ | ||||
|   private startQueueProcessing(): void { | ||||
|     if (this.queueProcessing) return; | ||||
|      | ||||
|     this.queueProcessing = true; | ||||
|     this.processQueue(); | ||||
|     console.log('Email queue processing started'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process emails in the queue | ||||
|    */ | ||||
|   private async processQueue(): Promise<void> { | ||||
|     if (!this.queueProcessing) return; | ||||
|      | ||||
|     try { | ||||
|       // Get pending emails ordered by next attempt time | ||||
|       const pendingEmails = Array.from(this.emailQueue.values()) | ||||
|         .filter(entry =>  | ||||
|           (entry.status === DeliveryStatus.PENDING || entry.status === DeliveryStatus.DEFERRED) &&  | ||||
|           !entry.processing && | ||||
|           (!entry.nextAttempt || entry.nextAttempt <= new Date()) | ||||
|         ) | ||||
|         .sort((a, b) => { | ||||
|           // Sort by next attempt time, then by added time | ||||
|           if (a.nextAttempt && b.nextAttempt) { | ||||
|             return a.nextAttempt.getTime() - b.nextAttempt.getTime(); | ||||
|           } else if (a.nextAttempt) { | ||||
|             return 1; | ||||
|           } else if (b.nextAttempt) { | ||||
|             return -1; | ||||
|           } else { | ||||
|             return a.addedAt.getTime() - b.addedAt.getTime(); | ||||
|           } | ||||
|         }); | ||||
|        | ||||
|       // Determine how many emails we can process concurrently | ||||
|       const availableSlots = Math.max(0, this.config.outbound.concurrency -  | ||||
|         Array.from(this.emailQueue.values()).filter(e => e.processing).length); | ||||
|        | ||||
|       // Process emails up to our concurrency limit | ||||
|       for (let i = 0; i < Math.min(availableSlots, pendingEmails.length); i++) { | ||||
|         const entry = pendingEmails[i]; | ||||
|          | ||||
|         // Check rate limits | ||||
|         if (!this.checkRateLimit(entry.email)) { | ||||
|           continue; | ||||
|         } | ||||
|          | ||||
|         // Mark as processing | ||||
|         entry.processing = true; | ||||
|          | ||||
|         // Process in background | ||||
|         this.processQueueEntry(entry).catch(error => { | ||||
|           console.error(`Error processing queue entry ${entry.id}:`, error); | ||||
|         }); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Error in queue processing:', error); | ||||
|     } finally { | ||||
|       // Schedule next processing cycle | ||||
|       setTimeout(() => this.processQueue(), 1000); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process a single queue entry | ||||
|    */ | ||||
|   private async processQueueEntry(entry: QueueEntry): Promise<void> { | ||||
|     try { | ||||
|       console.log(`Processing queue entry ${entry.id}`); | ||||
|        | ||||
|       // Update attempt counters | ||||
|       entry.attempts++; | ||||
|       entry.lastAttempt = new Date(); | ||||
|        | ||||
|       // Create send job | ||||
|       const sendJob = new EmailSendJob(this, entry.email, { | ||||
|         maxRetries: 1, // We handle retries at the queue level | ||||
|         tlsOptions: { | ||||
|           rejectUnauthorized: this.config.security.requireValidCerts | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Send the email | ||||
|       const status = await sendJob.send(); | ||||
|       entry.status = status; | ||||
|        | ||||
|       if (status === DeliveryStatus.DELIVERED) { | ||||
|         // Success - remove from queue | ||||
|         this.emailQueue.delete(entry.id); | ||||
|         this.stats.emailsSent++; | ||||
|         console.log(`Email ${entry.id} delivered successfully`); | ||||
|       } else if (status === DeliveryStatus.FAILED) { | ||||
|         // Permanent failure | ||||
|         entry.error = sendJob.deliveryInfo.error; | ||||
|         this.stats.emailsFailed++; | ||||
|         console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`); | ||||
|          | ||||
|         // Remove from queue | ||||
|         this.emailQueue.delete(entry.id); | ||||
|       } else if (status === DeliveryStatus.DEFERRED) { | ||||
|         // Temporary failure - schedule retry if attempts remain | ||||
|         entry.error = sendJob.deliveryInfo.error; | ||||
|          | ||||
|         if (entry.attempts >= this.config.outbound.retries.max) { | ||||
|           // Max retries reached - mark as failed | ||||
|           entry.status = DeliveryStatus.FAILED; | ||||
|           this.stats.emailsFailed++; | ||||
|           console.log(`Email ${entry.id} failed after ${entry.attempts} attempts: ${entry.error.message}`); | ||||
|            | ||||
|           // Remove from queue | ||||
|           this.emailQueue.delete(entry.id); | ||||
|         } else { | ||||
|           // Schedule retry | ||||
|           const delay = this.calculateRetryDelay(entry.attempts); | ||||
|           entry.nextAttempt = new Date(Date.now() + delay); | ||||
|           console.log(`Email ${entry.id} deferred, next attempt at ${entry.nextAttempt}`); | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error(`Unexpected error processing queue entry ${entry.id}:`, error); | ||||
|        | ||||
|       // Handle unexpected errors similarly to deferred | ||||
|       entry.error = error; | ||||
|        | ||||
|       if (entry.attempts >= this.config.outbound.retries.max) { | ||||
|         entry.status = DeliveryStatus.FAILED; | ||||
|         this.stats.emailsFailed++; | ||||
|         this.emailQueue.delete(entry.id); | ||||
|       } else { | ||||
|         entry.status = DeliveryStatus.DEFERRED; | ||||
|         const delay = this.calculateRetryDelay(entry.attempts); | ||||
|         entry.nextAttempt = new Date(Date.now() + delay); | ||||
|       } | ||||
|     } finally { | ||||
|       // Mark as no longer processing | ||||
|       entry.processing = false; | ||||
|        | ||||
|       // Update stats | ||||
|       this.stats.queueSize = this.emailQueue.size; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate delay before retry based on attempt number | ||||
|    */ | ||||
|   private calculateRetryDelay(attemptNumber: number): number { | ||||
|     const baseDelay = this.config.outbound.retries.delay; | ||||
|      | ||||
|     if (this.config.outbound.retries.useBackoff) { | ||||
|       // Exponential backoff: base_delay * (2^(attempt-1)) | ||||
|       return baseDelay * Math.pow(2, attemptNumber - 1); | ||||
|     } else { | ||||
|       return baseDelay; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if an email can be sent under rate limits | ||||
|    */ | ||||
|   private checkRateLimit(email: Email): boolean { | ||||
|     const config = this.config.outbound.rateLimit; | ||||
|     if (!config || !config.maxPerPeriod) { | ||||
|       return true; // No rate limit configured | ||||
|     } | ||||
|      | ||||
|     // Determine which limiter to use | ||||
|     const key = config.perDomain ? email.getFromDomain() : 'global'; | ||||
|      | ||||
|     // Initialize limiter if needed | ||||
|     if (!this.rateLimiters.has(key)) { | ||||
|       this.rateLimiters.set(key, { | ||||
|         tokens: config.maxPerPeriod, | ||||
|         lastRefill: Date.now() | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     const limiter = this.rateLimiters.get(key); | ||||
|      | ||||
|     // Refill tokens based on time elapsed | ||||
|     const now = Date.now(); | ||||
|     const elapsedMs = now - limiter.lastRefill; | ||||
|     const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod; | ||||
|      | ||||
|     if (tokensToAdd > 0) { | ||||
|       limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd); | ||||
|       limiter.lastRefill = now - (elapsedMs % config.periodMs); | ||||
|     } | ||||
|      | ||||
|     // Check if we have tokens available | ||||
|     if (limiter.tokens > 0) { | ||||
|       limiter.tokens--; | ||||
|       return true; | ||||
|     } else { | ||||
|       console.log(`Rate limit exceeded for ${key}`); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Load or provision a TLS certificate | ||||
|    */ | ||||
|   private async loadOrProvisionCertificate(): Promise<void> { | ||||
|     try { | ||||
|       // Check if we have manual cert paths specified | ||||
|       if (this.config.tls.keyPath && this.config.tls.certPath) { | ||||
|         console.log('Using manually specified certificate files'); | ||||
|          | ||||
|         const [privateKey, publicKey] = await Promise.all([ | ||||
|           plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf-8'), | ||||
|           plugins.fs.promises.readFile(this.config.tls.certPath, 'utf-8') | ||||
|         ]); | ||||
|          | ||||
|         this.certificate = { | ||||
|           privateKey, | ||||
|           publicKey, | ||||
|           expiresAt: this.getCertificateExpiry(publicKey) | ||||
|         }; | ||||
|          | ||||
|         console.log(`Certificate loaded, expires: ${this.certificate.expiresAt}`); | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|       // Otherwise, use auto-provisioning | ||||
|       console.log(`Provisioning certificate for ${this.config.tls.domain}`); | ||||
|       this.certificate = await this.provisionCertificate(this.config.tls.domain); | ||||
|       console.log(`Certificate provisioned, expires: ${this.certificate.expiresAt}`); | ||||
|        | ||||
|       // Set up auto-renewal if configured | ||||
|       if (this.config.tls.autoRenew) { | ||||
|         this.setupCertificateRenewal(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Error loading or provisioning certificate:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Provision a certificate from the certificate service | ||||
|    */ | ||||
|   private async provisionCertificate(domain: string): Promise<Certificate> { | ||||
|     try { | ||||
|       // Setup proper authentication | ||||
|       const authToken = await this.getAuthToken(); | ||||
|        | ||||
|       if (!authToken) { | ||||
|         throw new Error('Failed to obtain authentication token for certificate provisioning'); | ||||
|       } | ||||
|        | ||||
|       // Initialize client | ||||
|       const typedrouter = new plugins.typedrequest.TypedRouter(); | ||||
|       const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient( | ||||
|         typedrouter, | ||||
|         'https://cloudly.lossless.one:443' | ||||
|       ); | ||||
|        | ||||
|       try { | ||||
|         // Request certificate | ||||
|         const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate'); | ||||
|         const typedResponse = await typedCertificateRequest.fire({ | ||||
|           authToken, | ||||
|           requiredCertName: domain, | ||||
|         }); | ||||
|          | ||||
|         if (!typedResponse || !typedResponse.certificate) { | ||||
|           throw new Error('Invalid response from certificate service'); | ||||
|         } | ||||
|          | ||||
|         // Extract certificate information | ||||
|         const cert = typedResponse.certificate; | ||||
|          | ||||
|         // Determine expiry date | ||||
|         const expiresAt = this.getCertificateExpiry(cert.publicKey); | ||||
|          | ||||
|         return { | ||||
|           privateKey: cert.privateKey, | ||||
|           publicKey: cert.publicKey, | ||||
|           expiresAt | ||||
|         }; | ||||
|       } finally { | ||||
|         // Always close the client | ||||
|         await typedsocketClient.stop(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Certificate provisioning failed:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get authentication token for certificate service | ||||
|    */ | ||||
|   private async getAuthToken(): Promise<string> { | ||||
|     // Implementation would depend on authentication mechanism | ||||
|     // This is a simplified example assuming the platform service has an auth method | ||||
|     try { | ||||
|       // For now, return a placeholder token - in production this would | ||||
|       // authenticate properly with the certificate service | ||||
|       return 'mta-service-auth-token'; | ||||
|     } catch (error) { | ||||
|       console.error('Failed to obtain auth token:', error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract certificate expiry date from public key | ||||
|    */ | ||||
|   private getCertificateExpiry(publicKey: string): Date { | ||||
|     try { | ||||
|       // This is a simplified implementation | ||||
|       // In a real system, you would parse the certificate properly | ||||
|       // using a certificate parsing library | ||||
|        | ||||
|       // For now, set expiry to 90 days from now | ||||
|       const expiresAt = new Date(); | ||||
|       expiresAt.setDate(expiresAt.getDate() + 90); | ||||
|       return expiresAt; | ||||
|     } catch (error) { | ||||
|       console.error('Failed to extract certificate expiry:', error); | ||||
|        | ||||
|       // Default to 30 days from now | ||||
|       const defaultExpiry = new Date(); | ||||
|       defaultExpiry.setDate(defaultExpiry.getDate() + 30); | ||||
|       return defaultExpiry; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set up certificate auto-renewal | ||||
|    */ | ||||
|   private setupCertificateRenewal(): void { | ||||
|     if (!this.certificate || !this.certificate.expiresAt) { | ||||
|       console.warn('Cannot setup certificate renewal: no valid certificate'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Calculate time until renewal (30 days before expiry) | ||||
|     const now = new Date(); | ||||
|     const renewalDate = new Date(this.certificate.expiresAt); | ||||
|     renewalDate.setDate(renewalDate.getDate() - 30); | ||||
|      | ||||
|     const timeUntilRenewal = Math.max(0, renewalDate.getTime() - now.getTime()); | ||||
|      | ||||
|     console.log(`Certificate renewal scheduled for ${renewalDate}`); | ||||
|      | ||||
|     // Schedule renewal | ||||
|     setTimeout(() => { | ||||
|       this.renewCertificate().catch(error => { | ||||
|         console.error('Certificate renewal failed:', error); | ||||
|       }); | ||||
|     }, timeUntilRenewal); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Renew the certificate | ||||
|    */ | ||||
|   private async renewCertificate(): Promise<void> { | ||||
|     try { | ||||
|       console.log('Renewing certificate...'); | ||||
|        | ||||
|       // Provision new certificate | ||||
|       const newCertificate = await this.provisionCertificate(this.config.tls.domain); | ||||
|        | ||||
|       // Replace current certificate | ||||
|       this.certificate = newCertificate; | ||||
|        | ||||
|       console.log(`Certificate renewed, new expiry: ${newCertificate.expiresAt}`); | ||||
|        | ||||
|       // Update SMTP server with new certificate if running | ||||
|       if (this.server) { | ||||
|         // Restart server with new certificate | ||||
|         await this.server.stop(); | ||||
|          | ||||
|         const smtpOptions: ISmtpServerOptions = { | ||||
|           port: this.config.smtp.port, | ||||
|           key: this.certificate.privateKey, | ||||
|           cert: this.certificate.publicKey, | ||||
|           hostname: this.config.smtp.hostname | ||||
|         }; | ||||
|          | ||||
|         this.server = new SMTPServer(this, smtpOptions); | ||||
|         this.server.start(); | ||||
|          | ||||
|         console.log('SMTP server restarted with new certificate'); | ||||
|       } | ||||
|        | ||||
|       // Schedule next renewal | ||||
|       this.setupCertificateRenewal(); | ||||
|     } catch (error) { | ||||
|       console.error('Certificate renewal failed:', error); | ||||
|        | ||||
|       // Schedule retry after 24 hours | ||||
|       setTimeout(() => { | ||||
|         this.renewCertificate().catch(err => { | ||||
|           console.error('Certificate renewal retry failed:', err); | ||||
|         }); | ||||
|       }, 24 * 60 * 60 * 1000); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update DNS records for all local domains | ||||
|    */ | ||||
|   private async updateDnsRecordsForLocalDomains(): Promise<void> { | ||||
|     if (!this.config.domains.local || this.config.domains.local.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     console.log('Updating DNS records for local domains...'); | ||||
|      | ||||
|     for (const domain of this.config.domains.local) { | ||||
|       try { | ||||
|         console.log(`Updating DNS records for ${domain}`); | ||||
|          | ||||
|         // Generate DKIM keys if needed | ||||
|         await this.dkimCreator.handleDKIMKeysForDomain(domain); | ||||
|          | ||||
|         // Generate all recommended DNS records | ||||
|         const records = await this.dnsManager.generateAllRecommendedRecords(domain); | ||||
|          | ||||
|         console.log(`Generated ${records.length} DNS records for ${domain}`); | ||||
|       } catch (error) { | ||||
|         console.error(`Error updating DNS records for ${domain}:`, error); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate an email before sending | ||||
|    */ | ||||
|   private validateEmail(email: Email): void { | ||||
|     // The Email class constructor already performs basic validation | ||||
|     // Here we can add additional MTA-specific validation | ||||
|      | ||||
|     if (!email.from) { | ||||
|       throw new Error('Email must have a sender address'); | ||||
|     } | ||||
|      | ||||
|     if (!email.to || email.to.length === 0) { | ||||
|       throw new Error('Email must have at least one recipient'); | ||||
|     } | ||||
|      | ||||
|     // Check if the sender domain is allowed | ||||
|     const senderDomain = email.getFromDomain(); | ||||
|     if (!senderDomain) { | ||||
|       throw new Error('Invalid sender domain'); | ||||
|     } | ||||
|      | ||||
|     // If the sender domain is one of our local domains, ensure we have DKIM keys | ||||
|     if (this.isLocalDomain(senderDomain) && this.config.security.useDkim) { | ||||
|       // DKIM keys will be created if needed in the send method | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get MTA service statistics | ||||
|    */ | ||||
|   public getStats(): MtaStats { | ||||
|     // Update queue size | ||||
|     this.stats.queueSize = this.emailQueue.size; | ||||
|      | ||||
|     // Update certificate info if available | ||||
|     if (this.certificate) { | ||||
|       const now = new Date(); | ||||
|       const daysUntilExpiry = Math.floor( | ||||
|         (this.certificate.expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) | ||||
|       ); | ||||
|        | ||||
|       this.stats.certificateInfo = { | ||||
|         domain: this.config.tls.domain, | ||||
|         expiresAt: this.certificate.expiresAt, | ||||
|         daysUntilExpiry | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     return { ...this.stats }; | ||||
|   } | ||||
| } | ||||
| @@ -7,133 +7,345 @@ export interface ISmtpServerOptions { | ||||
|   port: number; | ||||
|   key: string; | ||||
|   cert: string; | ||||
|   hostname?: string; | ||||
| } | ||||
|  | ||||
| // SMTP Session States | ||||
| enum SmtpState { | ||||
|   GREETING, | ||||
|   AFTER_EHLO, | ||||
|   MAIL_FROM, | ||||
|   RCPT_TO, | ||||
|   DATA, | ||||
|   DATA_RECEIVING, | ||||
|   FINISHED | ||||
| } | ||||
|  | ||||
| // Structure to store session information | ||||
| interface SmtpSession { | ||||
|   state: SmtpState; | ||||
|   clientHostname: string; | ||||
|   mailFrom: string; | ||||
|   rcptTo: string[]; | ||||
|   emailData: string; | ||||
|   useTLS: boolean; | ||||
|   connectionEnded: boolean; | ||||
| } | ||||
|  | ||||
| export class SMTPServer { | ||||
|   public mtaRef: MtaService; | ||||
|   private smtpServerOptions: ISmtpServerOptions; | ||||
|   private server: plugins.net.Server; | ||||
|   private emailBufferStringMap: Map<plugins.net.Socket, string>; | ||||
|   private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>; | ||||
|   private hostname: string; | ||||
|  | ||||
|   constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) { | ||||
|     console.log('SMTPServer instance is being created...'); | ||||
|  | ||||
|     this.mtaRef = mtaRefArg; | ||||
|     this.smtpServerOptions = optionsArg; | ||||
|     this.emailBufferStringMap = new Map(); | ||||
|     this.sessions = new Map(); | ||||
|     this.hostname = optionsArg.hostname || 'mta.lossless.one'; | ||||
|  | ||||
|     this.server = plugins.net.createServer((socket) => { | ||||
|       console.log('New connection established...'); | ||||
|  | ||||
|       socket.write('220 mta.lossless.one ESMTP Postfix\r\n'); | ||||
|  | ||||
|       socket.on('data', (data) => { | ||||
|         this.processData(socket, data); | ||||
|       }); | ||||
|  | ||||
|       socket.on('end', () => { | ||||
|         console.log('Socket closed. Deleting related emailBuffer...'); | ||||
|         socket.destroy(); | ||||
|         this.emailBufferStringMap.delete(socket); | ||||
|       }); | ||||
|  | ||||
|       socket.on('error', () => { | ||||
|         console.error('Socket error occurred. Deleting related emailBuffer...'); | ||||
|         socket.destroy(); | ||||
|         this.emailBufferStringMap.delete(socket); | ||||
|       }); | ||||
|  | ||||
|       socket.on('close', () => { | ||||
|         console.log('Connection was closed by the client'); | ||||
|         socket.destroy(); | ||||
|         this.emailBufferStringMap.delete(socket); | ||||
|       }); | ||||
|       this.handleNewConnection(socket); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private startTLS(socket: plugins.net.Socket) { | ||||
|     const secureContext = plugins.tls.createSecureContext({ | ||||
|       key: this.smtpServerOptions.key, | ||||
|       cert: this.smtpServerOptions.cert, | ||||
|   private handleNewConnection(socket: plugins.net.Socket): void { | ||||
|     console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`); | ||||
|      | ||||
|     // Initialize a new session | ||||
|     this.sessions.set(socket, { | ||||
|       state: SmtpState.GREETING, | ||||
|       clientHostname: '', | ||||
|       mailFrom: '', | ||||
|       rcptTo: [], | ||||
|       emailData: '', | ||||
|       useTLS: false, | ||||
|       connectionEnded: false | ||||
|     }); | ||||
|  | ||||
|     const tlsSocket = new plugins.tls.TLSSocket(socket, { | ||||
|       secureContext: secureContext, | ||||
|       isServer: true, | ||||
|     // Send greeting | ||||
|     this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`); | ||||
|  | ||||
|     socket.on('data', (data) => { | ||||
|       this.processData(socket, data); | ||||
|     }); | ||||
|  | ||||
|     tlsSocket.on('secure', () => { | ||||
|       console.log('Connection secured.'); | ||||
|       this.emailBufferStringMap.set(tlsSocket, this.emailBufferStringMap.get(socket) || ''); | ||||
|       this.emailBufferStringMap.delete(socket); | ||||
|     }); | ||||
|  | ||||
|     // Use the same handler for the 'data' event as for the unsecured socket. | ||||
|     tlsSocket.on('data', (data: Buffer) => { | ||||
|       this.processData(tlsSocket, Buffer.from(data)); | ||||
|     }); | ||||
|  | ||||
|     tlsSocket.on('end', () => { | ||||
|       console.log('TLS socket closed. Deleting related emailBuffer...'); | ||||
|       this.emailBufferStringMap.delete(tlsSocket); | ||||
|     }); | ||||
|  | ||||
|     tlsSocket.on('error', (err) => { | ||||
|       console.error('TLS socket error occurred. Deleting related emailBuffer...'); | ||||
|       this.emailBufferStringMap.delete(tlsSocket); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer) { | ||||
|     const dataString = data.toString(); | ||||
|     console.log(`Received data:`); | ||||
|     console.log(`${dataString}`) | ||||
|  | ||||
|     if (dataString.startsWith('EHLO')) { | ||||
|       socket.write('250-mta.lossless.one Hello\r\n250 STARTTLS\r\n'); | ||||
|     } else if (dataString.startsWith('MAIL FROM')) { | ||||
|       socket.write('250 Ok\r\n'); | ||||
|     } else if (dataString.startsWith('RCPT TO')) { | ||||
|       socket.write('250 Ok\r\n'); | ||||
|     } else if (dataString.startsWith('STARTTLS')) { | ||||
|       socket.write('220 Ready to start TLS\r\n'); | ||||
|       this.startTLS(socket); | ||||
|     } else if (dataString.startsWith('DATA')) { | ||||
|       socket.write('354 End data with <CR><LF>.<CR><LF>\r\n'); | ||||
|       let emailBuffer = this.emailBufferStringMap.get(socket); | ||||
|       if (!emailBuffer) { | ||||
|         this.emailBufferStringMap.set(socket, ''); | ||||
|     socket.on('end', () => { | ||||
|       console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`); | ||||
|       const session = this.sessions.get(socket); | ||||
|       if (session) { | ||||
|         session.connectionEnded = true; | ||||
|       } | ||||
|     } else if (dataString.startsWith('QUIT')) { | ||||
|       socket.write('221 Bye\r\n'); | ||||
|       console.log('Received QUIT command, closing the socket...'); | ||||
|     }); | ||||
|  | ||||
|     socket.on('error', (err) => { | ||||
|       console.error(`Socket error: ${err.message}`); | ||||
|       this.sessions.delete(socket); | ||||
|       socket.destroy(); | ||||
|       this.parseEmail(socket); | ||||
|     } else { | ||||
|       let emailBuffer = this.emailBufferStringMap.get(socket); | ||||
|       if (typeof emailBuffer === 'string') { | ||||
|         emailBuffer += dataString; | ||||
|         this.emailBufferStringMap.set(socket, emailBuffer); | ||||
|       } | ||||
|       socket.write('250 Ok\r\n'); | ||||
|     }); | ||||
|  | ||||
|     socket.on('close', () => { | ||||
|       console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`); | ||||
|       this.sessions.delete(socket); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { | ||||
|     try { | ||||
|       socket.write(`${response}\r\n`); | ||||
|       console.log(`→ ${response}`); | ||||
|     } catch (error) { | ||||
|       console.error(`Error sending response: ${error.message}`); | ||||
|       socket.destroy(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) { | ||||
|       console.error('No session found for socket. Closing connection.'); | ||||
|       socket.destroy(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // If we're in DATA_RECEIVING state, handle differently | ||||
|     if (session.state === SmtpState.DATA_RECEIVING) { | ||||
|       return this.processEmailData(socket, data.toString()); | ||||
|     } | ||||
|  | ||||
|     // Process normal SMTP commands | ||||
|     const lines = data.toString().split('\r\n').filter(line => line.length > 0); | ||||
|     for (const line of lines) { | ||||
|       console.log(`← ${line}`); | ||||
|       this.processCommand(socket, line); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session || session.connectionEnded) return; | ||||
|  | ||||
|     const [command, ...args] = commandLine.split(' '); | ||||
|     const upperCommand = command.toUpperCase(); | ||||
|  | ||||
|     switch (upperCommand) { | ||||
|       case 'EHLO': | ||||
|       case 'HELO': | ||||
|         this.handleEhlo(socket, args.join(' ')); | ||||
|         break; | ||||
|       case 'STARTTLS': | ||||
|         this.handleStartTls(socket); | ||||
|         break; | ||||
|       case 'MAIL': | ||||
|         this.handleMailFrom(socket, args.join(' ')); | ||||
|         break; | ||||
|       case 'RCPT': | ||||
|         this.handleRcptTo(socket, args.join(' ')); | ||||
|         break; | ||||
|       case 'DATA': | ||||
|         this.handleData(socket); | ||||
|         break; | ||||
|       case 'RSET': | ||||
|         this.handleRset(socket); | ||||
|         break; | ||||
|       case 'QUIT': | ||||
|         this.handleQuit(socket); | ||||
|         break; | ||||
|       case 'NOOP': | ||||
|         this.sendResponse(socket, '250 OK'); | ||||
|         break; | ||||
|       default: | ||||
|         this.sendResponse(socket, '502 Command not implemented'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     if (!clientHostname) { | ||||
|       this.sendResponse(socket, '501 Syntax error in parameters or arguments'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     session.clientHostname = clientHostname; | ||||
|     session.state = SmtpState.AFTER_EHLO; | ||||
|  | ||||
|     // List available extensions | ||||
|     this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`); | ||||
|     this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max | ||||
|     this.sendResponse(socket, '250-8BITMIME'); | ||||
|      | ||||
|     // Only offer STARTTLS if we haven't already established it | ||||
|     if (!session.useTLS) { | ||||
|       this.sendResponse(socket, '250-STARTTLS'); | ||||
|     } | ||||
|      | ||||
|     if (dataString.endsWith('\r\n.\r\n')  ) { // End of data | ||||
|       console.log('Received end of data.'); | ||||
|     this.sendResponse(socket, '250 HELP'); | ||||
|   } | ||||
|  | ||||
|   private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     if (session.state !== SmtpState.AFTER_EHLO) { | ||||
|       this.sendResponse(socket, '503 Bad sequence of commands'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (session.useTLS) { | ||||
|       this.sendResponse(socket, '503 TLS already active'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.sendResponse(socket, '220 Ready to start TLS'); | ||||
|     this.startTLS(socket); | ||||
|   } | ||||
|  | ||||
|   private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     if (session.state !== SmtpState.AFTER_EHLO) { | ||||
|       this.sendResponse(socket, '503 Bad sequence of commands'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Extract email from MAIL FROM:<user@example.com> | ||||
|     const emailMatch = args.match(/FROM:<([^>]*)>/i); | ||||
|     if (!emailMatch) { | ||||
|       this.sendResponse(socket, '501 Syntax error in parameters or arguments'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const email = emailMatch[1]; | ||||
|     if (!this.isValidEmail(email)) { | ||||
|       this.sendResponse(socket, '501 Invalid email address'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     session.mailFrom = email; | ||||
|     session.state = SmtpState.MAIL_FROM; | ||||
|     this.sendResponse(socket, '250 OK'); | ||||
|   } | ||||
|  | ||||
|   private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) { | ||||
|       this.sendResponse(socket, '503 Bad sequence of commands'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Extract email from RCPT TO:<user@example.com> | ||||
|     const emailMatch = args.match(/TO:<([^>]*)>/i); | ||||
|     if (!emailMatch) { | ||||
|       this.sendResponse(socket, '501 Syntax error in parameters or arguments'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const email = emailMatch[1]; | ||||
|     if (!this.isValidEmail(email)) { | ||||
|       this.sendResponse(socket, '501 Invalid email address'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     session.rcptTo.push(email); | ||||
|     session.state = SmtpState.RCPT_TO; | ||||
|     this.sendResponse(socket, '250 OK'); | ||||
|   } | ||||
|  | ||||
|   private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     if (session.state !== SmtpState.RCPT_TO) { | ||||
|       this.sendResponse(socket, '503 Bad sequence of commands'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     session.state = SmtpState.DATA_RECEIVING; | ||||
|     session.emailData = ''; | ||||
|     this.sendResponse(socket, '354 End data with <CR><LF>.<CR><LF>'); | ||||
|   } | ||||
|  | ||||
|   private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     // Reset the session data but keep connection information | ||||
|     session.state = SmtpState.AFTER_EHLO; | ||||
|     session.mailFrom = ''; | ||||
|     session.rcptTo = []; | ||||
|     session.emailData = ''; | ||||
|      | ||||
|     this.sendResponse(socket, '250 OK'); | ||||
|   } | ||||
|  | ||||
|   private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     this.sendResponse(socket, '221 Goodbye'); | ||||
|      | ||||
|     // If we have collected email data, try to parse it before closing | ||||
|     if (session.state === SmtpState.FINISHED && session.emailData.length > 0) { | ||||
|       this.parseEmail(socket); | ||||
|     } | ||||
|      | ||||
|     socket.end(); | ||||
|     this.sessions.delete(socket); | ||||
|   } | ||||
|  | ||||
|   private processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     // Check for end of data marker | ||||
|     if (data.endsWith('\r\n.\r\n')) { | ||||
|       // Remove the end of data marker | ||||
|       const emailData = data.slice(0, -5); | ||||
|       session.emailData += emailData; | ||||
|       session.state = SmtpState.FINISHED; | ||||
|        | ||||
|       // Save and process the email | ||||
|       this.saveEmail(socket); | ||||
|       this.sendResponse(socket, '250 OK: Message accepted for delivery'); | ||||
|     } else { | ||||
|       // Accumulate the data | ||||
|       session.emailData += data; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket) { | ||||
|     let emailData = this.emailBufferStringMap.get(socket); | ||||
|     // lets strip the end sequence | ||||
|     emailData = emailData?.replace(/\r\n\.\r\n$/, ''); | ||||
|   private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session) return; | ||||
|  | ||||
|     plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir); | ||||
|     plugins.smartfile.memory.toFsSync(emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`)); | ||||
|     try { | ||||
|       // Ensure the directory exists | ||||
|       plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir); | ||||
|        | ||||
|       // Write the email to disk | ||||
|       plugins.smartfile.memory.toFsSync( | ||||
|         session.emailData, | ||||
|         plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`) | ||||
|       ); | ||||
|        | ||||
|       // Parse the email | ||||
|       this.parseEmail(socket); | ||||
|     } catch (error) { | ||||
|       console.error('Error saving email:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|     if (!emailData) { | ||||
|       console.error('No email data found for socket.'); | ||||
|   private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<void> { | ||||
|     const session = this.sessions.get(socket); | ||||
|     if (!session || !session.emailData) { | ||||
|       console.error('No email data found for session.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @@ -141,51 +353,124 @@ export class SMTPServer { | ||||
|  | ||||
|     // Verifying the email with DKIM | ||||
|     try { | ||||
|       const isVerified = await this.mtaRef.dkimVerifier.verify(emailData); | ||||
|       const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData); | ||||
|       mightBeSpam = !isVerified; | ||||
|     } catch (error) { | ||||
|       console.error('Failed to verify DKIM signature:', error); | ||||
|       mightBeSpam = true; | ||||
|     } | ||||
|  | ||||
|     const parsedEmail = await plugins.mailparser.simpleParser(emailData); | ||||
|     console.log(parsedEmail) | ||||
|     const email = new Email({ | ||||
|       from: parsedEmail.from?.value[0].address || '', | ||||
|       to: | ||||
|         parsedEmail.to instanceof Array | ||||
|           ? parsedEmail.to[0].value[0].address | ||||
|           : parsedEmail.to?.value[0].address, | ||||
|       subject: parsedEmail.subject || '', | ||||
|       text: parsedEmail.html || parsedEmail.text, | ||||
|       attachments: | ||||
|         parsedEmail.attachments?.map((attachment) => ({ | ||||
|     try { | ||||
|       const parsedEmail = await plugins.mailparser.simpleParser(session.emailData); | ||||
|        | ||||
|       const email = new Email({ | ||||
|         from: parsedEmail.from?.value[0].address || session.mailFrom, | ||||
|         to: session.rcptTo[0], // Use the first recipient | ||||
|         subject: parsedEmail.subject || '', | ||||
|         text: parsedEmail.html || parsedEmail.text || '', | ||||
|         attachments: parsedEmail.attachments?.map((attachment) => ({ | ||||
|           filename: attachment.filename || '', | ||||
|           content: attachment.content, | ||||
|           contentType: attachment.contentType, | ||||
|         })) || [], | ||||
|       mightBeSpam: mightBeSpam, | ||||
|     }); | ||||
|         mightBeSpam: mightBeSpam, | ||||
|       }); | ||||
|  | ||||
|     console.log('mail received!'); | ||||
|     console.log(email); | ||||
|       console.log('Email received and parsed:', { | ||||
|         from: email.from, | ||||
|         to: email.to, | ||||
|         subject: email.subject, | ||||
|         attachments: email.attachments.length, | ||||
|         mightBeSpam: email.mightBeSpam | ||||
|       }); | ||||
|  | ||||
|     this.emailBufferStringMap.delete(socket); | ||||
|       // Process or forward the email as needed | ||||
|       // this.mtaRef.processIncomingEmail(email); // You could add this method to your MTA service | ||||
|     } catch (error) { | ||||
|       console.error('Error parsing email:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public start() { | ||||
|   private startTLS(socket: plugins.net.Socket): void { | ||||
|     try { | ||||
|       const secureContext = plugins.tls.createSecureContext({ | ||||
|         key: this.smtpServerOptions.key, | ||||
|         cert: this.smtpServerOptions.cert, | ||||
|       }); | ||||
|  | ||||
|       const tlsSocket = new plugins.tls.TLSSocket(socket, { | ||||
|         secureContext: secureContext, | ||||
|         isServer: true, | ||||
|         server: this.server | ||||
|       }); | ||||
|  | ||||
|       const originalSession = this.sessions.get(socket); | ||||
|       if (!originalSession) { | ||||
|         console.error('No session found when upgrading to TLS'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Transfer the session data to the new TLS socket | ||||
|       this.sessions.set(tlsSocket, { | ||||
|         ...originalSession, | ||||
|         useTLS: true, | ||||
|         state: SmtpState.GREETING // Reset state to require a new EHLO | ||||
|       }); | ||||
|        | ||||
|       this.sessions.delete(socket); | ||||
|  | ||||
|       tlsSocket.on('secure', () => { | ||||
|         console.log('TLS negotiation successful'); | ||||
|       }); | ||||
|  | ||||
|       tlsSocket.on('data', (data: Buffer) => { | ||||
|         this.processData(tlsSocket, data); | ||||
|       }); | ||||
|  | ||||
|       tlsSocket.on('end', () => { | ||||
|         console.log('TLS socket ended'); | ||||
|         const session = this.sessions.get(tlsSocket); | ||||
|         if (session) { | ||||
|           session.connectionEnded = true; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       tlsSocket.on('error', (err) => { | ||||
|         console.error('TLS socket error:', err); | ||||
|         this.sessions.delete(tlsSocket); | ||||
|         tlsSocket.destroy(); | ||||
|       }); | ||||
|  | ||||
|       tlsSocket.on('close', () => { | ||||
|         console.log('TLS socket closed'); | ||||
|         this.sessions.delete(tlsSocket); | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       console.error('Error upgrading connection to TLS:', error); | ||||
|       socket.destroy(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private isValidEmail(email: string): boolean { | ||||
|     // Basic email validation - more comprehensive validation could be implemented | ||||
|     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||
|     return emailRegex.test(email); | ||||
|   } | ||||
|  | ||||
|   public start(): void { | ||||
|     this.server.listen(this.smtpServerOptions.port, () => { | ||||
|       console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   public stop() { | ||||
|   public stop(): void { | ||||
|     this.server.getConnections((err, count) => { | ||||
|       if (err) throw err; | ||||
|       console.log('Number of active connections: ', count); | ||||
|     }); | ||||
|      | ||||
|     this.server.close(() => { | ||||
|       console.log('SMTP Server is now stopped'); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user