1// Domain, record and blacklist tools. 2 3import { createResolver, parseDomainInput, parseIpv4 } from './dns.js'; 4import { checkSpf, checkDmarc, checkDkim, checkBimi, checkMx, checkMtaSts, checkTlsRpt } from './records.js'; 5import { checkIpBlacklists, checkDomainBlacklists, TIER_TEXT } from './blacklists.js'; 6import { checkDomainHealth } from './domain-check.js'; 7import { apiFetchers } from './remote.js'; 8import { esc, pill, card, findingsList, recordBlock, tagsTable, statusMessage, scoreRing, readParams, writeParams, track, fmtNum } from './ui.js'; 9 10const TOOL_PAGES = { 11 spf: '/tools/spf-checker', 12 dkim: '/tools/dkim-checker', 13 dmarc: '/tools/dmarc-checker', 14 bimi: '/tools/bimi-checker', 15 'mta-sts': '/tools/mta-sts-checker', 16 'tls-rpt': '/tools/tls-rpt-checker', 17 mx: '/tools/domain-check', 18 blacklists: '/tools/domain-blacklist-checker', 19}; 20 21const fetchers = apiFetchers(); 22 23function spfTree(node, depth = 0) { 24 if (!node) return ''; 25 const kids = (node.terms || []).filter((t) => t.child); 26 const own = node.lookups ? ` <span class="cost">(${node.lookups} lookup${node.lookups === 1 ? '' : 's'})</span>` : ''; 27 const errs = node.errors && node.errors.length && depth > 0 ? ` <span class="err">â ${esc(node.errors[0])}</span>` : ''; 28 return `<li>${esc(node.domain)}${own}${errs}${kids.length ? `<ul>${kids.map((t) => spfTree(t.child, depth + 1)).join('')}</ul>` : ''}</li>`; 29} 30 31function extraFor(r) { 32 const x = r.extra || {}; 33 let out = ''; 34 if (r.id === 'spf' && x.tree) { 35 out += `<div><div class="section-label">DNS lookups: ${x.lookups} of 10</div><div class="bar"><span style="width:${Math.min(100, x.lookups * 10)}%;background:${x.lookups > 10 ? 'var(--fail)' : x.lookups >= 9 ? 'var(--warn)' : 'var(--pass)'}"></span></div></div>`; 36 if (x.tree.children.length) out += `<div><div class="section-label">Include tree</div><ul class="tree">${spfTree(x.tree)}</ul></div>`; 37 } 38 if (r.id === 'dkim' && x.selectors && x.selectors.length > 1) { 39 out += `<div class="table-wrap"><table class="data"><thead><tr><th>Selector</th><th>Key</th><th>Status</th><th>Managed by</th></tr></thead><tbody>${x.selectors.map((s) => `<tr><td class="mono">${esc(s.selector)}</td><td>${s.revoked ? 'revoked' : s.key && s.key.bits ? `${esc(s.key.type)} ${s.key.bits}-bit` : 'unreadable'}</td><td>${pill(s.status)}</td><td class="mono dim">${esc(s.cname || 'this domain')}</td></tr>`).join('')}</tbody></table></div>`; 40 } 41 if (r.id === 'mx' && x.hosts) { 42 out += `<div class="table-wrap"><table class="data"><thead><tr><th>Priority</th><th>Host</th><th>Provider</th><th>IPv4</th></tr></thead><tbody>${x.hosts.map((h) => `<tr><td class="num">${esc(h.priority)}</td><td class="mono">${esc(h.host)}</td><td>${esc(h.provider || '')}</td><td class="mono">${h.addresses.map((ip) => `<a href="/tools/ip-blacklist-checker?ip=${esc(ip)}">${esc(ip)}</a>`).join('<br>')}</td></tr>`).join('')}</tbody></table></div>`; 43 } 44 if (r.id === 'bimi') { 45 if (x.logoUrl && /^https:\/\//i.test(x.logoUrl)) { 46 // Loaded on request: fetching it tells the domain owner who looked. 47 out += `<div class="logo-preview" data-logo="${esc(x.logoUrl)}"><button type="button" class="linkish" data-show-logo>Show the logo</button><span class="tool-hint" style="margin:0">Loads it from the domain's own server, in the circle (Gmail, Yahoo) and rounded-square (Apple Mail) crops inboxes use.</span></div>`; 48 } 49 if (x.assets && x.assets.cert) { 50 const c = x.assets.cert; 51 out += `<dl class="kv"><dt>Certificate for</dt><dd>${esc(c.subject.O || c.subject.CN || '')}</dd><dt>Issued by</dt><dd>${esc(c.issuer.O || c.issuer.CN || '')}</dd><dt>Valid</dt><dd>${esc((c.notBefore || '').slice(0, 10))} to ${esc((c.notAfter || '').slice(0, 10))}</dd>${c.sans.length ? `<dt>Domains</dt><dd>${esc(c.sans.join(', '))}</dd>` : ''}</dl>`; 52 } 53 } 54 if (r.id === 'mta-sts' && x.policy) { 55 out += `<div><div class="section-label">Policy file (${x.policySource === 'pasted' ? 'pasted' : esc(x.policyUrl)})</div>${recordBlock([`version: ${x.policy.version || ''}`, `mode: ${x.policy.mode || ''}`, ...x.policy.mx.map((m) => `mx: ${m}`), `max_age: ${x.policy.maxAge ?? ''}`].join('\n'))}</div>`; 56 } 57 return out; 58} 59 60export function renderRecord(r, { collapsible = false, open = true, link = null } = {}) { 61 let body = ''; 62 if (r.records && r.records.length > 1 && r.id !== 'dkim') body += `<div><div class="section-label">${r.records.length}
62 records found</div>${r.records.map(recordBlock).join('')}</div>`; 63 else if (r.record) body += recordBlock(r.record); 64 body += extraFor(r); 65 body += findingsList(r.findings); 66 body += tagsTable(r.tags); 67 return card({ title: r.title, status: r.status, headline: r.headline, sub: r.extra && r.extra.queried ? r.extra.queried : '', body, collapsible, open, link }); 68} 69 70function blacklistTable(res) { 71 const order = ['major', 'standard', 'network']; 72 const rows = res.rows.slice().sort((a, b) => order.indexOf(a.tier) - order.indexOf(b.tier) || (a.state === 'listed' ? -1 : 0) - (b.state === 'listed' ? -1 : 0)); 73 let tier = null; 74 let html = ''; 75 for (const row of rows) { 76 if (row.tier !== tier) { 77 tier = row.tier; 78 html += `<tr><th colspan="3">${esc(TIER_TEXT[tier] || tier)}</th></tr>`; 79 } 80 const detail = row.state === 'listed' 81 ? `${row.reason ? esc(row.reason) : `Returned ${esc((row.codes || []).join(', '))}`}` 82 : row.state === 'unavailable' ? esc(row.note || '') : esc(row.about || ''); 83 html += `<tr class="${row.state === 'listed' ? (row.tier === 'major' ? 'lvl-fail' : 'lvl-warn') : ''}"><td><a href="${esc(row.lookup)}" rel="noopener" target="_blank">${esc(row.name)}</a><div class="card-sub">${esc(row.zone)}</div></td><td>${pill(row.state === 'clean' ? 'clean' : row.state)}</td><td class="dim">${detail}</td></tr>`; 84 } 85 return `<div class="table-wrap"><table class="data"><thead><tr><th>List</th><th>Result</th><th>Detail</th></tr></thead><tbody>${html}</tbody></table></div>`; 86} 87 88export function renderBlacklist(res, title) { 89 const findings = []; 90 if (res.note) findings.push({ level: 'info', text: res.note }); 91 const listed = res.rows.filter((r) => r.state === 'listed'); 92 for (const l of listed) findings.push({ level: l.tier === 'major' ? 'fail' : 'warn', text: `Listed on ${l.name}. Follow its lookup link for the reason and removal steps; most lists delist automatically once the cause stops.` }); 93 if (!listed.length && res.checked) findings.push({ level: 'pass', text: `Not listed on any of the ${res.checked} lists that answered.` }); 94 if (res.unavailable) findings.push({ level: 'info', text: `${res.unavailable} list${res.unavailable === 1 ? '' : 's'} could not be queried from a public resolver and ${res.unavailable === 1 ? 'is' : 'are'} marked Unavailable rather than guessed. Check ${res.unavailable === 1 ? 'it' : 'them'} with the link.` }); 95 return card({ title: title || res.target, status: res.status, headline: res.headline, sub: res.kind === 'ip' ? `IPv4 ${res.target}` : res.target, body: findingsList(findings) + (res.rows.length ? blacklistTable(res) : '') }); 96} 97 98function domainCheckView(out) { 99 const counts = out.checks.reduce((m, c) => { m[c.status] = (m[c.status] || 0) + 1; return m; }, {}); 100 const gradeText = { strong: 'Strong setup', fair: 'Room to improve', weak: 'Needs work', unknown: 'Incomplete' }[out.grade]; 101 const tiles = out.checks.map((c) => `<a class="check-tile" href="#check-${esc(c.id)}"><div class="t">${esc(c.title)} ${pill(c.status)}</div><div class="h">${esc(c.headline)}</div></a>`).join(''); 102 const fixes = out.fixes.length 103 ? `<section class="card"><div class="card-head"><span class="card-title">What to fix first</span></div><div class="card-body"><ol class="fix-list">${out.fixes.map((f) => `<li><div><strong>${esc(f.title)}.</strong> ${esc(f.text)}</div></li>`).join('')}</ol></div></section>` 104 : ''; 105 const cards = out.checks.map((c) => { 106 const link = TOOL_PAGES[c.id] && c.id !== 'mx' ? { href: `${TOOL_PAGES[c.id]}?domain=${encodeURIComponent(out.domain)}`, text: 'Open checker â' } : null; 107 const html = c.id === 'blacklists' 108 ? card({ title: 'Blacklists', status: c.status, headline: c.headline, body: findingsList(c.findings) + [c.extra.domain, ...c.extra.ips].map((b) => `<div><div class="section-label">${esc(b.kind === 'ip' ? `Mail server ${b.target}` : `Domain ${b.target}`)} · ${esc(b.headline)}</div>
108${blacklistTable(b)}</div>`).join(''), collapsible: true, open: c.status === 'fail' || c.status === 'warn' }) 109 : renderRecord(c, { collapsible: true, open: c.status === 'fail' || c.status === 'warn', link }); 110 return `<div id="check-${esc(c.id)}">${html}</div>`; 111 }).join(''); 112 return `<section class="card"><div class="score-card">${scoreRing(out.score)}<div class="score-meta"><h3>${esc(out.domain)}: ${esc(gradeText)}</h3><p>${counts.pass || 0} passing, ${(counts.warn || 0)} with warnings, ${counts.fail || 0} failing${counts.info ? `, ${counts.info} optional not set up` : ''}. ${out.partial ? 'Some checks could not run, so the score covers only the rest. ' : ''}Checked ${fmtNum(out.dnsQueries)} DNS records.</p></div></div><div class="check-grid">${tiles}</div></section>${fixes}${cards}`; 113} 114 115function crossLinks(domain, current) { 116 const items = [ 117 ['domain-check', 'Full domain check', '/tools/domain-check'], 118 ['spf', 'SPF', '/tools/spf-checker'], 119 ['dkim', 'DKIM', '/tools/dkim-checker'], 120 ['dmarc', 'DMARC', '/tools/dmarc-checker'], 121 ['bimi', 'BIMI', '/tools/bimi-checker'], 122 ['mta-sts', 'MTA-STS', '/tools/mta-sts-checker'], 123 ['tls-rpt', 'TLS-RPT', '/tools/tls-rpt-checker'], 124 ['domain-blacklist', 'Blacklists', '/tools/domain-blacklist-checker'], 125 ].filter(([id]) => id !== current); 126 return `<p class="tool-hint" style="text-align:center;margin-top:1.5rem">Check ${esc(domain)} for: ${items.map(([, label, href]) => `<a href="${href}?domain=${encodeURIComponent(domain)}">${label}</a>`).join(' · ')}</p>`; 127} 128 129const RECORD_RUNNERS = { 130 spf: (dns, d) => checkSpf(dns, d), 131 dmarc: (dns, d) => checkDmarc(dns, d), 132 dkim: (dns, d, f) => checkDkim(dns, d, f.selector), 133 bimi: (dns, d, f) => checkBimi(dns, d, f.selector, fetchers), 134 'mta-sts': (dns, d, f) => checkMtaSts(dns, d, { ...fetchers, policyText: f.policy || null }), 135 'tls-rpt': (dns, d) => checkTlsRpt(dns, d), 136 mx: (dns, d) => checkMx(dns, d), 137}; 138 139export function mount(tool, form, results, setError) { 140 const field = (n) => form.elements.namedItem(n); 141 const val = (n) => (field(n) ? field(n).value.trim() : ''); 142 const button = form.querySelector('button[type=submit]'); 143 let running = false; 144 145 async function run() { 146 if (running) return; 147 setError(''); 148 const dns = createResolver(); 149 const started = performance.now(); 150 try { 151 running = true; 152 button.disabled = true; 153 if (tool === 'ip-blacklist') { 154 const ip = val('ip'); 155 if (!parseIpv4(ip)) { setError(ip.includes(':') ? 'IPv6 is not supported: almost no blacklist publishes IPv6 data.' : 'Enter a valid IPv4 address, like 203.0.113.10.'); return; } 156 writeParams({ ip }); 157 results.innerHTML = statusMessage(`Checking ${ip} against IP blacklistsâ¦`); 158 const res = await checkIpBlacklists(dns, ip); 159 results.innerHTML = renderBlacklist(res, 'IP blacklists') + (res.note && !res.rows.length ? `<p class="tool-hint">${esc(res.note)}</p>` : ''); 160 track('tool_run', { tool, status: res.status }); 161 return; 162 } 163 const parsed = parseDomainInput(val('domain')); 164 if (parsed.error) { setError(parsed.error); return; } 165 const domain = parsed.domain; 166 if (field('domain')) field('domain').value = domain; 167 const extra = { selector: val('selector'), policy: val('policy') }; 168 writeParams({ domain, selector: extra.selector }); 169 if (tool === 'domain-check') { 170 results.innerHTML = statusMessage(`Checking ${domain}: SPF, DKIM, DMARC, MX, MTA-STS, TLS-RPT, BIMI and blacklistsâ¦`); 171 const out = await checkDomainHealth(dns, domain, { ...fetchers, dkimSelector: extra.selector }); 172 results.innerHTML = domainCheckView(out) + crossLinks(domain, 'domain-check'); 173 track('tool_run', { tool, status: out.grade, score: out.score }); 174 return; 175 } 176 if (tool === 'domain-blacklist') { 177 results.innerHTML = statusMessage(`Checking ${domain} against domain blacklistsâ¦`); 178 const res = await checkDomainBlacklists(dns, domain); 179 results.innerHTML = renderBlacklist(res, 'Domain blacklists') + crossLinks(domain, 'domain-blacklist'); 180 track('tool_run', { tool, status: res.status }); 181 return; 182 } 183 results.innerHTML = statusMessage(`Looking up ${domain}â¦`); 184 const r = await RECORD_RUNNERS[tool](dns, domain, extra); 185 let html = renderRecord(r); 186 if (tool === 'mta-sts' && r.record && !(r.extra && r.extra.policy)) { 187 html += `<section class="card"><div class="card-head"><span class="card-title">Check a policy file by hand</span></div><div class="card-body"><p class="tool-hint" style="margin:0">Paste the contents of <code>${esc(r.extra.policyUrl)}</code> (or a draft you are about to publish) to check it against this domain's MX hosts.</p><textarea class="tool-textarea" id="policy-paste" spellcheck="false" placeholder="version: STSv1 mode: enforce mx: mx1.example.com max_age: 604800"></textarea><div class="tool-actions"><button type="button" class="run-btn" id="policy-check">Check pasted policy</button></div></div></section>`; 188 } 189 results.innerHTML = html + crossLinks(domain, tool); 190 const pb = results.querySelector('#policy-check'); 191 if (pb) pb.addEventListener('click', () => { 192 const text = results.querySelector('#policy-paste').value; 193 if (!text.trim()) return; 194 if (!field('policy')) { const h = document.createElement('input'); h.type = 'hidden'; h.name = 'policy'; form.appendChild(h); } 195 field('policy').value = text; 196 run(); 197 }); 198 track('tool_run', { tool, status: r.status }); 199 } catch (e) { 200 results.innerHTML = ''; 201 setError(`Something went wrong: ${e.message}`); 202 } finally { 203 running = false;
204 button.disabled = false; 205 if (field('policy') && field('policy').type === 'hidden') field('policy').value = ''; 206 results.dataset.ms = String(Math.round(performance.now() - started)); 207 } 208 } 209 210 form.addEventListener('submit', (e) => { e.preventDefault(); run(); }); 211 results.addEventListener('click', (e) => { 212 const b = e.target.closest('[data-show-logo]'); 213 if (!b) return; 214 const box = b.closest('[data-logo]'); 215 const src = box.getAttribute('data-logo'); 216 box.textContent = ''; 217 for (const round of [true, false]) { 218 const img = document.createElement('img'); 219 img.src = src; 220 img.alt = round ? 'BIMI logo, round crop' : 'BIMI logo, square crop'; 221 img.referrerPolicy = 'no-referrer'; 222 if (round) img.className = 'round'; 223 box.appendChild(img); 224 } 225 }); 226 const p = readParams(); 227 let auto = false; 228 for (const k of ['domain', 'selector', 'ip']) if (p[k] && field(k)) { field(k).value = p[k]; if (k !== 'selector') auto = true; } 229 if (p.email && field('domain')) { field('domain').value = p.email; auto = true; } 230 if (auto) run(); 231}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.