From 172bbecff7640d7b3a77187eda7f3c611b2b6cda Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 14:28:15 -0400 Subject: [PATCH] feature/IO-3587-Commision-Cut - Improved local email --- _reference/localEmailViewer/README.md | 30 +- _reference/localEmailViewer/index.js | 1072 ++++++++++++++++++++-- _reference/localEmailViewer/package.json | 5 +- 3 files changed, 1027 insertions(+), 80 deletions(-) diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 3f8a6fc71..6054686fd 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -1,7 +1,33 @@ -This will connect to your dockers local stack session and render the email in HTML. +This app connects to your Docker LocalStack SES endpoint and gives you a local inbox-style viewer +for generated emails. + +```shell +npm start +``` + +Or: ```shell node index.js ``` -http://localhost:3334 +Open: http://localhost:3334 + +Features: + +- Manual refresh +- Live refresh with adjustable polling interval +- Search across subject, addresses, preview text, and attachment names +- Expand/collapse all messages +- Rendered HTML, plain-text, and raw MIME views +- Copy raw MIME source +- New-message highlighting plus fetch timing and parse-error stats + +Optional environment variables: + +```shell +PORT=3334 +SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses +SES_VIEWER_REFRESH_MS=10000 +SES_VIEWER_FETCH_TIMEOUT_MS=5000 +``` diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 1dd17f779..4106d2903 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -1,96 +1,1016 @@ -// index.js - import express from "express"; import fetch from "node-fetch"; import { simpleParser } from "mailparser"; const app = express(); -const PORT = 3334; -app.get("/", async (req, res) => { +const PORT = Number(process.env.PORT || 3334); +const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; +const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); +const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); + +app.use((req, res, next) => { + res.set("Cache-Control", "no-store"); + next(); +}); + +app.get("/", (req, res) => { + res.type("html").send(renderHtml()); +}); + +app.get("/app.js", (req, res) => { + res.type("application/javascript").send(`(${clientApp.toString()})(${JSON.stringify(getClientConfig())});`); +}); + +app.get("/health", (req, res) => { + res.json({ + ok: true, + endpoint: SES_ENDPOINT, + port: PORT, + defaultRefreshMs: DEFAULT_REFRESH_MS + }); +}); + +app.get("/api/messages", async (req, res) => { try { - const response = await fetch("http://localhost:4566/_aws/ses"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - const data = await response.json(); - const messagesHtml = await parseMessages(data.messages); - res.send(renderHtml(messagesHtml)); + res.json(await loadMessages()); } catch (error) { console.error("Error fetching messages:", error); - res.status(500).send("Error fetching messages"); + res.status(502).json({ + error: "Unable to fetch messages from LocalStack SES", + details: error.message, + endpoint: SES_ENDPOINT + }); } }); -async function parseMessages(messages) { - const parsedMessages = await Promise.all( - messages.map(async (message, index) => { - try { - const parsed = await simpleParser(message.RawData); - return ` -
-
-
Message ${index + 1}
-
From: ${message.Source}
-
To: ${parsed.to.text || "No To Address"}
-
Subject: ${parsed.subject || "No Subject"}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
-
${parsed.html || parsed.textAsHtml || "No HTML content available"}
-
- `; - } catch (error) { - console.error("Error parsing email:", error); - return ` -
-
Message ${index + 1}
-
From: ${message.Source}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
Error parsing email content
-
- `; - } - }) - ); - return parsedMessages.join(""); +app.get("/api/messages/:id/raw", async (req, res) => { + try { + const messages = await fetchSesMessages(); + const message = messages.find((candidate) => resolveMessageId(candidate) === req.params.id); + + if (!message) { + res.status(404).type("text/plain").send("Message not found"); + return; + } + + res.type("text/plain").send(message.RawData || ""); + } catch (error) { + console.error("Error fetching raw message:", error); + res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`); + } +}); + +async function loadMessages() { + const startedAt = Date.now(); + const sesMessages = await fetchSesMessages(); + const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index))); + + messages.sort((left, right) => { + if ((right.timestampMs || 0) !== (left.timestampMs || 0)) { + return (right.timestampMs || 0) - (left.timestampMs || 0); + } + + return right.index - left.index; + }); + + return { + endpoint: SES_ENDPOINT, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalMessages: messages.length, + parseErrors: messages.filter((message) => Boolean(message.parseError)).length, + latestMessageTimestamp: messages[0]?.timestamp || "", + messages + }; } -function renderHtml(messagesHtml) { - return ` - - - - - - Email Messages Viewer - - - - -
-

Email Messages Viewer

-
${messagesHtml}
+async function fetchSesMessages() { + const response = await fetch(SES_ENDPOINT, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`SES endpoint responded with ${response.status}`); + } + + const data = await response.json(); + return Array.isArray(data.messages) ? data.messages : []; +} + +async function toMessageViewModel(message, index) { + const id = resolveMessageId(message, index); + + try { + const parsed = await simpleParser(message.RawData || ""); + const textContent = normalizeText(parsed.text || ""); + const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || ""); + const timestamp = normalizeTimestamp(message.Timestamp || parsed.date); + + return { + id, + index, + from: formatAddressList(parsed.from) || message.Source || "Unknown sender", + to: formatAddressList(parsed.to) || "No To Address", + replyTo: formatAddressList(parsed.replyTo), + subject: parsed.subject || "No Subject", + region: message.Region || "", + timestamp, + timestampMs: timestamp ? Date.parse(timestamp) : 0, + messageId: parsed.messageId || "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: parsed.attachments.length, + attachments: parsed.attachments.map((attachment) => ({ + filename: attachment.filename || "Unnamed attachment", + contentType: attachment.contentType || "application/octet-stream", + size: attachment.size || 0 + })), + preview: buildPreview(textContent, renderedHtml), + textContent, + renderedHtml, + hasHtml: Boolean(renderedHtml), + parseError: "" + }; + } catch (error) { + return { + id, + index, + from: message.Source || "Unknown sender", + to: "Unknown recipient", + replyTo: "", + subject: "Unable to parse message", + region: message.Region || "", + timestamp: normalizeTimestamp(message.Timestamp), + timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0, + messageId: "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: 0, + attachments: [], + preview: "This message could not be parsed. Open the raw view to inspect the MIME source.", + textContent: "", + renderedHtml: "", + hasHtml: false, + parseError: error.message + }; + } +} + +function resolveMessageId(message, index = 0) { + return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; +} + +function normalizeTimestamp(value) { + if (!value) { + return ""; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toISOString(); +} + +function normalizeText(value) { + return String(value || "") + .replace(/\r\n/g, "\n") + .trim(); +} + +function buildPreview(textContent, renderedHtml) { + const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim(); + + if (!source) { + return "No message preview available."; + } + + return source.length > 220 ? `${source.slice(0, 217)}...` : source; +} + +function buildRenderedHtml(html) { + if (!html) { + return ""; + } + + const value = String(html); + const hasDocument = /]/i.test(value) || / + + + + + + + + ${value} +`; +} + +function stripTags(value) { + return String(value || "") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " "); +} + +function formatAddressList(addresses) { + if (!addresses?.value?.length) { + return ""; + } + + return addresses.value + .map(({ name, address }) => { + if (name && address) { + return `${name} <${address}>`; + } + + return address || name || ""; + }) + .filter(Boolean) + .join(", "); +} + +function getClientConfig() { + return { + defaultRefreshMs: DEFAULT_REFRESH_MS, + endpoint: SES_ENDPOINT + }; +} + +function renderHtml() { + return ` + + + + + LocalStack Email Viewer + + + +
+
+
+

LocalStack SES

+

Email Viewer

+

A faster local inbox for generated emails with live refresh, manual refresh, search, and raw MIME inspection.

- - +
+
+ + + +
+
+ + +
+
+ + + Waiting for first refresh... +
+
+
+ +
+
Total00 visible
+
New0New since last refresh
+
NewestNo messagesNot refreshed yet
+
FetchIdleEndpoint: ${escapeHtml(SES_ENDPOINT)}
+
+ + + +
+
+ + + +`; +} + +function renderStyles() { + return ` + :root{--panel:rgba(255,255,255,.86);--panel-strong:#fff;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--accent:#cf6d3c;--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 18px 48px rgba(35,43,53,.12);} + *{box-sizing:border-box} + html,body{margin:0;min-height:100%} + body{background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.14),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:16px/1.5 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif} + button,input,select,textarea{font:inherit} + button{cursor:pointer} + .page{max-width:1440px;margin:0 auto;padding:24px} + .hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(320px,.9fr);gap:24px;margin-bottom:24px} + .hero-copy,.hero-controls,.stat,.card{background:var(--panel);backdrop-filter:blur(16px);border:1px solid var(--line);box-shadow:var(--shadow)} + .hero-copy,.hero-controls{border-radius:24px;padding:24px} + .eyebrow{margin:0 0 8px;color:var(--accent);font-size:.8rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} + h1{margin:0;font-size:clamp(2.2rem,5vw,4rem);line-height:.95;letter-spacing:-.05em} + .lede{margin:16px 0 0;max-width:60ch;color:var(--muted)} + .hero-controls{display:grid;gap:12px} + .row{display:flex;flex-wrap:wrap;gap:12px;align-items:center} + .primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .primary,.ghost{min-height:44px;padding:0 18px;font-weight:700} + .mini,.tab{min-height:34px;padding:0 12px;font-weight:600} + .primary{background:var(--accent);color:#fff7f2} + .ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)} + .tab{background:transparent;color:var(--muted)} + .tab.active{background:#fff;border-color:var(--line);color:var(--ink)} + .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} + .chip{display:inline-flex;align-items:center;gap:10px;min-height:44px;padding:0 16px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600} + .chip input{margin:0;accent-color:var(--accent)} + .chip select{border:none;background:transparent;outline:none;color:var(--ink)} + .search{flex:1 1 320px;min-height:48px;padding:0 16px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} + .status{display:inline-flex;align-items:center;min-height:40px;padding:0 14px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-weight:600} + .status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)} + .status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)} + .status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)} + .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:18px} + .stat{border-radius:18px;padding:18px} + .stat span{display:block;margin-bottom:10px;color:var(--muted);font-size:.82rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .stat strong{display:block;font-size:clamp(2rem,4vw,2.6rem);line-height:1;letter-spacing:-.05em} + .stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em} + .stat small{display:block;margin-top:10px;color:var(--muted);font-size:.95rem} + .banner,.empty{margin:0 0 18px;padding:16px 18px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.82)} + .banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)} + .list{display:grid;gap:18px} + .card{overflow:hidden;border-radius:18px} + .card.new{border-color:rgba(31,143,101,.28);box-shadow:var(--shadow),0 0 0 1px rgba(31,143,101,.12)} + .summary{list-style:none;display:grid;gap:12px;padding:20px;cursor:pointer} + .summary::-webkit-details-marker{display:none} + .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:10px;align-items:center} + .top{justify-content:space-between} + .head{min-width:0;flex:1 1 320px} + .head h2{margin:0;font-size:clamp(1.1rem,2vw,1.45rem);line-height:1.2;letter-spacing:-.03em;word-break:break-word} + .meta{margin:6px 0 0;color:var(--muted);font-size:.95rem;word-break:break-word} + .time,.tag{display:inline-flex;align-items:center;min-height:28px;padding:0 12px;border-radius:999px;font-size:.84rem;font-weight:700} + .time{background:rgba(31,41,51,.06)} + .tag{background:rgba(31,41,51,.06);color:var(--muted)} + .tag.new{background:rgba(31,143,101,.1);color:var(--ok)} + .tag.bad{background:rgba(179,58,58,.1);color:var(--bad)} + .preview{margin:0} + .body{display:grid;gap:16px;padding:18px 20px 20px;border-top:1px solid var(--line);background:var(--panel-strong)} + .toolbar{justify-content:space-between;align-items:flex-start} + .tabs{display:inline-flex;gap:6px;padding:6px;border-radius:999px;background:rgba(31,41,51,.05)} + .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} + .metaCard{padding:14px 16px;border-radius:14px;background:rgba(31,41,51,.035);border:1px solid rgba(31,41,51,.08)} + .metaCard dt{margin:0 0 6px;color:var(--muted);font-size:.8rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .metaCard dd{margin:0;word-break:break-word} + .attachments{gap:8px} + .attachment{padding:9px 12px;border-radius:12px;background:rgba(255,255,255,.72);border:1px solid var(--line);font-size:.92rem} + .panel{overflow:hidden;border-radius:14px;border:1px solid var(--line);background:#fff} + iframe{width:100%;min-height:720px;border:none;background:#fff} + pre{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:13px/1.55 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} + .placeholder,.inlineError{padding:16px} + .inlineError{color:var(--bad)} + @media (max-width:1080px){.hero{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media (max-width:720px){.page{padding:16px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:560px}} `; } +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function clientApp(config) { + const state = { + messages: [], + filtered: [], + search: "", + auto: true, + interval: config.defaultRefreshMs, + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + parseErrors: 0, + newest: "", + newIds: new Set(), + knownIds: new Set(), + openIds: new Set(), + views: {}, + raw: {} + }; + + const el = { + refreshButton: document.getElementById("refreshButton"), + autoToggle: document.getElementById("autoToggle"), + intervalSelect: document.getElementById("intervalSelect"), + searchInput: document.getElementById("searchInput"), + clearSearchButton: document.getElementById("clearSearchButton"), + expandAllButton: document.getElementById("expandAllButton"), + collapseAllButton: document.getElementById("collapseAllButton"), + statusChip: document.getElementById("statusChip"), + totalStat: document.getElementById("totalStat"), + visibleStat: document.getElementById("visibleStat"), + newStat: document.getElementById("newStat"), + newestStat: document.getElementById("newestStat"), + updatedStat: document.getElementById("updatedStat"), + fetchStat: document.getElementById("fetchStat"), + fetchDetail: document.getElementById("fetchDetail"), + banner: document.getElementById("banner"), + empty: document.getElementById("empty"), + list: document.getElementById("list") + }; + + el.intervalSelect.value = String(config.defaultRefreshMs); + wire(); + renderAll(); + refreshMessages("initial"); + window.setInterval(renderLiveClock, 1000); + + function wire() { + el.refreshButton.addEventListener("click", () => refreshMessages("manual")); + + el.autoToggle.addEventListener("change", () => { + state.auto = el.autoToggle.checked; + scheduleRefresh(); + renderStatus(); + }); + + el.intervalSelect.addEventListener("change", () => { + state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; + scheduleRefresh(); + renderStatus(); + }); + + el.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + applyFilter(); + }); + + el.clearSearchButton.addEventListener("click", () => { + state.search = ""; + el.searchInput.value = ""; + applyFilter(); + }); + + el.expandAllButton.addEventListener("click", () => { + state.filtered.forEach((message) => state.openIds.add(message.id)); + renderList(); + }); + + el.collapseAllButton.addEventListener("click", () => { + state.openIds.clear(); + renderList(); + }); + + el.list.addEventListener( + "toggle", + (event) => { + const details = event.target; + + if (!(details instanceof HTMLDetailsElement) || !details.matches(".card")) { + return; + } + + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + state.openIds.add(id); + } else { + state.openIds.delete(id); + } + + hydrate(details, getMessage(id)); + }, + true + ); + + el.list.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const message = getMessage(id); + + if (!message) { + return; + } + + if (button.dataset.action === "view") { + state.views[id] = button.dataset.view; + renderList(); + return; + } + + if (button.dataset.action === "load-raw") { + await loadRaw(id); + renderList(); + return; + } + + if (button.dataset.action === "copy-raw") { + const raw = await loadRaw(id); + + if (raw) { + await copyText(raw); + setStatus("Raw message copied to the clipboard.", "ok"); + } + } + }); + + document.addEventListener("visibilitychange", () => { + window.clearTimeout(state.timer); + + if (!document.hidden && state.auto) { + refreshMessages("visibility"); + } else { + renderStatus(); + } + }); + + window.addEventListener("keydown", (event) => { + const isField = + event.target instanceof HTMLElement && + (event.target.matches("input,textarea,select") || event.target.isContentEditable); + + if (!isField && event.key.toLowerCase() === "r") { + event.preventDefault(); + refreshMessages("keyboard"); + } + }); + } + + async function refreshMessages(source) { + if (state.loading) { + return; + } + + state.loading = true; + state.source = source; + state.error = ""; + renderStatus(); + renderFetch(); + + try { + const response = await fetch("/api/messages", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const nextIds = new Set(messages.map((message) => message.id)); + + state.newIds = state.updatedAt + ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) + : new Set(); + state.knownIds = nextIds; + state.messages = messages; + state.duration = payload.fetchDurationMs || 0; + state.parseErrors = payload.parseErrors || 0; + state.newest = payload.latestMessageTimestamp || ""; + state.updatedAt = Date.now(); + + pruneState(); + applyFilter(); + setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + state.error = error.message || "Unknown refresh error"; + setStatus(`Refresh failed: ${state.error}`, "bad"); + } finally { + state.loading = false; + scheduleRefresh(); + renderAll(); + } + } + + function pruneState() { + const ids = new Set(state.messages.map((message) => message.id)); + state.openIds = new Set([...state.openIds].filter((id) => ids.has(id))); + state.newIds = new Set([...state.newIds].filter((id) => ids.has(id))); + + Object.keys(state.views).forEach((id) => { + if (!ids.has(id)) { + delete state.views[id]; + } + }); + + Object.keys(state.raw).forEach((id) => { + if (!ids.has(id)) { + delete state.raw[id]; + } + }); + } + + function applyFilter() { + const search = state.search.trim().toLowerCase(); + state.filtered = !search + ? [...state.messages] + : state.messages.filter((message) => haystack(message).includes(search)); + renderAll(); + } + + function haystack(message) { + return [ + message.subject, + message.from, + message.to, + message.replyTo, + message.preview, + message.textContent, + message.region, + ...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function scheduleRefresh() { + window.clearTimeout(state.timer); + + if (!state.auto || document.hidden || state.loading) { + return; + } + + state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); + } + + function renderAll() { + renderStats(); + renderFetch(); + renderStatus(); + renderList(); + renderLiveClock(); + } + + function renderStats() { + el.totalStat.textContent = String(state.messages.length); + el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`; + el.newStat.textContent = String(state.newIds.size); + el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages"; + } + + function renderFetch() { + if (state.loading) { + el.fetchStat.textContent = "Refreshing..."; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + if (state.error) { + el.fetchStat.textContent = "Needs attention"; + el.fetchDetail.textContent = state.error; + return; + } + + if (!state.updatedAt) { + el.fetchStat.textContent = "Idle"; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + el.fetchStat.textContent = `${state.duration}ms`; + el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`; + } + + function renderStatus() { + el.statusChip.className = "status"; + + if (state.loading) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Refreshing messages..."; + return; + } + + if (state.error) { + el.statusChip.classList.add("bad"); + el.statusChip.textContent = `Refresh failed: ${state.error}`; + return; + } + + if (!state.auto) { + el.statusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!state.updatedAt) { + el.statusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000)); + el.statusChip.classList.add("ok"); + el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLiveClock() { + if (!state.updatedAt) { + el.updatedStat.textContent = "Not refreshed yet"; + return; + } + + el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`; + renderStatus(); + } + + function renderList() { + el.banner.hidden = !state.error; + el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : ""; + + if (!state.filtered.length) { + el.list.innerHTML = ""; + el.empty.hidden = false; + el.empty.textContent = state.messages.length + ? "No messages match the current search." + : "No emails yet. Send one through LocalStack SES and refresh."; + return; + } + + el.empty.hidden = true; + el.list.innerHTML = state.filtered.map(renderCard).join(""); + el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id))); + } + + function renderCard(message) { + const open = state.openIds.has(message.id); + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + const tags = []; + + if (state.newIds.has(message.id)) { + tags.push('New'); + } + + if (message.attachmentCount) { + tags.push( + `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}` + ); + } + + tags.push(`${message.hasHtml ? "HTML" : "Text only"}`); + + if (message.parseError) { + tags.push('Parse issue'); + } + + return ` +
+ +
+
+

${escapeHtml(message.subject)}

+

${escapeHtml(message.from)} to ${escapeHtml(message.to)}

+
+ ${escapeHtml(formatDateTime(message.timestamp))} +
+
${tags.join("")}
+

${escapeHtml(message.preview)}

+
+
+
+
+ ${ + message.hasHtml + ? `` + : "" + } + + +
+
+ +
+
+ +
+ ${metaCard("From", message.from)} + ${metaCard("To", message.to)} + ${metaCard("Reply-To", message.replyTo || "None")} + ${metaCard("Sent", formatDateTime(message.timestamp))} + ${metaCard("Region", message.region || "Unknown region")} + ${metaCard("LocalStack Id", message.id)} + ${metaCard("Message-Id", message.messageId || "Not available")} + ${metaCard("Raw size", formatBytes(message.rawSizeBytes))} + ${message.parseError ? metaCard("Parse error", message.parseError) : ""} +
+ + ${ + message.attachments?.length + ? `
${message.attachments + .map((attachment) => { + const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; + return `${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; + }) + .join("")}
` + : "" + } + +
${renderPanel(message, view)}
+
+
+ `; + } + + function renderPanel(message, view) { + if (view === "rendered" && message.hasHtml) { + return ``; + } + + if (view === "raw") { + const raw = state.raw[message.id]; + + if (!raw) { + return `
Raw MIME source is loaded on demand.
`; + } + + if (raw.status === "loading") { + return '
Loading raw source...
'; + } + + if (raw.status === "error") { + return `
Unable to load raw source: ${escapeHtml(raw.error)}
`; + } + + return `
${escapeHtml(raw.value)}
`; + } + + return `
${escapeHtml(message.textContent || "No plain-text content available for this message.")}
`; + } + + function metaCard(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; + } + + function hydrate(details, message) { + if (!details || !details.open || !message) { + return; + } + + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + + if (view !== "rendered" || !message.hasHtml) { + return; + } + + const iframe = details.querySelector("[data-frame]"); + + if (iframe) { + iframe.referrerPolicy = "no-referrer"; + iframe.sandbox = ""; + iframe.srcdoc = message.renderedHtml || ""; + } + } + + function getMessage(id) { + return state.messages.find((message) => message.id === id); + } + + async function loadRaw(id) { + if (state.raw[id]?.status === "loaded") { + return state.raw[id].value; + } + + if (state.raw[id]?.status === "loading") { + return null; + } + + state.raw[id] = { status: "loading" }; + renderList(); + + try { + const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" }); + + if (!response.ok) { + throw new Error((await response.text()) || `Request failed with ${response.status}`); + } + + const value = await response.text(); + state.raw[id] = { status: "loaded", value }; + return value; + } catch (error) { + state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" }; + setStatus("Could not load the raw message source.", "bad"); + return null; + } finally { + renderList(); + } + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + } + + function setStatus(message, tone) { + el.statusChip.className = "status"; + + if (tone) { + el.statusChip.classList.add(tone); + } + + el.statusChip.textContent = message; + } + + function formatDateTime(value) { + if (!value) { + return "Unknown time"; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "Unknown time" + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + } + + function formatRelative(timestampMs) { + const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000)); + + if (seconds < 60) { + return `${seconds}s ago`; + } + + const minutes = Math.round(seconds / 60); + + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.round(minutes / 60); + + if (hours < 24) { + return `${hours}h ago`; + } + + return `${Math.round(hours / 24)}d ago`; + } + + function formatBytes(value) { + if (!value) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let index = 0; + + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + + return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + async function safeJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } +} + app.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); + console.log(`Local email viewer is running on http://localhost:${PORT}`); + console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); }); diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index 5f553cf17..a6b522e0e 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -4,12 +4,13 @@ "main": "index.js", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node index.js", + "check": "node --check index.js" }, "keywords": [], "author": "", "license": "ISC", - "description": "", + "description": "LocalStack SES email viewer for inspecting local outbound mail", "dependencies": { "express": "^5.1.0", "mailparser": "^3.7.4",