Initial commit on Forgejo
Some checks are pending
CI and deploy / Test (push) Waiting to run
CI and deploy / Build, push, and deploy (push) Blocked by required conditions

Fresh repository history for elektrine/elektrine-haraka hosted at
https://git.elektrine.com/elektrine/elektrine-haraka.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:42 -04:00
commit 67c1d41a0c
82 changed files with 8713 additions and 0 deletions

234
lib/mime-parser.js Normal file
View 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
};