From 34c9a3854cbbec9c98ae9be2ce4a08380f727cb6 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 15:51:55 -0400 Subject: [PATCH] feature/IO-3587-Commision-Cut - Improved local email / Test Plans --- .gitignore | 3 - _reference/localEmailViewer/index.js | 251 +++++-- .../commission-based-cut-manual-test-plan.md | 0 .../testPlans/select-component-test-plan.md | 647 ++++++++++++++++++ 4 files changed, 832 insertions(+), 69 deletions(-) rename _reference/{ => testPlans}/commission-based-cut-manual-test-plan.md (100%) create mode 100644 _reference/testPlans/select-component-test-plan.md diff --git a/.gitignore b/.gitignore index 4b0d51183..59c48f34d 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,6 @@ docker_data /CLAUDE.md /COPILOT.md /GEMINI.md -/_reference/select-component-test-plan.md - /.cursorrules /AGENTS.md /AI_CONTEXT.md @@ -151,4 +149,3 @@ docker_data /COPILOT.md /.github/copilot-instructions.md /GEMINI.md -/_reference/select-component-test-plan.md diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 15b7736a9..7f35fa393 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -46,8 +46,7 @@ app.get("/api/messages", async (req, res) => { app.get("/api/messages/:id/raw", async (req, res) => { try { - const messages = await fetchSesMessages(); - const message = messages.find((candidate) => resolveMessageId(candidate) === req.params.id); + const message = await findSesMessageById(req.params.id); if (!message) { res.status(404).type("text/plain").send("Message not found"); @@ -61,6 +60,42 @@ app.get("/api/messages/:id/raw", async (req, res) => { } }); +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(); @@ -98,6 +133,21 @@ async function fetchSesMessages() { 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); @@ -120,8 +170,9 @@ async function toMessageViewModel(message, index) { messageId: parsed.messageId || "", rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), attachmentCount: parsed.attachments.length, - attachments: parsed.attachments.map((attachment) => ({ - filename: attachment.filename || "Unnamed attachment", + attachments: parsed.attachments.map((attachment, attachmentIndex) => ({ + index: attachmentIndex, + filename: resolveAttachmentFilename(attachment, attachmentIndex), contentType: attachment.contentType || "application/octet-stream", size: attachment.size || 0 })), @@ -159,6 +210,45 @@ 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 ""; @@ -254,11 +344,11 @@ function renderHtml() {

LocalStack SES

Email Viewer

-

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

+

Compact inbox for generated emails with live polling, search, and raw MIME inspection.

- +
- - + + Waiting for first refresh...
@@ -301,77 +391,80 @@ function renderHtml() { 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);} + :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,.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} + 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: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} + .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:44px;padding:0 18px;font-weight:700} - .mini,.tab{min-height:34px;padding:0 12px;font-weight:600} + .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:var(--line);color:var(--ink)} + .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: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{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 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} + .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: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} + .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: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)} + .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: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} + .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:10px;align-items:center} + .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(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} + .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:rgba(31,41,51,.06);color:var(--muted)} + .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} - .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} + .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: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} + .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:16px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:560px}} + @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}} `; } @@ -402,7 +495,8 @@ function clientApp(config) { knownIds: new Set(), openIds: new Set(), views: {}, - raw: {} + raw: {}, + listSignature: "" }; const el = { @@ -531,6 +625,8 @@ function clientApp(config) { return; } + let shouldRenderList = false; + state.loading = true; state.source = source; state.error = ""; @@ -548,19 +644,23 @@ function clientApp(config) { 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); - state.newIds = state.updatedAt - ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) - : new Set(); + 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(); + applyFilter(shouldRenderList); setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); } catch (error) { state.error = error.message || "Unknown refresh error"; @@ -568,7 +668,7 @@ function clientApp(config) { } finally { state.loading = false; scheduleRefresh(); - renderAll(); + renderAll({ renderList: shouldRenderList }); } } @@ -590,12 +690,28 @@ function clientApp(config) { }); } - function applyFilter() { + function applyFilter(shouldRenderList = true) { const search = state.search.trim().toLowerCase(); state.filtered = !search ? [...state.messages] : state.messages.filter((message) => haystack(message).includes(search)); - renderAll(); + 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) { @@ -624,11 +740,14 @@ function clientApp(config) { state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); } - function renderAll() { + function renderAll(options = {}) { + const { renderList: shouldRenderList = true } = options; renderStats(); renderFetch(); renderStatus(); - renderList(); + if (shouldRenderList) { + renderList(); + } renderLiveClock(); } @@ -814,7 +933,7 @@ function clientApp(config) { ? `
${message.attachments .map((attachment) => { const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; - return `${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; + return `${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; }) .join("")}
` : "" diff --git a/_reference/commission-based-cut-manual-test-plan.md b/_reference/testPlans/commission-based-cut-manual-test-plan.md similarity index 100% rename from _reference/commission-based-cut-manual-test-plan.md rename to _reference/testPlans/commission-based-cut-manual-test-plan.md diff --git a/_reference/testPlans/select-component-test-plan.md b/_reference/testPlans/select-component-test-plan.md new file mode 100644 index 000000000..ae5eff2c2 --- /dev/null +++ b/_reference/testPlans/select-component-test-plan.md @@ -0,0 +1,647 @@ +# Ant Design Select.Option Deprecation - Manual Testing Plan +**Branch:** `feature/IO-3544-Ant-Select-Deprecation` +**Base Branch:** `master-AIO` +**Jira:** IO-3544 + +## Overview +This branch migrates all Ant Design `` components to the new `options` prop pattern (required for Ant Design v5+). The deprecated `Select.Option` child component pattern has been replaced with the `options` array prop. + +## What Changed +- **Old Pattern:** `` +- **New Pattern:** `