import * as plugins from '../plugins.js'; import { Email } from './mta.classes.email.js'; import type { 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'; /** * Authentication options for API requests */ interface AuthOptions { /** Required API keys for different endpoints */ apiKeys: Map; /** 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; /** 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; } /** * Simple HTTP Response helper */ class HttpResponse { private headers: Record = { 'Content-Type': 'application/json' }; public statusCode: number = 200; constructor(private res: any) {} header(name: string, value: string): HttpResponse { this.headers[name] = value; return this; } status(code: number): HttpResponse { this.statusCode = code; return this; } json(data: any): void { this.res.writeHead(this.statusCode, this.headers); this.res.end(JSON.stringify(data)); } end(): void { this.res.writeHead(this.statusCode, this.headers); this.res.end(); } } /** * API Manager for MTA service */ export class ApiManager { /** TypedRouter for API routing */ public typedrouter = new plugins.typedrequest.TypedRouter(); /** MTA service reference */ private mtaRef: MtaService; /** HTTP server */ private server: any; /** Authentication options */ private authOptions: AuthOptions; /** API routes */ private routes: ApiRoute[] = []; /** Rate limiters */ private rateLimiters: Map; }> = new Map(); /** * Initialize API Manager * @param mtaRef MTA service reference */ constructor(mtaRef?: MtaService) { this.mtaRef = mtaRef; // Default authentication options this.authOptions = { apiKeys: new Map(), validateIp: false, allowedIps: [] }; // Register routes this.registerRoutes(); // Create HTTP server with request handler this.server = plugins.http.createServer(this.handleRequest.bind(this)); } /** * 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): void { this.authOptions = { ...this.authOptions, ...options }; } /** * Handle HTTP request */ private async handleRequest(req: any, res: any): Promise { const start = Date.now(); // Create a response helper const response = new HttpResponse(res); // Add CORS headers response.header('Access-Control-Allow-Origin', '*'); response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key'); // Handle preflight OPTIONS request if (req.method === 'OPTIONS') { return response.status(200).end(); } try { // Parse URL to get path and query const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const path = url.pathname; // Collect request body if POST or PUT let body = ''; if (req.method === 'POST' || req.method === 'PUT') { await new Promise((resolve, reject) => { req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { resolve(); }); req.on('error', (err: Error) => { reject(err); }); }); // Parse body as JSON if Content-Type is application/json const contentType = req.headers['content-type'] || ''; if (contentType.includes('application/json')) { try { req.body = JSON.parse(body); } catch (error) { return response.status(400).json({ code: 'INVALID_JSON', message: 'Invalid JSON in request body' }); } } else { req.body = body; } } // Add authentication level to 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]; // Note: We would need to add JWT verification // Using a simple placeholder for now const decoded = { level: 'none' }; // Simplified - would use actual JWT library 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.socket.remoteAddress; if (!this.authOptions.allowedIps.includes(clientIp)) { return response.status(403).json({ code: 'FORBIDDEN', message: 'IP address not allowed' }); } } // Find matching route const route = this.findRoute(req.method, path); if (!route) { return response.status(404).json({ code: 'NOT_FOUND', message: 'Endpoint not found' }); } // Check authentication if (route.authLevel !== 'none' && req.authLevel !== route.authLevel && req.authLevel !== 'admin') { return response.status(403).json({ code: 'FORBIDDEN', message: `This endpoint requires ${route.authLevel} access` }); } // Check rate limit if (route.rateLimit) { const exceeded = this.checkRateLimit(route, req); if (exceeded) { return response.status(429).json({ code: 'RATE_LIMIT_EXCEEDED', message: 'Rate limit exceeded, please try again later' }); } } // Extract path parameters const pathParams = this.extractPathParams(route.path, path); req.params = pathParams; // Extract query parameters req.query = {}; for (const [key, value] of url.searchParams.entries()) { req.query[key] = value; } // Handle the request await route.handler(req, response); // Log request const duration = Date.now() - start; console.log(`[API] ${req.method} ${path} ${response.statusCode} ${duration}ms`); } catch (error) { console.error(`Error handling request:`, 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; } response.status(status).json(apiError); } } /** * Find a route matching the method and path */ private findRoute(method: string, path: string): ApiRoute | null { for (const route of this.routes) { if (route.method === method && this.pathMatches(route.path, path)) { return route; } } return null; } /** * Check if a path matches a route pattern */ private pathMatches(pattern: string, path: string): boolean { // Convert route pattern to regex const patternParts = pattern.split('/'); const pathParts = path.split('/'); if (patternParts.length !== pathParts.length) { return false; } for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(':')) { // Parameter - always matches continue; } if (patternParts[i] !== pathParts[i]) { return false; } } return true; } /** * Extract path parameters from URL */ private extractPathParams(pattern: string, path: string): Record { const params: Record = {}; const patternParts = pattern.split('/'); const pathParts = path.split('/'); for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(':')) { const paramName = patternParts[i].substring(1); params[paramName] = pathParts[i]; } } return params; } /** * 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' }); } /** * Add an API route * @param route Route definition */ private addRoute(route: ApiRoute): void { this.routes.push(route); } /** * 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.socket.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 { 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 { 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 { 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 { 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 { this.validateMtaService(); const domain = req.params.domain; if (!domain) { throw this.createError('INVALID_REQUEST', 'Missing domain'); } try { // Generate DKIM keys await this.mtaRef.dkimCreator.handleDKIMKeysForDomain(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 { 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 { 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 { // 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 { return new Promise((resolve, reject) => { try { // Start HTTP server this.server.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 { if (this.server) { this.server.close(); console.log('API server stopped'); } } }