|
@@ -0,0 +1,205 @@
|
|
|
|
|
+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.");
|
|
|
|
|
+}
|