bernn преди 2 месеца
родител
ревизия
5babb4194f
променени са 3 файла, в които са добавени 358 реда и са изтрити 0 реда
  1. 205 0
      app.js
  2. 46 0
      index.html
  3. 107 0
      styles.css

+ 205 - 0
app.js

@@ -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.");
+}

+ 46 - 0
index.html

@@ -0,0 +1,46 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Email Drop Parser</title>
+    <link rel="stylesheet" href="./styles.css" />
+  </head>
+  <body>
+    <main class="container">
+      <h1>Email Drop Parser</h1>
+      <p class="subtitle">
+        Drag an email file from Thunderbird onto the drop zone. This page expects
+        a saved <code>.eml</code> message.
+      </p>
+
+      <section id="dropZone" class="drop-zone" aria-label="Email drop zone">
+        <p><strong>Drop email here</strong></p>
+        <p class="hint">Supports .eml files</p>
+      </section>
+
+      <section class="fields">
+        <h2>Parsed Fields</h2>
+        <dl id="fieldList"></dl>
+      </section>
+
+      <section class="body-grid">
+        <article>
+          <h3>Text Body</h3>
+          <pre id="textBody" class="body-box empty">No text body parsed yet.</pre>
+        </article>
+        <article>
+          <h3>HTML Body (raw)</h3>
+          <pre id="htmlBody" class="body-box empty">No HTML body parsed yet.</pre>
+        </article>
+      </section>
+
+      <section class="raw-message">
+        <h3>Raw Message</h3>
+        <pre id="rawPreview" class="body-box empty">Drop an email to preview raw content.</pre>
+      </section>
+    </main>
+
+    <script type="module" src="./app.js"></script>
+  </body>
+</html>

+ 107 - 0
styles.css

@@ -0,0 +1,107 @@
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+  background: #0b1020;
+  color: #e6ecff;
+}
+
+.container {
+  max-width: 980px;
+  margin: 0 auto;
+  padding: 2rem 1rem 3rem;
+}
+
+h1,
+h2,
+h3 {
+  margin: 0 0 0.75rem;
+}
+
+.subtitle {
+  color: #b8c2e9;
+  margin-bottom: 1.5rem;
+}
+
+.drop-zone {
+  border: 2px dashed #4960b8;
+  background: #131b35;
+  border-radius: 12px;
+  padding: 2.5rem 1rem;
+  text-align: center;
+  transition: border-color 160ms ease, background 160ms ease;
+  margin-bottom: 1.5rem;
+}
+
+.drop-zone.active {
+  border-color: #73afff;
+  background: #18244a;
+}
+
+.hint {
+  color: #94a3dc;
+  margin: 0.5rem 0 0;
+}
+
+.fields,
+.raw-message,
+.body-grid article {
+  background: #131b35;
+  border: 1px solid #283560;
+  border-radius: 10px;
+  padding: 1rem;
+}
+
+.fields {
+  margin-bottom: 1rem;
+}
+
+dl {
+  display: grid;
+  grid-template-columns: 160px 1fr;
+  gap: 0.5rem 0.75rem;
+  margin: 0;
+}
+
+dt {
+  font-weight: 600;
+  color: #c5d2ff;
+}
+
+dd {
+  margin: 0;
+  color: #e8eeff;
+  overflow-wrap: anywhere;
+}
+
+.body-grid {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 1rem;
+  margin-bottom: 1rem;
+}
+
+@media (min-width: 900px) {
+  .body-grid {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
+.body-box {
+  margin: 0;
+  max-height: 300px;
+  overflow: auto;
+  background: #0a122c;
+  border: 1px solid #283560;
+  border-radius: 8px;
+  padding: 0.75rem;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+}
+
+.empty {
+  color: #95a4db;
+}