File: //proc/1/cwd/usr/share/rspamd/www/js/app/libft.js
define(["app/common", "bootstrap", "app/tab-utils", "tabulator"],
(common, bootstrap, tabUtils, Tabulator) => {
"use strict";
const ui = {};
const columnsCustom = JSON.parse(localStorage.getItem("columns")) || {};
// responsive:100 alone only collapses a column when the table already
// overflows the viewport; pairing it with a very large minWidth forces
// the overflow, so the column always renders in the detail row ("Row"
// mode). Same trick the symbols/rcpt_mime columns use by default.
const FORCE_COLLAPSE_MIN_WIDTH = 4000;
let pageSizeTimerId = null;
// Per-selector cleanups for the delegated fuzzy-hash button handlers,
// so rebinding (on every scan/history render) removes the previous
// listener. Keys are selectors embedding the table name, which assumes
// table names are unique (two tables cannot share a DOM id anyway).
const fuzzyCleanups = new Map();
// bindHistoryTableEventHandlers binds to static elements that survive
// table rebuilds, so it must run at most once per table — a repeat call
// would stack change/click listeners. (The original .unbind() only
// deduped selSymOrder; this guard covers all three bindings.)
const boundSymOrderTables = new Set();
function get_compare_function(table) {
const compare_functions = {
magnitude: function (e1, e2) {
return Math.abs(e2.score) - Math.abs(e1.score);
},
name: function (e1, e2) {
return e1.name.localeCompare(e2.name);
},
score: function (e1, e2) {
return e2.score - e1.score;
}
};
return compare_functions[common.getSelector("selSymOrder_" + table)];
}
function sort_symbols(o, compare_function) {
return Object.keys(o)
.map((key) => o[key])
.sort(compare_function)
.map((e) => e.str)
.join("<br>\n");
}
// Highlight the active symbol-order button for a table. The buttons are
// part of the symbols column title, which is rendered inside each row's
// collapsed detail, so they are recreated on every render — call this
// after each build/render, not at handler bind time.
function setActiveSymOrderButton(table, order) {
const active = order || common.getSelector("selSymOrder_" + table);
if (!active) return;
// The buttons are rendered inside each row's collapsed detail, so
// there are many instances — set active on all of them.
document.querySelectorAll(".btn-sym-" + table + "-" + active).forEach((btn) => {
btn.classList.add("active");
Array.from(btn.parentElement.children).forEach((sib) => {
if (sib !== btn) sib.classList.remove("active");
});
});
}
function ipSorter(a, b) {
function norm(ip) {
return (typeof ip === "string" ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0");
}
return norm(a).localeCompare(norm(b));
}
// ── Action filter for history/scan tables ─────────────────────────────
const actionValues = ["reject", "add header", "greylist", "no action", "soft reject", "rewrite subject"];
function actionHeaderFilter(cell, onRendered, success) {
const container = document.createElement("div");
container.style.display = "flex";
container.style.gap = "4px";
container.style.alignItems = "center";
const select = document.createElement("select");
select.className = "form-select form-select-sm";
select.appendChild(new Option("Any action", ""));
actionValues.forEach((a) => select.appendChild(new Option(a, a)));
const notLabel = document.createElement("label");
notLabel.style.whiteSpace = "nowrap";
notLabel.title = "Invert action match";
const not = document.createElement("input");
not.type = "checkbox";
notLabel.append(not, " not");
container.append(notLabel, select);
function onChange() {
if (!select.value) {
success(false);
} else {
success({action: select.value, not: not.checked});
}
}
select.addEventListener("change", onChange);
not.addEventListener("change", onChange);
return container;
}
function actionFilterFunc(filterVal, cellVal) {
if (!filterVal || !filterVal.action) return true;
const match = cellVal === filterVal.action;
return filterVal.not ? !match : match;
}
// ── Column formatters ────────────────────────────────────────────────
function actionFormatter(cell) {
const action = cell.getValue();
const cls = {
"clean": "success",
"no action": "success",
"rewrite subject": "warning",
"add header": "warning",
"probable spam": "warning",
"spam": "danger",
"reject": "danger"
}[action] || "info";
return `<div style="font-size:11px" class="badge text-bg-${cls}">${action}</div>`;
}
function scoreFormatter(cell) {
const data = cell.getData();
const score = cell.getValue();
const required = data.required_score;
const cls = score < required ? "text-success" : "text-danger";
return `<span class="${cls}">${score.toFixed(2)} / ${required}</span>`;
}
function symOrderTitle(table) {
return "Symbols" +
'<div class="sym-order-toggle">' +
'<br><span style="font-weight:normal;">Sort by:</span><br>' +
'<div class="btn-group btn-group-xs btn-sym-order-' + table + '">' +
'<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-magnitude">' +
'<input type="radio" class="btn-check" value="magnitude">Magnitude</label>' +
'<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-score">' +
'<input type="radio" class="btn-check" value="score">Value</label>' +
'<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-name">' +
'<input type="radio" class="btn-check" value="name">Name</label>' +
"</div>" +
"</div>";
}
// ── Public functions ─────────────────────────────────────────────────
ui.formatBytesIEC = function (bytes) {
if (!Number.isInteger(Number(bytes)) || bytes < 0) return "NaN";
const base = 1024;
const exponent = Math.floor(Math.log(bytes) / Math.log(base));
if (exponent > 8) return "∞";
const value = parseFloat((bytes / (base ** exponent)).toPrecision(3));
let unit = "BKMGTPEZY"[exponent];
if (exponent) unit += "iB";
return value + " " + unit;
};
// ── Global (boolean) query filter ─────────────────────────────────
// A single search box per table driving a Tabulator setFilter(). The
// query language:
// `foo bar` → foo AND bar (whitespace is AND)
// `foo OR bar` → foo OR bar
// `foo -bar` → foo AND NOT bar (leading "-" negates)
// `"soft reject"` → exact contiguous phrase
// `foo bar OR baz` → (foo AND bar) OR baz
// Matching is case-insensitive substring. Quotes are honoured when
// splitting on AND/OR, so a phrase may itself contain those words.
// The setFilter() predicate ANDs with the per-column header filters.
const SEARCH_FIELDS = ["id", "ip", "sender_mime", "rcpt_mime", "rcpt_mime_short",
"subject", "user", "passthrough_module", "file", "action"];
// Haystack cache keyed by row data object (see buildSearchHaystack).
const haystackCache = new WeakMap();
function decodeEntities(str) {
// Values are HTML-escaped upstream (preprocess_item); decode so a
// literal "&" or "<" typed in the box still matches. & last to
// avoid double-decoding entities like &lt;.
return str.replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
}
// The visible To/Cc/Bcc column shows a truncated recipient list
// (rcpt_mime_short); match against the full rcpt_mime so recipients
// hidden behind "… (N)" are still found. (scan rows keep it as an array.)
function rcptFilterFunc(filterVal, _cellVal, rowData) {
if (!filterVal) return true;
const raw = rowData.rcpt_mime;
const hay = Array.isArray(raw) ? raw.join(", ") : (raw || "");
return decodeEntities(hay).toLowerCase().includes(filterVal.toLowerCase());
}
// Compile a query string into a {test(haystack)} predicate, or null when
// the query is blank (meaning "no filter"). Tokenize keeping quoted
// phrases intact; "AND"/"OR" are operators; a leading "-" negates the
// following term or phrase.
function compileFilterQuery(input) {
if (!input || !input.trim()) return null;
const tokens = input.match(/"[^"]*"|\S+/g) || [];
const groups = [[]]; // OR of AND-groups
tokens.forEach((token) => {
if (token.toUpperCase() === "OR") {
groups.push([]);
return;
}
if (token.toUpperCase() === "AND") return;
let term = token;
const negate = term.charAt(0) === "-";
if (negate) term = term.slice(1);
const quoted = term.length >= 2 && term.charAt(0) === '"' && term.slice(-1) === '"';
if (quoted) term = term.slice(1, -1);
if (!term) return;
groups[groups.length - 1].push({q: term.toLowerCase(), negate});
});
// Drop groups left empty by dangling/leading/doubled operators
// (e.g. "foo OR", "OR foo", "foo OR OR bar") so they don't match all
// rows via [].every() vacuous truth.
const orGroups = groups.filter((g) => g.length);
if (!orGroups.length) return null;
return {
test(haystack) {
return orGroups.some((group) => group.every((t) => {
const found = haystack.indexOf(t.q) !== -1;
return t.negate ? !found : found;
}));
}
};
}
// Lowercased searchable text for a row. Numeric/formatted columns (time,
// time_real, size, score) are excluded — their raw values are useless for
// free-text search, and the per-column header filters cover them. Memoized
// in a WeakMap keyed by the data object so it is built once per row, not
// on every keystroke; entries are GC'd when the row data is replaced.
function buildSearchHaystack(data) {
if (haystackCache.has(data)) return haystackCache.get(data);
let s = SEARCH_FIELDS.map((f) => (typeof data[f] === "string" ? data[f] : "")).join(" ");
if (data.symbols_obj) {
s += " " + Object.values(data.symbols_obj)
.map((sym) => sym.name + " " + (sym.description || ""))
.join(" ");
}
s = decodeEntities(s).toLowerCase();
haystackCache.set(data, s);
return s;
}
// Apply the current filter box value to the table, or clear it. Reads the
// Tabulator instance lazily so it works across a destroy+re-init.
function applyGlobalFilter(table) {
const tab = common.tables[table];
if (!tab) return;
const input = document.getElementById("filter_" + table);
const compiled = compileFilterQuery(input ? input.value : null);
if (!compiled) {
tab.clearFilter();
return;
}
tab.setFilter((data) => compiled.test(buildSearchHaystack(data)));
}
// Bind the filter box: live, debounced. Bound once per table; the handler
// resolves the current instance at fire time, so it survives rebuilds.
function bindGlobalFilter(table) {
const input = document.getElementById("filter_" + table);
if (!input) return;
input.title = "Search syntax: match all rows containing\n\n" +
'"exact phrase" — exact string (including spaces)\n' +
"term1 OR term2 — either term\n" +
"term1 AND term2 — both terms\n" +
"term1 term2 — both terms (same as AND)\n" +
"term1 -term2 — term1 but exclude rows with term2";
let timer = null;
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => applyGlobalFilter(table), 250);
});
}
ui.columns_v2 = function (table) {
const cols = [{
// Toggle to expand collapsed (responsive) columns
formatter: "responsiveCollapse",
width: 23,
minWidth: 23,
responsive: 0,
hozAlign: "center",
resizable: false,
headerSort: false,
}, {
title: "ID",
field: "id",
headerFilter: "input",
responsive: 0,
minWidth: 130,
widthGrow: 2,
}, {
title: "File name",
field: "file",
headerFilter: "input",
responsive: 1,
minWidth: 260,
widthGrow: 4,
}, {
title: "IP address",
field: "ip",
headerFilter: "input",
responsive: 3,
minWidth: 98,
width: 98,
sorter: ipSorter,
}, {
title: "[Envelope From] From",
field: "sender_mime",
headerFilter: "input",
responsive: 3,
minWidth: 100,
maxWidth: 200,
}, {
title: "[Envelope To] To/Cc/Bcc",
field: "rcpt_mime_short",
responsive: 3,
headerFilter: "input",
headerFilterFunc: rcptFilterFunc,
minWidth: 100,
maxWidth: 200,
}, {
title: "[Envelope To] To/Cc/Bcc",
field: "rcpt_mime",
responsive: 100,
minWidth: FORCE_COLLAPSE_MIN_WIDTH,
}, {
title: "Subject",
field: "subject",
headerFilter: "input",
responsive: 3,
minWidth: 150,
widthGrow: 2,
}, {
title: "Action",
field: "action",
responsive: 0,
minWidth: 108,
width: 108,
formatter: actionFormatter,
headerFilter: actionHeaderFilter,
headerFilterFunc: actionFilterFunc,
}, {
title: '<div title="The module that has set the pre-result"><nobr>Pass-through</nobr> module</div>',
field: "passthrough_module",
headerFilter: "input",
minWidth: 98,
width: 98,
}, {
title: "Score",
field: "score",
responsive: 0,
sorter: "number",
minWidth: 64,
width: 64,
formatter: scoreFormatter,
}, {
title: symOrderTitle(table),
field: "symbols",
formatter: "html",
headerSort: false,
// Highest responsive priority, so symbols collapses first. The
// large minWidth forces overflow at any realistic width, so the
// column always renders in the detail row (Tabulator has no native
// "always collapse" mode).
responsive: 100,
minWidth: FORCE_COLLAPSE_MIN_WIDTH,
}, {
title: "Msg size",
field: "size",
responsive: 3,
sorter: "number",
formatter: (cell) => ui.formatBytesIEC(cell.getValue()),
minWidth: 56,
width: 56,
}, {
title: "Scan time",
field: "time_real",
responsive: 3,
sorter: "number",
minWidth: 60,
width: 60,
}, {
title: "Time",
field: "time",
responsive: 0,
sorter: "number",
formatter: (cell) => ui.unix_time_format(cell.getValue()),
minWidth: 72,
width: 72,
}, {
title: "Authenticated user",
field: "user",
headerFilter: "input",
responsive: 3,
minWidth: 100,
maxWidth: 130,
}];
return cols.filter((col) => {
if (!col.field) return true; // Toggle column
switch (table) {
case "history":
return (col.field !== "file");
case "scan":
return ["ip", "sender_mime", "rcpt_mime_short", "rcpt_mime", "subject", "size", "user"]
.every((name) => col.field !== name);
default:
return null;
}
});
};
ui.set_page_size = function (table, page_size, changeTablePageSize) {
const n = parseInt(page_size, 10);
if (n > 0) {
common.page_size[table] = n;
if (changeTablePageSize && common.tables[table]) {
clearTimeout(pageSizeTimerId);
pageSizeTimerId = setTimeout(() => {
common.tables[table]?.setPageSize(n);
}, 1000);
}
}
};
ui.bindHistoryTableEventHandlers = function (table) {
if (boundSymOrderTables.has(table)) return;
boundSymOrderTables.add(table);
function change_symbols_order(order) {
const compare_function = get_compare_function(table);
common.tables[table].getRows().forEach((row) => {
const cell_val = sort_symbols(row.getData().symbols_obj, compare_function);
// row.update (not cell.setValue) so the responsive-collapse
// detail row is regenerated; setValue only re-renders the
// hidden cell element and never refreshes the collapsed view.
row.update({symbols: cell_val});
});
// row.update recreates each detail's buttons (without active);
// re-apply the active state to the fresh elements.
setActiveSymOrderButton(table, order);
}
document.getElementById("selSymOrder_" + table).addEventListener("change", function () {
change_symbols_order(this.value);
});
document.getElementById(table + "_page_size")
.addEventListener("change", (e) => ui.set_page_size(table, e.target.value, true));
common.delegate(document, "click", ".btn-sym-order-" + table + " input", (event, target) => {
const order = target.value;
document.getElementById("selSymOrder_" + table).value = order;
change_symbols_order(order);
});
bindGlobalFilter(table);
};
ui.destroyTable = function (table) {
const openColumnsBtn = document.querySelector("#" + table + " .tab-columns-btn.show");
if (openColumnsBtn) {
bootstrap.Dropdown.getOrCreateInstance(openColumnsBtn).hide();
}
document.querySelectorAll("#" + table + " .tab-columns-btn").forEach((el) => { el.disabled = true; });
if (common.tables[table]) {
common.tables[table].destroy();
delete common.tables[table];
}
};
ui.initHistoryTable = function (data, items, table, columnsDefault, expandFirst, postdrawCallback) {
// A persisted "Row" column is stored as responsive:100; it must also
// receive the force-collapse minWidth so it renders in the detail row.
const baseColumns = (table in columnsCustom)
? columnsDefault.map((column) => ({...column, ...(columnsCustom[table][column.field] || {})}))
: columnsDefault.map((column) => column);
const columns = baseColumns.map((column) => (column.responsive === 100
? {...column, minWidth: FORCE_COLLAPSE_MIN_WIDTH}
: column));
common.tables[table] = new Tabulator("#historyTable_" + table, {
layout: "fitColumns",
responsiveLayout: "collapse",
responsiveLayoutCollapseStartOpen: expandFirst,
// Cell values are HTML-escaped upstream (preprocess_item); render
// them as HTML so entities decode. The default "plaintext"
// formatter re-escapes and would show "/& literally.
// Explicit column formatters override this.
columnDefaults: {formatter: "html"},
selectable: false,
pagination: "local",
paginationSize: common.page_size[table],
paginationButtonCount: 5,
data: items,
initialSort: [{column: "time", dir: "desc"}],
columns: columns,
});
tabUtils.hideFooterOnSinglePage(table);
tabUtils.stripTableholderTabindex(table);
tabUtils.bindRowClickToggle(table);
tabUtils.patchScrollIntoViewOnce();
tabUtils.installScrollPreservation(table);
common.tables[table].on("tableBuilt", () => {
setActiveSymOrderButton(table);
// Re-apply a global filter in place when the table was rebuilt
// (e.g. via the column-options dropdown): a fresh Tabulator
// instance starts unfiltered, but the search box keeps its value.
const filterBox = document.getElementById("filter_" + table);
if (filterBox && filterBox.value.trim()) applyGlobalFilter(table);
});
if (postdrawCallback) common.tables[table].on("renderComplete", postdrawCallback);
// The "Sort by:" buttons are rendered inside each row's collapsed
// detail (via the column title), so they are recreated on every
// render — re-apply the active state each time.
common.tables[table].on("renderComplete", () => setActiveSymOrderButton(table));
// Column options dropdown
(() => {
// Changes the ResponsiveLayout module owns — "Row" toggles and
// visibility on always-collapsed (responsive:100) columns — can't be
// applied incrementally, so they're deferred to dropdown close / Save.
// This keeps the dropdown open while toggling several at once.
let rebuildPending = false;
// Column visibility and the emulated "Row" mode (responsive:100) both
// depend on ResponsiveLayout's collapse pointer, which is set once at
// construction and never reconciled after a runtime definition change —
// the root cause of the erratic collapse/expand behaviour (columns that
// expand but never collapse back, neighbours collapsing in sympathy).
// Rather than poke at the module's private state, rebuild the whole
// table: a fresh instance converges to the correct collapse set, exactly
// as on first load. The current data is preserved; the initial-render
// callback is skipped (buttons/fuzzy are already wired and delegated).
function rebuild() {
const rows = common.tables[table].getData();
ui.destroyTable(table);
ui.initHistoryTable(data, rows, table, columnsDefault, expandFirst);
}
// Apply deferred "Row" changes (if any) and clear the dirty flag.
function applyPendingRebuild() {
if (!rebuildPending) return;
rebuildPending = false;
rebuild();
}
const tbody = common.el("tbody", {class: "table-group-divider"});
const dropdown = document.querySelector("#" + table + " .tab-columns-dropdown");
dropdown.replaceChildren(
common.el("table", {class: "table table-sm table-striped text-center"},
common.el("thead", null,
common.el("tr", null,
common.el("th", {text: "Row", title: "Display column cells in a detail row"}),
common.el("th", {text: "Hidden", title: "Hide column completely"}),
common.el("th", {text: "Column name", class: "text-start"})
)
),
tbody
),
common.el("button", {
type: "button",
class: "btn btn-xs btn-secondary float-start",
text: "Reset to default",
click: () => {
const custom = columnsCustom[table] || {};
// A reset needs a rebuild whenever the live table is in a
// state ResponsiveLayout owns: a "Row" override, or a hidden
// always-collapsed (responsive:100) column. Plain hidden
// columns restore via showColumn with no rebuild.
const needsRebuild = rebuildPending || columnsDefault.some((c) => {
if (!c.field) return false;
const cfg = custom[c.field];
return cfg && ((cfg.responsive === 100 && c.responsive !== 100) ||
(c.responsive === 100 && cfg.visible === false));
});
// Only plain (non-responsive:100) hidden columns can be
// restored without a rebuild; responsive ones are rebuilt.
const hiddenFields = columnsDefault
.filter((c) => c.field && c.responsive !== 100 &&
custom[c.field]?.visible === false)
.map((c) => c.field);
delete columnsCustom[table];
localStorage.setItem("columns", JSON.stringify(columnsCustom));
// Clear before rebuild: the rebuild closes this open dropdown,
// whose close handler (applyPendingRebuild) must see nothing
// pending — otherwise it re-inits again.
rebuildPending = false;
if (needsRebuild) {
rebuild();
} else {
const tab = common.tables[table];
hiddenFields.forEach((f) => {
tab.showColumn(f);
const cb = tbody.querySelector('input[data-name="' + f + '"][data-option="visible"]');
if (cb) cb.checked = false;
});
}
}
}),
common.el("button", {
type: "button",
class: "btn btn-xs btn-primary float-end btn-dropdown-apply",
text: "Save",
title: "Save column settings to browser storage",
click: () => {
localStorage.setItem("columns", JSON.stringify(columnsCustom));
applyPendingRebuild();
}
})
);
function columnLabel(column) {
switch (column.field) {
case "passthrough_module": return "Pass-through module";
case "symbols": return "Symbols";
default: return (column.title || "").replace(/<[^>]*>/g, "");
}
}
function checkbox(i, column, cellIdx) {
const option = ["responsive", "visible"][cellIdx];
const isRow = option === "responsive";
return common.el("td", null,
common.el("input", {
type: "checkbox",
class: "form-check-input",
dataset: {table, name: column.field, option},
checked: (isRow && column.responsive === 100) ||
(!isRow && column.visible === false),
disabled: isRow && columnsDefault[i].responsive === 100,
change: (e) => {
const {checked} = e.target;
columnsCustom[table] = columnsCustom[table] || {};
columnsCustom[table][column.field] = columnsCustom[table][column.field] || {};
// Columns currently in "Row" mode (responsive:100) are owned by
// ResponsiveLayout: hiding them no-ops and showing them pops them
// out of the detail row, because the module can't reconcile at
// runtime. So both "Row" and visibility changes on such columns
// are deferred to a rebuild; plain columns toggle in place.
if (isRow || column.responsive === 100) {
if (isRow) {
if (checked) {
columnsCustom[table][column.field].responsive = 100;
} else {
delete columnsCustom[table][column.field].responsive;
}
} else {
columnsCustom[table][column.field].visible = !checked;
}
rebuildPending = true;
} else {
columnsCustom[table][column.field].visible = !checked;
const tab = common.tables[table];
if (checked) tab.hideColumn(column.field); else tab.showColumn(column.field);
}
}
})
);
}
columns.forEach((column, i) => {
if (!column.field) return; // responsiveCollapse toggle is a control, not a column
tbody.append(
common.el("tr", null,
checkbox(i, column, 0),
checkbox(i, column, 1),
common.el("td", {class: "text-start", text: columnLabel(column)})
)
);
});
// Apply deferred "Row" changes when the dropdown closes. The trigger
// element survives rebuilds, so track the handler and re-bind it each
// build to avoid stacking.
const columnsBtn = document.querySelector("#" + table + " .tab-columns-btn");
const prevHandler = common.data(columnsBtn, "dropdownHandler");
if (prevHandler) columnsBtn.removeEventListener("hidden.bs.dropdown", prevHandler);
columnsBtn.addEventListener("hidden.bs.dropdown", applyPendingRebuild);
common.data(columnsBtn, "dropdownHandler", applyPendingRebuild);
columnsBtn.disabled = false;
})();
};
ui.preprocess_item = function (item) {
function escape_HTML_array(arr) {
arr.forEach((d, i) => { arr[i] = common.escapeHTML(d); });
}
for (const prop in item) {
if (!{}.hasOwnProperty.call(item, prop)) continue;
switch (prop) {
case "rcpt_mime":
case "rcpt_smtp":
escape_HTML_array(item[prop]);
break;
case "symbols":
Object.keys(item.symbols).forEach((key) => {
const sym = item.symbols[key];
if (!sym.name) {
sym.name = key;
}
sym.name = common.escapeHTML(sym.name);
if (sym.description) {
sym.description = common.escapeHTML(sym.description);
}
if (sym.options) {
escape_HTML_array(sym.options);
}
});
break;
default:
if (typeof item[prop] === "string") item[prop] = common.escapeHTML(item[prop]);
}
}
};
ui.unix_time_format = function (tm) {
const date = new Date(tm ? tm * 1000 : 0);
return (common.locale)
? date.toLocaleString(common.locale)
: date.toLocaleString();
};
function isFuzzySymbol(sym) {
if (!sym.options) return false;
return sym.options.some((opt) => (/^\d+:[a-f0-9]+:[\d.]+:/).test(opt));
}
function attachFuzzyIndices(sym, fuzzyHashesArray, fuzzyHashIndex) {
sym.fuzzyHashIndices = [];
if (!fuzzyHashesArray || Object.keys(fuzzyHashIndex).length === 0) return;
const foundIndices = new Set();
sym.options.forEach((opt) => {
const match = opt.match(/^\d+:([a-f0-9]+):[\d.]+:/);
if (match) {
const [,shortHash] = match;
const indices = fuzzyHashIndex[shortHash];
if (Array.isArray(indices)) indices.forEach((i) => foundIndices.add(i));
}
});
sym.fuzzyHashIndices = Array.from(foundIndices).sort((a, b) => a - b);
}
function generateFuzzySearchData(sym, fuzzyHashesArray) {
if (!sym.fuzzyHashIndices?.length) return "";
const fullHashes = sym.fuzzyHashIndices
.filter((i) => i >= 0 && i < fuzzyHashesArray.length)
.map((i) => fuzzyHashesArray[i]);
return `<span class="visually-hidden">${common.escapeHTML(fullHashes.join(" "))}</span>`;
}
function generateFuzzyActions(sym, table, item) {
const hasHashes = sym.fuzzyHashIndices?.length > 0;
// eslint-disable-next-line init-declarations
let copyTitle, delistTitle;
if (hasHashes) {
copyTitle = "Copy full hashes to clipboard";
delistTitle = "Open bl.rspamd.com delisting page";
} else if (table === "history") {
copyTitle = "Full fuzzy hashes are not available for this message";
delistTitle = copyTitle;
} else {
copyTitle = "Full fuzzy hashes are not available. Enable milter_headers module with 'fuzzy-hashes' routine";
delistTitle = copyTitle;
}
function makeButton(cssClass, action, icon, label, title) {
const dataAttrs = hasHashes
? `data-indices='${common.escapeHTML(JSON.stringify(sym.fuzzyHashIndices))}' ` +
`data-hashes='${common.escapeHTML(JSON.stringify(item.fuzzy_hashes))}' data-table="${table}"`
: `data-table="${table}"`;
const disabled = hasHashes ? "" : " disabled";
const button = `<button class="btn btn-xs ${cssClass} ${action}${disabled}" ${dataAttrs}${disabled} ` +
`title="${title}"><i class="fas ${icon}"></i> ${label}</button>`;
return hasHashes ? button : `<span title="${title}">${button}</span>`;
}
const copyBtn = makeButton("btn-outline-secondary", "fuzzy-copy", "fa-copy", "Copy", copyTitle);
const delistBtn = makeButton("btn-outline-primary", "fuzzy-delist", "fa-external-link", "Delist", delistTitle);
return `<span class="fuzzy-hash-actions d-inline-flex gap-1 ms-1 align-baseline">${copyBtn}${delistBtn}</span>`;
}
ui.process_history_v2 = function (data, table) {
// Display no more than rcpt_lim recipients
const rcpt_lim = 3;
const items = [];
const unsorted_symbols = [];
const compare_function = get_compare_function(table);
common.show("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "']");
data.rows.forEach(
(item) => {
function more(p) {
const l = item[p].length;
return (l > rcpt_lim) ? " … (" + l + ")" : "";
}
function format_rcpt(smtp, mime) {
let full = "";
let shrt = "";
if (smtp) {
full = "[" + item.rcpt_smtp.join(", ") + "] ";
shrt = "[" + item.rcpt_smtp.slice(0, rcpt_lim).join(",​") + more("rcpt_smtp") + "]";
if (mime) {
full += " ";
shrt += " ";
}
}
if (mime) {
full += item.rcpt_mime.join(", ");
shrt += item.rcpt_mime.slice(0, rcpt_lim).join(",​") + more("rcpt_mime");
}
return {full: full, shrt: shrt};
}
function get_symbol_class(name, score) {
if (name.match(/^GREYLIST$/)) {
return "symbol-special";
}
if (score < 0) {
return "symbol-negative";
} else if (score > 0) {
return "symbol-positive";
}
return null;
}
ui.preprocess_item(item);
// Build fuzzy hash index for this item
const fuzzyHashIndex = {};
if (Array.isArray(item.fuzzy_hashes)) {
item.fuzzy_hashes.forEach((fullHash, idx) => {
const shortHash = fullHash.substring(0, 10);
if (!fuzzyHashIndex[shortHash]) fuzzyHashIndex[shortHash] = [];
fuzzyHashIndex[shortHash].push(idx);
});
}
Object.values(item.symbols).forEach((sym) => {
sym.str = `
<span class="symbol-default ${get_symbol_class(sym.name, sym.score)} ${sym.description ? "has-description" : ""}" tabindex="0">
<strong>${sym.name}</strong>
${sym.description ? `<span class="symbol-description"> • ${sym.description}</span>` : ""}
(${sym.score})
</span>`;
if (sym.options) {
sym.str += ` [${sym.options.join(",")}]`;
if (isFuzzySymbol(sym)) {
attachFuzzyIndices(sym, item.fuzzy_hashes, fuzzyHashIndex);
sym.str += generateFuzzySearchData(sym, item.fuzzy_hashes);
sym.str += generateFuzzyActions(sym, table, item);
}
}
});
unsorted_symbols.push(item.symbols);
item.symbols_obj = item.symbols;
item.symbols = sort_symbols(item.symbols, compare_function);
if (table === "scan") {
item.unix_time = (new Date()).getTime() / 1000;
}
item.time = item.unix_time;
item.time_real = item.time_real.toFixed(3);
item.id = item["message-id"];
if (table === "history") {
// eslint-disable-next-line no-useless-assignment
let rcpt = {};
if (!item.rcpt_mime.length) {
rcpt = format_rcpt(true, false);
} else if (
item.rcpt_mime.some((x) => !item.rcpt_smtp.includes(x)) ||
item.rcpt_smtp.some((x) => !item.rcpt_mime.includes(x))
) {
rcpt = format_rcpt(true, true);
} else {
rcpt = format_rcpt(false, true);
}
item.rcpt_mime_short = rcpt.shrt;
item.rcpt_mime = rcpt.full;
if (item.sender_mime !== item.sender_smtp) {
item.sender_mime = "[" + item.sender_smtp + "] " + item.sender_mime;
}
}
items.push(item);
});
return {items: items, symbols: unsorted_symbols};
};
ui.bindFuzzyHashButtons = function (table) {
function bindAction(action, handler) {
const selector = `.fuzzy-${action}[data-table="${table}"]:not(:disabled)`;
const prevCleanup = fuzzyCleanups.get(selector);
if (prevCleanup) prevCleanup();
fuzzyCleanups.set(selector, common.delegate(document, "click", selector, (event, target) => {
event.preventDefault();
event.stopPropagation();
// eslint-disable-next-line init-declarations
let hashes, indices;
try {
indices = JSON.parse(target.getAttribute("data-indices") || "[]");
hashes = JSON.parse(target.getAttribute("data-hashes") || "[]");
} catch (err) {
common.alertMessage("alert-danger", "Invalid hash data: " + err.message);
return;
}
if (indices.length === 0 || hashes.length === 0) {
common.alertMessage("alert-warning", "No full hashes available");
return;
}
const fullHashes = [...new Set(indices.map((i) => hashes[i]))];
handler(target, fullHashes);
}));
}
bindAction("copy", (btn, fullHashes) => {
const textToCopy = fullHashes.join("\n");
common.copyToClipboard(textToCopy)
.then(() => {
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = originalHtml; }, 2000);
})
.catch((err) => {
common.alertMessage("alert-danger", "Copy failed: " + err.message);
});
});
bindAction("delist", (_btn, fullHashes) => {
const url = "https://bl.rspamd.com/removal?type=fuzzy&hash=" + encodeURIComponent(fullHashes.join(","));
window.open(url, "_blank");
});
};
return ui;
});