SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Check a domain's email authentication

Read the DMARC, SPF and DKIM records a sending domain publishes, serve an MTA-STS policy for your own domain, and parse the DMARC aggregate reports receivers send back.

After reading this guide, you will know

  • How to check a customer's DMARC policy, SPF lookups and DKIM key before you send from their domain
  • How to serve an MTA-STS policy and its TLS-RPT record for your own domain
  • What a DMARC aggregate report says about who sends mail as your domain

When your app sends mail from a customer's domain, receivers judge it by the records that domain publishes in DNS. A missing DKIM key, or an SPF record that never names your servers, lands that mail in spam, and the customer blames your product. Checking the records before the first send turns that into a checklist the customer can fix.

This guide builds that check as a background job over DMARC, SPF and DKIM, then serves an MTA-STS policy for your own domain and reads the DMARC aggregate reports receivers send back. @sdxc/email-auth parses and evaluates every record over DNS over HTTPS, so it runs in a Worker, and @sdxc/jobs runs the checks off the request.

Terminal
npm add @sdxc/doh @sdxc/email-auth @sdxc/jobs @sdxc/result remix

Find the DMARC policy that applies

DMARC tells receivers what to do with mail that fails authentication. A subdomain without its own record inherits its Organizational Domain's, so discoverDmarc walks the DNS tree the way a receiver does and answers the record that applies, where it was published and the policy it sets:

app/services/sending-domain.ts
import { discoverDmarc } from "@sdxc/email-auth";
import { isFailure, success } from "@sdxc/result";

export interface Finding {
	check: "dmarc" | "spf" | "dkim";
	ok: boolean;
	message: string;
}

export async function checkDmarc(domain: string) {
	let discovery = await discoverDmarc(domain);
	if (isFailure(discovery)) return discovery;

	let { policy, policyDomain, record } = discovery.data;
	if (policy === null || record === null) {
		return success<Finding>({
			check: "dmarc",
			ok: false,
			message: `Publish a DMARC record at _dmarc.${domain}.`,
		});
	}
	if (policy === "none" || record.testing) {
		return success<Finding>({
			check: "dmarc",
			ok: false,
			message: `The policy at ${policyDomain} only reports; set p=quarantine or p=reject.`,
		});
	}
	return success<Finding>({
		check: "dmarc",
		ok: true,
		message: `Receivers ${policy} mail that fails DMARC.`,
	});
}

policy is null when no DMARC applies, and record.testing is the t=y flag. A resolver that could not answer is the failure, never "no policy", so the job retries it rather than telling the customer their record is missing.

Evaluate SPF for your sending address

SPF lists the addresses allowed to send for a domain. It fails when the record never authorizes your servers, or when its include chain needs more than ten DNS lookups. analyzeSpf counts the lookups and lists what is wrong; checkHost runs the evaluation a receiver runs for one address:

app/services/sending-domain.ts
import { analyzeSpf, checkHost } from "@sdxc/email-auth";
import { isFailure, success } from "@sdxc/result";

const SENDING_IP = "203.0.113.25";
const SPF_INCLUDE = "_spf.mailer.example.com";

export async function checkSpf(domain: string) {
	let audit = await analyzeSpf(domain);
	if (isFailure(audit)) return audit;

	let { lookups, problems, status } = audit.data;
	if (status === "none") {
		return success<Finding>({
			check: "spf",
			ok: false,
			message: `Publish v=spf1 include:${SPF_INCLUDE} -all at ${domain}.`,
		});
	}
	if (problems.length > 0) {
		let codes = problems.map((problem) => problem.code).join(", ");
		return success<Finding>({
			check: "spf",
			ok: false,
			message: `Fix SPF: ${codes}.`,
		});
	}

	let evaluation = await checkHost({
		ip: SENDING_IP,
		domain,
		sender: `bounces@${domain}`,
	});
	if (evaluation.result !== "pass") {
		return success<Finding>({
			check: "spf",
			ok: false,
			message: `SPF answers ${evaluation.result} for us; add include:${SPF_INCLUDE}.`,
		});
	}
	return success<Finding>({
		check: "spf",
		ok: true,
		message: `SPF passes with ${lookups} of 10 lookups.`,
	});
}

