Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
/**
|
|
* Elektrine Webhook Plugin
|
|
*
|
|
* Processes inbound emails and forwards them to the Phoenix application
|
|
* via webhook. Handles email parsing, spam detection, and attachment extraction.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const constants = require('haraka-constants');
|
|
|
|
// Shared library modules
|
|
const {
|
|
config,
|
|
http: httpClient,
|
|
mime,
|
|
domains,
|
|
spam: spamExtractor,
|
|
attachments: attachmentHandler,
|
|
bounce: bounceDetector,
|
|
text
|
|
} = require('../lib');
|
|
|
|
// Retry configuration
|
|
const MAX_RETRIES = 2;
|
|
const RETRY_BASE_DELAY_MS = 1000;
|
|
|
|
exports.register = function() {
|
|
const plugin = this;
|
|
|
|
// Load configuration from .ini file with environment variable overrides
|
|
plugin.load_config();
|
|
|
|
// Validate critical config at startup
|
|
if (!plugin.cfg.phoenix_api_key) {
|
|
plugin.logerror('CRITICAL: Phoenix API key is not configured. Set PHOENIX_API_KEY environment variable.');
|
|
}
|
|
if (!plugin.cfg.webhook_url) {
|
|
plugin.logerror('CRITICAL: Webhook URL is not configured. Set PHOENIX_WEBHOOK_URL environment variable.');
|
|
}
|
|
|
|
// Initialize domain cache from Phoenix (async)
|
|
// This fetches custom domains in addition to built-in domains
|
|
domains.init((msg) => plugin.loginfo(`[domains] ${msg}`))
|
|
.then(() => {
|
|
plugin.loginfo('Domain cache initialized successfully');
|
|
})
|
|
.catch((err) => {
|
|
plugin.logwarn(`Domain cache initialization failed: ${err.message}`);
|
|
});
|
|
|
|
// Register for the queue event (when email is fully received)
|
|
plugin.register_hook('queue', 'send_to_elektrine');
|
|
};
|
|
|
|
exports.load_config = function() {
|
|
const plugin = this;
|
|
|
|
// Load Haraka .ini config
|
|
const haraka_cfg = plugin.config.get('elektrine_webhook.ini', {
|
|
booleans: [
|
|
'+main.enabled',
|
|
'+main.include_headers',
|
|
'+main.include_body',
|
|
'+main.include_attachments'
|
|
]
|
|
}, function() {
|
|
plugin.load_config();
|
|
});
|
|
|
|
// Merge with centralized config
|
|
plugin.cfg = config.load(haraka_cfg);
|
|
};
|
|
|
|
exports.send_to_elektrine = function(next, connection) {
|
|
const plugin = this;
|
|
|
|
if (!plugin.cfg.webhook_enabled) {
|
|
return next(constants.ok, 'Webhook disabled');
|
|
}
|
|
|
|
const transaction = connection.transaction;
|
|
if (!transaction) {
|
|
return next(constants.ok, 'No transaction');
|
|
}
|
|
|
|
// Only process emails TO local domains (inbound emails)
|
|
const is_inbound = transaction.rcpt_to.some(rcpt => {
|
|
return domains.is_local_domain(rcpt.host);
|
|
});
|
|
|
|
if (!is_inbound) {
|
|
plugin.loginfo('Skipping outbound email - not destined for local domains');
|
|
return next(constants.ok, 'Not inbound email');
|
|
}
|
|
|
|
// Parse email and send webhook
|
|
plugin.parse_and_send(transaction, connection, next);
|
|
};
|
|
|
|
exports.parse_and_send = function(transaction, connection, next) {
|
|
const plugin = this;
|
|
|
|
if (!transaction.message_stream) {
|
|
return next(constants.ok, 'No message stream available');
|
|
}
|
|
|
|
mime.read_message_stream(transaction.message_stream)
|
|
.then((raw_buffer) => mime.parse_mime(raw_buffer, {
|
|
logger: (level, message) => {
|
|
if (level === 'warn') plugin.logwarn(message);
|
|
}
|
|
}))
|
|
.then((parsed) => {
|
|
// Build email data for webhook
|
|
const email_data = plugin.build_webhook_data(transaction, connection, parsed);
|
|
|
|
// Check for bounce message
|
|
if (email_data.is_bounce) {
|
|
plugin.loginfo(`Skipping bounce message: ${email_data.subject}`);
|
|
return next(constants.ok, 'Bounce message - not forwarding');
|
|
}
|
|
|
|
// Send to Phoenix
|
|
plugin.send_webhook(email_data, next);
|
|
})
|
|
.catch((err) => {
|
|
plugin.logerror(`Email parsing failed: ${err.message}`);
|
|
return next(constants.ok, 'Message queued despite parsing error');
|
|
});
|
|
};
|
|
|
|
function get_local_recipients(recipients) {
|
|
const unique = new Set();
|
|
|
|
return (Array.isArray(recipients) ? recipients : [])
|
|
.map((recipient) => text.normalize_header(recipient || ''))
|
|
.filter((recipient) => recipient && domains.is_local_domain(domains.extract_domain(recipient)))
|
|
.filter((recipient) => !unique.has(recipient) && unique.add(recipient));
|
|
}
|
|
|
|
exports.build_webhook_data = function(transaction, connection, parsed) {
|
|
const plugin = this;
|
|
|
|
const message_id = transaction.uuid || crypto.randomUUID();
|
|
const mail_from = transaction.mail_from ? transaction.mail_from.address() : '';
|
|
const rcpt_to = transaction.rcpt_to.map(rcpt => rcpt.address());
|
|
const local_recipients = get_local_recipients(rcpt_to);
|
|
|
|
// Extract from email
|
|
const from_email = text.normalize_header(parsed.from ? parsed.from.text : mail_from);
|
|
const subject = text.normalize_header(parsed.subject || '');
|
|
const text_body = parsed.text || '';
|
|
const html_body = parsed.html || '';
|
|
const to_email = text.normalize_header(parsed.to ? parsed.to.text : local_recipients[0] || rcpt_to[0] || '');
|
|
|
|
// Check if bounce
|
|
const is_bounce = bounceDetector.is_bounce(from_email, subject, text_body, {
|
|
envelope_from: mail_from
|
|
});
|
|
|
|
// Extract spam info
|
|
const spam_data = spamExtractor.extract(connection, transaction, parsed.headers);
|
|
|
|
// Extract attachments
|
|
const attachment_data = attachmentHandler.extract(transaction, parsed, {
|
|
include_content: plugin.cfg.include_attachments
|
|
});
|
|
|
|
return {
|
|
message_id: message_id,
|
|
from: from_email,
|
|
to: to_email,
|
|
rcpt_to: local_recipients[0] || text.normalize_header(rcpt_to[0] || ''),
|
|
all_local_rcpt_to: local_recipients,
|
|
mail_from: text.normalize_header(mail_from),
|
|
subject: subject,
|
|
text_body: text_body,
|
|
html_body: html_body,
|
|
headers: plugin.cfg.include_headers ? parsed.headers : undefined,
|
|
spam_status: spam_data.status,
|
|
spam_score: spam_data.score,
|
|
spam_threshold: spam_data.threshold,
|
|
spam_report: spam_data.report,
|
|
spam_status_header: spam_data.status_header,
|
|
attachments: attachment_data.attachments,
|
|
attachment_count: attachment_data.count,
|
|
has_attachments: attachment_data.has_attachments,
|
|
size: transaction.data_bytes,
|
|
timestamp: new Date().toISOString(),
|
|
is_bounce: is_bounce
|
|
};
|
|
};
|
|
|
|
exports.send_webhook = function(email_data, next) {
|
|
const plugin = this;
|
|
|
|
plugin.send_webhook_payloads(plugin.expand_webhook_payloads(email_data), 0, next);
|
|
};
|
|
|
|
exports.expand_webhook_payloads = function(email_data) {
|
|
const local_recipients = get_local_recipients(email_data.all_local_rcpt_to || [email_data.rcpt_to]);
|
|
|
|
if (local_recipients.length <= 1) {
|
|
return [email_data];
|
|
}
|
|
|
|
return local_recipients.map((recipient) => ({
|
|
...email_data,
|
|
rcpt_to: recipient
|
|
}));
|
|
};
|
|
|
|
exports.send_webhook_payloads = function(payloads, index, next) {
|
|
const plugin = this;
|
|
|
|
if (index >= payloads.length) {
|
|
return next(constants.ok, 'Message accepted');
|
|
}
|
|
|
|
return plugin.send_webhook_with_retry(payloads[index], 0, (code, message) => {
|
|
if (code === constants.ok) {
|
|
return plugin.send_webhook_payloads(payloads, index + 1, next);
|
|
}
|
|
|
|
return next(code, message);
|
|
});
|
|
};
|
|
|
|
exports.send_webhook_with_retry = function(email_data, attempt, next) {
|
|
const plugin = this;
|
|
|
|
httpClient.send_webhook(plugin.cfg.webhook_url, email_data, {
|
|
api_key: plugin.cfg.phoenix_api_key,
|
|
timeout: plugin.cfg.webhook_timeout,
|
|
logger: (msg) => plugin.loginfo(msg)
|
|
})
|
|
.then((result) => {
|
|
plugin.loginfo(`Email sent to Elektrine successfully: ${result.message_id || email_data.message_id}`);
|
|
return next(constants.ok, 'Message accepted');
|
|
})
|
|
.catch((err) => {
|
|
plugin.logerror(`Webhook failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${err.message}`);
|
|
|
|
// Don't retry client errors (4xx) - they won't succeed on retry
|
|
if (err.status && err.status >= 400 && err.status < 500) {
|
|
return plugin.handle_webhook_error(err.status, next);
|
|
}
|
|
|
|
// Retry on server errors (5xx) and network errors
|
|
if (attempt < MAX_RETRIES) {
|
|
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
plugin.loginfo(`Retrying webhook in ${delay}ms...`);
|
|
setTimeout(() => {
|
|
plugin.send_webhook_with_retry(email_data, attempt + 1, next);
|
|
}, delay);
|
|
return;
|
|
}
|
|
|
|
// All retries exhausted
|
|
if (err.status) {
|
|
return plugin.handle_webhook_error(err.status, next);
|
|
}
|
|
|
|
// Network failure after all retries - defer so sending server retries later
|
|
plugin.logwarn('Webhook failed after all retries, deferring');
|
|
return next(constants.denysoft, 'Temporary delivery failure, please retry');
|
|
});
|
|
};
|
|
|
|
exports.handle_webhook_error = function(status_code, next) {
|
|
const plugin = this;
|
|
|
|
if (status_code === 404 || status_code === 400) {
|
|
// Mailbox does not exist or invalid recipient - hard bounce
|
|
plugin.loginfo('Bouncing email - mailbox does not exist or invalid recipient');
|
|
return next(constants.deny, 'Mailbox does not exist');
|
|
}
|
|
|
|
if (status_code === 401 || status_code === 403 || status_code === 429) {
|
|
// Auth/rate-limit errors are usually operational and should not hard-bounce inbound mail.
|
|
plugin.logwarn(`Deferring email - upstream auth/rate-limit issue (${status_code})`);
|
|
return next(constants.denysoft, 'Temporary upstream authentication/rate-limit failure');
|
|
}
|
|
|
|
if (status_code === 507) {
|
|
// Storage limit exceeded - soft bounce
|
|
plugin.loginfo('Deferring email - mailbox storage limit exceeded');
|
|
return next(constants.denysoft, 'Mailbox storage limit exceeded');
|
|
}
|
|
|
|
if (status_code >= 500) {
|
|
// 5xx errors - soft bounce (temporary failure)
|
|
plugin.loginfo('Deferring email - temporary server error');
|
|
return next(constants.denysoft, 'Temporary server error');
|
|
}
|
|
|
|
// Other 4xx errors - hard bounce
|
|
plugin.loginfo('Bouncing email - client error');
|
|
return next(constants.deny, 'Client error');
|
|
};
|