Files
bodyshop/_reference/localEmailViewer/index.js

1017 lines
34 KiB
JavaScript

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 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
};
}
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 = /<html[\s>]/i.test(value) || /<!doctype/i.test(value);
if (hasDocument) {
return value;
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base target="_blank">
<style>body{margin:0;padding:16px;font-family:Arial,sans-serif;background:#fff;}</style>
</head>
<body>${value}</body>
</html>`;
}
function stripTags(value) {
return String(value || "")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalStack Email Viewer</title>
<style>${renderStyles()}</style>
</head>
<body>
<div class="page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">LocalStack SES</p>
<h1>Email Viewer</h1>
<p class="lede">A faster local inbox for generated emails with live refresh, manual refresh, search, and raw MIME inspection.</p>
</div>
<div class="hero-controls">
<div class="row">
<button id="refreshButton" class="primary" type="button">Refresh now</button>
<label class="chip"><input id="autoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="intervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
</div>
<div class="row">
<input id="searchInput" class="search" type="search" placeholder="Search subject, addresses, preview, attachment..." autocomplete="off">
<button id="clearSearchButton" class="ghost" type="button">Clear</button>
</div>
<div class="row">
<button id="expandAllButton" class="ghost" type="button">Expand all</button>
<button id="collapseAllButton" class="ghost" type="button">Collapse all</button>
<span id="statusChip" class="status">Waiting for first refresh...</span>
</div>
</div>
</header>
<section class="stats">
<article class="stat"><span>Total</span><strong id="totalStat">0</strong><small id="visibleStat">0 visible</small></article>
<article class="stat"><span>New</span><strong id="newStat">0</strong><small>New since last refresh</small></article>
<article class="stat"><span>Newest</span><strong id="newestStat" class="small">No messages</strong><small id="updatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article>
</section>
<div id="banner" class="banner" hidden></div>
<div id="empty" class="empty" hidden></div>
<section id="list" class="list" aria-live="polite"></section>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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('<span class="tag new">New</span>');
}
if (message.attachmentCount) {
tags.push(
`<span class="tag">${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}</span>`
);
}
tags.push(`<span class="tag">${message.hasHtml ? "HTML" : "Text only"}</span>`);
if (message.parseError) {
tags.push('<span class="tag bad">Parse issue</span>');
}
return `
<details class="card ${state.newIds.has(message.id) ? "new" : ""}" data-id="${escapeHtml(message.id)}" ${open ? "open" : ""}>
<summary class="summary">
<div class="top">
<div class="head">
<h2>${escapeHtml(message.subject)}</h2>
<p class="meta">${escapeHtml(message.from)} to ${escapeHtml(message.to)}</p>
</div>
<span class="time">${escapeHtml(formatDateTime(message.timestamp))}</span>
</div>
<div class="tags">${tags.join("")}</div>
<p class="preview">${escapeHtml(message.preview)}</p>
</summary>
<div class="body">
<div class="toolbar">
<div class="tabs">
${
message.hasHtml
? `<button class="tab ${view === "rendered" ? "active" : ""}" type="button" data-action="view" data-view="rendered" data-id="${escapeHtml(message.id)}">Rendered</button>`
: ""
}
<button class="tab ${view === "text" ? "active" : ""}" type="button" data-action="view" data-view="text" data-id="${escapeHtml(message.id)}">Text</button>
<button class="tab ${view === "raw" ? "active" : ""}" type="button" data-action="view" data-view="raw" data-id="${escapeHtml(message.id)}">Raw</button>
</div>
<div class="actions">
<button class="mini" type="button" data-action="copy-raw" data-id="${escapeHtml(message.id)}">Copy raw</button>
</div>
</div>
<dl class="grid">
${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) : ""}
</dl>
${
message.attachments?.length
? `<div class="attachments">${message.attachments
.map((attachment) => {
const size = attachment.size ? `, ${formatBytes(attachment.size)}` : "";
return `<span class="attachment">${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})</span>`;
})
.join("")}</div>`
: ""
}
<div class="panel">${renderPanel(message, view)}</div>
</div>
</details>
`;
}
function renderPanel(message, view) {
if (view === "rendered" && message.hasHtml) {
return `<iframe title="${escapeHtml(message.subject)}" data-frame loading="lazy"></iframe>`;
}
if (view === "raw") {
const raw = state.raw[message.id];
if (!raw) {
return `<div class="placeholder">Raw MIME source is loaded on demand.<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-action="load-raw" data-id="${escapeHtml(message.id)}">Load raw source</button></div></div>`;
}
if (raw.status === "loading") {
return '<div class="placeholder">Loading raw source...</div>';
}
if (raw.status === "error") {
return `<div class="inlineError">Unable to load raw source: ${escapeHtml(raw.error)}<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-action="load-raw" data-id="${escapeHtml(message.id)}">Retry</button></div></div>`;
}
return `<pre>${escapeHtml(raw.value)}</pre>`;
}
return `<pre>${escapeHtml(message.textContent || "No plain-text content available for this message.")}</pre>`;
}
function metaCard(label, value) {
return `<div class="metaCard"><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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}`);
});