Each problem carries a code, such as too-many-lookups, and the domain it was found at. checkHost answers a value rather than a Result: a DNS failure is its temperror result.

Confirm the DKIM key is published

DKIM signs each message with a key the domain publishes at <selector>._domainkey.<domain>. Your app picks the selector and hands the customer the record to publish; the check reads it back:

app/services/sending-domain.ts
import { lookupDkimKey, RecordParseError } from "@sdxc/email-auth";
import { isFailure, success } from "@sdxc/result";

const DKIM_SELECTOR = "mailer";

export async function checkDkim(domain: string) {
	let name = `${DKIM_SELECTOR}._domainkey.${domain}`;
	let key = await lookupDkimKey(domain, DKIM_SELECTOR);
	if (isFailure(key)) {
		if (!(key.error instanceof RecordParseError)) return key;
		return success<Finding>({
			check: "dkim",
			ok: false,
			message: key.error.message,
		});
	}

	if (key.data === null) {
		return success<Finding>({
			check: "dkim",
			ok: false,
			message: `Publish our key at ${name}.`,
		});
	}
	if (key.data.revoked || key.data.testing) {
		return success<Finding>({
			check: "dkim",
			ok: false,
			message: `The key at ${name} is revoked or in testing mode.`,
		});
	}
	return success<Finding>({
		check: "dkim",
		ok: true,
		message: `${name} is published.`,
	});
}

An unpublished selector is null. A record that does not parse is a RecordParseError, which the customer fixes, so it becomes a finding; a resolver failure stays a failure for the job to retry.

Run the audit off the request

Each check is several DNS queries, and a customer who just published a record waits on their provider's TTL before it shows up. Run all three in a job and store the findings for the settings page to read:

app/jobs/sending-domains/audit.ts
import type { Finding } from "~/app/services/sending-domain";

import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";

import jobs from "~/app/jobs";
import { SendingDomains } from "~/app/models/sending-domains";
import { checkDkim, checkDmarc, checkSpf } from "~/app/services/sending-domain";

export default createJobHandler(jobs.sendingDomains.audit, async (ctx) => {
	let domain = await SendingDomains.find(ctx.db, ctx.input.domainId);
	if (domain === null) return ctx.exit("The domain was removed");

	let checks = await Promise.all([
		checkDmarc(domain.hostname),
		checkSpf(domain.hostname),
		checkDkim(domain.hostname),
	]);
	let findings: Finding[] = [];
	for (let check of checks) {
		if (isFailure(check))
			return ctx.retry({ delay: "5 minutes", cause: check.error });
		findings.push(check.data);
	}

	await SendingDomains.saveFindings(ctx.db, domain.id, findings);
	ctx.log.set({
		sendingDomain: { failing: findings.filter((it) => !it.ok).length },
	});
});

The job is declared as audit: job({ input: s.object({ domainId: s.string() }) }) under sendingDomains, and SendingDomains is your own model. Background jobs and cron builds the queue.

Serve an MTA-STS policy for your own domain

MTA-STS tells servers delivering mail to your domain to require TLS and accept only the MX hosts you list. The policy is a text file at https://mta-sts.<domain>/.well-known/mta-sts.txt:

config/mta-sts.ts
export const MTA_STS_POLICY = [
	"version: STSv1",
	"mode: enforce",
	"mx: *.mx.cloudflare.net",
	"max_age: 604800",
].join("\r\n");
app/http/controllers/mta-sts.ts
import { createAction } from "remix/router";

import { MTA_STS_POLICY } from "~/config/mta-sts";
import routes from "~/routes/web";

export default createAction(routes.mtaSts, () => {
	return new Response(MTA_STS_POLICY, {
		headers: { "content-type": "text/plain; charset=utf-8" },
	});
});

