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
234
lib/mime-parser.js
Normal file
234
lib/mime-parser.js
Normal 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
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue