mail: make status a tristate

status can be 'passed', 'failed' or 'skipped'. The motivation for this
change is that when using a relay, we can provide a message indicating
why the check was skipped.
This commit is contained in:
Girish Ramakrishnan
2025-06-27 18:51:03 +02:00
parent ec17e58eed
commit 9428cf0d06
4 changed files with 226 additions and 274 deletions
+145 -194
View File
@@ -171,45 +171,30 @@ function validateDisplayName(name) {
}
async function checkOutboundPort25() {
const relay = {
value: 'OK',
status: false,
errorMessage: ''
};
return await new Promise((resolve) => {
const client = new net.Socket();
client.setTimeout(5000);
client.connect({ port: 25, host: constants.PORT25_CHECK_SERVER, family: 4 }); // family is 4 to keep it predictable
client.on('connect', function () {
relay.status = true;
relay.value = 'OK';
client.destroy(); // do not use end() because it still triggers timeout
resolve(relay);
resolve({ status: 'passed', message: 'Port 25 (outbound) is unblocked' });
});
client.on('timeout', function () {
relay.status = false;
relay.value = `Connect to ${constants.PORT25_CHECK_SERVER} timed out. Check if port 25 (outbound) is blocked`;
client.destroy();
relay.errorMessage = `Connect to ${constants.PORT25_CHECK_SERVER} timed out.`;
resolve(relay);
resolve({ status: 'failed', message: `Connect to ${constants.PORT25_CHECK_SERVER} timed out. Check if port 25 (outbound) is blocked` });
});
client.on('error', function (error) {
relay.status = false;
relay.value = `Connect to ${constants.PORT25_CHECK_SERVER} failed: ${error.message}. Check if port 25 (outbound) is blocked`;
client.destroy();
relay.errorMessage = `Connect to ${constants.PORT25_CHECK_SERVER} failed: ${error.message}`;
resolve(relay);
resolve({ status: 'failed', message: `Connect to ${constants.PORT25_CHECK_SERVER} failed: ${error.message}. Check if port 25 (outbound) is blocked` });
});
});
}
async function checkSmtpRelay(relay) {
const result = {
value: 'OK',
status: false,
errorMessage: ''
};
assert.strictEqual(typeof relay, 'object');
if (relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (relay.provider === 'cloudron-smtp') return await checkOutboundPort25();
const options = {
connectionTimeout: 5000,
@@ -233,133 +218,122 @@ async function checkSmtpRelay(relay) {
const transporter = nodemailer.createTransport(options);
const [error] = await safe(transporter.verify());
result.status = !error;
if (error) {
result.value = result.errorMessage = error.message;
return result;
}
const result = {
status: error ? 'failed' : 'passed',
message: error ? error.message : `Connection to ${relay.host}:${relay.port} succeeded`
};
return result;
}
async function verifyRelay(relay) {
assert.strictEqual(typeof relay, 'object');
// we used to verify cloudron-smtp with checkOutboundPort25 but that is unreliable given that we just
// randomly select some smtp server
if (relay.provider === 'cloudron-smtp' || relay.provider === 'noop') return null;
const result = await checkSmtpRelay(relay);
if (result.errorMessage) return new BoxError(BoxError.BAD_FIELD, result.errorMessage);
}
async function checkDkim(mailDomain) {
assert.strictEqual(typeof mailDomain, 'object');
const domain = mailDomain.domain;
const dkim = {
if (mailDomain.relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (mailDomain.relay.provider !== 'cloudron-smtp') return { status: 'skipped', message: 'DKIM check skipped, email is sent through a relay service' };
const { domain } = mailDomain;
const result = {
domain: `${mailDomain.dkimSelector}._domainkey.${domain}`,
name: `${mailDomain.dkimSelector}._domainkey`,
type: 'TXT',
expected: null,
value: null,
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const publicKey = mailDomain.dkimKey.publicKey.split('\n').slice(1, -2).join(''); // remove header, footer and new lines
dkim.expected = `v=DKIM1; t=s; p=${publicKey}`;
result.expected = `v=DKIM1; t=s; p=${publicKey}`;
const [error, txtRecords] = await safe(dig.resolve(dkim.domain, dkim.type, DNS_OPTIONS));
if (error) {
dkim.errorMessage = error.message;
return dkim;
}
const [error, txtRecords] = await safe(dig.resolve(result.domain, result.type, DNS_OPTIONS));
if (error) return Object.assign(result, { status: 'failed', message: error.message });
if (txtRecords.length === 0) return Object.assign(result, { status: 'failed', message: 'No DKIM record' });
if (txtRecords.length !== 0) {
dkim.value = txtRecords[0].join('');
const actual = txtToDict(dkim.value);
dkim.status = actual.p === publicKey;
}
result.value = txtRecords[0].join('');
const actual = txtToDict(result.value);
result.status = actual.p === publicKey ? 'passed' : 'failed';
return dkim;
return result;
}
async function checkSpf(domain, mailFqdn) {
assert.strictEqual(typeof domain, 'string');
async function checkSpf(mailDomain, mailFqdn) {
assert.strictEqual(typeof mailDomain, 'object');
assert.strictEqual(typeof mailFqdn, 'string');
const spf = {
domain,
if (mailDomain.relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (mailDomain.relay.provider !== 'cloudron-smtp') return { status: 'skipped', message: 'SPF check skipped. Please check that the relay provider has correct SPF settings for this domain' };
const result = {
domain: mailDomain.domain,
name: '@',
type: 'TXT',
value: null,
expected: `v=spf1 a:${mailFqdn} ~all`,
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const [error, txtRecords] = await safe(dig.resolve(spf.domain, spf.type, DNS_OPTIONS));
if (error) {
spf.errorMessage = error.message;
return spf;
}
const [error, txtRecords] = await safe(dig.resolve(result.domain, result.type, DNS_OPTIONS));
if (error) return Object.assign(result, { message: error.message });
let i;
for (i = 0; i < txtRecords.length; i++) {
const txtRecord = txtRecords[i].join(''); // https://agari.zendesk.com/hc/en-us/articles/202952749-How-long-can-my-SPF-record-be-
if (txtRecord.indexOf('v=spf1 ') !== 0) continue; // not SPF
spf.value = txtRecord;
spf.status = spf.value.indexOf(` a:${mailFqdn}`) !== -1;
result.value = txtRecord;
result.status = result.value.indexOf(` a:${mailFqdn}`) !== -1 ? 'passed' : 'failed';
break;
}
if (spf.status) {
spf.expected = spf.value;
if (result.status === 'passed') {
result.expected = result.value;
} else if (i !== txtRecords.length) {
spf.expected = `v=spf1 a:${mailFqdn} ` + spf.value.slice('v=spf1 '.length);
result.expected = `v=spf1 a:${mailFqdn} ` + result.value.slice('v=spf1 '.length);
}
return spf;
return result;
}
async function checkMx(domain, mailFqdn) {
assert.strictEqual(typeof domain, 'string');
async function checkMx(mailDomain, mailFqdn) {
assert.strictEqual(typeof mailDomain, 'object');
assert.strictEqual(typeof mailFqdn, 'string');
const mx = {
if (!mailDomain.enabled) return { status: 'skipped', message: 'MX check skipped, server does not handle incoming email for this domain' };
const { domain } = mailDomain;
const result = {
domain,
name: '@',
type: 'MX',
value: null,
expected: `10 ${mailFqdn}.`,
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const [error, mxRecords] = await safe(dig.resolve(mx.domain, mx.type, DNS_OPTIONS));
if (error) {
mx.errorMessage = error.message;
return mx;
}
if (mxRecords.length === 0) return mx;
const [error, mxRecords] = await safe(dig.resolve(result.domain, result.type, DNS_OPTIONS));
if (error) return Object.assign(result, { status: 'failed', message: error.message });
if (mxRecords.length === 0) return Object.assign(result, { status: 'failed', message: 'No MX record' });
mx.status = mxRecords.some(mx => mx.exchange === mailFqdn); // this lets use change priority and/or setup backup MX
mx.value = mxRecords.map(function (r) { return r.priority + ' ' + r.exchange + '.'; }).join(' ');
result.status = mxRecords.some(mx => mx.exchange === mailFqdn) ? 'passed' : 'failed'; // this lets use change priority and/or setup backup MX
result.value = mxRecords.map(function (r) { return r.priority + ' ' + r.exchange + '.'; }).join(' ');
if (mx.status) return mx; // MX record is "my."
if (result.status === 'passed') return result; // MX record is "my."
// cloudflare might create a conflict subdomain (https://support.cloudflare.com/hc/en-us/articles/360020296512-DNS-Troubleshooting-FAQ)
const [error2, mxIps] = await safe(dig.resolve(mxRecords[0].exchange, 'A', DNS_OPTIONS));
if (error2 || mxIps.length !== 1) return mx;
if (error2 || mxIps.length !== 1) return result;
const [error3, ip] = await safe(network.getIPv4());
if (error3) return mx;
if (error3) return result;
mx.status = mxIps[0] === ip;
result.status = mxIps[0] === ip ? 'passed' : 'failed';
return mx;
return result;
}
function txtToDict(txt) {
@@ -371,57 +345,54 @@ function txtToDict(txt) {
return dict;
}
async function checkDmarc(domain) {
async function checkDmarc(mailDomain) {
assert.strictEqual(typeof mailDomain, 'object');
const dmarc = {
if (!mailDomain.enabled) return { status: 'skipped', message: 'DMARC check skipped, server does not handle incoming email for this domain' };
const { domain } = mailDomain;
const result = {
domain: `_dmarc.${domain}`,
name: '_dmarc',
type: 'TXT',
value: null,
expected: 'v=DMARC1; p=reject; pct=100',
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const [error, txtRecords] = await safe(dig.resolve(dmarc.domain, dmarc.type, DNS_OPTIONS));
const [error, txtRecords] = await safe(dig.resolve(result.domain, result.type, DNS_OPTIONS));
if (error) return Object.assign(result, { status: 'failed', message: error.message });
if (txtRecords.length === 0) return Object.assign(result, { status: 'failed', message: 'No DMARC records' });
if (error) {
dmarc.errorMessage = error.message;
return dmarc;
}
result.value = txtRecords[0].join('');
const actual = txtToDict(result.value);
result.status = actual.v === 'DMARC1' ? 'passed' : 'failed'; // see box#666
if (txtRecords.length !== 0) {
dmarc.value = txtRecords[0].join('');
const actual = txtToDict(dmarc.value);
dmarc.status = actual.v === 'DMARC1'; // see box#666
}
return dmarc;
return result;
}
async function checkPtr6(mailFqdn) {
async function checkPtr6(mailDomain, mailFqdn) {
assert.strictEqual(typeof mailDomain, 'object');
assert.strictEqual(typeof mailFqdn, 'string');
const ptr = {
if (mailDomain.relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (mailDomain.relay.provider !== 'cloudron-smtp') return { status: 'skipped', message: 'PTR6 check was skipped, email is sent through a relay service' };
const result = {
domain: null,
name: null,
type: 'PTR',
value: null,
expected: mailFqdn, // any trailing '.' is added by client software (https://lists.gt.net/spf/devel/7918)
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const [error, ip] = await safe(network.getIPv6());
if (error) {
ptr.errorMessage = error.message;
return ptr;
}
if (ip === null) {
ptr.status = true;
ptr.expected = 'Check skipped, server has no IPv6';
return ptr;
}
if (error) return Object.assign(result, { status: 'failed', message: error.message });
if (ip === null) return Object.assign(result, { status: 'skipped', message: 'PTR6 check was skipped, server has no IPv6' });
function expandIPv6(ipv6) {
const parts = ipv6.split('::');
@@ -436,65 +407,54 @@ async function checkPtr6(mailFqdn) {
const reversed = expanded.split('').reverse().join('');
const reversedWithDots = reversed.split('').join('.');
ptr.domain = `${reversedWithDots}.ip6.arpa`;
ptr.name = ip;
result.domain = `${reversedWithDots}.ip6.arpa`;
result.name = ip;
const [error2, ptrRecords] = await safe(dig.resolve(ptr.domain, 'PTR', DNS_OPTIONS));
if (error2) {
ptr.errorMessage = error2.message;
return ptr;
}
const [error2, ptrRecords] = await safe(dig.resolve(result.domain, 'PTR', DNS_OPTIONS));
if (error2) return Object.assign(result, { status: 'failed', message: error2.message });
if (ptrRecords.length === 0) return Object.assign(result, { status: 'failed', message: 'No PTR6 record' });
if (ptrRecords.length !== 0) {
ptr.value = ptrRecords.join(' ');
ptr.status = ptrRecords.some(function (v) { return v === ptr.expected; });
}
result.value = ptrRecords.join(' ');
result.status = ptrRecords.some(function (v) { return v === result.expected; }) ? 'passed' : 'failed';
return ptr;
return result;
}
async function checkPtr4(mailFqdn) {
async function checkPtr4(mailDomain, mailFqdn) {
assert.strictEqual(typeof mailDomain, 'object');
assert.strictEqual(typeof mailFqdn, 'string');
const ptr = {
if (mailDomain.relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (mailDomain.relay.provider !== 'cloudron-smtp') return { status: 'skipped', message: 'PTR4 check was skipped, email is sent through a relay service' };
const result = {
domain: null,
name: null,
type: 'PTR',
value: null,
expected: mailFqdn, // any trailing '.' is added by client software (https://lists.gt.net/spf/devel/7918)
status: false,
errorMessage: ''
status: 'failed',
message: ''
};
const [error, ip] = await safe(network.getIPv4());
if (error) {
ptr.errorMessage = error.message;
return ptr;
}
if (ip === null) {
ptr.status = true;
ptr.expected = 'Check skipped, server has no IPv4';
return ptr;
}
if (error) return Object.assign(result, { status: 'failed', message: error.message });
if (ip === null) return Object.assign(result, { status: 'skipped', message: 'PTR4 check was skipped, server has no IPv4' });
ptr.domain = ip.split('.').reverse().join('.') + '.in-addr.arpa';
ptr.name = ip;
result.domain = ip.split('.').reverse().join('.') + '.in-addr.arpa';
result.name = ip;
const [error2, ptrRecords] = await safe(dig.resolve(ptr.domain, 'PTR', DNS_OPTIONS));
if (error2) {
ptr.errorMessage = error2.message;
return ptr;
}
const [error2, ptrRecords] = await safe(dig.resolve(result.domain, 'PTR', DNS_OPTIONS));
if (error2) return Object.assign(result, { status: 'failed', message: error2.message });
if (ptrRecords.length === 0) return Object.assign(result, { status: 'failed', message: 'No PTR4 record' });
if (ptrRecords.length !== 0) {
ptr.value = ptrRecords.join(' ');
ptr.status = ptrRecords.some(function (v) { return v === ptr.expected; });
}
result.value = ptrRecords.join(' ');
result.status = ptrRecords.some(function (v) { return v === result.expected; }) ? 'passed' : 'failed';
return ptr;
return result;
}
// https://raw.githubusercontent.com/jawsome/node-dnsbl/master/list.json
// https://raw.githubusercontent.com/jawsome/node-dnsbl/master/list.json https://multirbl.valli.org/list/
const RBL_LIST = [
{
'name': 'Abuse.ch',
@@ -550,12 +510,17 @@ const RBL_LIST = [
];
// this function currently only looks for black lists based on IP. TODO: also look up by domain
async function checkRblStatus(domain) {
async function checkRbl4(mailDomain) {
assert.strictEqual(typeof mailDomain, 'object');
if (mailDomain.relay.provider === 'noop') return { status: 'skipped', message: 'Outbound disabled' };
if (mailDomain.relay.provider !== 'cloudron-smtp') return { status: 'skipped', message: 'RBL check was skipped, email is sent through a relay service' };
const { domain } = mailDomain;
const [error, ip] = await safe(network.getIPv4());
if (error) {
debug(`checkRblStatus: unable to determine server IPv4: ${error.message}`);
return { status: false, ip: null, servers: [] };
}
if (error) return { status: 'failed', ip: null, servers: [], message: `Unable to determine server IPv4: ${error.message}` };
if (ip === null) return { status: 'skipped', ip: null, servers: [], message: 'RBL check was skipped, server has no IPv4' };
const flippedIp = ip.split('.').reverse().join('.');
@@ -579,48 +544,34 @@ async function checkRblStatus(domain) {
debug(`checkRblStatus: ${domain} (ip: ${ip}) blockedServers: ${JSON.stringify(blockedServers)})`);
return { status: blockedServers.length === 0, ip, servers: blockedServers };
return { status: blockedServers.length === 0 ? 'passed' : 'failed', ip, servers: blockedServers };
}
async function getStatus(domain) {
assert.strictEqual(typeof domain, 'string');
// ensure we always have a valid toplevel properties for the api
const results = {
dns: {}, // { mx/dmarc/dkim/spf/ptr: { expected, value, name, domain, type, status } }
rbl: {}, // { status, ip, servers: [{name,site,dns}]} optional. only for cloudron-smtp
relay: {} // { status, value } always checked
};
const { fqdn } = await mailServer.getLocation();
const mailDomain = await getDomain(domain);
if (!mailDomain) throw new BoxError(BoxError.NOT_FOUND, 'mail domain not found');
const checks = [];
if (mailDomain.enabled) {
checks.push(
{ what: 'dns.mx', promise: checkMx(domain, fqdn) },
{ what: 'dns.dmarc', promise: checkDmarc(domain) }
);
}
// mx/dmarc/dkim/spf/ptr: { expected, value, name, domain, type, status } }
// rbl4: { status, ip, servers: [{name,site,dns}]}
// relay: { status, message } always checked
const results = {};
if (mailDomain.relay.provider === 'cloudron-smtp') {
// these tests currently only make sense when using Cloudron's SMTP server at this point
checks.push(
{ what: 'dns.spf', promise: checkSpf(domain, fqdn) },
{ what: 'dns.dkim', promise: checkDkim(mailDomain) },
{ what: 'dns.ptr4', promise: checkPtr4(fqdn) },
{ what: 'dns.ptr6', promise: checkPtr6(fqdn) },
{ what: 'relay', promise: checkOutboundPort25() },
{ what: 'rbl', promise: checkRblStatus(domain) },
);
} else if (mailDomain.relay.provider !== 'noop') {
checks.push({ what: 'relay', promise: checkSmtpRelay(mailDomain.relay) });
}
const checks = [
{ what: 'mx', promise: checkMx(mailDomain, fqdn) },
{ what: 'dmarc', promise: checkDmarc(mailDomain) },
{ what: 'spf', promise: checkSpf(mailDomain, fqdn) },
{ what: 'dkim', promise: checkDkim(mailDomain) },
{ what: 'ptr4', promise: checkPtr4(mailDomain, fqdn) },
{ what: 'ptr6', promise: checkPtr6(mailDomain, fqdn) },
{ what: 'rbl4', promise: checkRbl4(mailDomain) },
{ what: 'relay', promise: checkSmtpRelay(mailDomain.relay) }
];
// wait for all the checks and record the result
const responses = await Promise.allSettled(checks.map(c => c.promise));
const responses = await Promise.allSettled(checks.map(c => c.promise)); // wait for all the checks and record the result
for (let i = 0; i < checks.length; i++) {
const response = responses[i], check = checks[i];
if (response.status !== 'fulfilled') {
@@ -628,7 +579,7 @@ async function getStatus(domain) {
continue;
}
if (response.value.errorMessage) debug(`Ignored error - ${check.what} : ${response.value.errorMessage}`);
if (response.value.message) debug(`${check.what} : ${response.value.message}`);
safe.set(results, checks[i].what, response.value || {});
}
@@ -842,8 +793,8 @@ async function setMailRelay(domain, relay, options) {
if (relay.password === constants.SECRET_PLACEHOLDER) relay.password = result.relay.password;
if (!options.skipVerify) {
const error = await verifyRelay(relay);
if (error) throw error;
const result = await checkSmtpRelay(relay);
if (result.status === 'failed') throw new BoxError(BoxError.BAD_FIELD, result.message);
}
await updateDomain(domain, { relay });