elektrine-haraka/lib/bounce-detector.js
Maxfield Luke 67c1d41a0c
Some checks are pending
CI and deploy / Test (push) Waiting to run
CI and deploy / Build, push, and deploy (push) Blocked by required conditions
Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at
https://git.elektrine.com/elektrine/elektrine-haraka.
2026-07-29 04:43:42 -04:00

267 lines
8.3 KiB
JavaScript

/**
* 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
};