/** * Shared HTTP Client Module * * Provides a unified HTTP/HTTPS request helper used by all Elektrine plugins. * Handles timeouts, error handling, and JSON parsing consistently. */ 'use strict'; const http = require('http'); const https = require('https'); const url = require('url'); /** * Make an HTTP/HTTPS request * @param {Object} options - Request options * @param {string} options.url - Full URL to request * @param {string} [options.method='POST'] - HTTP method * @param {Object} [options.data] - Data to send (will be JSON stringified) * @param {Object} [options.headers] - Additional headers * @param {number} [options.timeout=30000] - Request timeout in milliseconds * @param {string} [options.api_key] - API key to include in X-API-Key header * @param {Function} [options.logger] - Logger function for debug output * @returns {Promise} Response object with status, data, and headers */ // Connection pooling agents for keep-alive const http_agent = new http.Agent({ keepAlive: true, maxSockets: 10, keepAliveMsecs: 30000 }); const https_agent = new https.Agent({ keepAlive: true, maxSockets: 10, keepAliveMsecs: 30000 }); function request(options) { return new Promise((resolve, reject) => { const { url: request_url, method = 'POST', data = null, headers = {}, timeout = 30000, api_key = null, logger = null } = options; const parsed_url = url.parse(request_url); // Validate URL scheme to prevent SSRF via non-HTTP protocols if (parsed_url.protocol !== 'http:' && parsed_url.protocol !== 'https:') { return reject(new Error(`Invalid URL scheme: ${parsed_url.protocol} (only http/https allowed)`)); } const payload = data ? JSON.stringify(data) : null; const request_options = { hostname: parsed_url.hostname, port: parsed_url.port || (parsed_url.protocol === 'https:' ? 443 : 80), path: parsed_url.path || '/', method: method, headers: { 'Content-Type': 'application/json', 'User-Agent': 'elektrine-haraka/1.0', ...headers }, timeout: timeout }; if ( parsed_url.protocol === 'http:' && typeof parsed_url.hostname === 'string' && parsed_url.hostname.endsWith('.internal') && !request_options.headers['X-Forwarded-Proto'] && !request_options.headers['x-forwarded-proto'] ) { request_options.headers['X-Forwarded-Proto'] = 'https'; } // Add content length for POST/PUT requests if (payload) { request_options.headers['Content-Length'] = Buffer.byteLength(payload); } // Add API key if provided if (api_key) { request_options.headers['X-API-Key'] = api_key; } // Use pooled agents for connection reuse const is_https = parsed_url.protocol === 'https:'; const protocol = is_https ? https : http; request_options.agent = is_https ? https_agent : http_agent; const req = protocol.request(request_options, (res) => { let response_data = ''; res.on('data', (chunk) => { response_data += chunk; }); res.on('end', () => { const result = { status: res.statusCode, headers: res.headers, raw: response_data, data: null }; // Try to parse JSON response if (response_data) { try { result.data = JSON.parse(response_data); } catch (e) { result.data = response_data; } } if (res.statusCode >= 200 && res.statusCode < 300) { resolve(result); } else { const error = new Error(`HTTP ${res.statusCode}: ${response_data}`); error.status = res.statusCode; error.response = result; reject(error); } }); }); req.on('error', (err) => { if (logger) logger(`HTTP request error: ${err.message}`); reject(err); }); req.on('timeout', () => { req.destroy(); const error = new Error('Request timed out'); error.code = 'ETIMEDOUT'; reject(error); }); if (payload) { req.write(payload); } req.end(); }); } /** * Send a webhook to the Phoenix app * @param {string} webhook_url - Webhook URL * @param {Object} data - Data to send * @param {Object} options - Additional options * @param {string} [options.api_key] - API key * @param {number} [options.timeout=30000] - Timeout in milliseconds * @param {Function} [options.logger] - Logger function * @returns {Promise} Response data */ async function send_webhook(webhook_url, data, options = {}) { const result = await request({ url: webhook_url, method: 'POST', data: data, headers: options.headers || {}, api_key: options.api_key, timeout: options.timeout || 30000, logger: options.logger }); return result.data; } /** * Verify a recipient with the Phoenix app * @param {string} verify_url - Verification URL * @param {string} email - Email address to verify * @param {Object} options - Additional options * @param {string} [options.api_key] - API key * @param {number} [options.timeout=5000] - Timeout in milliseconds * @param {Function} [options.logger] - Logger function * @returns {Promise} True if recipient exists */ async function verify_recipient(verify_url, email, options = {}) { try { const result = await request({ url: verify_url, method: 'POST', data: { email: email }, api_key: options.api_key, timeout: options.timeout || 5000, logger: options.logger }); return result.data && result.data.exists === true; } catch (err) { // 404 means recipient does not exist if (err.status === 404) { return false; } throw err; } } /** * Fetch list of valid domains from the Phoenix app * This includes configured local domains plus any custom domains * that have email enabled. * @param {string} domains_url - Domains API URL * @param {Object} options - Additional options * @param {string} [options.api_key] - API key * @param {number} [options.timeout=10000] - Timeout in milliseconds * @param {Function} [options.logger] - Logger function * @returns {Promise} Array of domain names */ async function fetch_domains(domains_url, options = {}) { try { const result = await request({ url: domains_url, method: 'GET', api_key: options.api_key, timeout: options.timeout || 10000, logger: options.logger }); if (result.data && Array.isArray(result.data.domains)) { return result.data.domains; } if (options.logger) { options.logger('Unexpected domains response format, using empty list'); } return []; } catch (err) { if (options.logger) { options.logger(`Failed to fetch domains: ${err.message}`); } throw err; } } module.exports = { request, send_webhook, verify_recipient, fetch_domains };