Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
commit
67c1d41a0c
82 changed files with 8713 additions and 0 deletions
181
lib/attachment-handler.js
Normal file
181
lib/attachment-handler.js
Normal 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
267
lib/bounce-detector.js
Normal 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
328
lib/config.js
Normal 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
246
lib/domains.js
Normal 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
522
lib/email-builder.js
Normal 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
240
lib/http-client.js
Normal 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
19
lib/index.js
Normal 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
234
lib/mime-parser.js
Normal 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
67
lib/queue-client.js
Normal 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
170
lib/spam-extractor.js
Normal 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
65
lib/telemetry.js
Normal 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
73
lib/text-normalizer.js
Normal 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
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue