← back to Purelymail Fleet Viewer
viewer: server-rendered screenshots for every domain + Site reachability column
d0d1ab64d1a79b1c7154908ea7fd04b01ccb5084 · 2026-05-18 16:54:29 -0700 · SteveStudio2
~half the fleet blocks iframe embedding (X-Frame-Options/CSP). Added a
headless-Chromium screenshot service (/api/shot, disk-cached 24h, conc 3)
so grid + table previews show every site reliably. New Site column shows
reachable/no-embed/unreachable per domain via /api/embedcheck.
Files touched
M .gitignoreA package-lock.jsonA package.jsonM public/index.htmlM server.js
Diff
commit d0d1ab64d1a79b1c7154908ea7fd04b01ccb5084
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 18 16:54:29 2026 -0700
viewer: server-rendered screenshots for every domain + Site reachability column
~half the fleet blocks iframe embedding (X-Frame-Options/CSP). Added a
headless-Chromium screenshot service (/api/shot, disk-cached 24h, conc 3)
so grid + table previews show every site reliably. New Site column shows
reachable/no-embed/unreachable per domain via /api/embedcheck.
---
.gitignore | 1 +
package-lock.json | 56 +++++++++++++++++++++++++++
package.json | 5 +++
public/index.html | 82 ++++++++++++++++++++++++++++++++++++----
server.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 247 insertions(+), 8 deletions(-)
diff --git a/.gitignore b/.gitignore
index 3e0aace..e8638f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
.next/
data/receive-test-sends.jsonl
data/send-test-sends.jsonl
+data/shots/
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8757ffe
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,56 @@
+{
+ "name": "purelymail-fleet-viewer",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "playwright": "^1.60.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f2eab14
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "playwright": "^1.60.0"
+ }
+}
diff --git a/public/index.html b/public/index.html
index bee4134..0c66567 100644
--- a/public/index.html
+++ b/public/index.html
@@ -58,10 +58,19 @@
.dgrid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:14px;margin-top:6px}
.dcard{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
.dcard .thumb{width:100%;height:230px;border:0;background:#fff;display:block}
+ .shot{width:100%;height:230px;object-fit:cover;object-position:top;
+ background:#fff;display:block}
+ .shot.tall{height:540px}
.dcard .meta{padding:8px 10px}
.dcard .meta .dn{font-size:12px;font-weight:600}
.dcard .meta .dn a{color:var(--gold);text-decoration:none}
.dcard .meta .st{margin-top:6px;display:flex;gap:4px;flex-wrap:wrap}
+ .thumb-wrap{display:block}
+ .frame-fallback{display:flex;flex-direction:column;align-items:center;justify-content:center;
+ gap:6px;background:#0d0c0a;color:var(--dim);text-align:center;padding:14px}
+ .frame-fallback .ff-i{font-size:26px}
+ .frame-fallback .ff-m{font-size:12px;color:var(--ink)}
+ .frame-fallback .ff-d{font-size:11px;color:var(--dim)}
.foot{color:var(--dim);font-size:11px;margin-top:24px;padding-top:12px;border-top:1px solid var(--line)}
#err{color:var(--bad);font-size:12px}
</style>
@@ -114,7 +123,7 @@
<table id="domains">
<thead><tr>
<th>Domain</th><th>MX</th><th>SPF</th><th>DKIM</th><th>DMARC</th>
- <th>Routing</th><th>Deliverable</th><th>Issues</th>
+ <th>Routing</th><th>Deliverable</th><th>Issues</th><th>Site</th>
</tr></thead>
<tbody></tbody>
</table>
@@ -202,6 +211,32 @@ function draw(){
if (v==='grid') drawGrid(); else drawTable();
}
+const embedResults = {}; // domain -> {reachable, embeddable, status, reason}
+let embedRunId = 0;
+
+function shotFail(img, domain, h){
+ const w = document.createElement('div');
+ w.className = 'frame-fallback';
+ w.style.height = (h||230)+'px';
+ w.innerHTML = '<div class="ff-i">✕</div><div class="ff-m">site not reachable</div>'
+ + '<div class="ff-d">'+domain+'</div>';
+ img.replaceWith(w);
+}
+
+// Table detail: live iframe when the site allows embedding, otherwise a
+// server-rendered screenshot (always shows — no blank frames).
+function frameHTML(domain){
+ const r = embedResults[domain];
+ const open = '<div class="frame-link"><a href="https://'+domain
+ +'" target="_blank" rel="noopener">open https://'+domain+' ↗</a></div>';
+ if (r && r.embeddable){
+ return '<iframe src="https://'+domain
+ +'" loading="lazy" referrerpolicy="no-referrer"></iframe>'+open;
+ }
+ return '<img class="shot tall" src="/api/shot?domain='+encodeURIComponent(domain)
+ +'" loading="lazy" onerror="shotFail(this,\''+domain+'\',540)">'+open;
+}
+
function toggleRow(safe, domain){
const det = $('#detail-'+safe), caret = $('#caret-'+safe);
if (!det) return;
@@ -212,21 +247,50 @@ function toggleRow(safe, domain){
const fw = $('#fw-'+safe);
if (fw && !fw.dataset.loaded){
fw.dataset.loaded = '1';
- fw.innerHTML = '<iframe src="https://'+domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
- + '<div class="frame-link"><a href="https://'+domain+'" target="_blank" rel="noopener">open https://'
- + domain+' ↗ (blank = site blocks embedding)</a></div>';
+ fw.innerHTML = frameHTML(domain);
}
}
}
+function applyEmbedResult(safe, domain, r){
+ const cell = $('#site-'+safe);
+ if (!cell) return;
+ const cls = r.embeddable ? 'ok' : r.reachable ? 'warn' : 'bad';
+ const txt = r.embeddable ? 'reachable' : r.reachable ? 'no-embed' : 'unreachable';
+ cell.innerHTML = '<span class="pill '+cls+'" title="'+(r.reason||'').replace(/"/g,'')+'">'+txt+'</span>';
+}
+
+async function runEmbedChecks(domains){
+ const myRun = ++embedRunId;
+ const queue = domains.slice();
+ async function worker(){
+ while(queue.length){
+ if (myRun !== embedRunId) return; // a redraw superseded this run
+ const domain = queue.shift();
+ const safe = domain.replace(/[^a-z0-9]/gi,'-');
+ let r = embedResults[domain];
+ if (!r){
+ try{
+ r = await (await fetch('/api/embedcheck?domain='+encodeURIComponent(domain))).json();
+ }catch(e){ r = {domain, reachable:false, embeddable:false, reason:'check failed'}; }
+ embedResults[domain] = r;
+ }
+ applyEmbedResult(safe, domain, r);
+ }
+ }
+ await Promise.all(Array.from({length:8}, worker)); // 8 concurrent
+}
+
function drawGrid(){
const rows = getRows();
$('#rowcount').textContent = rows.length + ' shown';
$('#grid').innerHTML = rows.map(d=>{
+ const safe = d.domain.replace(/[^a-z0-9]/gi,'-');
const deliv = d.deliverable ? pill('ok','yes') : pill('bad','no');
const iss = (d.issues||[]).length ? pill('warn',d.issues.join(', ')) : pill('ok','clean');
return '<div class="dcard">'
- + '<iframe class="thumb" src="https://'+d.domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
+ + '<img class="shot" src="/api/shot?domain='+encodeURIComponent(d.domain)
+ + '" loading="lazy" onerror="shotFail(this,\''+d.domain+'\',230)">'
+ '<div class="meta"><div class="dn"><a href="https://'+d.domain
+ '" target="_blank" rel="noopener">'+d.domain+'</a></div>'
+ '<div class="st">'+deliv+' '+iss+'</div></div></div>';
@@ -246,10 +310,12 @@ function drawTable(){
+ '<td>'+yn(d.passesMx)+'</td><td>'+yn(d.passesSpf)+'</td>'
+ '<td>'+yn(d.passesDkim)+'</td><td>'+yn(d.passesDmarc)+'</td>'
+ '<td>'+yn(d.hasRouting)+'</td><td>'+deliv+'</td>'
- + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td></tr>'
+ + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td>'
+ + '<td id="site-'+safe+'"><span class="pill pend">checking…</span></td></tr>'
+ '<tr class="detail" id="detail-'+safe+'" style="display:none">'
- + '<td colspan="8"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
- }).join('') || '<tr><td colspan="8" class="muted">no domains match</td></tr>';
+ + '<td colspan="9"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
+ }).join('') || '<tr><td colspan="9" class="muted">no domains match</td></tr>';
+ runEmbedChecks(rows.map(d => d.domain));
}
function setDensity(){
diff --git a/server.js b/server.js
index 4f87026..9d9080c 100644
--- a/server.js
+++ b/server.js
@@ -30,12 +30,123 @@ function buildStatus() {
};
}
+// --- embed-reachability check ----------------------------------------------
+// Server-side probe: is the site reachable, and does it allow being framed?
+// (X-Frame-Options / CSP frame-ancestors). Cached 15 min so the table fills
+// fast on reload and we don't hammer 200+ sites.
+const embedCache = new Map();
+const EMBED_TTL = 15 * 60 * 1000;
+
+async function embedCheck(domain) {
+ const hit = embedCache.get(domain);
+ if (hit && Date.now() - hit.ts < EMBED_TTL) return hit.result;
+ let result;
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), 9000);
+ try {
+ const r = await fetch('https://' + domain + '/', {
+ method: 'GET', redirect: 'follow', signal: ac.signal,
+ headers: { 'User-Agent': 'Mozilla/5.0 (purelymail-fleet-viewer embed-check)' },
+ });
+ const xfo = (r.headers.get('x-frame-options') || '').toLowerCase();
+ const csp = (r.headers.get('content-security-policy') || '').toLowerCase();
+ const fa = (csp.match(/frame-ancestors([^;]*)/) || [])[1];
+ const blockedByXFO = xfo.includes('deny') || xfo.includes('sameorigin');
+ const blockedByCSP = fa != null && !fa.includes('*');
+ const reachable = r.status > 0 && r.status < 500;
+ result = {
+ domain, reachable, status: r.status,
+ embeddable: reachable && !blockedByXFO && !blockedByCSP,
+ reason: !reachable ? ('HTTP ' + r.status)
+ : blockedByXFO ? 'blocks embedding (X-Frame-Options)'
+ : blockedByCSP ? 'blocks embedding (CSP frame-ancestors)' : 'ok',
+ };
+ } catch (e) {
+ result = { domain, reachable: false, status: 0, embeddable: false,
+ reason: e.name === 'AbortError' ? 'timeout / no response' : (e.cause && e.cause.code) || e.code || 'unreachable' };
+ } finally { clearTimeout(timer); }
+ embedCache.set(domain, { ts: Date.now(), result });
+ return result;
+}
+
+// --- website screenshot service --------------------------------------------
+// ~half the fleet blocks iframe embedding (X-Frame-Options / CSP). Screenshots
+// always render, so the grid + table previews show every domain reliably.
+// Captured via headless Chromium, disk-cached 24h, concurrency-limited to 3.
+const { chromium } = require('playwright');
+const SHOT_DIR = path.join(__dirname, 'data/shots');
+const SHOT_TTL = 24 * 60 * 60 * 1000;
+try { fs.mkdirSync(SHOT_DIR, { recursive: true }); } catch {}
+
+let browserP = null;
+const getBrowser = () => (browserP = browserP || chromium.launch({ args: ['--no-sandbox'] }));
+
+let active = 0;
+const shotQueue = [];
+function runQueue() {
+ while (active < 3 && shotQueue.length) {
+ const job = shotQueue.shift();
+ active++;
+ captureShot(job.domain).then(job.resolve, () => job.resolve({ ok: false }))
+ .finally(() => { active--; runQueue(); });
+ }
+}
+const queueShot = domain => new Promise(resolve => { shotQueue.push({ domain, resolve }); runQueue(); });
+
+async function captureShot(domain) {
+ const file = path.join(SHOT_DIR, domain + '.png');
+ try {
+ const st = fs.statSync(file);
+ if (Date.now() - st.mtimeMs < SHOT_TTL && st.size > 0) return { ok: true, file };
+ } catch {}
+ let page;
+ try {
+ page = await (await getBrowser()).newPage({ viewport: { width: 1280, height: 800 } });
+ await page.goto('https://' + domain + '/', { waitUntil: 'domcontentloaded', timeout: 15000 });
+ await page.waitForTimeout(1800);
+ await page.screenshot({ path: file, clip: { x: 0, y: 0, width: 1280, height: 800 } });
+ return { ok: true, file };
+ } catch (e) {
+ return { ok: false, error: e.message };
+ } finally {
+ if (page) await page.close().catch(() => {});
+ }
+}
+
const server = http.createServer((req, res) => {
+ if (req.url.startsWith('/api/shot')) {
+ const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
+ .replace(/[^a-z0-9.\-]/gi, '');
+ if (!domain) { res.writeHead(400); res.end(); return; }
+ queueShot(domain).then(r => {
+ if (r && r.ok) {
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
+ fs.createReadStream(r.file).pipe(res);
+ } else {
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: (r && r.error) || 'capture failed' }));
+ }
+ });
+ return;
+ }
if (req.url === '/api/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(buildStatus()));
return;
}
+ if (req.url.startsWith('/api/embedcheck')) {
+ const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
+ .replace(/[^a-z0-9.\-]/gi, '');
+ if (!domain) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{}'); return; }
+ embedCheck(domain).then(r => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(r));
+ }).catch(() => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ domain, reachable: false, embeddable: false, reason: 'check failed' }));
+ });
+ return;
+ }
try {
const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
← 7696460 viewer: grid view + density slider + collapsible per-domain
·
back to Purelymail Fleet Viewer
·
viewer: add fleet-cover hero with 1-up/4-up rotate toggle 2aafd6e →