import express from "express"; import fetch from "node-fetch"; import { simpleParser } from "mailparser"; const app = express(); 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 { res.json(await loadMessages()); } catch (error) { console.error("Error fetching messages:", error); res.status(502).json({ error: "Unable to fetch messages from LocalStack SES", details: error.message, endpoint: SES_ENDPOINT }); } }); app.get("/api/messages/:id/raw", async (req, res) => { try { const message = await findSesMessageById(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}`); } }); app.get("/api/messages/:id/attachments/:index", async (req, res) => { try { const attachmentIndex = Number.parseInt(req.params.index, 10); if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) { res.status(400).type("text/plain").send("Attachment index must be a non-negative integer"); return; } const parsed = await parseSesMessageById(req.params.id); if (!parsed) { res.status(404).type("text/plain").send("Message not found"); return; } const attachment = parsed.attachments?.[attachmentIndex]; if (!attachment) { res.status(404).type("text/plain").send("Attachment not found"); return; } const filename = resolveAttachmentFilename(attachment, attachmentIndex); const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || ""); res.setHeader("Content-Type", attachment.contentType || "application/octet-stream"); res.setHeader("Content-Disposition", buildAttachmentDisposition(filename)); res.setHeader("Content-Length", String(content.length)); res.send(content); } catch (error) { console.error("Error downloading attachment:", error); res.status(502).type("text/plain").send(`Unable to download attachment: ${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 }; } 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 findSesMessageById(id) { const messages = await fetchSesMessages(); return messages.find((message, index) => resolveMessageId(message, index) === id) || null; } async function parseSesMessageById(id) { const message = await findSesMessageById(id); if (!message) { return null; } return simpleParser(message.RawData || ""); } 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, attachmentIndex) => ({ index: attachmentIndex, filename: resolveAttachmentFilename(attachment, attachmentIndex), 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 resolveAttachmentFilename(attachment, index = 0) { if (attachment?.filename) { return attachment.filename; } return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`; } function attachmentExtension(contentType) { const normalized = String(contentType || "") .split(";")[0] .trim() .toLowerCase(); return ( { "application/json": ".json", "application/pdf": ".pdf", "application/zip": ".zip", "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "text/calendar": ".ics", "text/csv": ".csv", "text/html": ".html", "text/plain": ".txt" }[normalized] || "" ); } function buildAttachmentDisposition(filename) { const fallback = String(filename || "attachment") .replace(/[^\x20-\x7e]/g, "_") .replace(/["\\]/g, "_"); return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`; } 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

Compact inbox for generated emails with live polling, 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,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.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,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif} button,input,select,textarea{font:inherit} button{cursor:pointer} .page{max-width:1360px;margin:0 auto;padding:18px} .hero{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:14px;margin-bottom:14px} .hero-copy,.hero-controls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)} .hero-copy,.hero-controls{border-radius:18px;padding:18px} .eyebrow{margin:0 0 6px;color:var(--accent);font-size:.76rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} h1{margin:0;font-size:clamp(1.9rem,4vw,3.1rem);line-height:.98;letter-spacing:-.05em} .lede{margin:10px 0 0;max-width:54ch;color:var(--muted);font-size:.96rem} .hero-controls{display:grid;gap:10px} .row{display:flex;flex-wrap:wrap;gap:8px;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:38px;padding:0 14px;font-weight:700} .mini,.tab{min-height:30px;padding:0 10px;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:rgba(207,109,60,.18);color:var(--ink)} .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} .chip{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.92rem} .chip input{margin:0;accent-color:var(--accent)} .chip select{border:none;background:transparent;outline:none;color:var(--ink)} .search{flex:1 1 260px;min-height:40px;padding:0 14px;border-radius:14px;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:34px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.9rem;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:10px;margin-bottom:12px} .stat{border-radius:16px;padding:14px} .stat span{display:block;margin-bottom:8px;color:var(--muted);font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} .stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);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:8px;color:var(--muted);font-size:.85rem} .banner,.empty{margin:0 0 12px;padding:12px 14px;border-radius:14px;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:12px} .card{overflow:hidden;border-radius:16px} .card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)} .summary{list-style:none;display:grid;gap:8px;padding:14px 16px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))} .summary::-webkit-details-marker{display:none} .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .top{justify-content:space-between} .head{min-width:0;flex:1 1 320px} .head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word} .meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word} .time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700} .time{background:rgba(31,41,51,.06)} .tag{background:var(--accent-soft);color:#8d5632} .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;color:#324150;font-size:.94rem} .body{display:grid;gap:12px;padding:12px 16px 16px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)} .toolbar{justify-content:space-between;align-items:center} .tabs{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(207,109,60,.08)} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} .metaCard{padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)} .metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} .metaCard dd{margin:0;word-break:break-word} .attachments{gap:6px} .attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem} .attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} .attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)} .panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff} iframe{width:100%;min-height:560px;border:none;background:#fff} pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} .placeholder,.inlineError{padding:12px} .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:12px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:420px}} `; } 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: {}, listSignature: "" }; 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("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; } let shouldRenderList = false; 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)); const nextSignature = computeListSignature(messages); shouldRenderList = nextSignature !== state.listSignature; state.newIds = state.updatedAt && shouldRenderList ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) : state.newIds; state.knownIds = nextIds; state.messages = messages; state.duration = payload.fetchDurationMs || 0; state.parseErrors = payload.parseErrors || 0; state.newest = payload.latestMessageTimestamp || ""; state.updatedAt = Date.now(); state.listSignature = nextSignature; pruneState(); applyFilter(shouldRenderList); 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({ renderList: shouldRenderList }); } } 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(shouldRenderList = true) { const search = state.search.trim().toLowerCase(); state.filtered = !search ? [...state.messages] : state.messages.filter((message) => haystack(message).includes(search)); renderAll({ renderList: shouldRenderList }); } function computeListSignature(messages) { return messages .map((message) => [ message.id, message.timestampMs || 0, message.rawSizeBytes || 0, message.attachmentCount || 0, message.hasHtml ? 1 : 0, message.preview || "", message.parseError || "" ].join("::") ) .join("|"); } 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(options = {}) { const { renderList: shouldRenderList = true } = options; renderStats(); renderFetch(); renderStatus(); if (shouldRenderList) { 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(""); bindCardToggles(); el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id))); } function bindCardToggles() { el.list.querySelectorAll(".card").forEach((details) => { details.addEventListener("toggle", () => { const id = details.dataset.id; if (!id) { return; } if (details.open) { state.openIds.add(id); } else { state.openIds.delete(id); } hydrate(details, getMessage(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(`Local email viewer is running on http://localhost:${PORT}`); console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); });