The route is mtaSts: get("/.well-known/mta-sts.txt"), served on mta-sts.example.com as a custom domain of the Worker. Two TXT records complete it: _mta-sts.example.com with v=STSv1; id=20261024, and _smtp._tls.example.com with v=TLSRPTv1; rua=mailto:tls-reports@example.com, where senders report the TLS failures they see.

Read DMARC aggregate reports

The rua address in a DMARC record receives a daily report from every large receiver: which addresses sent mail as your domain, and whether DKIM and SPF passed. Most arrive gzipped:

app/services/dmarc-reports.ts
import { parseDmarcReport } from "@sdxc/email-auth";
import { isFailure, success, wrap } from "@sdxc/result";

export async function readAggregateReport(gzipped: ReadableStream<Uint8Array>) {
	let xml = await wrap(() =>
		new Response(gzipped.pipeThrough(new DecompressionStream("gzip"))).text(),
	);
	if (isFailure(xml)) return xml;

	let report = parseDmarcReport(xml.data);
	if (isFailure(report)) return report;

	let { metadata, policyPublished, records } = report.data;
	let failing = records.filter((row) => row.dkim === "fail" && row.spf === "fail");
	return success({
		reporter: metadata.orgName,
		domain: policyPublished.domain,
		from: metadata.begin,
		to: metadata.end,
		failing: failing.map(({ sourceIp, count, disposition }) => ({
			sourceIp,
			count,
			disposition,
		})),
	});
}

A source that fails both DKIM and SPF is either a service you forgot to authorize or someone sending as your domain, and disposition says what the receiver did with it.

Test the checks

Every lookup is a DNS-over-HTTPS request to the resolver @sdxc/doh asks by default, so MSW answers it from a table of TXT records, with NXDOMAIN for every name the table leaves out:

app/services/sending-domain.test.ts
import { CLOUDFLARE } from "@sdxc/doh";
import { unwrap } from "@sdxc/result";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";

import { checkDkim, checkDmarc, checkSpf } from "~/app/services/sending-domain";

let server = setupServer();

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

function publish(records: { [name: string]: string }) {
	server.use(
		http.get(CLOUDFLARE.url, ({ request }) => {
			let name = new URL(request.url).searchParams.get("name") ?? "";
			let text = records[name.replace(/\.$/, "")];
			if (text === undefined) return HttpResponse.json({ Status: 3 });
			let Answer = [{ name: `${name}.`, type: 16, TTL: 60, data: `"${text}"` }];
			return HttpResponse.json({ Status: 0, Answer });
		}),
	);
}

test("a domain that authorizes us passes every check", async () => {
	publish({
		"_dmarc.shop.com": "v=DMARC1; p=reject; rua=mailto:dmarc@shop.com",
		"shop.com": "v=spf1 include:_spf.mailer.example.com -all",
		"_spf.mailer.example.com": "v=spf1 ip4:203.0.113.0/24 -all",
		"mailer._domainkey.shop.com":
			"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
	});

	let findings = [
		unwrap(await checkDmarc("shop.com")),
		unwrap(await checkSpf("shop.com")),
		unwrap(await checkDkim("shop.com")),
	];

	expect(findings.every((finding) => finding.ok)).toBe(true);
});

test("a reporting-only policy is flagged", async () => {
	publish({ "_dmarc.shop.com": "v=DMARC1; p=none; rua=mailto:dmarc@shop.com" });

	let finding = unwrap(await checkDmarc("shop.com"));

	expect(finding.ok).toBe(false);
});

Status: 3 is NXDOMAIN, which every check reads as nothing published.

Run it from the terminal

Run the same check by hand while a customer fixes their records, or in CI before your own domain starts sending:

Terminal
sdxc pkg email-auth check example.com --selector mailer
sdxc pkg email-auth check example.com --selector mailer --skip bimi --json

Repeat --selector for each DKIM key the domain signs with. The command exits 1 when a checked policy is missing, invalid or unreadable, and 0 when it finds warnings alone. Use a package from the terminal covers what every package command line shares.

Where to go next

Documentation

Can’t find it? Contact us