/**
* @module previewMail
* @file SusiMail message preview on hover.
* Displays a floating preview popup when hovering over mail subject links
* in the folder list, with caching, timeout handling, and theme-aware styling.
* @author dr|z3d
* @license AGPL3 or later
*/
/**
* Sets up the preview-popup system. Wires up mouseenter/mouseleave
* event delegation on the document, creates the popup element, and
* manages fetch caching for message previews.
* @function previewMail
* @returns {void}
*/
function previewMail() {
const PREVIEW_DELAY_MS = 150;
const PREVIEW_HIDE_DELAY_MS = 250;
const FETCH_TIMEOUT_MS = 3000;
const MAX_PREVIEW_SIZE = 300000;
const previewCache = new Map();
let hoverTimer = null;
let hideTimer = null;
let popup = null;
let currentPreviewUrl = null;
/**
* Returns the preview text for the given URL, using an in-memory cache
* and deduplicating concurrent requests for the same resource.
*
* @async
* @function fetchPreview
* @param {string} showValue - The URL to fetch the mail preview from.
* @param {number} [depth=0] - Current recursion depth (max 1) for iframe/attachment follow.
* @returns {Promise<string>} The cleaned preview text, or empty string on failure.
*/
async function fetchPreview(showValue, depth = 0) {
const cacheKey = `${depth}:${showValue}`;
if (previewCache.has(cacheKey)) {
const cached = previewCache.get(cacheKey);
if (typeof cached === "string") return cached;
if (cached instanceof Promise) {
try {
const result = await cached;
return result || "";
} catch {
previewCache.delete(cacheKey);
return "";
}
}
}
const fetchPromise = _doFetchPreview(showValue, depth);
previewCache.set(cacheKey, fetchPromise);
try {
const result = await fetchPromise;
previewCache.set(cacheKey, result);
return result;
} catch (err) {
previewCache.delete(cacheKey);
return "";
}
}
/**
* Performs the actual fetch and parses the response. Handles binary
* content detection, size limits, iframe/attachment recursion, and
* extracts mailbody paragraph text.
*
* @async
* @function _doFetchPreview
* @param {string} showValue - The URL to fetch.
* @param {number} [depth=0] - Recursion depth; stops at 1.
* @returns {Promise<string>} Preview text or status message string.
*/
async function _doFetchPreview(showValue, depth = 0) {
if (depth > 1) return "";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response;
try {
response = await fetch(showValue, {
credentials: "same-origin",
headers: { Accept: "text/html" },
signal: controller.signal
});
} catch (e) {
clearTimeout(timeoutId);
if (e.name === "AbortError") {
return "Could not preview mail (not downloaded?)";
}
return "Network error loading preview.";
}
clearTimeout(timeoutId);
if (!response.ok) return "";
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (contentType.includes("image/") ||
contentType.includes("application/pdf") ||
contentType.includes("application/zip") ||
contentType.includes("application/x-rar-compressed") ||
contentType.includes("application/x-7z-compressed") ||
contentType.includes("application/octet-stream") ||
contentType.includes("application/msword") ||
contentType.includes("application/vnd.openxmlformats-officedocument")) {
return `Binary file attachment detected: ${contentType.split(";")[0]}.`;
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > MAX_PREVIEW_SIZE) {
return "Preview skipped: message too large.";
}
let text;
try {
text = await response.text();
} catch (e) {
return "Failed to read message content.";
}
if (text.length === 0) return "";
if (text.length > MAX_PREVIEW_SIZE) {
return "Preview skipped: message too large.";
}
await new Promise(resolve => setTimeout(resolve, 0));
const doc = new DOMParser().parseFromString(text, "text/html");
const mailbodyParagraphs = Array.from(doc.querySelectorAll("p.mailbody"))
.map(p => p.textContent.trim())
.filter(t => t.length > 0);
if (mailbodyParagraphs.length > 0) {
return cleanText(mailbodyParagraphs.join("\n\n"));
}
const iframe = doc.querySelector('iframe[src*="?att="][src*="msg="]');
if (iframe?.src) {
const iframeUrl = new URL(iframe.src, window.location.origin).href;
return await fetchPreview(iframeUrl, depth + 1);
}
const attLink = doc.querySelector('a[href*="?att="][href*="msg="]');
if (attLink?.href) {
const attUrl = new URL(attLink.href, window.location.origin).href;
return await fetchPreview(attUrl, depth + 1);
}
return cleanText(doc.body?.textContent || "") || "";
}
/**
* Normalises raw extracted text by collapsing whitespace, removing
* boilerplate "view online" lines, and stripping residual HTML tags.
*
* @function cleanText
* @param {string} raw - Unprocessed text content.
* @returns {string} Cleaned and trimmed text.
*/
function cleanText(raw) {
if (!raw) return "";
raw = raw.replace(/Having[\s\u00A0\u2000-\u200B\u3000]+problems[\s\u00A0\u2000-\u200B\u3000]+viewing[\s\u00A0\u2000-\u200B\u3000]+this[\s\u00A0\u2000-\u200B\u3000]+email\?View[\s\u00A0\u2000-\u200B\u3000]+email[\s\u00A0\u2000-\u200B\u3000]+online/gi, "");
raw = raw.replace(/[\s\u00A0\u2000-\u200B\u3000]+/g, " ");
raw = raw.replace(/,(?=\S)/g, ", ");
raw = raw.replace(/<([a-zA-Z][\w-]*)[^>]*>(.*?)<\/\1>/gi, "$2");
raw = raw.replace(/<([a-zA-Z][\w-]*)\b[^>]*\/>/g, "");
return raw.trim();
}
/**
* Lazily creates the preview popup `<div>` element with theme-aware
* styling and hover-persistence listeners, appending it to the document body.
*
* @function ensurePopup
* @returns {HTMLElement} The popup element.
*/
function ensurePopup() {
if (popup) return popup;
const htmlTag = document.documentElement;
popup = document.createElement("div");
popup.id = "mail-preview-popup";
let popupBg = "#000";
let popupBorder = "#555";
if (
(window.theme && ["light", "classic"].includes(window.theme)) ||
htmlTag.classList.contains("light") ||
htmlTag.classList.contains("classic")
) {
popupBg = "#f2f2f2";
popupBorder = "2px solid #bbc";
}
Object.assign(popup.style, {
padding: "10px 16px",
maxWidth: "70%",
minWidth: "480px",
display: "none",
position: "absolute",
zIndex: "9999",
wordBreak: "break-word",
border: popupBorder,
borderRadius: "2px",
background: popupBg,
boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
});
const inner = document.createElement("div");
inner.className = "mail-preview-content";
inner.textContent = "Loading…";
Object.assign(inner.style, {
padding: "4px 8px",
maxHeight: "300px",
display: "inline-block",
overflow: "auto"
});
popup.appendChild(inner);
document.body.appendChild(popup);
popup.addEventListener("mouseenter", () => {
clearTimeout(hideTimer);
});
popup.addEventListener("mouseleave", () => {
hideTimer = setTimeout(() => hidePopup(), PREVIEW_HIDE_DELAY_MS);
});
return popup;
}
/**
* Positions the popup element relative to the target anchor, preferring
* below but flipping above if space is insufficient, and clamping to
* the viewport width.
*
* @function positionPopup
* @param {HTMLElement} targetEl - The element to position the popup near.
* @returns {void}
*/
function positionPopup(targetEl) {
const rect = targetEl.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
let top;
if (spaceBelow >= popupRect.height + 16) {
top = window.scrollY + rect.bottom + 8;
} else if (spaceAbove >= popupRect.height + 16) {
top = window.scrollY + rect.top - popupRect.height - 8;
} else {
top = window.scrollY + rect.bottom + 8;
}
let left = window.scrollX + rect.left;
if (left + popupRect.width > window.innerWidth) {
left = window.scrollX + Math.max(0, window.innerWidth - popupRect.width - 16);
}
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
}
/**
* Displays the preview popup anchored to the given element, fetches
* the preview content, and updates the popup text once loaded.
*
* @async
* @function showPopup
* @param {HTMLElement} el - The anchor element triggering the preview.
* @param {string} showValue - The URL of the mail to preview.
* @returns {Promise<void>}
*/
async function showPopup(el, showValue) {
const p = ensurePopup();
const content = p.querySelector(".mail-preview-content");
clearTimeout(hideTimer);
clearTimeout(hoverTimer);
content.textContent = "Loading…";
content.classList.add("fetching");
currentPreviewUrl = showValue;
positionPopup(el);
popup.style.display = "block";
const preview = await fetchPreview(showValue);
if (currentPreviewUrl === showValue) {
if (preview) {
content.textContent = preview;
} else {
content.textContent = "No preview available";
}
content.classList.remove("fetching");
}
}
/**
* Hides the preview popup if it exists.
*
* @function hidePopup
* @returns {void}
*/
function hidePopup() {
if (popup) popup.style.display = "none";
}
document.documentElement.addEventListener("mouseenter", (e) => {
const anchor = e.target.closest(".mailListSubject a");
if (!anchor) return;
const showValue = anchor.getAttribute("href");
if (!showValue) return;
clearTimeout(hideTimer);
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => showPopup(anchor, showValue), PREVIEW_DELAY_MS);
}, true);
document.documentElement.addEventListener("mouseleave", (e) => {
const anchor = e.target.closest(".mailListSubject a");
if (!anchor) return;
clearTimeout(hoverTimer);
hideTimer = setTimeout(() => hidePopup(), PREVIEW_HIDE_DELAY_MS);
}, true);
ensurePopup();
}
document.addEventListener("DOMContentLoaded", previewMail);