Initial commit on Forgejo
Some checks are pending
CI and deploy / Test (push) Waiting to run
CI and deploy / Build, push, and deploy (push) Blocked by required conditions

Fresh repository history for elektrine/elektrine-haraka hosted at
https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:42 -04:00
commit 67c1d41a0c
82 changed files with 8713 additions and 0 deletions

View 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');
});
};

View 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`);
};

View 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
});
};

View 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');
});
};

View 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();
};

View 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');
};