const dropZone = document.getElementById("dropZone"); const fieldList = document.getElementById("fieldList"); const textBody = document.getElementById("textBody"); const htmlBody = document.getElementById("htmlBody"); const rawPreview = document.getElementById("rawPreview"); const FIELDS = [ "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Date", "Message-ID", "Content-Type", ]; ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); event.stopPropagation(); dropZone.classList.add("active"); }); }); ["dragleave", "drop"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); event.stopPropagation(); dropZone.classList.remove("active"); }); }); dropZone.addEventListener("drop", async (event) => { const file = event.dataTransfer?.files?.[0]; if (!file) { renderError("No file was dropped."); return; } if (!file.name.toLowerCase().endsWith(".eml")) { renderError("Please drop a .eml file from Thunderbird."); return; } try { const messageText = await file.text(); const parsed = parseEml(messageText); renderParsedEmail(parsed, messageText); } catch (error) { const message = error instanceof Error ? error.message : String(error); renderError(`Failed to parse email: ${message}`); } }); function parseEml(messageText) { const normalized = messageText.replace(/\r\n/g, "\n"); const splitIndex = normalized.indexOf("\n\n"); const headerBlock = splitIndex >= 0 ? normalized.slice(0, splitIndex) : normalized; const bodyBlock = splitIndex >= 0 ? normalized.slice(splitIndex + 2) : ""; const headers = parseHeaders(headerBlock); const contentType = headers.get("content-type") || ""; const bodyParts = extractBodiesFromMime(bodyBlock, contentType); return { headers, bodyParts }; } function parseHeaders(headerBlock) { const map = new Map(); const lines = headerBlock.split("\n"); let currentName = ""; let currentValue = ""; const commitHeader = () => { if (!currentName) return; map.set(currentName.toLowerCase(), currentValue.trim()); }; for (const line of lines) { if (/^\s/.test(line) && currentName) { currentValue += ` ${line.trim()}`; continue; } commitHeader(); const separatorIndex = line.indexOf(":"); if (separatorIndex < 0) { currentName = ""; currentValue = ""; continue; } currentName = line.slice(0, separatorIndex).trim(); currentValue = line.slice(separatorIndex + 1).trim(); } commitHeader(); return map; } function extractBodiesFromMime(bodyBlock, contentTypeHeader) { const result = { text: "", html: "" }; if (!contentTypeHeader.includes("multipart/")) { result.text = decodeBodyByEncoding(bodyBlock, ""); return result; } const boundaryMatch = contentTypeHeader.match(/boundary="?([^";]+)"?/i); if (!boundaryMatch) { result.text = decodeBodyByEncoding(bodyBlock, ""); return result; } const boundary = boundaryMatch[1]; const boundaryToken = `--${boundary}`; const segments = bodyBlock.split(boundaryToken); for (const segment of segments) { const trimmed = segment.trim(); if (!trimmed || trimmed === "--") continue; const normalized = trimmed.replace(/^\n+/, ""); const splitIndex = normalized.indexOf("\n\n"); if (splitIndex < 0) continue; const rawPartHeaders = normalized.slice(0, splitIndex); const rawPartBody = normalized.slice(splitIndex + 2); const partHeaders = parseHeaders(rawPartHeaders); const partType = (partHeaders.get("content-type") || "").toLowerCase(); const transferEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase(); const decodedPartBody = decodeBodyByEncoding(rawPartBody, transferEncoding); if (!result.text && partType.includes("text/plain")) { result.text = decodedPartBody; } else if (!result.html && partType.includes("text/html")) { result.html = decodedPartBody; } } if (!result.text) { result.text = decodeBodyByEncoding(bodyBlock, ""); } return result; } function decodeBodyByEncoding(body, transferEncoding) { const normalized = body.replace(/\r\n/g, "\n").trim(); if (transferEncoding.includes("base64")) { try { return atob(normalized.replace(/\s+/g, "")); } catch { return normalized; } } if (transferEncoding.includes("quoted-printable")) { return decodeQuotedPrintable(normalized); } return normalized; } function decodeQuotedPrintable(input) { const softBreaksRemoved = input.replace(/=\n/g, ""); return softBreaksRemoved.replace(/=([A-Fa-f0-9]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)), ); } function renderParsedEmail(parsed, messageText) { fieldList.innerHTML = ""; for (const field of FIELDS) { const value = parsed.headers.get(field.toLowerCase()) || "(not present)"; appendField(field, value); } setBoxContent(textBody, parsed.bodyParts.text || "No text body found."); setBoxContent(htmlBody, parsed.bodyParts.html || "No HTML body found."); setBoxContent(rawPreview, messageText.slice(0, 12000)); } function appendField(label, value) { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value; fieldList.append(dt, dd); } function setBoxContent(element, text) { element.textContent = text; element.classList.toggle("empty", !text || text.startsWith("No ")); } function renderError(message) { fieldList.innerHTML = ""; appendField("Error", message); setBoxContent(textBody, "No text body parsed yet."); setBoxContent(htmlBody, "No HTML body parsed yet."); setBoxContent(rawPreview, "Drop an email to preview raw content."); }