/** * Elektrine HTTP API Plugin * * Provides a REST API endpoint for sending emails via Haraka. * Supports structured email data and raw MIME format. * * Endpoint: POST /api/v1/send * Authentication: X-API-Key header */ 'use strict'; const fs = require('fs'); const http = require('http'); const path = require('path'); const crypto = require('crypto'); const net = require('net'); const constants = require('haraka-constants'); // Shared library modules const { config, domains, email: emailBuilder } = require('../lib'); // Maximum request body size (50MB - handles large attachments) const MAX_BODY_SIZE = 50 * 1024 * 1024; // Request body read timeout (30 seconds) const BODY_READ_TIMEOUT_MS = 30000; exports.register = function() { const plugin = this; // Load configuration plugin.load_config(); plugin.stats = { started_at: new Date().toISOString(), requests_total: 0, auth_failures: 0, rate_limited: 0, sent_ok: 0, sent_error: 0, dkim_sync_ok: 0, dkim_sync_error: 0, dkim_delete_ok: 0, dkim_delete_error: 0 }; // Validate critical config at startup if (!plugin.cfg.http_api_key || plugin.cfg.http_api_key === 'elektrine_webhook_key_default') { plugin.logerror('CRITICAL: HTTP API key is not configured. Set HARAKA_HTTP_API_KEY environment variable.'); } // Initialize rate limiting storage plugin.rate_limit_storage = new Map(); plugin.allowlists = { trusted_proxies: null, ops: null, metrics: null }; plugin.rebuild_allowlists(); // Start HTTP server plugin.start_server(); }; exports.load_config = function() { const plugin = this; const haraka_cfg = plugin.config.get('elektrine.ini', { booleans: [ '+main.enabled', '+main.include_headers', '+main.include_body', '+main.include_attachments' ] }, function() { plugin.load_config(); }); plugin.cfg = config.load(haraka_cfg); plugin.rebuild_allowlists(); }; exports.start_server = function() { const plugin = this; const server = http.createServer((req, res) => { plugin.handle_request(req, res); }); const port = plugin.cfg.http_port; const host = plugin.cfg.http_host; server.listen(port, host, () => { plugin.loginfo(`HTTP API listening on ${host}:${port}`); }); }; exports.handle_request = function(req, res) { const plugin = this; plugin.stats.requests_total += 1; const request_path = plugin.get_request_path(req); if (req.method === 'GET' && (request_path === '/status' || request_path === '/healthz')) { if (!plugin.is_ops_request_allowed(req, request_path)) { return plugin.send_response(res, 403, { ok: false, error: 'Forbidden' }); } return plugin.send_response(res, 200, { ok: true, role: plugin.cfg.role, started_at: plugin.stats.started_at }); } if (req.method === 'GET' && request_path === '/metrics') { if (!plugin.is_ops_request_allowed(req, request_path)) { return plugin.send_response(res, 403, { ok: false, error: 'Forbidden' }); } return plugin.send_metrics(res); } // CORS headers - only allow same-origin requests (API is internal) const allowed_origin = plugin.cfg.cors_origin || null; if (allowed_origin) { res.setHeader('Access-Control-Allow-Origin', allowed_origin); } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); // Handle preflight if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } const route = plugin.resolve_api_route(req.method, request_path); if (!route) { plugin.logwarn( `Unmatched HTTP route method=${req.method || 'UNKNOWN'} url=${req.url || ''} path=${request_path}` ); return plugin.send_response(res, 404, { success: false, error: 'Not found' }); } if (!plugin.authenticate_api_request(req, res)) { return; } if (!plugin.check_rate_limit_or_reject(req, res)) { return; } if (route.kind === 'send') { return plugin.read_request_body(req, res, (body) => { plugin.process_send_request(body, res); }); } if (route.kind === 'dkim_upsert') { return plugin.read_request_body(req, res, (body) => { plugin.process_dkim_upsert_request(route.domain, body, res); }); } if (route.kind === 'dkim_get') { return plugin.process_dkim_get_request(route.domain, res); } if (route.kind === 'dkim_delete') { return plugin.process_dkim_delete_request(route.domain, res); } }; exports.resolve_api_route = function(method, request_path) { if (method === 'POST' && request_path === '/api/v1/send') { return { kind: 'send' }; } const dkim_domain = this.get_dkim_domain_from_path(request_path); if (!dkim_domain) return null; if (method === 'GET') { return { kind: 'dkim_get', domain: dkim_domain }; } if (method === 'PUT') { return { kind: 'dkim_upsert', domain: dkim_domain }; } if (method === 'DELETE') { return { kind: 'dkim_delete', domain: dkim_domain }; } return null; }; exports.rebuild_allowlists = function() { const plugin = this; plugin.allowlists = { trusted_proxies: plugin.build_cidr_allowlist(plugin.cfg.trusted_proxy_cidrs || []), ops: plugin.build_cidr_allowlist(plugin.cfg.ops_allowed_cidrs || []), metrics: plugin.build_cidr_allowlist(plugin.cfg.metrics_allowed_cidrs || []) }; }; exports.build_cidr_allowlist = function(cidr_values) { const plugin = this; const allowlist = new net.BlockList(); for (const raw_value of cidr_values) { if (!raw_value) continue; const value = String(raw_value).trim(); if (!value) continue; try { if (value.includes('/')) { const [raw_ip, raw_prefix] = value.split('/'); const ip = plugin.parse_ip(raw_ip); const family = net.isIP(ip); const prefix = Number.parseInt(raw_prefix, 10); if (!family || !Number.isInteger(prefix)) { plugin.logwarn(`Skipping invalid CIDR allowlist entry: ${value}`); continue; } if ((family === 4 && (prefix < 0 || prefix > 32)) || (family === 6 && (prefix < 0 || prefix > 128))) { plugin.logwarn(`Skipping CIDR allowlist entry with invalid prefix: ${value}`); continue; } allowlist.addSubnet(ip, prefix, family === 4 ? 'ipv4' : 'ipv6'); continue; } const ip = plugin.parse_ip(value); const family = net.isIP(ip); if (!family) { plugin.logwarn(`Skipping invalid allowlist IP entry: ${value}`); continue; } allowlist.addAddress(ip, family === 4 ? 'ipv4' : 'ipv6'); } catch (err) { plugin.logwarn(`Skipping malformed allowlist entry (${value}): ${err.message}`); } } return allowlist; }; exports.get_request_path = function(req) { const raw_target = String(req.url || '').trim(); if (!raw_target) return '/'; if (raw_target === '*') return '*'; let path_target = raw_target; if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(path_target)) { const scheme_sep = path_target.indexOf('://'); const authority_start = scheme_sep >= 0 ? scheme_sep + 3 : 0; const first_slash = path_target.indexOf('/', authority_start); path_target = first_slash >= 0 ? path_target.slice(first_slash) : '/'; } else if (!path_target.startsWith('/')) { // Some lightweight clients send a non-standard request target like // `127.0.0.1:8080/status`; normalize that into a path instead of // falling back to `/` and returning a misleading 404. const first_slash = path_target.indexOf('/'); path_target = first_slash >= 0 ? path_target.slice(first_slash) : `/${path_target}`; } const query_index = path_target.indexOf('?'); const hash_index = path_target.indexOf('#'); let cut_index = -1; if (query_index >= 0 && hash_index >= 0) { cut_index = Math.min(query_index, hash_index); } else if (query_index >= 0) { cut_index = query_index; } else if (hash_index >= 0) { cut_index = hash_index; } if (cut_index >= 0) { path_target = path_target.slice(0, cut_index); } if (!path_target) return '/'; return path_target.startsWith('/') ? path_target : `/${path_target}`; }; exports.get_header_value = function(req, key) { const value = req.headers[key]; if (Array.isArray(value)) return value[0] || ''; return value || ''; }; exports.constant_time_equal = function(received, expected) { if (!received || !expected) return false; const received_buffer = Buffer.from(String(received)); const expected_buffer = Buffer.from(String(expected)); if (received_buffer.length !== expected_buffer.length) return false; return crypto.timingSafeEqual(received_buffer, expected_buffer); }; exports.authenticate_api_request = function(req, res) { const plugin = this; const api_key = plugin.get_header_value(req, 'x-api-key'); const expected_key = plugin.cfg.http_api_key || ''; if (!plugin.constant_time_equal(api_key, expected_key)) { plugin.stats.auth_failures += 1; plugin.send_response(res, 401, { success: false, error: 'Unauthorized' }); return false; } return true; }; exports.check_rate_limit_or_reject = function(req, res) { const plugin = this; const client_ip = plugin.get_client_ip(req); if (!plugin.check_rate_limit(client_ip)) { plugin.stats.rate_limited += 1; plugin.send_response(res, 429, { success: false, error: 'Rate limit exceeded' }); return false; } return true; }; exports.read_request_body = function(req, res, callback) { const plugin = this; let body = ''; let body_size = 0; let finished = false; const finish_once = (handler) => { if (finished) return; finished = true; clearTimeout(body_timeout); handler(); }; const body_timeout = setTimeout(() => { req.destroy(); finish_once(() => { plugin.send_response(res, 408, { success: false, error: 'Request timeout' }); }); }, BODY_READ_TIMEOUT_MS); req.on('data', (chunk) => { if (finished) return; body_size += chunk.length; if (body_size > MAX_BODY_SIZE) { req.destroy(); return finish_once(() => { plugin.send_response(res, 413, { success: false, error: 'Request body too large' }); }); } body += chunk; }); req.on('end', () => { finish_once(() => callback(body)); }); req.on('error', () => { finish_once(() => {}); }); }; exports.parse_ip = function(value) { if (!value) return ''; let candidate = String(value).trim(); if (!candidate) return ''; if (candidate.startsWith('"') && candidate.endsWith('"') && candidate.length > 1) { candidate = candidate.slice(1, -1).trim(); } if (candidate.startsWith('for=')) { candidate = candidate.slice(4).trim(); } if (candidate.startsWith('[')) { const closing_index = candidate.indexOf(']'); if (closing_index > 0) { candidate = candidate.slice(1, closing_index); } } else { const ipv4_port_match = candidate.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/); if (ipv4_port_match) { candidate = ipv4_port_match[1]; } } if (candidate.startsWith('::ffff:')) { candidate = candidate.slice('::ffff:'.length); } return net.isIP(candidate) ? candidate : ''; }; exports.is_ip_allowlisted = function(ip, allowlist) { if (!allowlist) return false; const parsed_ip = this.parse_ip(ip); const family = net.isIP(parsed_ip); if (!family) return false; return allowlist.check(parsed_ip, family === 4 ? 'ipv4' : 'ipv6'); }; exports.get_forwarded_for_chain = function(req) { const forwarded_header = this.get_header_value(req, 'x-forwarded-for'); if (!forwarded_header) return []; return forwarded_header .split(',') .map((entry) => this.parse_ip(entry)) .filter(Boolean); }; exports.get_client_ip = function(req) { const plugin = this; const remote_ip = plugin.parse_ip(req.socket && req.socket.remoteAddress); const trusted_proxies = plugin.allowlists && plugin.allowlists.trusted_proxies; if (!plugin.is_ip_allowlisted(remote_ip, trusted_proxies)) { return remote_ip || '0.0.0.0'; } const forwarded_chain = plugin.get_forwarded_for_chain(req); if (forwarded_chain.length > 0) { // Walk right-to-left to find the first untrusted hop. for (let i = forwarded_chain.length - 1; i >= 0; i -= 1) { const hop_ip = forwarded_chain[i]; if (!plugin.is_ip_allowlisted(hop_ip, trusted_proxies)) { return hop_ip; } } // If every forwarded hop is trusted, use the left-most original value. return forwarded_chain[0]; } const real_ip = plugin.parse_ip(plugin.get_header_value(req, 'x-real-ip')); if (real_ip && !plugin.is_ip_allowlisted(real_ip, trusted_proxies)) { return real_ip; } return remote_ip || real_ip || '0.0.0.0'; }; exports.is_ops_request_allowed = function(req, path) { const plugin = this; if (plugin.is_internal_api_request_authenticated(req)) { return true; } const client_ip = plugin.get_client_ip(req); const allowlist = path === '/metrics' ? plugin.allowlists.metrics : plugin.allowlists.ops; const allowed = plugin.is_ip_allowlisted(client_ip, allowlist); if (!allowed) { plugin.logwarn(`Denied ${path} request from ${client_ip || 'unknown'}`); } return allowed; }; exports.is_internal_api_request_authenticated = function(req) { const api_key = this.get_header_value(req, 'x-api-key'); const expected_key = this.cfg.http_api_key || ''; return this.constant_time_equal(api_key, expected_key); }; exports.get_dkim_domain_from_path = function(request_path) { const match = request_path.match(/^\/api\/v1\/dkim\/domains\/([^/]+)$/); if (!match) return null; try { const decoded = decodeURIComponent(match[1]); return this.normalize_dkim_domain(decoded); } catch (err) { return null; } }; exports.normalize_dkim_domain = function(value) { const domain = String(value || '').trim().toLowerCase().replace(/\.+$/, ''); if (!domain) return null; const labels = domain.split('.'); if (labels.length < 2) return null; for (const label of labels) { if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) { return null; } } return domain; }; exports.normalize_dkim_selector = function(value) { const selector = String(value || '').trim(); if (!selector) return null; if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/.test(selector)) { return null; } return selector; }; exports.normalize_private_key = function(value) { if (typeof value !== 'string') return null; const private_key = value.replace(/\r\n/g, '\n').trim(); if (!private_key.includes('-----BEGIN') || !private_key.includes('PRIVATE KEY-----')) { return null; } if (private_key.includes('\u0000')) { return null; } return `${private_key}\n`; }; exports.get_dkim_storage_dir = function() { const configured = String(this.cfg.dkim_storage_dir || '').trim(); if (configured) return configured; const haraka_dir = String(process.env.HARAKA || '').trim(); if (haraka_dir) return path.resolve(haraka_dir, 'config', 'dkim'); const runtime_dir = '/tmp/haraka-config/config/dkim'; if (fs.existsSync(runtime_dir)) { return runtime_dir; } return path.resolve(process.cwd(), 'config', 'dkim'); }; exports.process_dkim_upsert_request = function(domain, body, res) { const plugin = this; let payload; try { payload = JSON.parse(body || '{}'); } catch (err) { plugin.stats.dkim_sync_error += 1; return plugin.send_response(res, 400, { success: false, error: 'Invalid JSON' }); } const selector = plugin.normalize_dkim_selector(payload.selector); const private_key = plugin.normalize_private_key(payload.private_key); if (!selector || !private_key) { plugin.stats.dkim_sync_error += 1; return plugin.send_response(res, 400, { success: false, error: 'selector and private_key are required' }); } try { plugin.upsert_dkim_domain(domain, selector, private_key); plugin.stats.dkim_sync_ok += 1; plugin.loginfo(`Installed DKIM key for ${domain} with selector ${selector}`); return plugin.send_response(res, 200, { success: true, domain, selector }); } catch (err) { plugin.stats.dkim_sync_error += 1; plugin.logerror(`Failed to install DKIM key for ${domain}: ${err.message}`); return plugin.send_response(res, 500, { success: false, error: `Failed to install DKIM key: ${err.message}` }); } }; exports.process_dkim_delete_request = function(domain, res) { const plugin = this; try { const deleted = plugin.delete_dkim_domain(domain); plugin.stats.dkim_delete_ok += 1; plugin.loginfo(`${deleted ? 'Removed' : 'DKIM key not present for'} ${domain}`); return plugin.send_response(res, 200, { success: true, domain, deleted }); } catch (err) { plugin.stats.dkim_delete_error += 1; plugin.logerror(`Failed to remove DKIM key for ${domain}: ${err.message}`); return plugin.send_response(res, 500, { success: false, error: `Failed to remove DKIM key: ${err.message}` }); } }; exports.process_dkim_get_request = function(domain, res) { const plugin = this; try { const dkim_domain = plugin.read_dkim_domain(domain); return plugin.send_response(res, 200, { success: true, domain, selector: dkim_domain.selector, public_key: dkim_domain.public_key_pem, value: dkim_domain.value, private_key_present: true }); } catch (err) { const not_found = err && err.code === 'ENOENT'; return plugin.send_response(res, not_found ? 404 : 500, { success: false, error: not_found ? `DKIM key not found for ${domain}` : `Failed to read DKIM key: ${err.message}` }); } }; exports.upsert_dkim_domain = function(domain, selector, private_key) { const storage_dir = this.get_dkim_storage_dir(); const domain_dir = path.join(storage_dir, domain); const private_path = path.join(domain_dir, 'private'); const selector_path = path.join(domain_dir, 'selector'); fs.mkdirSync(domain_dir, { recursive: true, mode: 0o755 }); fs.chmodSync(domain_dir, 0o755); this.atomic_write_file(private_path, private_key, 0o600); this.atomic_write_file(selector_path, `${selector}\n`, 0o644); }; exports.delete_dkim_domain = function(domain) { const domain_dir = path.join(this.get_dkim_storage_dir(), domain); if (!fs.existsSync(domain_dir)) { return false; } fs.rmSync(domain_dir, { recursive: true, force: true }); return true; }; exports.read_dkim_domain = function(domain) { const domain_dir = path.join(this.get_dkim_storage_dir(), domain); const private_path = path.join(domain_dir, 'private'); const selector_path = path.join(domain_dir, 'selector'); const private_key = fs.readFileSync(private_path, 'utf8'); const selector = String(fs.readFileSync(selector_path, 'utf8') || '').trim(); const public_key = this.derive_public_key_from_private(private_key); return { selector, private_key, public_key_pem: public_key.pem, public_key_dns: public_key.dns, value: `v=DKIM1; k=rsa; p=${public_key.dns}` }; }; exports.derive_public_key_from_private = function(private_key) { const private_object = crypto.createPrivateKey({ key: private_key, format: 'pem' }); const public_object = crypto.createPublicKey(private_object); const pem = public_object.export({ type: 'spki', format: 'pem' }).toString(); const dns = public_object.export({ type: 'spki', format: 'der' }).toString('base64'); return { pem, dns }; }; exports.atomic_write_file = function(target_path, contents, mode) { const directory = path.dirname(target_path); const temp_path = path.join( directory, `.${path.basename(target_path)}.${process.pid}.${Date.now()}.tmp` ); fs.writeFileSync(temp_path, contents, { mode }); fs.renameSync(temp_path, target_path); fs.chmodSync(target_path, mode); }; exports.process_send_request = function(body, res) { const plugin = this; try { const email_data = JSON.parse(body); // Validate required fields if (!email_data.from || !email_data.to) { return plugin.send_response(res, 400, { success: false, error: 'Missing required fields: from and to' }); } // Subject required for non-raw emails if (!email_data.raw && !email_data.raw_base64 && email_data.subject === undefined) { return plugin.send_response(res, 400, { success: false, error: 'Missing required field: subject (required for non-raw emails)' }); } if (!email_data.raw && !email_data.raw_base64 && !plugin.has_structured_content(email_data)) { plugin.logwarn( `Rejecting structured send with no body or attachments from=${plugin.redact_email(email_data.from)} to_count=${emailBuilder.collect_recipients(email_data).length}` ); return plugin.send_response(res, 400, { success: false, error: 'Missing message body: set text_body, text, body, html_body, html, attachments, raw, or raw_base64' }); } plugin.loginfo( `HTTP send payload summary ${plugin.payload_summary(email_data)}` ); // Queue email for delivery plugin.queue_email(email_data, (err, message_id) => { if (err) { plugin.stats.sent_error += 1; plugin.send_response(res, 400, { success: false, error: err.message }); } else { plugin.stats.sent_ok += 1; plugin.send_response(res, 200, { success: true, message_id: message_id }); } }); } catch (e) { plugin.send_response(res, 400, { success: false, error: 'Invalid JSON' }); } }; exports.has_structured_content = function(email_data) { const body_fields = ['text_body', 'text', 'body', 'html_body', 'html']; const has_body = body_fields.some((field) => { const value = email_data[field]; return typeof value === 'string' && value.trim() !== ''; }); if (has_body) return true; return Array.isArray(email_data.attachments) && email_data.attachments.length > 0; }; exports.redact_email = function(value) { const email = domains.extract_email(String(value || '').replace(/[\r\n]+/g, ' ').trim()); if (!email || !email.includes('@')) return ''; const [local, domain] = email.split('@'); return `${local.slice(0, 2)}***@${domain}`; }; exports.payload_summary = function(email_data) { return [ `from=${this.redact_email(email_data.from)}`, `to_count=${emailBuilder.collect_recipients(email_data).length}`, `text_body_bytes=${this.string_size(email_data.text_body)}`, `text_bytes=${this.string_size(email_data.text)}`, `body_bytes=${this.string_size(email_data.body)}`, `html_body_bytes=${this.string_size(email_data.html_body)}`, `html_bytes=${this.string_size(email_data.html)}`, `raw_bytes=${this.string_size(email_data.raw)}`, `raw_base64_bytes=${this.string_size(email_data.raw_base64)}`, `attachments=${Array.isArray(email_data.attachments) ? email_data.attachments.length : 0}` ].join(' '); }; exports.string_size = function(value) { return typeof value === 'string' ? Buffer.byteLength(value) : 0; }; exports.message_body_summary = function(email_content) { const separator = '\r\n\r\n'; const index = email_content.indexOf(separator); const body = index >= 0 ? email_content.slice(index + separator.length) : ''; const canonical = body.replace(/(\r\n)*$/, '') + '\r\n'; const hash = crypto.createHash('sha256').update(canonical, 'binary').digest('base64'); return `message_bytes=${Buffer.byteLength(email_content)} body_bytes=${Buffer.byteLength(body)} body_hash=${hash}`; }; exports.ensure_terminal_crlf = function(email_content) { if (typeof email_content !== 'string' || email_content.endsWith('\r\n')) { return email_content; } return email_content.replace(/\r?\n?$/, '') + '\r\n'; }; exports.queue_email = function(email_data, callback) { const plugin = this; try { const message_id = crypto.randomUUID(); const all_recipients = emailBuilder.collect_recipients(email_data); if (all_recipients.length === 0) { throw new Error('Invalid recipient list'); } let email_content; let sender_email; // Handle raw email formats if (email_data.raw_base64) { plugin.loginfo('Using base64-encoded raw email format'); email_content = Buffer.from(email_data.raw_base64, 'base64').toString('binary'); sender_email = domains.extract_email(String(email_data.from || '').replace(/[\r\n]+/g, ' ').trim()); } else if (email_data.raw) { plugin.loginfo('Using raw email format'); email_content = email_data.raw; sender_email = domains.extract_email(String(email_data.from || '').replace(/[\r\n]+/g, ' ').trim()); } else { // Build email from structured data const built = emailBuilder.build(email_data, message_id); email_content = built.email_content; sender_email = built.sender_email; } email_content = plugin.ensure_terminal_crlf(email_content); plugin.loginfo(`Queued outbound MIME summary ${plugin.message_body_summary(email_content)}`); if (!/^[^\s@<>]+@[A-Za-z0-9.-]+$/.test(sender_email)) { throw new Error('Invalid from address'); } // Always deliver through Haraka outbound. // Local domains are steered back to inbound-mx by elektrine_local_mx. if (domains.all_recipients_local(all_recipients)) { plugin.loginfo(`All recipients are local; routing via SMTP/local MX for message: ${message_id}`); } plugin.deliver_outbound(sender_email, all_recipients, email_content, message_id, callback); } catch (err) { plugin.logerror(`Error building email: ${err.message}`); callback(err); } }; exports.deliver_outbound = function(sender_email, recipients, email_content, message_id, callback) { const plugin = this; const outbound = require('./outbound'); plugin.loginfo(`Processing outbound delivery for: ${recipients.join(', ')}`); outbound.send_email(sender_email, recipients, email_content, (code, msg) => { if (code === constants.cont || (msg && msg.includes('Message Queued'))) { plugin.loginfo(`Email queued successfully: ${message_id}`); callback(null, message_id); } else { plugin.logerror(`Email queueing failed: ${msg}`); callback(new Error(msg || 'Failed to queue email')); } }); }; exports.check_rate_limit = function(ip) { const plugin = this; const now = Date.now(); const window_ms = plugin.cfg.rate_limit_window_ms; const max_requests = plugin.cfg.rate_limit_max_requests; // Clean expired entries for (const [stored_ip, timestamps] of plugin.rate_limit_storage.entries()) { const valid = timestamps.filter(time => now - time < window_ms); if (valid.length === 0) { plugin.rate_limit_storage.delete(stored_ip); } else { plugin.rate_limit_storage.set(stored_ip, valid); } } // Check current IP const timestamps = plugin.rate_limit_storage.get(ip) || []; const valid_timestamps = timestamps.filter(time => now - time < window_ms); if (valid_timestamps.length >= max_requests) { plugin.logwarn(`Rate limit exceeded for IP: ${ip}`); return false; } // Record this request valid_timestamps.push(now); plugin.rate_limit_storage.set(ip, valid_timestamps); return true; }; exports.send_response = function(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }; exports.send_metrics = function(res) { const plugin = this; const uptime_seconds = Math.floor((Date.now() - Date.parse(plugin.stats.started_at)) / 1000); const lines = [ '# HELP elektrine_http_api_requests_total Total HTTP requests handled', '# TYPE elektrine_http_api_requests_total counter', `elektrine_http_api_requests_total ${plugin.stats.requests_total}`, '# HELP elektrine_http_api_auth_failures_total Authentication failures', '# TYPE elektrine_http_api_auth_failures_total counter', `elektrine_http_api_auth_failures_total ${plugin.stats.auth_failures}`, '# HELP elektrine_http_api_rate_limited_total Requests rejected by rate limit', '# TYPE elektrine_http_api_rate_limited_total counter', `elektrine_http_api_rate_limited_total ${plugin.stats.rate_limited}`, '# HELP elektrine_http_api_sent_ok_total Successful send requests', '# TYPE elektrine_http_api_sent_ok_total counter', `elektrine_http_api_sent_ok_total ${plugin.stats.sent_ok}`, '# HELP elektrine_http_api_sent_error_total Failed send requests', '# TYPE elektrine_http_api_sent_error_total counter', `elektrine_http_api_sent_error_total ${plugin.stats.sent_error}`, '# HELP elektrine_http_api_dkim_sync_ok_total Successful DKIM sync requests', '# TYPE elektrine_http_api_dkim_sync_ok_total counter', `elektrine_http_api_dkim_sync_ok_total ${plugin.stats.dkim_sync_ok}`, '# HELP elektrine_http_api_dkim_sync_error_total Failed DKIM sync requests', '# TYPE elektrine_http_api_dkim_sync_error_total counter', `elektrine_http_api_dkim_sync_error_total ${plugin.stats.dkim_sync_error}`, '# HELP elektrine_http_api_dkim_delete_ok_total Successful DKIM delete requests', '# TYPE elektrine_http_api_dkim_delete_ok_total counter', `elektrine_http_api_dkim_delete_ok_total ${plugin.stats.dkim_delete_ok}`, '# HELP elektrine_http_api_dkim_delete_error_total Failed DKIM delete requests', '# TYPE elektrine_http_api_dkim_delete_error_total counter', `elektrine_http_api_dkim_delete_error_total ${plugin.stats.dkim_delete_error}`, '# HELP elektrine_http_api_uptime_seconds Process uptime in seconds', '# TYPE elektrine_http_api_uptime_seconds gauge', `elektrine_http_api_uptime_seconds ${uptime_seconds}` ]; res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' }); res.end(`${lines.join('\n')}\n`); };