app.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. const dropZone = document.getElementById("dropZone");
  2. const fieldList = document.getElementById("fieldList");
  3. const textBody = document.getElementById("textBody");
  4. const htmlBody = document.getElementById("htmlBody");
  5. const rawPreview = document.getElementById("rawPreview");
  6. const FIELDS = [
  7. "From",
  8. "To",
  9. "Cc",
  10. "Bcc",
  11. "Reply-To",
  12. "Subject",
  13. "Date",
  14. "Message-ID",
  15. "Content-Type",
  16. ];
  17. ["dragenter", "dragover"].forEach((eventName) => {
  18. dropZone.addEventListener(eventName, (event) => {
  19. event.preventDefault();
  20. event.stopPropagation();
  21. dropZone.classList.add("active");
  22. });
  23. });
  24. ["dragleave", "drop"].forEach((eventName) => {
  25. dropZone.addEventListener(eventName, (event) => {
  26. event.preventDefault();
  27. event.stopPropagation();
  28. dropZone.classList.remove("active");
  29. });
  30. });
  31. dropZone.addEventListener("drop", async (event) => {
  32. const file = event.dataTransfer?.files?.[0];
  33. if (!file) {
  34. renderError("No file was dropped.");
  35. return;
  36. }
  37. if (!file.name.toLowerCase().endsWith(".eml")) {
  38. renderError("Please drop a .eml file from Thunderbird.");
  39. return;
  40. }
  41. try {
  42. const messageText = await file.text();
  43. const parsed = parseEml(messageText);
  44. renderParsedEmail(parsed, messageText);
  45. } catch (error) {
  46. const message = error instanceof Error ? error.message : String(error);
  47. renderError(`Failed to parse email: ${message}`);
  48. }
  49. });
  50. function parseEml(messageText) {
  51. const normalized = messageText.replace(/\r\n/g, "\n");
  52. const splitIndex = normalized.indexOf("\n\n");
  53. const headerBlock = splitIndex >= 0 ? normalized.slice(0, splitIndex) : normalized;
  54. const bodyBlock = splitIndex >= 0 ? normalized.slice(splitIndex + 2) : "";
  55. const headers = parseHeaders(headerBlock);
  56. const contentType = headers.get("content-type") || "";
  57. const bodyParts = extractBodiesFromMime(bodyBlock, contentType);
  58. return { headers, bodyParts };
  59. }
  60. function parseHeaders(headerBlock) {
  61. const map = new Map();
  62. const lines = headerBlock.split("\n");
  63. let currentName = "";
  64. let currentValue = "";
  65. const commitHeader = () => {
  66. if (!currentName) return;
  67. map.set(currentName.toLowerCase(), currentValue.trim());
  68. };
  69. for (const line of lines) {
  70. if (/^\s/.test(line) && currentName) {
  71. currentValue += ` ${line.trim()}`;
  72. continue;
  73. }
  74. commitHeader();
  75. const separatorIndex = line.indexOf(":");
  76. if (separatorIndex < 0) {
  77. currentName = "";
  78. currentValue = "";
  79. continue;
  80. }
  81. currentName = line.slice(0, separatorIndex).trim();
  82. currentValue = line.slice(separatorIndex + 1).trim();
  83. }
  84. commitHeader();
  85. return map;
  86. }
  87. function extractBodiesFromMime(bodyBlock, contentTypeHeader) {
  88. const result = { text: "", html: "" };
  89. if (!contentTypeHeader.includes("multipart/")) {
  90. result.text = decodeBodyByEncoding(bodyBlock, "");
  91. return result;
  92. }
  93. const boundaryMatch = contentTypeHeader.match(/boundary="?([^";]+)"?/i);
  94. if (!boundaryMatch) {
  95. result.text = decodeBodyByEncoding(bodyBlock, "");
  96. return result;
  97. }
  98. const boundary = boundaryMatch[1];
  99. const boundaryToken = `--${boundary}`;
  100. const segments = bodyBlock.split(boundaryToken);
  101. for (const segment of segments) {
  102. const trimmed = segment.trim();
  103. if (!trimmed || trimmed === "--") continue;
  104. const normalized = trimmed.replace(/^\n+/, "");
  105. const splitIndex = normalized.indexOf("\n\n");
  106. if (splitIndex < 0) continue;
  107. const rawPartHeaders = normalized.slice(0, splitIndex);
  108. const rawPartBody = normalized.slice(splitIndex + 2);
  109. const partHeaders = parseHeaders(rawPartHeaders);
  110. const partType = (partHeaders.get("content-type") || "").toLowerCase();
  111. const transferEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase();
  112. const decodedPartBody = decodeBodyByEncoding(rawPartBody, transferEncoding);
  113. if (!result.text && partType.includes("text/plain")) {
  114. result.text = decodedPartBody;
  115. } else if (!result.html && partType.includes("text/html")) {
  116. result.html = decodedPartBody;
  117. }
  118. }
  119. if (!result.text) {
  120. result.text = decodeBodyByEncoding(bodyBlock, "");
  121. }
  122. return result;
  123. }
  124. function decodeBodyByEncoding(body, transferEncoding) {
  125. const normalized = body.replace(/\r\n/g, "\n").trim();
  126. if (transferEncoding.includes("base64")) {
  127. try {
  128. return atob(normalized.replace(/\s+/g, ""));
  129. } catch {
  130. return normalized;
  131. }
  132. }
  133. if (transferEncoding.includes("quoted-printable")) {
  134. return decodeQuotedPrintable(normalized);
  135. }
  136. return normalized;
  137. }
  138. function decodeQuotedPrintable(input) {
  139. const softBreaksRemoved = input.replace(/=\n/g, "");
  140. return softBreaksRemoved.replace(/=([A-Fa-f0-9]{2})/g, (_, hex) =>
  141. String.fromCharCode(parseInt(hex, 16)),
  142. );
  143. }
  144. function renderParsedEmail(parsed, messageText) {
  145. fieldList.innerHTML = "";
  146. for (const field of FIELDS) {
  147. const value = parsed.headers.get(field.toLowerCase()) || "(not present)";
  148. appendField(field, value);
  149. }
  150. setBoxContent(textBody, parsed.bodyParts.text || "No text body found.");
  151. setBoxContent(htmlBody, parsed.bodyParts.html || "No HTML body found.");
  152. setBoxContent(rawPreview, messageText.slice(0, 12000));
  153. }
  154. function appendField(label, value) {
  155. const dt = document.createElement("dt");
  156. dt.textContent = label;
  157. const dd = document.createElement("dd");
  158. dd.textContent = value;
  159. fieldList.append(dt, dd);
  160. }
  161. function setBoxContent(element, text) {
  162. element.textContent = text;
  163. element.classList.toggle("empty", !text || text.startsWith("No "));
  164. }
  165. function renderError(message) {
  166. fieldList.innerHTML = "";
  167. appendField("Error", message);
  168. setBoxContent(textBody, "No text body parsed yet.");
  169. setBoxContent(htmlBody, "No HTML body parsed yet.");
  170. setBoxContent(rawPreview, "Drop an email to preview raw content.");
  171. }