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) || /
LocalStack SES
Compact inbox for generated emails with live polling, search, and raw MIME inspection.