Mail deliverability dashboard: findings with fixes, DMARC report ingestion, alerts on regressions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39659f0144
commit
6d912ddd22
11 files changed
+890
-18
No files matched your search
+52
-16
@@ -1029,23 +1029,58 @@ views.domains = async () => {
|
||||
// ----- mail egress -----
|
||||
const spfFor = ip => ip.includes(':') ? `v=spf1 ip6:${ip} ~all` : `v=spf1 ip4:${ip} ~all`;
|
||||
|
||||
const findingBadge = f => f.status === 'pass' ? badge('Pass', 'success') : f.status === 'fail' ? badge('Fix', 'danger')
|
||||
: f.status === 'none' ? (f.id.startsWith('mtasts:') ? badge('Optional') : badge('Set up', 'danger')) : badge('Check', 'warning');
|
||||
const findingsOk = e => !!e.check && (e.findings || []).every(f => f.status === 'pass' || f.status === 'warn' || (f.status === 'none' && f.id.startsWith('mtasts:')));
|
||||
|
||||
function checklist(e) {
|
||||
const c = e.check, ip = e.sourceIp, helo = e.heloName;
|
||||
const item = (ok, title, fix) => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||
h('span', {style:'margin-top:1px;flex:none'}, ok === true ? badge('Pass', 'success') : ok === false ? badge('Fix', 'danger') : badge('Check', 'warning')),
|
||||
h('div', {}, h('div', {}, title), fix ? h('div', {class:'dim small'}, fix) : null));
|
||||
const body = h('div', {},
|
||||
const c = e.check, ip = e.sourceIp;
|
||||
const row = f => h('div', {style:'display:flex;gap:10px;align-items:flex-start;margin-bottom:12px'},
|
||||
h('span', {style:'margin-top:1px;flex:none'}, findingBadge(f)),
|
||||
h('div', {}, h('div', {}, h('strong', {}, f.label + (f.domain ? ' · ' + f.domain : '')), ' ', f.detail), f.hint ? h('div', {class:'dim small'}, f.hint) : null));
|
||||
const o = c && c.outcomes;
|
||||
return h('div', {},
|
||||
c ? h('p', {class:'dim small'}, 'Checked ' + ago(c.checkedAt) + ' from the edge, using ' + ip + '.') : h('p', {class:'dim'}, 'No report yet. The edge sends one shortly after it picks up this egress.'),
|
||||
c ? item(c.ptrMatchesHelo, 'Reverse DNS of ' + ip + (c.ptr.length ? ' is ' + c.ptr.join(', ') : ' is not set'),
|
||||
c.ptrMatchesHelo ? null : `Receiving servers check this. Ask your VPS provider to set the reverse DNS (PTR) of ${ip} to ${helo}.`) : null,
|
||||
c ? item(c.heloResolves, helo + (c.heloResolves ? ' resolves to ' + ip : ' does not resolve to ' + ip),
|
||||
c.heloResolves ? null : `Create an A record: ${helo} → ${ip} (it must match the reverse DNS).`) : null,
|
||||
c ? item(c.port25 === 'open', 'Outbound port 25 from ' + ip + ': ' + (c.port25 === 'open' ? 'open' : 'blocked'),
|
||||
c.port25 === 'open' ? null : c.port25) : null,
|
||||
item(null, 'SPF', 'On each domain you send from, publish a TXT record, or add this address to your existing one:'),
|
||||
cmdBox(spfFor(ip)),
|
||||
item(null, 'DKIM and DMARC', 'Keep signing mail with DKIM in your mail server, as before. This relay passes the message through untouched, so signatures stay valid.'));
|
||||
return body;
|
||||
...(e.findings || []).map(row),
|
||||
!(e.findings || []).some(f => f.id.startsWith('spf:')) && c ? row({id:'spf', label:'SPF', status:'warn', detail:'Create a DKIM key for each domain you send from and the edge will check its SPF, DMARC and MTA-STS records too.', hint:'Meanwhile, publish this on each sending domain: ' + spfFor(ip)}) : null,
|
||||
o ? h('div', {style:'margin-top:16px'}, h('h3', {}, 'Delivery, last 24 hours'),
|
||||
h('p', {class:'small'}, `${o.delivered} delivered, ${o.deferred} deferred, ${o.failed} refused.`),
|
||||
Object.keys(o.byDomain || {}).length ? h('table', {}, h('thead', {}, h('tr', {}, ['Recipient domain', 'Delivered', 'Deferred', 'Refused'].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, Object.entries(o.byDomain).sort((a, b) => (b[1].failed + b[1].deferred) - (a[1].failed + a[1].deferred)).slice(0, 15).map(([d, n]) =>
|
||||
h('tr', {}, h('td', {}, d), h('td', {}, n.delivered), h('td', {}, n.deferred), h('td', {}, n.failed))))) : null,
|
||||
(o.recent || []).length ? h('div', {style:'margin-top:8px'}, h('div', {class:'dim small'}, 'Latest problems'),
|
||||
...o.recent.slice(0, 5).map(r => h('div', {class:'mono small'}, `${ago(r.time)} · ${r.domain} · ${r.code} ${r.text}`))) : null) : null);
|
||||
}
|
||||
|
||||
async function dmarcCard() {
|
||||
const doms = await api('GET', O('/dmarc'));
|
||||
const upload = h('input', {type:'file', multiple:true, accept:'.xml,.gz,.zip,.eml,.txt', style:'display:none'});
|
||||
upload.onchange = act(async () => {
|
||||
let added = 0, dup = 0, msgs = 0;
|
||||
for (const f of upload.files) {
|
||||
const r = await fetch(O('/dmarc'), {method:'POST', body: await f.arrayBuffer()});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(f.name + ': ' + (j.error || r.statusText));
|
||||
j.duplicate ? dup++ : (added++, msgs += j.messages);
|
||||
}
|
||||
upload.value = '';
|
||||
toast(`${added} report${added === 1 ? '' : 's'} added (${msgs} messages)` + (dup ? `, ${dup} already uploaded` : ''));
|
||||
refresh();
|
||||
});
|
||||
const pct = (a, b) => b ? Math.round(100 * a / b) + '%' : '-';
|
||||
return card('DMARC reports',
|
||||
h('p', {class:'dim'}, 'Add rua=mailto:you@yourdomain to the DMARC record of a domain and receivers send a daily summary of every message that claimed to be from it. Upload those emails or their attachments (.xml, .gz, .zip or .eml) to see what passed and which senders are failing. Reports for the last 30 days are shown.'),
|
||||
doms.length ? h('div', {}, ...doms.map(d => h('div', {style:'margin-bottom:18px'},
|
||||
h('div', {class:'row-h'}, h('strong', {}, d.domain), ' ', badge('Policy: ' + (d.policy || '?')), ' ',
|
||||
badge(pct(d.passed, d.messages) + ' passed', d.messages && d.passed / d.messages >= 0.9 ? 'success' : 'danger'),
|
||||
h('span', {class:'dim small', style:'margin-left:8px'}, `${d.messages} messages, ${d.reports} reports from ${d.reporters.join(', ')}`),
|
||||
h('button', {class:'ghost danger', style:'margin-left:auto', onclick: ask({title:`Delete the DMARC reports for ${d.domain}?`, body:'The uploaded reports are removed. You can upload them again.', confirm:'Delete reports'}, async () => { await api('DELETE', O('/dmarc/' + d.domain)); refresh(); })}, 'Delete')),
|
||||
...(d.advice || []).map(a => h('p', {class:'small'}, a)),
|
||||
h('table', {}, h('thead', {}, h('tr', {}, ['Sending address', 'Messages', 'Passed', 'DKIM', 'SPF', 'Fix'].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, d.sources.slice(0, 12).map(s => h('tr', {}, h('td', {class:'mono'}, s.ip, s.ours ? h('span', {style:'margin-left:6px'}, badge('Your relay', 'accent')) : null),
|
||||
h('td', {}, s.messages), h('td', {}, pct(s.passed, s.messages)), h('td', {}, pct(s.dkimPass, s.messages)), h('td', {}, pct(s.spfPass, s.messages)),
|
||||
h('td', {class:'dim small'}, s.hint || '')))))))) : h('p', {class:'dim'}, 'No reports yet.'),
|
||||
h('div', {class:'row-h'}, h('button', {class:'ghost', onclick:() => upload.click()}, icon('add', 16), 'Upload reports'), upload));
|
||||
}
|
||||
|
||||
views.egress = async () => {
|
||||
@@ -1062,7 +1097,7 @@ views.egress = async () => {
|
||||
else if (e.status) { tone = 'danger'; label = 'Error'; why = e.status; }
|
||||
else { tone = 'success'; label = 'Listening'; why = 'listening'; }
|
||||
}
|
||||
const good = c && c.ptrMatchesHelo && c.heloResolves && c.port25 === 'open';
|
||||
const good = findingsOk(e);
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, badge(label, tone)),
|
||||
h('td', {}, e.name, e.statusKnown && e.status ? h('div', {class:'err small mono'}, e.status) : null,
|
||||
@@ -1081,6 +1116,7 @@ views.egress = async () => {
|
||||
!remote.length ? h('div', {class:'notice'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
||||
h('div', {class:'card flush'}, table),
|
||||
await dkimCard(),
|
||||
await dmarcCard(),
|
||||
card('How it works', h('p', {class:'dim'}, 'Bamboo opens a small SMTP relay on your mail server\'s network. Point your mail server at it, and each outgoing message is carried through the WireGuard tunnel to the edge, which delivers it straight to the recipient\'s mail server from the edge\'s IP, announcing itself with the HELO name you set. The recipient\'s replies come straight back, so your server\'s queue and retries work as usual.'),
|
||||
cmdBox('# Postfix (main.cf)\nrelayhost = [127.0.0.1]:2525\n\n# Exim: use a smarthost router pointing at 127.0.0.1 port 2525')));
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user