Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
304 lines
9.3 KiB
JavaScript
Executable file
304 lines
9.3 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Elektrine async worker.
|
|
*
|
|
* Consumes queued inbound RFC822 messages from Redis, parses them,
|
|
* and delivers normalized payloads to the Phoenix webhook endpoint.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const {
|
|
config,
|
|
http,
|
|
mime,
|
|
spam,
|
|
attachments,
|
|
bounce,
|
|
text,
|
|
domains
|
|
} = require('../lib');
|
|
const queue = require('../lib/queue-client');
|
|
const telemetry = require('../lib/telemetry');
|
|
|
|
const cfg = config.load();
|
|
const logger = telemetry.create_console_logger('worker');
|
|
const queue_client = new queue.QueueClient(cfg, logger);
|
|
|
|
let shutting_down = false;
|
|
const counters = {
|
|
consumed: 0,
|
|
delivered: 0,
|
|
skipped_bounce: 0,
|
|
retried: 0,
|
|
failed: 0,
|
|
dlq: 0
|
|
};
|
|
|
|
function as_header_object(headers) {
|
|
if (!(headers instanceof Map)) {
|
|
return headers;
|
|
}
|
|
|
|
const out = {};
|
|
headers.forEach((value, key) => {
|
|
if (value === undefined || value === null) return;
|
|
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
out[key] = typeof value === 'string' ? text.normalize_header(value) : value;
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
out[key] = value.map((item) => String(item)).join(', ');
|
|
return;
|
|
}
|
|
|
|
if (value && typeof value.text === 'string') {
|
|
out[key] = text.normalize_header(value.text);
|
|
return;
|
|
}
|
|
|
|
out[key] = String(value);
|
|
});
|
|
|
|
return out;
|
|
}
|
|
|
|
function build_spam_context(envelope) {
|
|
const notes = {};
|
|
if (envelope.spamassassin) {
|
|
notes.spamassassin = {
|
|
score: envelope.spamassassin.score,
|
|
reqd: envelope.spamassassin.required,
|
|
flag: envelope.spamassassin.flag,
|
|
tests: envelope.spamassassin.tests
|
|
};
|
|
}
|
|
|
|
return {
|
|
transaction: { notes },
|
|
connection: { notes: {} }
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
function build_webhook_data(envelope, parsed, recipient = null) {
|
|
const from_email = text.normalize_header(parsed.from ? parsed.from.text : envelope.mail_from);
|
|
const subject = text.normalize_header(parsed.subject || '');
|
|
const text_body = cfg.include_body ? (parsed.text || '') : '';
|
|
const html_body = cfg.include_body ? (parsed.html || '') : '';
|
|
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
|
const effective_recipient = text.normalize_header(recipient || local_recipients[0] || (envelope.rcpt_to && envelope.rcpt_to[0]) || '');
|
|
const to_email = text.normalize_header(parsed.to ? parsed.to.text : effective_recipient);
|
|
const rcpt_to = effective_recipient;
|
|
const mail_from = text.normalize_header(envelope.mail_from || '');
|
|
|
|
const spam_context = build_spam_context(envelope);
|
|
const spam_data = spam.extract(spam_context.connection, spam_context.transaction, parsed.headers);
|
|
const attachment_data = attachments.extract(null, parsed, {
|
|
include_content: cfg.include_attachments
|
|
});
|
|
|
|
return {
|
|
message_id: envelope.message_id,
|
|
from: from_email,
|
|
to: to_email,
|
|
rcpt_to: rcpt_to,
|
|
mail_from: mail_from,
|
|
subject,
|
|
text_body,
|
|
html_body,
|
|
headers: cfg.include_headers ? as_header_object(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: envelope.data_bytes,
|
|
timestamp: new Date().toISOString(),
|
|
is_bounce: bounce.is_bounce(from_email, subject, text_body, {
|
|
envelope_from: mail_from
|
|
})
|
|
};
|
|
}
|
|
|
|
async function send_webhook_with_retry(payload) {
|
|
let attempt = 0;
|
|
while (attempt <= cfg.webhook_max_retries) {
|
|
try {
|
|
await http.send_webhook(cfg.webhook_url, payload, {
|
|
api_key: cfg.phoenix_api_key,
|
|
timeout: cfg.webhook_timeout,
|
|
headers: {
|
|
'X-Message-Id': payload.message_id,
|
|
'X-Idempotency-Key': payload.message_id
|
|
},
|
|
logger: (message) => logger.debug('http_client', { message })
|
|
});
|
|
|
|
return;
|
|
} catch (err) {
|
|
const status = err.status || null;
|
|
const is_permanent_4xx = status >= 400 && status < 500 && status !== 429;
|
|
const exhausted = attempt >= cfg.webhook_max_retries;
|
|
|
|
if (is_permanent_4xx || exhausted) {
|
|
throw err;
|
|
}
|
|
|
|
counters.retried += 1;
|
|
const delay_ms = cfg.webhook_retry_base_delay_ms * Math.pow(2, attempt);
|
|
logger.warn('webhook_retry', {
|
|
message_id: payload.message_id,
|
|
attempt: attempt + 1,
|
|
status,
|
|
delay_ms,
|
|
message: err.message
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, delay_ms));
|
|
}
|
|
|
|
attempt += 1;
|
|
}
|
|
}
|
|
|
|
async function push_dlq(envelope, error) {
|
|
const dlq_payload = {
|
|
failed_at: new Date().toISOString(),
|
|
message_id: envelope.message_id,
|
|
error: {
|
|
status: error.status || null,
|
|
message: error.message
|
|
},
|
|
payload: envelope
|
|
};
|
|
|
|
await queue_client.enqueue_dlq(cfg.queue_dlq_name, dlq_payload);
|
|
counters.dlq += 1;
|
|
}
|
|
|
|
async function process_message(raw_element) {
|
|
counters.consumed += 1;
|
|
|
|
let envelope;
|
|
try {
|
|
envelope = JSON.parse(raw_element);
|
|
} catch (err) {
|
|
counters.failed += 1;
|
|
logger.error('invalid_queue_payload', { message: err.message });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const raw_buffer = Buffer.from(envelope.raw_rfc822_base64 || '', 'base64');
|
|
const parsed = await mime.parse_mime(raw_buffer, {
|
|
logger: (level, message) => {
|
|
if (level === 'warn') logger.warn('mime_parse_fallback', { message });
|
|
}
|
|
});
|
|
const local_recipients = get_local_recipients(envelope.rcpt_to);
|
|
const target_recipients = local_recipients.length > 0 ? local_recipients : [null];
|
|
const sample_payload = build_webhook_data(envelope, parsed, target_recipients[0]);
|
|
|
|
if (sample_payload.is_bounce) {
|
|
counters.skipped_bounce += 1;
|
|
logger.info('skip_bounce', {
|
|
message_id: sample_payload.message_id,
|
|
subject: sample_payload.subject
|
|
});
|
|
return;
|
|
}
|
|
|
|
for (const recipient of target_recipients) {
|
|
const webhook_payload = build_webhook_data(envelope, parsed, recipient);
|
|
|
|
await send_webhook_with_retry(webhook_payload);
|
|
counters.delivered += 1;
|
|
logger.info('webhook_delivered', {
|
|
message_id: webhook_payload.message_id,
|
|
rcpt_to: webhook_payload.rcpt_to,
|
|
attachment_count: webhook_payload.attachment_count
|
|
});
|
|
}
|
|
} catch (err) {
|
|
counters.failed += 1;
|
|
logger.error('process_failed', {
|
|
message_id: envelope.message_id,
|
|
status: err.status || null,
|
|
message: err.message
|
|
});
|
|
|
|
try {
|
|
await push_dlq(envelope, err);
|
|
} catch (dlq_err) {
|
|
logger.error('dlq_write_failed', {
|
|
message_id: envelope.message_id,
|
|
message: dlq_err.message
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
if (!cfg.phoenix_api_key || !cfg.webhook_url) {
|
|
logger.error('missing_required_config', {
|
|
has_api_key: Boolean(cfg.phoenix_api_key),
|
|
webhook_url: cfg.webhook_url || null
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
logger.info('worker_start', {
|
|
queue_name: cfg.queue_name,
|
|
dlq_name: cfg.queue_dlq_name,
|
|
redis_url: cfg.redis_url,
|
|
webhook_url: cfg.webhook_url,
|
|
max_retries: cfg.webhook_max_retries
|
|
});
|
|
|
|
setInterval(() => {
|
|
logger.info('worker_stats', { ...counters });
|
|
}, 60000);
|
|
|
|
while (!shutting_down) {
|
|
try {
|
|
const raw_element = await queue_client.pop(cfg.queue_name, cfg.queue_pop_timeout_sec);
|
|
if (!raw_element) continue;
|
|
await process_message(raw_element);
|
|
} catch (err) {
|
|
logger.error('worker_loop_error', { message: err.message });
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
}
|
|
|
|
await queue_client.close();
|
|
logger.info('worker_stop');
|
|
}
|
|
|
|
function start_shutdown(signal) {
|
|
if (shutting_down) return;
|
|
shutting_down = true;
|
|
logger.warn('shutdown_signal', { signal });
|
|
}
|
|
|
|
process.on('SIGTERM', () => start_shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => start_shutdown('SIGINT'));
|
|
|
|
run().catch((err) => {
|
|
logger.error('worker_fatal', { message: err.message, stack: err.stack });
|
|
process.exit(1);
|
|
});
|