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