Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
commit
67c1d41a0c
82 changed files with 8713 additions and 0 deletions
184
plugins/elektrine_async_queue.js
Normal file
184
plugins/elektrine_async_queue.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Elektrine Async Queue Plugin
|
||||
*
|
||||
* Keeps SMTP transactions fast by enqueueing inbound messages to Redis,
|
||||
* then acknowledging the SMTP queue hook immediately.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
const {
|
||||
config,
|
||||
domains
|
||||
} = require('../lib');
|
||||
const queue_lib = require('../lib/queue-client');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
function parse_stream_data(arg1, arg2) {
|
||||
if (arg2 !== undefined) {
|
||||
if (arg1 instanceof Error) throw arg1;
|
||||
return arg2;
|
||||
}
|
||||
|
||||
return arg1;
|
||||
}
|
||||
|
||||
function read_transaction_raw(transaction) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const message_stream = transaction && transaction.message_stream;
|
||||
if (!message_stream) {
|
||||
return resolve(Buffer.from(''));
|
||||
}
|
||||
|
||||
const on_data = (arg1, arg2) => {
|
||||
try {
|
||||
const raw = parse_stream_data(arg1, arg2);
|
||||
if (Buffer.isBuffer(raw)) return resolve(raw);
|
||||
if (typeof raw === 'string') return resolve(Buffer.from(raw, 'binary'));
|
||||
return resolve(Buffer.from(String(raw || ''), 'utf8'));
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof message_stream.get_data === 'function') {
|
||||
message_stream.get_data(on_data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message_stream.get_data_string === 'function') {
|
||||
message_stream.get_data_string((raw) => {
|
||||
if (typeof raw === 'string') {
|
||||
resolve(Buffer.from(raw, 'binary'));
|
||||
} else {
|
||||
resolve(Buffer.from(''));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('transaction.message_stream does not support get_data')); // eslint-disable-line max-len
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function transaction_spam_notes(transaction) {
|
||||
const notes = transaction && transaction.notes ? transaction.notes : {};
|
||||
const spamassassin = notes.spamassassin || null;
|
||||
|
||||
if (!spamassassin || typeof spamassassin !== 'object') return null;
|
||||
|
||||
return {
|
||||
score: spamassassin.score !== undefined ? spamassassin.score : null,
|
||||
required: spamassassin.reqd !== undefined ? spamassassin.reqd : null,
|
||||
flag: spamassassin.flag !== undefined ? spamassassin.flag : null,
|
||||
tests: spamassassin.tests || null
|
||||
};
|
||||
}
|
||||
|
||||
exports.register = function () {
|
||||
this.load_config();
|
||||
this.logger = telemetry.create_plugin_logger(this, 'async_queue');
|
||||
this.queue_client = new queue_lib.QueueClient(this.cfg, this.logger);
|
||||
|
||||
domains.init((msg) => this.logger.info('domains_init', { message: msg }))
|
||||
.then(() => {
|
||||
this.logger.info('domains_cache_ready');
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.warn('domains_cache_init_failed', { message: err.message });
|
||||
});
|
||||
|
||||
this.register_hook('queue', 'enqueue_inbound_message');
|
||||
};
|
||||
|
||||
exports.load_config = function () {
|
||||
const haraka_cfg = this.config.get('elektrine_queue.ini', {
|
||||
booleans: ['+main.enabled', '+main.include_headers', '+main.include_body', '+main.include_attachments']
|
||||
}, () => {
|
||||
this.load_config();
|
||||
});
|
||||
|
||||
this.cfg = config.load(haraka_cfg);
|
||||
};
|
||||
|
||||
exports.enqueue_inbound_message = function (next, connection) {
|
||||
const plugin = this;
|
||||
const transaction = connection.transaction;
|
||||
|
||||
if (!plugin.cfg.webhook_enabled) {
|
||||
plugin.logger.warn('queue_disabled');
|
||||
return next(constants.DENYSOFT, 'Inbound processing is temporarily disabled');
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return next(constants.OK, 'No transaction to enqueue');
|
||||
}
|
||||
|
||||
const rcpt_to = transaction.rcpt_to.map((rcpt) => rcpt.address());
|
||||
const has_local_recipient = transaction.rcpt_to.some((rcpt) => domains.is_local_domain(rcpt.host));
|
||||
|
||||
if (!has_local_recipient) {
|
||||
plugin.logger.warn('non_local_recipient_in_inbound_profile', {
|
||||
transaction_id: transaction.uuid,
|
||||
recipients: rcpt_to
|
||||
});
|
||||
return next(constants.DENY, 'Inbound role only accepts local recipients');
|
||||
}
|
||||
|
||||
read_transaction_raw(transaction)
|
||||
.then((raw_buffer) => {
|
||||
if (raw_buffer.length > plugin.cfg.queue_max_raw_bytes) {
|
||||
throw new Error(`Message exceeds queue_max_raw_bytes (${raw_buffer.length} > ${plugin.cfg.queue_max_raw_bytes})`); // eslint-disable-line max-len
|
||||
}
|
||||
|
||||
const message_id = transaction.uuid || crypto.randomUUID();
|
||||
const payload = {
|
||||
schema_version: 1,
|
||||
message_id,
|
||||
enqueued_at: new Date().toISOString(),
|
||||
mail_from: transaction.mail_from ? transaction.mail_from.address() : '',
|
||||
rcpt_to,
|
||||
data_bytes: transaction.data_bytes || raw_buffer.length,
|
||||
remote: {
|
||||
ip: connection.remote && connection.remote.ip,
|
||||
host: connection.remote && connection.remote.host,
|
||||
info: connection.remote && connection.remote.info
|
||||
},
|
||||
hello: connection.hello ? {
|
||||
host: connection.hello.host,
|
||||
verb: connection.hello.verb
|
||||
} : null,
|
||||
tls: Boolean(connection.tls),
|
||||
spamassassin: transaction_spam_notes(transaction),
|
||||
raw_rfc822_base64: raw_buffer.toString('base64')
|
||||
};
|
||||
|
||||
return plugin.queue_client.enqueue(plugin.cfg.queue_name, payload)
|
||||
.then(() => {
|
||||
plugin.logger.info('message_enqueued', {
|
||||
transaction_id: transaction.uuid,
|
||||
message_id,
|
||||
rcpt_count: rcpt_to.length,
|
||||
bytes: payload.data_bytes,
|
||||
queue: plugin.cfg.queue_name
|
||||
});
|
||||
|
||||
return next(constants.OK, 'Queued for async processing');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logger.error('enqueue_failed', {
|
||||
transaction_id: transaction.uuid,
|
||||
message: err.message
|
||||
});
|
||||
|
||||
return next(constants.DENYSOFT, 'Temporary queueing failure, please retry');
|
||||
});
|
||||
};
|
||||
948
plugins/elektrine_http_api.js
Normal file
948
plugins/elektrine_http_api.js
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
/**
|
||||
* 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 '<invalid>';
|
||||
|
||||
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`);
|
||||
};
|
||||
79
plugins/elektrine_local_mx.js
Normal file
79
plugins/elektrine_local_mx.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Elektrine Local MX Routing Plugin
|
||||
*
|
||||
* Routes local/protected domains to the internal inbound service instead of
|
||||
* external/public MX records. This keeps local delivery on the
|
||||
* inbound -> queue -> worker -> Phoenix path.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
const { domains } = require('../lib');
|
||||
|
||||
const DEFAULTS = {
|
||||
enabled: true,
|
||||
host: 'haraka-inbound',
|
||||
port: 25
|
||||
};
|
||||
|
||||
function to_int(value, fallback) {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
exports.register = function () {
|
||||
this.load_config();
|
||||
this.register_hook('get_mx', 'route_local_mx');
|
||||
|
||||
domains.init((msg) => this.logdebug(`domains: ${msg}`))
|
||||
.catch((err) => {
|
||||
this.logwarn(`Domain cache init failed: ${err.message}`);
|
||||
});
|
||||
|
||||
if (this.cfg.enabled) {
|
||||
this.loginfo(`Local MX routing enabled -> ${this.cfg.host}:${this.cfg.port}`);
|
||||
} else {
|
||||
this.loginfo('Local MX routing disabled');
|
||||
}
|
||||
};
|
||||
|
||||
exports.load_config = function () {
|
||||
const plugin = this;
|
||||
const cfg = this.config.get('elektrine_local_mx.ini', {
|
||||
booleans: ['+main.enabled']
|
||||
}, () => {
|
||||
plugin.load_config();
|
||||
});
|
||||
|
||||
const main = (cfg && cfg.main) ? cfg.main : {};
|
||||
|
||||
this.cfg = {
|
||||
enabled: main.enabled !== undefined ? main.enabled : DEFAULTS.enabled,
|
||||
host: main.host || DEFAULTS.host,
|
||||
port: to_int(main.port, DEFAULTS.port)
|
||||
};
|
||||
|
||||
if (process.env.ELEKTRINE_LOCAL_MX_HOST) {
|
||||
this.cfg.host = process.env.ELEKTRINE_LOCAL_MX_HOST;
|
||||
}
|
||||
if (process.env.ELEKTRINE_LOCAL_MX_PORT) {
|
||||
this.cfg.port = to_int(process.env.ELEKTRINE_LOCAL_MX_PORT, this.cfg.port);
|
||||
}
|
||||
};
|
||||
|
||||
exports.route_local_mx = function (next, hmail, domain) {
|
||||
if (!this.cfg.enabled) return next();
|
||||
|
||||
const recipient_domain = String(domain || '').toLowerCase();
|
||||
if (!recipient_domain) return next();
|
||||
|
||||
if (!domains.is_local_domain(recipient_domain)) return next();
|
||||
|
||||
this.loginfo(`Routing local domain ${recipient_domain} to ${this.cfg.host}:${this.cfg.port}`);
|
||||
return next(constants.OK, {
|
||||
priority: 0,
|
||||
exchange: this.cfg.host,
|
||||
port: this.cfg.port
|
||||
});
|
||||
};
|
||||
86
plugins/elektrine_rcpt_verify.js
Normal file
86
plugins/elektrine_rcpt_verify.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Elektrine Recipient Verification Plugin
|
||||
*
|
||||
* Verifies that recipients exist before accepting email.
|
||||
* Prevents backscatter attacks from bouncing to spoofed senders.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const { config, http: httpClient, domains } = require('../lib');
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load configuration
|
||||
plugin.cfg = config.load();
|
||||
|
||||
// Log initial domains (may be updated dynamically from Phoenix)
|
||||
const local_domains = domains.get_local_domains();
|
||||
plugin.loginfo(`Recipient verification enabled for: ${local_domains.join(', ')}`);
|
||||
|
||||
// Log cache status periodically (every 10 minutes)
|
||||
setInterval(() => {
|
||||
const cache_status = domains.get_cache_status();
|
||||
if (cache_status.cached) {
|
||||
plugin.logdebug(`Domain cache: ${cache_status.domains.length} domains, age: ${Math.round(cache_status.age_ms / 1000)}s`);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
};
|
||||
|
||||
exports.hook_rcpt = function(next, connection, params) {
|
||||
const plugin = this;
|
||||
const rcpt = params[0];
|
||||
|
||||
if (!rcpt || !rcpt.host) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const recipient_domain = rcpt.host.toLowerCase();
|
||||
const recipient_email = rcpt.address().toLowerCase();
|
||||
|
||||
// Check if this is for a local domain
|
||||
const is_local = domains.is_local_domain(recipient_domain);
|
||||
|
||||
if (!is_local) {
|
||||
// External domain - only allow if authenticated (relay)
|
||||
// Check if connection is authenticated (handled by auth/auth_proxy plugin)
|
||||
if (connection.relaying) {
|
||||
plugin.loginfo(`Authenticated relay to external domain: ${recipient_email}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Not authenticated and not local domain - reject (prevent open relay)
|
||||
plugin.logwarn(`Rejecting relay attempt to external domain: ${recipient_email}`);
|
||||
return next(constants.DENY, `Relay not permitted for ${recipient_domain}`);
|
||||
}
|
||||
|
||||
// Local domain - verify recipient exists in Phoenix
|
||||
plugin.loginfo(`Verifying recipient exists: ${recipient_email}`);
|
||||
|
||||
// Verify with Phoenix app
|
||||
httpClient.verify_recipient(plugin.cfg.verify_url, recipient_email, {
|
||||
api_key: plugin.cfg.phoenix_api_key,
|
||||
timeout: plugin.cfg.verify_timeout,
|
||||
logger: (msg) => plugin.logdebug(msg)
|
||||
})
|
||||
.then((exists) => {
|
||||
if (exists) {
|
||||
plugin.loginfo(`Recipient verified: ${recipient_email}`);
|
||||
return next(constants.OK);
|
||||
} else {
|
||||
plugin.logwarn(`Recipient does not exist: ${recipient_email}`);
|
||||
return next(constants.DENY, `Recipient ${recipient_email} does not exist`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Recipient verification failed: ${err.message}`);
|
||||
// On error, defer (temp fail) so the sending server retries later
|
||||
// This avoids accepting mail for potentially non-existent recipients
|
||||
// which would cause backscatter bounces
|
||||
return next(constants.DENYSOFT, 'Temporary recipient verification failure, please retry');
|
||||
});
|
||||
};
|
||||
85
plugins/elektrine_spf_enforcer.js
Normal file
85
plugins/elektrine_spf_enforcer.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Elektrine SPF Enforcer Plugin
|
||||
*
|
||||
* Strictly enforces SPF for emails claiming to be from protected domains.
|
||||
* Prevents spoofing of local domain addresses.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const { domains } = require('../lib');
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
plugin.loginfo(`SPF Enforcer loaded - protecting: ${domains.get_local_domains().join(', ')}`);
|
||||
};
|
||||
|
||||
exports.hook_mail = function(next, connection, params) {
|
||||
const plugin = this;
|
||||
const mail_from = params[0];
|
||||
|
||||
if (!mail_from || !mail_from.host) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const from_domain = mail_from.host.toLowerCase();
|
||||
|
||||
// Check if email claims to be from one of our protected domains
|
||||
if (!domains.is_local_domain(from_domain)) {
|
||||
return next(); // Not our domain, let other plugins handle it
|
||||
}
|
||||
|
||||
// Store that this is from our domain for later checks
|
||||
connection.transaction.notes.from_protected_domain = true;
|
||||
connection.transaction.notes.protected_domain = from_domain;
|
||||
|
||||
plugin.loginfo(`Email claims to be from protected domain: ${from_domain}`);
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
exports.hook_data_post = function(next, connection) {
|
||||
const plugin = this;
|
||||
const transaction = connection.transaction;
|
||||
|
||||
// Only check emails claiming to be from our domains
|
||||
if (!transaction.notes.from_protected_domain) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const protected_domain = transaction.notes.protected_domain;
|
||||
|
||||
// Check SPF result
|
||||
const spf_result = transaction.results.get('spf');
|
||||
|
||||
if (!spf_result) {
|
||||
plugin.logwarn(`No SPF result for email from ${protected_domain} - accepting anyway`);
|
||||
return next();
|
||||
}
|
||||
|
||||
const spf_status = spf_result.result;
|
||||
|
||||
plugin.loginfo(`SPF check for ${protected_domain}: ${spf_status}`);
|
||||
|
||||
// Reject if SPF failed
|
||||
if (spf_status === 'Fail' || spf_status === 'fail') {
|
||||
plugin.logwarn(`Rejecting spoofed email from ${protected_domain} - SPF: ${spf_status}`);
|
||||
return next(constants.DENY,
|
||||
`Email rejected: SPF validation failed for ${protected_domain}. This email appears to be spoofed.`);
|
||||
}
|
||||
|
||||
// Reject on SPF errors (suspicious)
|
||||
if (spf_status === 'TempError' || spf_status === 'PermError') {
|
||||
plugin.logwarn(`Rejecting email from ${protected_domain} due to SPF error: ${spf_status}`);
|
||||
return next(constants.DENYSOFT,
|
||||
`Email temporarily rejected: SPF check failed for ${protected_domain}`);
|
||||
}
|
||||
|
||||
// Accept if SPF passed or softfail
|
||||
plugin.loginfo(`Accepting email from ${protected_domain} - SPF: ${spf_status}`);
|
||||
return next();
|
||||
};
|
||||
302
plugins/elektrine_webhook.js
Normal file
302
plugins/elektrine_webhook.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Elektrine Webhook Plugin
|
||||
*
|
||||
* Processes inbound emails and forwards them to the Phoenix application
|
||||
* via webhook. Handles email parsing, spam detection, and attachment extraction.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const constants = require('haraka-constants');
|
||||
|
||||
// Shared library modules
|
||||
const {
|
||||
config,
|
||||
http: httpClient,
|
||||
mime,
|
||||
domains,
|
||||
spam: spamExtractor,
|
||||
attachments: attachmentHandler,
|
||||
bounce: bounceDetector,
|
||||
text
|
||||
} = require('../lib');
|
||||
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
exports.register = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load configuration from .ini file with environment variable overrides
|
||||
plugin.load_config();
|
||||
|
||||
// Validate critical config at startup
|
||||
if (!plugin.cfg.phoenix_api_key) {
|
||||
plugin.logerror('CRITICAL: Phoenix API key is not configured. Set PHOENIX_API_KEY environment variable.');
|
||||
}
|
||||
if (!plugin.cfg.webhook_url) {
|
||||
plugin.logerror('CRITICAL: Webhook URL is not configured. Set PHOENIX_WEBHOOK_URL environment variable.');
|
||||
}
|
||||
|
||||
// Initialize domain cache from Phoenix (async)
|
||||
// This fetches custom domains in addition to built-in domains
|
||||
domains.init((msg) => plugin.loginfo(`[domains] ${msg}`))
|
||||
.then(() => {
|
||||
plugin.loginfo('Domain cache initialized successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logwarn(`Domain cache initialization failed: ${err.message}`);
|
||||
});
|
||||
|
||||
// Register for the queue event (when email is fully received)
|
||||
plugin.register_hook('queue', 'send_to_elektrine');
|
||||
};
|
||||
|
||||
exports.load_config = function() {
|
||||
const plugin = this;
|
||||
|
||||
// Load Haraka .ini config
|
||||
const haraka_cfg = plugin.config.get('elektrine_webhook.ini', {
|
||||
booleans: [
|
||||
'+main.enabled',
|
||||
'+main.include_headers',
|
||||
'+main.include_body',
|
||||
'+main.include_attachments'
|
||||
]
|
||||
}, function() {
|
||||
plugin.load_config();
|
||||
});
|
||||
|
||||
// Merge with centralized config
|
||||
plugin.cfg = config.load(haraka_cfg);
|
||||
};
|
||||
|
||||
exports.send_to_elektrine = function(next, connection) {
|
||||
const plugin = this;
|
||||
|
||||
if (!plugin.cfg.webhook_enabled) {
|
||||
return next(constants.ok, 'Webhook disabled');
|
||||
}
|
||||
|
||||
const transaction = connection.transaction;
|
||||
if (!transaction) {
|
||||
return next(constants.ok, 'No transaction');
|
||||
}
|
||||
|
||||
// Only process emails TO local domains (inbound emails)
|
||||
const is_inbound = transaction.rcpt_to.some(rcpt => {
|
||||
return domains.is_local_domain(rcpt.host);
|
||||
});
|
||||
|
||||
if (!is_inbound) {
|
||||
plugin.loginfo('Skipping outbound email - not destined for local domains');
|
||||
return next(constants.ok, 'Not inbound email');
|
||||
}
|
||||
|
||||
// Parse email and send webhook
|
||||
plugin.parse_and_send(transaction, connection, next);
|
||||
};
|
||||
|
||||
exports.parse_and_send = function(transaction, connection, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (!transaction.message_stream) {
|
||||
return next(constants.ok, 'No message stream available');
|
||||
}
|
||||
|
||||
mime.read_message_stream(transaction.message_stream)
|
||||
.then((raw_buffer) => mime.parse_mime(raw_buffer, {
|
||||
logger: (level, message) => {
|
||||
if (level === 'warn') plugin.logwarn(message);
|
||||
}
|
||||
}))
|
||||
.then((parsed) => {
|
||||
// Build email data for webhook
|
||||
const email_data = plugin.build_webhook_data(transaction, connection, parsed);
|
||||
|
||||
// Check for bounce message
|
||||
if (email_data.is_bounce) {
|
||||
plugin.loginfo(`Skipping bounce message: ${email_data.subject}`);
|
||||
return next(constants.ok, 'Bounce message - not forwarding');
|
||||
}
|
||||
|
||||
// Send to Phoenix
|
||||
plugin.send_webhook(email_data, next);
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Email parsing failed: ${err.message}`);
|
||||
return next(constants.ok, 'Message queued despite parsing error');
|
||||
});
|
||||
};
|
||||
|
||||
function get_local_recipients(recipients) {
|
||||
const unique = new Set();
|
||||
|
||||
return (Array.isArray(recipients) ? recipients : [])
|
||||
.map((recipient) => text.normalize_header(recipient || ''))
|
||||
.filter((recipient) => recipient && domains.is_local_domain(domains.extract_domain(recipient)))
|
||||
.filter((recipient) => !unique.has(recipient) && unique.add(recipient));
|
||||
}
|
||||
|
||||
exports.build_webhook_data = function(transaction, connection, parsed) {
|
||||
const plugin = this;
|
||||
|
||||
const message_id = transaction.uuid || crypto.randomUUID();
|
||||
const mail_from = transaction.mail_from ? transaction.mail_from.address() : '';
|
||||
const rcpt_to = transaction.rcpt_to.map(rcpt => rcpt.address());
|
||||
const local_recipients = get_local_recipients(rcpt_to);
|
||||
|
||||
// Extract from email
|
||||
const from_email = text.normalize_header(parsed.from ? parsed.from.text : mail_from);
|
||||
const subject = text.normalize_header(parsed.subject || '');
|
||||
const text_body = parsed.text || '';
|
||||
const html_body = parsed.html || '';
|
||||
const to_email = text.normalize_header(parsed.to ? parsed.to.text : local_recipients[0] || rcpt_to[0] || '');
|
||||
|
||||
// Check if bounce
|
||||
const is_bounce = bounceDetector.is_bounce(from_email, subject, text_body, {
|
||||
envelope_from: mail_from
|
||||
});
|
||||
|
||||
// Extract spam info
|
||||
const spam_data = spamExtractor.extract(connection, transaction, parsed.headers);
|
||||
|
||||
// Extract attachments
|
||||
const attachment_data = attachmentHandler.extract(transaction, parsed, {
|
||||
include_content: plugin.cfg.include_attachments
|
||||
});
|
||||
|
||||
return {
|
||||
message_id: message_id,
|
||||
from: from_email,
|
||||
to: to_email,
|
||||
rcpt_to: local_recipients[0] || text.normalize_header(rcpt_to[0] || ''),
|
||||
all_local_rcpt_to: local_recipients,
|
||||
mail_from: text.normalize_header(mail_from),
|
||||
subject: subject,
|
||||
text_body: text_body,
|
||||
html_body: html_body,
|
||||
headers: plugin.cfg.include_headers ? parsed.headers : undefined,
|
||||
spam_status: spam_data.status,
|
||||
spam_score: spam_data.score,
|
||||
spam_threshold: spam_data.threshold,
|
||||
spam_report: spam_data.report,
|
||||
spam_status_header: spam_data.status_header,
|
||||
attachments: attachment_data.attachments,
|
||||
attachment_count: attachment_data.count,
|
||||
has_attachments: attachment_data.has_attachments,
|
||||
size: transaction.data_bytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
is_bounce: is_bounce
|
||||
};
|
||||
};
|
||||
|
||||
exports.send_webhook = function(email_data, next) {
|
||||
const plugin = this;
|
||||
|
||||
plugin.send_webhook_payloads(plugin.expand_webhook_payloads(email_data), 0, next);
|
||||
};
|
||||
|
||||
exports.expand_webhook_payloads = function(email_data) {
|
||||
const local_recipients = get_local_recipients(email_data.all_local_rcpt_to || [email_data.rcpt_to]);
|
||||
|
||||
if (local_recipients.length <= 1) {
|
||||
return [email_data];
|
||||
}
|
||||
|
||||
return local_recipients.map((recipient) => ({
|
||||
...email_data,
|
||||
rcpt_to: recipient
|
||||
}));
|
||||
};
|
||||
|
||||
exports.send_webhook_payloads = function(payloads, index, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (index >= payloads.length) {
|
||||
return next(constants.ok, 'Message accepted');
|
||||
}
|
||||
|
||||
return plugin.send_webhook_with_retry(payloads[index], 0, (code, message) => {
|
||||
if (code === constants.ok) {
|
||||
return plugin.send_webhook_payloads(payloads, index + 1, next);
|
||||
}
|
||||
|
||||
return next(code, message);
|
||||
});
|
||||
};
|
||||
|
||||
exports.send_webhook_with_retry = function(email_data, attempt, next) {
|
||||
const plugin = this;
|
||||
|
||||
httpClient.send_webhook(plugin.cfg.webhook_url, email_data, {
|
||||
api_key: plugin.cfg.phoenix_api_key,
|
||||
timeout: plugin.cfg.webhook_timeout,
|
||||
logger: (msg) => plugin.loginfo(msg)
|
||||
})
|
||||
.then((result) => {
|
||||
plugin.loginfo(`Email sent to Elektrine successfully: ${result.message_id || email_data.message_id}`);
|
||||
return next(constants.ok, 'Message accepted');
|
||||
})
|
||||
.catch((err) => {
|
||||
plugin.logerror(`Webhook failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${err.message}`);
|
||||
|
||||
// Don't retry client errors (4xx) - they won't succeed on retry
|
||||
if (err.status && err.status >= 400 && err.status < 500) {
|
||||
return plugin.handle_webhook_error(err.status, next);
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx) and network errors
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
||||
plugin.loginfo(`Retrying webhook in ${delay}ms...`);
|
||||
setTimeout(() => {
|
||||
plugin.send_webhook_with_retry(email_data, attempt + 1, next);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if (err.status) {
|
||||
return plugin.handle_webhook_error(err.status, next);
|
||||
}
|
||||
|
||||
// Network failure after all retries - defer so sending server retries later
|
||||
plugin.logwarn('Webhook failed after all retries, deferring');
|
||||
return next(constants.denysoft, 'Temporary delivery failure, please retry');
|
||||
});
|
||||
};
|
||||
|
||||
exports.handle_webhook_error = function(status_code, next) {
|
||||
const plugin = this;
|
||||
|
||||
if (status_code === 404 || status_code === 400) {
|
||||
// Mailbox does not exist or invalid recipient - hard bounce
|
||||
plugin.loginfo('Bouncing email - mailbox does not exist or invalid recipient');
|
||||
return next(constants.deny, 'Mailbox does not exist');
|
||||
}
|
||||
|
||||
if (status_code === 401 || status_code === 403 || status_code === 429) {
|
||||
// Auth/rate-limit errors are usually operational and should not hard-bounce inbound mail.
|
||||
plugin.logwarn(`Deferring email - upstream auth/rate-limit issue (${status_code})`);
|
||||
return next(constants.denysoft, 'Temporary upstream authentication/rate-limit failure');
|
||||
}
|
||||
|
||||
if (status_code === 507) {
|
||||
// Storage limit exceeded - soft bounce
|
||||
plugin.loginfo('Deferring email - mailbox storage limit exceeded');
|
||||
return next(constants.denysoft, 'Mailbox storage limit exceeded');
|
||||
}
|
||||
|
||||
if (status_code >= 500) {
|
||||
// 5xx errors - soft bounce (temporary failure)
|
||||
plugin.loginfo('Deferring email - temporary server error');
|
||||
return next(constants.denysoft, 'Temporary server error');
|
||||
}
|
||||
|
||||
// Other 4xx errors - hard bounce
|
||||
plugin.loginfo('Bouncing email - client error');
|
||||
return next(constants.deny, 'Client error');
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue