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

181
lib/attachment-handler.js Normal file
View file

@ -0,0 +1,181 @@
/**
* Attachment Handler Module
*
* Extracts and processes email attachments from parsed emails.
* Converts attachment content to base64 for API transmission.
*/
'use strict';
/**
* Extract attachment information from a parsed email
*
* @param {Object} transaction - Haraka transaction object
* @param {Object} parsed - Parsed email from mailparser
* @param {Object} [options] - Options
* @param {boolean} [options.include_content=true] - Include base64 content
* @param {boolean} [options.debug=false] - Enable debug logging
* @param {Function} [options.logger] - Logger function
* @returns {Object} Object with attachments array and count
*/
function extract(transaction, parsed, options = {}) {
const {
include_content = true,
debug = false,
logger = console.log
} = options;
if (debug) logger('Extracting attachment information');
let attachments = [];
let attachment_count = 0;
// Primary: Use mailparser attachments (best source for content)
if (parsed && parsed.attachments && parsed.attachments.length > 0) {
if (debug) logger(`Found ${parsed.attachments.length} parsed attachments`);
attachment_count = parsed.attachments.length;
attachments = parsed.attachments.map((att, index) => {
return format_attachment(att, index, include_content, debug, logger);
});
}
// Fallback: Check transaction notes for attachment plugin results
else if (transaction && transaction.notes && transaction.notes.attachment) {
if (debug) logger('Using attachment notes fallback');
const att_notes = transaction.notes.attachment;
if (att_notes.files && Array.isArray(att_notes.files)) {
attachment_count = att_notes.files.length;
attachments = att_notes.files.map((file, index) => ({
filename: file.filename || file.name || `attachment_${index}`,
content_type: file.ctype || file.content_type || 'application/octet-stream',
size: file.bytes || file.size || 0,
md5: file.md5,
content: null, // No content available from plugin notes
encoding: 'base64',
index: index
}));
}
}
if (debug) {
const with_content = attachments.filter(a => a.content).length;
logger(`Final attachment count: ${attachment_count}, with content: ${with_content}`);
}
return {
attachments,
count: attachment_count,
has_attachments: attachment_count > 0
};
}
/**
* Format a single attachment for API transmission
*
* @param {Object} att - Attachment object from mailparser
* @param {number} index - Attachment index
* @param {boolean} include_content - Include base64 content
* @param {boolean} debug - Debug mode
* @param {Function} logger - Logger function
* @returns {Object} Formatted attachment object
*/
function format_attachment(att, index, include_content, debug, logger) {
let content_base64 = null;
if (include_content && att.content) {
content_base64 = to_base64(att.content);
}
const attachment_info = {
filename: att.filename || att.name || `attachment_${index}`,
content_type: att.contentType || att.content_type || 'application/octet-stream',
size: att.size || (att.content ? att.content.length : 0),
content_id: att.cid || null,
content: content_base64,
encoding: 'base64',
index: index
};
if (debug) {
logger(`Attachment ${index}: ${attachment_info.filename}, ` +
`${attachment_info.size} bytes, type: ${attachment_info.content_type}`);
}
return attachment_info;
}
/**
* Convert content to base64 string
*
* @param {Buffer|string} content - Content to convert
* @returns {string|null} Base64 encoded string or null
*/
function to_base64(content) {
if (!content) return null;
if (Buffer.isBuffer(content)) {
return content.toString('base64');
}
if (typeof content === 'string') {
return Buffer.from(content).toString('base64');
}
return null;
}
/**
* Calculate total attachment size
*
* @param {Array} attachments - Array of attachment objects
* @returns {number} Total size in bytes
*/
function get_total_size(attachments) {
if (!attachments || !Array.isArray(attachments)) return 0;
return attachments.reduce((total, att) => {
return total + (att.size || 0);
}, 0);
}
/**
* Check if any attachment exceeds size limit
*
* @param {Array} attachments - Array of attachment objects
* @param {number} max_size - Maximum size in bytes
* @returns {boolean} True if any attachment exceeds limit
*/
function has_oversized(attachments, max_size) {
if (!attachments || !Array.isArray(attachments)) return false;
return attachments.some(att => (att.size || 0) > max_size);
}
/**
* Filter attachments by content type
*
* @param {Array} attachments - Array of attachment objects
* @param {string|string[]} content_types - Content type(s) to match
* @returns {Array} Filtered attachments
*/
function filter_by_type(attachments, content_types) {
if (!attachments || !Array.isArray(attachments)) return [];
const types = Array.isArray(content_types) ? content_types : [content_types];
return attachments.filter(att => {
const att_type = (att.content_type || '').toLowerCase();
return types.some(type => att_type.includes(type.toLowerCase()));
});
}
module.exports = {
extract,
format_attachment,
to_base64,
get_total_size,
has_oversized,
filter_by_type
};

267
lib/bounce-detector.js Normal file
View file

@ -0,0 +1,267 @@
/**
* Bounce Detector Module
*
* Detects bounce/DSN (Delivery Status Notification) messages
* to prevent forwarding them to webhooks and avoid bounce loops.
*/
'use strict';
/**
* Indicators that suggest an email is a bounce message
*/
const BOUNCE_INDICATORS = {
// Common bounce sender addresses
sender_patterns: [
'mailer-daemon',
'postmaster',
'mail-daemon',
'mailerdaemon'
],
// Common bounce subject keywords
subject_patterns: [
'delivery',
'bounce',
'failure',
'undelivered',
'returned',
'undeliverable',
'delivery status',
'delivery failed',
'mail delivery',
'delivery notification',
'could not be delivered',
'not delivered'
],
// DSN (Delivery Status Notification) body indicators
body_patterns: [
'Original-Envelope-Id:',
'Reporting-MTA:',
'Final-Recipient:',
'Action: failed',
'Action: delayed',
'Diagnostic-Code:',
'Remote-MTA:',
'X-Postfix-Queue-ID:',
'This is the mail system at host',
'This message was created automatically',
'Delivery to the following recipient'
]
};
/**
* Check if an email is a bounce message
*
* @param {string} from_email - Sender email address
* @param {string} subject - Email subject
* @param {string} text_body - Plain text body
* @param {Object} [options] - Options
* @param {boolean} [options.strict=false] - Require multiple indicators
* @param {boolean} [options.debug=false] - Enable debug logging
* @param {Function} [options.logger] - Logger function
* @returns {boolean} True if message appears to be a bounce
*/
function is_bounce(from_email, subject, text_body, options = {}) {
const { strict = false, debug = false, logger = console.log } = options;
const envelope_from = options.envelope_from;
const indicators_found = [];
let sender_indicator = false;
let subject_indicator = false;
let body_indicator_count = 0;
let null_sender = false;
// Prefer envelope sender when available. True bounces normally use "<>".
if (typeof envelope_from === 'string') {
const trimmed = envelope_from.trim();
if (!trimmed || trimmed === '<>') {
null_sender = true;
indicators_found.push('envelope:null_sender');
}
} else if (!from_email || from_email.trim() === '') {
null_sender = true;
indicators_found.push('header:empty_sender');
}
// Check sender patterns
if (from_email) {
const from_lower = from_email.toLowerCase();
for (const pattern of BOUNCE_INDICATORS.sender_patterns) {
if (from_lower.includes(pattern)) {
sender_indicator = true;
indicators_found.push(`sender:${pattern}`);
break;
}
}
}
// Check subject patterns
if (subject) {
const subject_lower = subject.toLowerCase();
for (const pattern of BOUNCE_INDICATORS.subject_patterns) {
if (subject_lower.includes(pattern)) {
subject_indicator = true;
indicators_found.push(`subject:${pattern}`);
break;
}
}
}
// Check body patterns (DSN indicators)
if (text_body) {
for (const pattern of BOUNCE_INDICATORS.body_patterns) {
if (text_body.includes(pattern)) {
body_indicator_count += 1;
indicators_found.push(`body:${pattern}`);
}
}
}
if (debug && indicators_found.length > 0) {
logger(`Bounce indicators found: ${indicators_found.join(', ')}`);
}
// In strict mode, require stronger DSN evidence.
if (strict) {
if (null_sender && (subject_indicator || body_indicator_count >= 1 || sender_indicator)) return true;
if (body_indicator_count >= 2) return true;
if (sender_indicator && (subject_indicator || body_indicator_count >= 1)) return true;
if (subject_indicator && body_indicator_count >= 1) return true;
return false;
}
// Default mode: avoid false positives (e.g. noreply notifications) by
// requiring correlated bounce/DSN signals instead of a single keyword.
if (null_sender && (subject_indicator || body_indicator_count >= 1 || sender_indicator)) return true;
if (body_indicator_count >= 2) return true;
if (sender_indicator && (subject_indicator || body_indicator_count >= 1)) return true;
if (subject_indicator && body_indicator_count >= 1) return true;
return false;
}
/**
* Get detailed bounce analysis
*
* @param {string} from_email - Sender email address
* @param {string} subject - Email subject
* @param {string} text_body - Plain text body
* @returns {Object} Detailed bounce analysis
*/
function analyze(from_email, subject, text_body) {
const result = {
is_bounce: false,
confidence: 'none',
indicators: [],
dsn_detected: false,
bounce_type: null
};
// Check empty sender header
if (!from_email || from_email.trim() === '') {
result.indicators.push({ type: 'sender', reason: 'empty_sender_header' });
}
// Check sender patterns
if (from_email) {
const from_lower = from_email.toLowerCase();
for (const pattern of BOUNCE_INDICATORS.sender_patterns) {
if (from_lower.includes(pattern)) {
result.indicators.push({ type: 'sender', reason: pattern });
}
}
}
// Check subject patterns
if (subject) {
const subject_lower = subject.toLowerCase();
for (const pattern of BOUNCE_INDICATORS.subject_patterns) {
if (subject_lower.includes(pattern)) {
result.indicators.push({ type: 'subject', reason: pattern });
}
}
}
// Check body patterns
if (text_body) {
let dsn_indicators = 0;
for (const pattern of BOUNCE_INDICATORS.body_patterns) {
if (text_body.includes(pattern)) {
result.indicators.push({ type: 'body', reason: pattern });
dsn_indicators++;
}
}
// If multiple DSN indicators, it's definitely a DSN
if (dsn_indicators >= 2) {
result.dsn_detected = true;
}
}
result.is_bounce = is_bounce(from_email, subject, text_body);
// Determine confidence (conservative: single weak indicator is low confidence
// but not necessarily classified as a bounce).
const count = result.indicators.length;
if (!result.is_bounce && count === 0) {
result.confidence = 'none';
} else if (count <= 1) {
result.confidence = 'low';
} else if (count === 2) {
result.confidence = 'medium';
} else {
result.confidence = 'high';
}
// Determine bounce type
if (result.is_bounce) {
if (result.dsn_detected) {
result.bounce_type = 'dsn';
} else if (result.indicators.some(i => i.reason === 'empty_sender' || i.reason === 'empty_sender_header')) {
result.bounce_type = 'null_sender';
} else {
result.bounce_type = 'automated';
}
}
return result;
}
/**
* Check if message is an auto-reply (out of office, etc.)
*
* @param {Object} headers - Email headers
* @returns {boolean} True if auto-reply
*/
function is_auto_reply(headers) {
if (!headers) return false;
// Check Auto-Submitted header (RFC 3834)
const auto_submitted = headers['auto-submitted'] || headers['Auto-Submitted'];
if (auto_submitted && auto_submitted !== 'no') {
return true;
}
// Check X-Auto-Response-Suppress header
const auto_suppress = headers['x-auto-response-suppress'] || headers['X-Auto-Response-Suppress'];
if (auto_suppress) {
return true;
}
// Check Precedence header
const precedence = headers['precedence'] || headers['Precedence'];
if (precedence) {
const prec_lower = precedence.toLowerCase();
if (prec_lower === 'bulk' || prec_lower === 'junk' || prec_lower === 'auto_reply') {
return true;
}
}
return false;
}
module.exports = {
is_bounce,
analyze,
is_auto_reply,
BOUNCE_INDICATORS
};

328
lib/config.js Normal file
View file

@ -0,0 +1,328 @@
/**
* Centralized Configuration Module
*
* Single source of truth for Haraka plugin configuration.
* Prioritizes environment variables over .ini file settings.
*/
'use strict';
// Default configuration values
const DEFAULTS = {
// Runtime role
role: 'inbound-mx',
// Phoenix API endpoints
webhook_url: 'https://app.example.com/api/haraka/inbound',
verify_url: 'https://app.example.com/api/haraka/verify-recipient',
domains_url: 'https://app.example.com/api/haraka/domains',
// Directional API authentication
// phoenix_api_key: used when Haraka calls Phoenix endpoints
// http_api_key: used to authenticate callers to Haraka /api/v1/send
phoenix_api_key: '',
http_api_key: '',
// HTTP server settings
http_port: 8080,
http_host: '0.0.0.0',
cors_origin: '',
dkim_storage_dir: '',
// HTTP endpoint access controls
ops_allowed_cidrs: [],
metrics_allowed_cidrs: [],
trusted_proxy_cidrs: [
'127.0.0.1/32',
'::1/128',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'fc00::/7'
],
// Timeouts (milliseconds)
webhook_timeout: 30000,
verify_timeout: 5000,
// Webhook retry controls
webhook_max_retries: 5,
webhook_retry_base_delay_ms: 1000,
// Rate limiting
rate_limit_window_ms: 60000, // 1 minute
rate_limit_max_requests: 50, // 50 requests per window
// Local domains (protected from spoofing, receive inbound mail)
local_domains: ['example.com'],
// Domain cache behavior
domain_cache_ttl_ms: 5 * 60 * 1000,
// Async queue settings
redis_url: 'redis://redis:6379',
queue_name: 'elektrine:inbound',
queue_dlq_name: 'elektrine:inbound:dlq',
queue_pop_timeout_sec: 5,
queue_max_raw_bytes: 25 * 1024 * 1024,
// Feature flags
webhook_enabled: true,
include_headers: true,
include_body: true,
include_attachments: true
};
function to_int(value, fallback) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function to_bool(value, fallback) {
if (value === undefined || value === null) return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parse_string_list(value, { lowercase = false } = {}) {
if (!value) return null;
const normalized = Array.isArray(value)
? value.map((entry) => String(entry).trim()).filter(Boolean)
: String(value).split(/[\s,]+/).map((entry) => entry.trim()).filter(Boolean);
if (normalized.length === 0) return null;
if (!lowercase) return normalized;
return normalized.map((entry) => entry.toLowerCase());
}
function parse_domain_list(value) {
return parse_string_list(value, { lowercase: true });
}
function parse_cidr_list(value) {
return parse_string_list(value, { lowercase: true });
}
function apply_env_overrides(config) {
if (process.env.HARAKA_ROLE) config.role = process.env.HARAKA_ROLE;
if (process.env.PHOENIX_WEBHOOK_URL) config.webhook_url = process.env.PHOENIX_WEBHOOK_URL;
if (process.env.PHOENIX_VERIFY_URL) config.verify_url = process.env.PHOENIX_VERIFY_URL;
if (process.env.PHOENIX_DOMAINS_URL) config.domains_url = process.env.PHOENIX_DOMAINS_URL;
if (process.env.PHOENIX_API_KEY) config.phoenix_api_key = process.env.PHOENIX_API_KEY;
if (process.env.HARAKA_HTTP_API_KEY) config.http_api_key = process.env.HARAKA_HTTP_API_KEY;
if (process.env.HARAKA_API_KEY) {
if (!process.env.PHOENIX_API_KEY) {
config.phoenix_api_key = process.env.HARAKA_API_KEY;
}
if (!process.env.HARAKA_HTTP_API_KEY) {
config.http_api_key = process.env.HARAKA_API_KEY;
}
}
if (process.env.HARAKA_HTTP_PORT) config.http_port = to_int(process.env.HARAKA_HTTP_PORT, DEFAULTS.http_port);
if (process.env.HARAKA_HTTP_HOST) config.http_host = process.env.HARAKA_HTTP_HOST;
if (process.env.HARAKA_CORS_ORIGIN !== undefined) config.cors_origin = process.env.HARAKA_CORS_ORIGIN;
if (process.env.HARAKA_DKIM_DIR !== undefined) config.dkim_storage_dir = process.env.HARAKA_DKIM_DIR;
if (process.env.LOCAL_DOMAINS) config.local_domains = parse_domain_list(process.env.LOCAL_DOMAINS) || DEFAULTS.local_domains;
if (process.env.OPS_ALLOWED_CIDRS) {
config.ops_allowed_cidrs = parse_cidr_list(process.env.OPS_ALLOWED_CIDRS) || DEFAULTS.ops_allowed_cidrs;
}
if (process.env.METRICS_ALLOWED_CIDRS) {
config.metrics_allowed_cidrs = parse_cidr_list(process.env.METRICS_ALLOWED_CIDRS) || DEFAULTS.metrics_allowed_cidrs;
}
if (process.env.HARAKA_TRUSTED_PROXY_CIDRS) {
config.trusted_proxy_cidrs = parse_cidr_list(process.env.HARAKA_TRUSTED_PROXY_CIDRS) || DEFAULTS.trusted_proxy_cidrs;
}
if (process.env.REDIS_URL) config.redis_url = process.env.REDIS_URL;
if (process.env.ELEKTRINE_QUEUE_NAME) config.queue_name = process.env.ELEKTRINE_QUEUE_NAME;
if (process.env.ELEKTRINE_DLQ_NAME) config.queue_dlq_name = process.env.ELEKTRINE_DLQ_NAME;
if (process.env.ELEKTRINE_QUEUE_POP_TIMEOUT) {
config.queue_pop_timeout_sec = to_int(process.env.ELEKTRINE_QUEUE_POP_TIMEOUT, DEFAULTS.queue_pop_timeout_sec);
}
if (process.env.ELEKTRINE_QUEUE_MAX_RAW_BYTES) {
config.queue_max_raw_bytes = to_int(process.env.ELEKTRINE_QUEUE_MAX_RAW_BYTES, DEFAULTS.queue_max_raw_bytes);
}
if (process.env.WEBHOOK_MAX_RETRIES) {
config.webhook_max_retries = to_int(process.env.WEBHOOK_MAX_RETRIES, DEFAULTS.webhook_max_retries);
}
if (process.env.WEBHOOK_RETRY_BASE_MS) {
config.webhook_retry_base_delay_ms = to_int(process.env.WEBHOOK_RETRY_BASE_MS, DEFAULTS.webhook_retry_base_delay_ms);
}
if (process.env.DOMAIN_CACHE_TTL_MS) {
config.domain_cache_ttl_ms = to_int(process.env.DOMAIN_CACHE_TTL_MS, DEFAULTS.domain_cache_ttl_ms);
}
if (process.env.HARAKA_INCLUDE_HEADERS !== undefined) {
config.include_headers = to_bool(process.env.HARAKA_INCLUDE_HEADERS, config.include_headers);
}
if (process.env.HARAKA_INCLUDE_BODY !== undefined) {
config.include_body = to_bool(process.env.HARAKA_INCLUDE_BODY, config.include_body);
}
if (process.env.HARAKA_INCLUDE_ATTACHMENTS !== undefined) {
config.include_attachments = to_bool(process.env.HARAKA_INCLUDE_ATTACHMENTS, config.include_attachments);
}
}
/**
* Load configuration from environment variables and optional Haraka config
* @param {Object} haraka_config - Optional Haraka plugin config object
* @returns {Object} Merged configuration object
*/
function load(haraka_config = null) {
const config = { ...DEFAULTS };
// Environment variables (highest priority)
apply_env_overrides(config);
// Haraka .ini config overrides (if provided)
if (haraka_config && haraka_config.main) {
const main = haraka_config.main;
if (main.role) config.role = main.role;
if (main.url) config.webhook_url = main.url;
if (main.verify_url) config.verify_url = main.verify_url;
if (main.domains_url) config.domains_url = main.domains_url;
if (main.phoenix_api_key) config.phoenix_api_key = main.phoenix_api_key;
if (main.http_api_key) config.http_api_key = main.http_api_key;
if (main.api_key) {
if (!main.phoenix_api_key) config.phoenix_api_key = main.api_key;
if (!main.http_api_key) config.http_api_key = main.api_key;
}
if (main.port) config.http_port = to_int(main.port, config.http_port);
if (main.host) config.http_host = main.host;
if (main.cors_origin !== undefined) config.cors_origin = main.cors_origin;
if (main.dkim_storage_dir !== undefined) config.dkim_storage_dir = main.dkim_storage_dir;
if (main.timeout) config.webhook_timeout = to_int(main.timeout, config.webhook_timeout);
if (main.verify_timeout) config.verify_timeout = to_int(main.verify_timeout, config.verify_timeout);
if (main.domain_cache_ttl_ms) {
config.domain_cache_ttl_ms = to_int(main.domain_cache_ttl_ms, config.domain_cache_ttl_ms);
}
// Boolean flags
if (main.enabled !== undefined) config.webhook_enabled = main.enabled;
if (main.include_headers !== undefined) config.include_headers = main.include_headers;
if (main.include_body !== undefined) config.include_body = main.include_body;
if (main.include_attachments !== undefined) config.include_attachments = main.include_attachments;
}
if (haraka_config && haraka_config.http_api) {
const http_api = haraka_config.http_api;
if (http_api.port) config.http_port = to_int(http_api.port, config.http_port);
if (http_api.host) config.http_host = http_api.host;
if (http_api.cors_origin !== undefined) config.cors_origin = http_api.cors_origin;
if (http_api.dkim_storage_dir !== undefined) config.dkim_storage_dir = http_api.dkim_storage_dir;
if (http_api.ops_allowed_cidrs) {
const parsed_ops_cidrs = parse_cidr_list(http_api.ops_allowed_cidrs);
if (parsed_ops_cidrs && parsed_ops_cidrs.length > 0) {
config.ops_allowed_cidrs = parsed_ops_cidrs;
}
}
if (http_api.metrics_allowed_cidrs) {
const parsed_metrics_cidrs = parse_cidr_list(http_api.metrics_allowed_cidrs);
if (parsed_metrics_cidrs && parsed_metrics_cidrs.length > 0) {
config.metrics_allowed_cidrs = parsed_metrics_cidrs;
}
}
if (http_api.trusted_proxy_cidrs) {
const parsed_trusted_proxy_cidrs = parse_cidr_list(http_api.trusted_proxy_cidrs);
if (parsed_trusted_proxy_cidrs && parsed_trusted_proxy_cidrs.length > 0) {
config.trusted_proxy_cidrs = parsed_trusted_proxy_cidrs;
}
}
}
if (haraka_config && haraka_config.ops && haraka_config.ops.allowed_cidrs) {
const parsed_ops_cidrs = parse_cidr_list(haraka_config.ops.allowed_cidrs);
if (parsed_ops_cidrs && parsed_ops_cidrs.length > 0) {
config.ops_allowed_cidrs = parsed_ops_cidrs;
}
}
if (haraka_config && haraka_config.metrics && haraka_config.metrics.allowed_cidrs) {
const parsed_metrics_cidrs = parse_cidr_list(haraka_config.metrics.allowed_cidrs);
if (parsed_metrics_cidrs && parsed_metrics_cidrs.length > 0) {
config.metrics_allowed_cidrs = parsed_metrics_cidrs;
}
}
if (haraka_config && haraka_config.rate_limit) {
const rate_limit = haraka_config.rate_limit;
if (rate_limit.window_ms) {
config.rate_limit_window_ms = to_int(rate_limit.window_ms, config.rate_limit_window_ms);
}
if (rate_limit.max_requests) {
config.rate_limit_max_requests = to_int(rate_limit.max_requests, config.rate_limit_max_requests);
}
}
if (haraka_config && haraka_config.domains) {
const domains = haraka_config.domains;
if (domains.local) {
const parsed = parse_domain_list(domains.local);
if (parsed && parsed.length > 0) {
config.local_domains = parsed;
}
}
if (domains.cache_ttl_ms) {
config.domain_cache_ttl_ms = to_int(domains.cache_ttl_ms, config.domain_cache_ttl_ms);
}
}
if (haraka_config && haraka_config.queue) {
const queue = haraka_config.queue;
if (queue.redis_url) config.redis_url = queue.redis_url;
if (queue.name) config.queue_name = queue.name;
if (queue.dlq_name) config.queue_dlq_name = queue.dlq_name;
if (queue.pop_timeout_sec) {
config.queue_pop_timeout_sec = to_int(queue.pop_timeout_sec, config.queue_pop_timeout_sec);
}
if (queue.max_raw_bytes) {
config.queue_max_raw_bytes = to_int(queue.max_raw_bytes, config.queue_max_raw_bytes);
}
}
if (haraka_config && haraka_config.dkim && haraka_config.dkim.storage_dir !== undefined) {
config.dkim_storage_dir = haraka_config.dkim.storage_dir;
}
if (haraka_config && haraka_config.worker) {
const worker = haraka_config.worker;
if (worker.webhook_max_retries) {
config.webhook_max_retries = to_int(worker.webhook_max_retries, config.webhook_max_retries);
}
if (worker.webhook_retry_base_delay_ms) {
config.webhook_retry_base_delay_ms = to_int(worker.webhook_retry_base_delay_ms, config.webhook_retry_base_delay_ms);
}
}
// Re-apply env values last so they always win over any .ini value.
apply_env_overrides(config);
config.api_key = config.phoenix_api_key;
return config;
}
/**
* Get a specific configuration value with fallback
* @param {string} key - Configuration key
* @param {*} fallback - Fallback value if key not found
* @returns {*} Configuration value
*/
function get(key, fallback = null) {
const loaded = load();
return Object.prototype.hasOwnProperty.call(loaded, key) ? loaded[key] : fallback;
}
module.exports = {
load,
get,
DEFAULTS
};

246
lib/domains.js Normal file
View file

@ -0,0 +1,246 @@
/**
* Domain Utilities Module
*
* Single source of truth for local/protected domain management.
* Provides helper functions for domain-related operations.
*
* Supports dynamic domain loading from Phoenix API for custom domains.
*/
'use strict';
const config = require('./config');
const http_client = require('./http-client');
// Cache for domains fetched from Phoenix
let cached_domains = null;
let cache_timestamp = 0;
let refresh_interval = null;
// Flag to track if we're currently refreshing
let refresh_in_progress = false;
/**
* Get the list of local domains from configuration
* These are domains that receive inbound mail and are protected from spoofing.
* @returns {string[]} Array of local domain names
*/
function get_local_domains() {
const cfg = config.load();
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
// If we have cached domains from Phoenix, use them
if (cached_domains && (Date.now() - cache_timestamp < cache_ttl_ms)) {
return cached_domains;
}
// Return config domains while we refresh in background
return cfg.local_domains || ['example.com'];
}
/**
* Refresh the domain cache from Phoenix API
* This is called periodically and on startup
* @param {Function} [logger] - Optional logger function
* @returns {Promise<string[]>} Array of domain names
*/
async function refresh_domains(logger = null) {
if (refresh_in_progress) {
if (logger) logger('Domain refresh already in progress, skipping');
return cached_domains || get_local_domains();
}
refresh_in_progress = true;
const cfg = config.load();
try {
if (logger) logger(`Fetching domains from ${cfg.domains_url}`);
const domains = await http_client.fetch_domains(cfg.domains_url, {
api_key: cfg.phoenix_api_key,
timeout: 10000,
logger: logger
});
if (domains && domains.length > 0) {
cached_domains = domains.map(d => d.toLowerCase());
cache_timestamp = Date.now();
if (logger) {
logger(`Domain cache updated with ${domains.length} domains: ${domains.join(', ')}`);
}
return cached_domains;
} else {
if (logger) logger('No domains returned from Phoenix, keeping existing cache');
return cached_domains || cfg.local_domains || ['example.com'];
}
} catch (err) {
if (logger) {
logger(`Failed to refresh domains from Phoenix: ${err.message}`);
}
// On error, keep using cached domains or fall back to config
return cached_domains || cfg.local_domains || ['example.com'];
} finally {
refresh_in_progress = false;
}
}
/**
* Initialize the domain cache
* Call this on Haraka startup to pre-populate the cache
* @param {Function} [logger] - Optional logger function
* @returns {Promise<void>}
*/
async function init(logger = null) {
if (logger) logger('Initializing domain cache from Phoenix');
const cfg = config.load();
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
// Initial fetch
await refresh_domains(logger);
// Set up periodic refresh once per process
if (!refresh_interval) {
refresh_interval = setInterval(() => {
refresh_domains(logger).catch(err => {
if (logger) logger(`Periodic domain refresh failed: ${err.message}`);
});
}, cache_ttl_ms);
}
if (logger) logger('Domain cache initialized');
}
/**
* Check if a domain is a local (protected) domain
* @param {string} domain - Domain name to check
* @returns {boolean} True if domain is local
*/
function is_local_domain(domain) {
if (!domain) return false;
const local_domains = get_local_domains();
return local_domains.includes(domain.toLowerCase());
}
/**
* Check if all recipients are on local domains
* @param {string[]} recipients - Array of email addresses
* @returns {boolean} True if all recipients are on local domains
*/
function all_recipients_local(recipients) {
if (!recipients || recipients.length === 0) return false;
const local_domains = get_local_domains();
return recipients.every(recipient => {
const domain = extract_domain(recipient);
return domain && local_domains.includes(domain);
});
}
/**
* Check if any recipient is on a local domain
* @param {string[]} recipients - Array of email addresses
* @returns {boolean} True if any recipient is on a local domain
*/
function any_recipient_local(recipients) {
if (!recipients || recipients.length === 0) return false;
const local_domains = get_local_domains();
return recipients.some(recipient => {
const domain = extract_domain(recipient);
return domain && local_domains.includes(domain);
});
}
/**
* Extract domain from an email address
* Handles formats like "email@domain.com" and "Display Name <email@domain.com>"
* @param {string} email - Email address (possibly with display name)
* @returns {string|null} Domain name or null if invalid
*/
function extract_domain(email) {
if (!email) return null;
// Handle "Display Name <email@domain.com>" format
const match = email.match(/<([^>]+)>/);
const clean_email = match ? match[1] : email;
// Extract domain part
const parts = clean_email.split('@');
if (parts.length !== 2) return null;
return parts[1].toLowerCase().trim();
}
/**
* Extract the clean email address from potentially formatted input
* Handles formats like "email@domain.com" and "Display Name <email@domain.com>"
* @param {string} email - Email address (possibly with display name)
* @returns {string} Clean email address
*/
function extract_email(email) {
if (!email) return '';
// Handle "Display Name <email@domain.com>" format
const match = email.match(/<([^>]+)>/);
return match ? match[1] : email.trim();
}
/**
* Filter recipients by local/external domain
* @param {string[]} recipients - Array of email addresses
* @returns {Object} Object with 'local' and 'external' arrays
*/
function partition_recipients(recipients) {
const result = {
local: [],
external: []
};
if (!recipients || recipients.length === 0) return result;
const local_domains = get_local_domains();
recipients.forEach(recipient => {
const domain = extract_domain(recipient);
if (domain && local_domains.includes(domain)) {
result.local.push(recipient);
} else {
result.external.push(recipient);
}
});
return result;
}
/**
* Get the current cached domains (for debugging/monitoring)
* @returns {Object} Cache status and domains
*/
function get_cache_status() {
const cfg = config.load();
const cache_ttl_ms = cfg.domain_cache_ttl_ms || config.DEFAULTS.domain_cache_ttl_ms;
return {
cached: cached_domains !== null,
domains: cached_domains || [],
age_ms: cached_domains ? Date.now() - cache_timestamp : null,
ttl_ms: cache_ttl_ms
};
}
module.exports = {
init,
refresh_domains,
get_local_domains,
is_local_domain,
all_recipients_local,
any_recipient_local,
extract_domain,
extract_email,
partition_recipients,
get_cache_status
};

522
lib/email-builder.js Normal file
View file

@ -0,0 +1,522 @@
/**
* Email Builder Module
*
* Constructs RFC 5322 compliant email messages with proper MIME structure.
* Supports plain text, HTML, multipart/alternative, and attachments.
*/
'use strict';
const crypto = require('crypto');
const domains = require('./domains');
const HEADER_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
// Headers a client may not set via email_data.headers: identity/routing
// fields the builder owns, and MIME structure fields whose first occurrence
// would shadow the real ones emitted with the body.
const FORBIDDEN_CUSTOM_HEADERS = new Set([
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'mime-version',
'reply-to', 'sender', 'return-path', 'received',
'content-type', 'content-transfer-encoding'
]);
function sanitize_header_value(value) {
if (value === undefined || value === null) return '';
return String(value).replace(/[\r\n]+/g, ' ').trim();
}
function sanitize_header_name(name) {
const normalized = sanitize_header_value(name);
if (!normalized || !HEADER_TOKEN_RE.test(normalized)) return '';
return normalized;
}
function sanitize_message_id(value) {
const normalized = sanitize_header_value(value).replace(/[^A-Za-z0-9._-]/g, '');
return normalized || crypto.randomUUID();
}
function normalize_address(value) {
const extracted = sanitize_header_value(domains.extract_email(sanitize_header_value(value)));
if (!extracted) return '';
if (!/^[^\s@<>]+@[A-Za-z0-9.-]+$/.test(extracted)) return '';
return extracted;
}
function normalize_address_list(value) {
if (value === undefined || value === null) return [];
const list = Array.isArray(value) ? value : [value];
return list.map((entry) => normalize_address(entry)).filter(Boolean);
}
// RFC 2047 "B" encoded-word for header text containing non-ASCII characters.
// Chunks on code-point boundaries so a multi-byte character is never split
// across two encoded-words (each word's base64 payload stays well under 75).
function encode_mime_word(value) {
const str = sanitize_header_value(value);
if (str === '') return '';
// Pure ASCII (printable) needs no encoding.
if (/^[\x20-\x7E]*$/.test(str)) return str;
// Already a single MIME encoded-word: leave it alone (don't double-encode).
if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(str)) return str;
const words = [];
let chunk = [];
let chunk_bytes = 0;
const flush = () => {
if (chunk.length === 0) return;
const encoded = Buffer.from(chunk.join(''), 'utf8').toString('base64');
words.push(`=?UTF-8?B?${encoded}?=`);
chunk = [];
chunk_bytes = 0;
};
for (const ch of str) {
const ch_bytes = Buffer.byteLength(ch, 'utf8');
if (chunk_bytes + ch_bytes > 45) flush();
chunk.push(ch);
chunk_bytes += ch_bytes;
}
flush();
// Fold multiple encoded-words with CRLF + space per RFC 2047/5322.
return words.join('\r\n ');
}
// Render an address header value preserving a sanitized display name.
// normalize_address() strips the name down to the bare address, which is right
// for envelope/routing but wrong for the From/Reply-To/To/Cc headers shown to
// the recipient. This keeps "Display Name <email>" while validating the
// address, MIME-encoding non-ASCII names, and guarding against header injection.
function format_address_header(value) {
const email = normalize_address(value);
if (!email) return '';
const raw = sanitize_header_value(value);
const match = raw.match(/^(.*?)\s*<[^<>]*>\s*$/);
let name = match ? match[1].trim() : '';
if (!name) return email;
// A pre-formed MIME encoded-word must not be quoted or re-encoded.
if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(name)) {
return `${name} <${email}>`;
}
// Unwrap an existing quoted-string to recover the raw display name.
let raw_name = name;
if (name.length >= 2 && name.startsWith('"') && name.endsWith('"')) {
raw_name = name.slice(1, -1).replace(/\\(.)/g, '$1');
}
if (/[^\x20-\x7E]/.test(raw_name)) {
// Non-ASCII display name: RFC 2047 encode (encoded-words are not quoted).
name = encode_mime_word(raw_name);
} else if (/[(),:;<>@[\]".\\]/.test(raw_name)) {
// Quote names containing RFC 5322 special characters.
name = '"' + raw_name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
} else {
name = raw_name;
}
return `${name} <${email}>`;
}
function format_address_header_list(value) {
if (value === undefined || value === null) return [];
const list = Array.isArray(value) ? value : [value];
return list.map((entry) => format_address_header(entry)).filter(Boolean);
}
// RFC 5322 date, e.g. "Fri, 27 Jun 2026 09:59:05 +0000".
// Date.toUTCString() emits the obsolete "GMT" zone; this uses the numeric zone.
function rfc5322_date(date) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const pad = (n) => String(n).padStart(2, '0');
return `${days[date.getUTCDay()]}, ${pad(date.getUTCDate())} ` +
`${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ` +
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`;
}
function first_string(...values) {
for (const value of values) {
if (typeof value === 'string') return value;
}
return undefined;
}
function get_text_body(email_data) {
return first_string(email_data.text_body, email_data.text, email_data.body);
}
function get_html_body(email_data) {
return first_string(email_data.html_body, email_data.html);
}
function encode_quoted_printable(value) {
const lines = String(value || '').replace(/\r\n|\r|\n/g, '\n').split('\n');
return lines.map((line) => {
const tokens = [];
for (const byte of Buffer.from(line, 'utf8')) {
if ((byte >= 33 && byte <= 60) || (byte >= 62 && byte <= 126)) {
tokens.push(String.fromCharCode(byte));
} else {
tokens.push(`=${byte.toString(16).toUpperCase().padStart(2, '0')}`);
}
}
const wrapped = [];
let current = '';
for (const token of tokens) {
if (current && current.length + token.length > 75) {
wrapped.push(`${current}=`);
current = '';
}
current += token;
}
wrapped.push(current);
return wrapped.join('\r\n');
}).join('\r\n');
}
// Attachments arrive as base64 strings (see README). Validate the payload and
// re-wrap it at 76 characters so a large blob never exceeds the SMTP
// 998-octet line limit. Returns null for missing or non-base64 data.
function encode_attachment_data(data) {
if (typeof data !== 'string') return null;
const compact = data.replace(/\s+/g, '');
if (compact === '' || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
return null;
}
// Re-encode to normalize non-canonical trailing bits.
const normalized = Buffer.from(compact, 'base64').toString('base64');
return normalized.match(/.{1,76}/g).join('\r\n');
}
// Content-Disposition filename parameter(s). ASCII names use the plain quoted
// form; non-ASCII names get an RFC 2231 filename* value with an ASCII
// fallback filename for parsers that don't support the extended syntax.
function format_attachment_filename(filename) {
const raw = sanitize_header_value(filename) || 'attachment';
const safe = raw.replace(/["\\/]/g, '_');
if (/^[\x20-\x7E]*$/.test(safe)) {
return `filename="${safe}"`;
}
const fallback = safe.replace(/[^\x20-\x7E]/g, '_');
const encoded = Array.from(Buffer.from(safe, 'utf8'))
.map((byte) => {
const ch = String.fromCharCode(byte);
return /[A-Za-z0-9._-]/.test(ch)
? ch
: `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
})
.join('');
return `filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
/**
* Build an RFC 5322 compliant email message
* @param {Object} email_data - Email data object
* @param {string} email_data.from - Sender email address
* @param {string|string[]} email_data.to - Recipient(s)
* @param {string} [email_data.cc] - CC recipients
* @param {string} [email_data.subject] - Email subject
* @param {string} [email_data.text_body] - Plain text body
* @param {string} [email_data.html_body] - HTML body
* @param {string} [email_data.reply_to] - Reply-To address
* @param {Object} [email_data.headers] - Custom headers
* @param {Array} [email_data.attachments] - Attachments array
* @param {string} [message_id] - Optional message ID (generated if not provided)
* @returns {Object} Object with email_content and message_id
*/
function build(email_data, message_id = null) {
message_id = sanitize_message_id(message_id || crypto.randomUUID());
const safe_from = normalize_address(email_data.from);
const from_header = format_address_header(email_data.from);
const reply_to_header = format_address_header(email_data.reply_to || email_data.from) || from_header;
const safe_to_recipients = normalize_address_list(email_data.to);
const safe_subject = sanitize_header_value(email_data.subject || '');
const text_body = get_text_body(email_data);
const html_body = get_html_body(email_data);
if (!safe_from) {
throw new Error('Invalid from address');
}
if (safe_to_recipients.length === 0) {
throw new Error('Invalid to recipient list');
}
// Extract sender domain for Message-ID
const sender_email = domains.extract_email(safe_from);
const sender_domain = domains.extract_domain(safe_from) || 'haraka.local';
// Header rendering keeps display names; envelope/routing uses the bare
// lists above (and collect_recipients()), so this is header-only.
const to_header_recipients = format_address_header_list(email_data.to);
const cc_header_recipients = format_address_header_list(email_data.cc);
// Start building headers
const headers = [
`Message-ID: <${message_id}@${sender_domain}>`,
`Date: ${rfc5322_date(new Date())}`,
`From: ${from_header}`,
`Reply-To: ${reply_to_header}`,
// Fold after each address so long recipient lists never exceed the
// RFC 5322 998-octet line limit.
`To: ${to_header_recipients.join(',\r\n ')}`
];
// Add CC if present
if (cc_header_recipients.length > 0) {
headers.push(`Cc: ${cc_header_recipients.join(',\r\n ')}`);
}
// Add subject
headers.push(`Subject: ${encode_mime_word(safe_subject)}`);
headers.push('MIME-Version: 1.0');
// Add custom headers (with injection prevention)
if (email_data.headers) {
for (const [key, value] of Object.entries(email_data.headers)) {
const safe_key = sanitize_header_name(key);
if (!safe_key) continue;
const safe_value = sanitize_header_value(value);
// Skip dangerous headers that could override critical fields.
// Content-Type/Content-Transfer-Encoding matter because custom
// headers are emitted before the body's real Content-Type, and
// many parsers honor the first occurrence.
const lower_key = safe_key.toLowerCase();
if (FORBIDDEN_CUSTOM_HEADERS.has(lower_key)) {
continue;
}
headers.push(`${safe_key}: ${encode_mime_word(safe_value)}`);
}
}
// Determine email structure and build body
const has_attachments = email_data.attachments &&
Array.isArray(email_data.attachments) &&
email_data.attachments.length > 0;
let body_parts;
if (has_attachments) {
body_parts = build_multipart_mixed(email_data);
} else if (text_body && html_body) {
body_parts = build_multipart_alternative(text_body, html_body);
} else if (html_body) {
body_parts = build_html_body(html_body);
} else {
body_parts = build_text_body(text_body || '');
}
// Combine headers and body
const email_content = [...headers, ...body_parts].join('\r\n');
return {
email_content,
message_id,
sender_email
};
}
/**
* Build a plain text body
* @param {string} text - Plain text content
* @returns {string[]} Body lines
*/
function build_text_body(text) {
return [
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(text)
];
}
/**
* Build an HTML body
* @param {string} html - HTML content
* @returns {string[]} Body lines
*/
function build_html_body(html) {
return [
'Content-Type: text/html; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(html)
];
}
/**
* Build a multipart/alternative body (text + HTML)
* @param {string} text - Plain text content
* @param {string} html - HTML content
* @returns {string[]} Body lines
*/
function build_multipart_alternative(text, html) {
const boundary = `boundary-${crypto.randomUUID()}`;
return [
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(text),
'',
`--${boundary}`,
'Content-Type: text/html; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(html),
'',
`--${boundary}--`
];
}
/**
* Build a multipart/mixed body (content + attachments)
* @param {Object} email_data - Email data with attachments
* @returns {string[]} Body lines
*/
function build_multipart_mixed(email_data) {
const mixed_boundary = `boundary-mixed-${crypto.randomUUID()}`;
const text_body = get_text_body(email_data);
const html_body = get_html_body(email_data);
const parts = [
`Content-Type: multipart/mixed; boundary="${mixed_boundary}"`,
'',
`--${mixed_boundary}`
];
// Add message body as first part
if (text_body && html_body) {
// Nested multipart/alternative for text + HTML
const alt_boundary = `boundary-alt-${crypto.randomUUID()}`;
parts.push(
`Content-Type: multipart/alternative; boundary="${alt_boundary}"`,
'',
`--${alt_boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(text_body),
'',
`--${alt_boundary}`,
'Content-Type: text/html; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(html_body),
'',
`--${alt_boundary}--`
);
} else if (html_body) {
parts.push(
'Content-Type: text/html; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(html_body)
);
} else {
parts.push(
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
encode_quoted_printable(text_body || '')
);
}
// Add attachment parts
for (const attachment of email_data.attachments) {
const safe_content_type = sanitize_header_value(attachment.content_type || 'application/octet-stream') || 'application/octet-stream';
const encoded_data = encode_attachment_data(attachment.data);
if (encoded_data === null) {
throw new Error(`Invalid attachment data for "${sanitize_header_value(attachment.filename) || 'attachment'}": expected a base64 string`);
}
parts.push(
'',
`--${mixed_boundary}`,
`Content-Type: ${safe_content_type}`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; ${format_attachment_filename(attachment.filename)}`,
'',
encoded_data
);
}
// Close boundary
parts.push('', `--${mixed_boundary}--`);
return parts;
}
/**
* Convert structured email data to webhook format for local delivery
* @param {Object} email_data - Structured email data
* @param {string} message_id - Message ID
* @returns {Object} Webhook-formatted data
*/
function to_webhook_format(email_data, message_id) {
return {
message_id: message_id,
from: email_data.from,
to: Array.isArray(email_data.to) ? email_data.to[0] : email_data.to,
cc: email_data.cc,
bcc: email_data.bcc,
subject: email_data.subject,
text_body: get_text_body(email_data),
html_body: get_html_body(email_data),
attachments: email_data.attachments || [],
timestamp: new Date().toISOString(),
id: message_id
};
}
/**
* Collect all recipients from email data (to, cc, bcc)
* @param {Object} email_data - Email data
* @returns {string[]} Array of all recipient email addresses
*/
function collect_recipients(email_data) {
const recipients = [];
// Add TO recipients
if (email_data.to) {
const to_list = normalize_address_list(email_data.to);
recipients.push(...to_list);
}
// Add CC recipients
if (email_data.cc) {
const cc_list = normalize_address_list(email_data.cc);
recipients.push(...cc_list);
}
// Add BCC recipients
if (email_data.bcc) {
const bcc_list = normalize_address_list(email_data.bcc);
recipients.push(...bcc_list);
}
return recipients;
}
module.exports = {
build,
build_text_body,
build_html_body,
build_multipart_alternative,
build_multipart_mixed,
encode_quoted_printable,
to_webhook_format,
collect_recipients
};

240
lib/http-client.js Normal file
View file

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

19
lib/index.js Normal file
View file

@ -0,0 +1,19 @@
/**
* Elektrine Haraka Library
*
* Central export for all shared modules used by Elektrine Haraka plugins.
*/
'use strict';
module.exports = {
config: require('./config'),
http: require('./http-client'),
mime: require('./mime-parser'),
domains: require('./domains'),
email: require('./email-builder'),
spam: require('./spam-extractor'),
attachments: require('./attachment-handler'),
bounce: require('./bounce-detector'),
text: require('./text-normalizer')
};

234
lib/mime-parser.js Normal file
View file

@ -0,0 +1,234 @@
/**
* MIME parsing helpers with charset-decoding fallback behavior.
*/
'use strict';
const { simpleParser } = require('mailparser');
const text_normalizer = require('./text-normalizer');
let cached_libmime = undefined;
let cached_iconv = undefined;
function get_iconv_constructor() {
if (cached_iconv !== undefined) return cached_iconv;
try {
cached_iconv = require('iconv').Iconv;
} catch (err) {
cached_iconv = null;
}
return cached_iconv;
}
function get_libmime() {
if (cached_libmime !== undefined) return cached_libmime;
try {
cached_libmime = require('libmime');
} catch (err) {
cached_libmime = null;
}
return cached_libmime;
}
function is_charset_decode_error(err) {
if (!err || !err.message) return false;
const message = String(err.message).toLowerCase();
return (
message.includes('charset') ||
message.includes('encoding') ||
message.includes('iconv') ||
message.includes('decode')
);
}
function count_mojibake_pairs(value) {
if (typeof value !== 'string' || value.length < 2) return 0;
let count = 0;
for (let i = 0; i < value.length - 1; i += 1) {
const a = value.charCodeAt(i);
const b = value.charCodeAt(i + 1);
if (a >= 0x00C2 && a <= 0x00F4 && b >= 0x0080 && b <= 0x00BF) {
count += 1;
}
}
return count;
}
function parsed_mojibake_score(parsed) {
if (!parsed || typeof parsed !== 'object') return 0;
const fields = [
parsed.subject,
parsed.text,
parsed.html,
parsed.from && parsed.from.text,
parsed.to && parsed.to.text,
parsed.cc && parsed.cc.text
];
let score = 0;
for (const value of fields) {
if (typeof value !== 'string') continue;
const sample = value.length > 4096 ? value.slice(0, 4096) : value;
score += count_mojibake_pairs(sample);
}
return score;
}
function text_quality_score(value) {
if (typeof value !== 'string') return Number.MAX_SAFE_INTEGER;
const mojibake_pairs = count_mojibake_pairs(value);
const control_count = (value.match(/[\u0080-\u009F]/g) || []).length;
const replacement_count = (value.match(/\uFFFD/g) || []).length;
// Lower is better.
return mojibake_pairs * 5 + control_count * 3 + replacement_count * 8;
}
function decode_subject_from_header_lines(parsed) {
if (!parsed || !Array.isArray(parsed.headerLines)) return null;
const subject_line = parsed.headerLines.find((line) => line && line.key === 'subject');
if (!subject_line || typeof subject_line.line !== 'string') return null;
const raw = subject_line.line.replace(/^subject\s*:/i, '').trim();
if (!raw) return null;
const libmime = get_libmime();
try {
if (libmime && typeof libmime.decodeWords === 'function') {
return String(libmime.decodeWords(raw));
}
} catch (_err) {
// fall through to returning raw text for further normalization
}
return raw;
}
function choose_best_subject(parsed_subject, header_line_subject) {
const parsed_candidate = text_normalizer.normalize_header(parsed_subject || '');
const header_candidate = text_normalizer.normalize_header(header_line_subject || '');
const parsed_score = text_quality_score(parsed_candidate);
const header_score = text_quality_score(header_candidate);
if (header_candidate && header_score < parsed_score) {
return header_candidate;
}
return parsed_candidate;
}
function normalize_parsed_headers(parsed) {
if (!parsed || typeof parsed !== 'object') return parsed;
const decoded_subject = decode_subject_from_header_lines(parsed);
parsed.subject = choose_best_subject(parsed.subject || '', decoded_subject || '');
return parsed;
}
function parse_stream_data(arg1, arg2) {
if (arg2 !== undefined) {
if (arg1 instanceof Error) throw arg1;
return arg2;
}
return arg1;
}
function read_message_stream(message_stream) {
return new Promise((resolve, reject) => {
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('message_stream does not support get_data/get_data_string'));
} catch (err) {
reject(err);
}
});
}
async function parse_mime(input, options = {}) {
const iconv = get_iconv_constructor();
const logger = options.logger;
if (iconv) {
try {
const parsed_with_iconv = await simpleParser(input, { Iconv: iconv });
const iconv_score = parsed_mojibake_score(parsed_with_iconv);
if (iconv_score === 0) {
return normalize_parsed_headers(parsed_with_iconv);
}
const parsed_with_iconv_lite = await simpleParser(input);
const iconv_lite_score = parsed_mojibake_score(parsed_with_iconv_lite);
if (iconv_lite_score < iconv_score) {
if (logger) {
logger(
'warn',
`native iconv output looked mojibake-prone (score ${iconv_score}), using iconv-lite result (score ${iconv_lite_score})`
);
}
return normalize_parsed_headers(parsed_with_iconv_lite);
}
return normalize_parsed_headers(parsed_with_iconv);
} catch (err) {
if (!is_charset_decode_error(err)) throw err;
if (logger) {
logger('warn', `native iconv parsing failed, retrying with iconv-lite: ${err.message}`);
}
}
}
const parsed = await simpleParser(input);
return normalize_parsed_headers(parsed);
}
module.exports = {
parse_mime,
read_message_stream,
get_iconv_constructor
};

67
lib/queue-client.js Normal file
View file

@ -0,0 +1,67 @@
/**
* Redis-backed queue helper for async inbound processing.
*/
'use strict';
const { createClient } = require('redis');
class QueueClient {
constructor(cfg, logger) {
this.cfg = cfg;
this.logger = logger;
this.client = null;
this.connecting = null;
}
async connect() {
if (this.client && this.client.isReady) return;
if (this.connecting) return this.connecting;
this.client = createClient({ url: this.cfg.redis_url });
this.client.on('error', (err) => {
this.logger.error('redis_client_error', { message: err.message });
});
this.client.on('reconnecting', () => {
this.logger.warn('redis_reconnecting');
});
this.connecting = this.client.connect()
.then(() => {
this.logger.info('redis_connected', { redis_url: this.cfg.redis_url });
})
.finally(() => {
this.connecting = null;
});
return this.connecting;
}
async enqueue(queue_name, payload) {
await this.connect();
await this.client.lPush(queue_name, JSON.stringify(payload));
}
async pop(queue_name, timeout_sec) {
await this.connect();
const result = await this.client.brPop(queue_name, timeout_sec);
if (!result) return null;
return result.element;
}
async enqueue_dlq(queue_name, payload) {
await this.enqueue(queue_name, payload);
}
async close() {
if (this.client && this.client.isOpen) {
await this.client.quit();
}
}
}
module.exports = {
QueueClient
};

170
lib/spam-extractor.js Normal file
View file

@ -0,0 +1,170 @@
/**
* Spam Extractor Module
*
* Extracts spam information from Haraka connection/transaction notes
* and email headers. Supports SpamAssassin results.
*/
'use strict';
/**
* Default spam info structure
* @returns {Object} Default spam info object
*/
function get_default_spam_info() {
return {
status: 'unknown',
score: 0.0,
threshold: 5.0,
report: null,
status_header: null
};
}
/**
* Extract spam information from multiple sources
* Checks transaction notes, connection notes, and headers in order of priority.
*
* @param {Object} connection - Haraka connection object
* @param {Object} transaction - Haraka transaction object
* @param {Object} headers - Parsed email headers (from mailparser)
* @param {Object} [options] - Optional settings
* @param {boolean} [options.debug=false] - Enable debug logging
* @param {Function} [options.logger] - Logger function
* @returns {Object} Spam info object
*/
function extract(connection, transaction, headers, options = {}) {
const { debug = false, logger = console.log } = options;
if (debug) logger('Starting spam extraction');
const spam_info = get_default_spam_info();
// Method 1: Check transaction notes (highest priority)
if (transaction && transaction.notes && transaction.notes.spamassassin) {
if (debug) logger('Found spamassassin in transaction.notes');
return parse_spamassassin_notes(transaction.notes.spamassassin, spam_info, debug, logger);
}
// Method 2: Check connection notes
if (connection && connection.notes && connection.notes.spamassassin) {
if (debug) logger('Found spamassassin in connection.notes');
return parse_spamassassin_notes(connection.notes.spamassassin, spam_info, debug, logger);
}
// Method 3: Check email headers (fallback)
if (headers) {
const header_result = parse_spam_headers(headers, spam_info, debug, logger);
if (header_result.status !== 'unknown') {
return header_result;
}
}
if (debug) logger('No spam info found in any source');
return spam_info;
}
/**
* Parse SpamAssassin notes from Haraka
* @param {Object} sa - SpamAssassin notes object
* @param {Object} spam_info - Base spam info object
* @param {boolean} debug - Debug mode
* @param {Function} logger - Logger function
* @returns {Object} Updated spam info
*/
function parse_spamassassin_notes(sa, spam_info, debug, logger) {
if (debug) logger(`Parsing SpamAssassin notes: ${JSON.stringify(sa)}`);
if (sa.score !== undefined) {
spam_info.score = parseFloat(sa.score) || 0.0;
}
if (sa.reqd !== undefined) {
spam_info.threshold = parseFloat(sa.reqd) || 5.0;
}
if (sa.flag !== undefined) {
spam_info.status = sa.flag === 'Yes' ? 'spam' : 'ham';
}
if (sa.tests) {
spam_info.report = sa.tests;
}
return spam_info;
}
/**
* Parse spam information from email headers
* @param {Object} headers - Email headers (Map or object)
* @param {Object} spam_info - Base spam info object
* @param {boolean} debug - Debug mode
* @param {Function} logger - Logger function
* @returns {Object} Updated spam info
*/
function parse_spam_headers(headers, spam_info, debug, logger) {
if (debug) logger('Checking headers for spam info');
// Get header values (handle both Map and object formats)
const get_header = (name) => {
if (headers instanceof Map) {
return headers.get(name) || headers.get(name.toLowerCase());
}
return headers[name] || headers[name.toLowerCase()];
};
const spam_status = get_header('X-Spam-Status') || get_header('x-spam-status');
const spam_score = get_header('X-Spam-Score') || get_header('x-spam-score');
const spam_report = get_header('X-Spam-Report') || get_header('x-spam-report');
if (spam_status || spam_score) {
if (debug) logger(`Found spam headers - Status: ${spam_status}, Score: ${spam_score}`);
if (spam_status && typeof spam_status === 'string') {
spam_info.status_header = spam_status;
// Parse "Yes, score=5.2, required=5.0" or "No, score=-1.6, required=5.0"
const score_match = spam_status.match(/score=([-\d.]+)/);
const req_match = spam_status.match(/required=([-\d.]+)/);
if (score_match) {
spam_info.score = parseFloat(score_match[1]) || 0.0;
}
if (req_match) {
spam_info.threshold = parseFloat(req_match[1]) || 5.0;
}
spam_info.status = spam_status.startsWith('Yes') ? 'spam' : 'ham';
}
// Override score if separate header exists
if (spam_score && typeof spam_score === 'string') {
spam_info.score = parseFloat(spam_score) || spam_info.score;
}
// Add report if available
if (spam_report && typeof spam_report === 'string') {
spam_info.report = spam_report;
}
}
return spam_info;
}
/**
* Determine if an email should be marked as spam based on score
* @param {number} score - Spam score
* @param {number} [threshold=5.0] - Spam threshold
* @returns {boolean} True if spam
*/
function is_spam(score, threshold = 5.0) {
return score >= threshold;
}
module.exports = {
extract,
get_default_spam_info,
parse_spamassassin_notes,
parse_spam_headers,
is_spam
};

65
lib/telemetry.js Normal file
View file

@ -0,0 +1,65 @@
/**
* Minimal structured telemetry helper.
*/
'use strict';
function compact(fields) {
const out = {};
Object.entries(fields || {}).forEach(([key, value]) => {
if (value !== undefined) out[key] = value;
});
return out;
}
function emit(log_fn, level, scope, event, fields = {}) {
const payload = compact({
ts: new Date().toISOString(),
level,
scope,
event,
...fields
});
const line = JSON.stringify(payload);
log_fn(line);
}
function create_plugin_logger(plugin, scope) {
return {
info(event, fields) {
emit((line) => plugin.loginfo(line), 'info', scope, event, fields);
},
warn(event, fields) {
emit((line) => plugin.logwarn(line), 'warn', scope, event, fields);
},
error(event, fields) {
emit((line) => plugin.logerror(line), 'error', scope, event, fields);
},
debug(event, fields) {
emit((line) => plugin.logdebug(line), 'debug', scope, event, fields);
}
};
}
function create_console_logger(scope) {
return {
info(event, fields) {
emit((line) => console.log(line), 'info', scope, event, fields);
},
warn(event, fields) {
emit((line) => console.warn(line), 'warn', scope, event, fields);
},
error(event, fields) {
emit((line) => console.error(line), 'error', scope, event, fields);
},
debug(event, fields) {
emit((line) => console.debug(line), 'debug', scope, event, fields);
}
};
}
module.exports = {
create_plugin_logger,
create_console_logger
};

73
lib/text-normalizer.js Normal file
View file

@ -0,0 +1,73 @@
/**
* Header text normalization helpers.
*
* Fixes common mojibake where UTF-8 bytes were interpreted as Latin-1.
*/
'use strict';
function count_replacement_chars(value) {
if (typeof value !== 'string' || value.length === 0) return 0;
return (value.match(/\uFFFD/g) || []).length;
}
function has_c1_controls(value) {
return /[\u0080-\u009F]/.test(value);
}
function count_mojibake_utf8_latin1_pairs(value) {
let count = 0;
for (let i = 0; i < value.length - 1; i += 1) {
const a = value.charCodeAt(i);
const b = value.charCodeAt(i + 1);
// Pattern of UTF-8 byte sequences misread as Latin-1 code points.
if (a >= 0x00C2 && a <= 0x00F4 && b >= 0x0080 && b <= 0x00BF) {
count += 1;
}
}
return count;
}
function try_repair_utf8_latin1_mojibake(value) {
if (typeof value !== 'string' || value.length === 0) return value;
const cps = Array.from(value, (char) => char.codePointAt(0));
if (cps.some((cp) => cp > 0xFF)) {
return value;
}
const repaired = Buffer.from(cps).toString('utf8');
if (!repaired) return value;
const before_controls = has_c1_controls(value);
const after_controls = has_c1_controls(repaired);
const before_pairs = count_mojibake_utf8_latin1_pairs(value);
const after_pairs = count_mojibake_utf8_latin1_pairs(repaired);
const replacement_count = count_replacement_chars(repaired);
if (before_controls && !after_controls) {
if (replacement_count === 0) return repaired;
// Allow partial recovery when controls disappear and mojibake pairs drop sharply.
// This handles cases where one continuation byte was dropped upstream.
if (before_pairs >= 3 && after_pairs < before_pairs && replacement_count <= 2) {
return repaired;
}
}
if (before_pairs > 0 && after_pairs < before_pairs && replacement_count === 0) {
return repaired;
}
return value;
}
function normalize_header(value) {
if (typeof value !== 'string') return value;
return try_repair_utf8_latin1_mojibake(value);
}
module.exports = {
normalize_header,
try_repair_utf8_latin1_mojibake
};