#!/bin/sh
#
# mail-dns-check.sh - report MX, TXT and DKIM records for a domain.
#
# Reports, for one or more domains:
#   * MX records (sorted by preference)
#   * every TXT record at the apex, classified (SPF / DMARC / verification / ...)
#   * SPF sanity, including the final "all" mechanism
#   * every MX address evaluated against the SPF policy, following include:
#     and redirect= recursively, naming the mechanism that decided each one
#   * DMARC (_dmarc) policy
#   * DKIM public keys, with selectors auto-discovered from SPF and MX
#   * TLS: STARTTLS on every MX, plus submission and mailbox ports (465, 587,
#     993, 995, 143, 110), MTA-STS (DNS and policy, id compared), TLS-RPT
#     and DANE/TLSA
#   * a short summary line
#
# Requirements: POSIX sh and nslookup(8), or dig(1) where nslookup is absent.
# openssl (1.1 or newer) is used to probe STARTTLS and size RSA DKIM keys; curl
# or wget fetches the MTA-STS policy. All three are optional - the matching
# check is reported as skipped when its tool is missing. Pass -D to restrict TLS
# to its DNS records and skip the live port 25/465/... and HTTPS connections.
#
# The live port probes run in parallel. Results depend on where you run this
# from: a port that silently drops packets is reported as "filtered" rather
# than as a failure, because that is indistinguishable from your own egress
# firewall. Override the port list with MAILCHECK_SERVICES, e.g.
# MAILCHECK_SERVICES="465/- 587/smtp".
#
# Usage: mail-dns-check.sh [-t timeout] [-r retries] [-s resolver] [-D]
#                          [-X nslookup|dig] domain ...
#
# Environment: MAILCHECK_TIMEOUT, MAILCHECK_RETRY, MAILCHECK_DNS,
#   MAILCHECK_DNS_TOOL, MAILCHECK_MAX_HOSTS, MAILCHECK_MAX_SUB_HOSTS,
#   MAILCHECK_MAX_SPF
#   (defaults 3, 1, the system resolver, auto, and caps of 10, 2 and 25)
#
# -s selects the resolver for the DNS queries only. openssl and curl resolve
# through the system resolver and are unaffected, so the live port probes can
# still differ from the DNS answers. The override is verified with a root NS
# query before any result is reported, because a resolver that silently dropped
# everything would otherwise look exactly like a domain with no mail records at
# all.
#
# -X picks the DNS tool, defaulting to nslookup and falling back to dig where
# nslookup is not installed. Both produce the same report; running the same
# domain with each and diffing is how that stays true, since dig is emulated
# rather than parsed natively.
#
# Written 2026-09-26.
#
# Developed with help of Space Bunny Free via OpenCode.

set -u

PROG=${0##*/}
NS_TIMEOUT=${MAILCHECK_TIMEOUT:-3}
NS_RETRY=${MAILCHECK_RETRY:-1}
DNS_SERVER=${MAILCHECK_DNS:-}
DNS_TOOL=${MAILCHECK_DNS_TOOL:-auto}

TLS_PROBE_TIMEOUT=${MAILCHECK_TLS_TIMEOUT:-10}
MAX_TLS_HOSTS=${MAILCHECK_MAX_HOSTS:-10}
MAX_SUB_HOSTS=${MAILCHECK_MAX_SUB_HOSTS:-2}
TLS_LIVE=1
# Additional services to probe, "port/protocol" with "-" meaning implicit TLS
# (no STARTTLS). Port 25 is covered separately by the STARTTLS block.
TLS_SERVICES=${MAILCHECK_SERVICES:-"465/- 587/smtp 993/- 995/- 143/imap 110/pop3"}
# High port nothing should be listening on, used as a negative control. If the
# close is dropped instead of refused, silence on the real ports proves
# nothing about whether those ports are open - unless some other port returned
# an RST, which shows the host is reachable and makes the silence a real
# per-port drop. The report distinguishes those two cases.
CONTROL_PORT=${MAILCHECK_CONTROL_PORT:-12345}

MAX_SPF_FETCH=${MAILCHECK_MAX_SPF:-25}
MAX_DKIM_QUERIES=200

WORKDIR=${TMPDIR:-/tmp}/maildns.$$
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
# SIGPIPE is the one that matters: piping into head or less kills the shell
# outright and skips the EXIT trap, which would leave the workdir behind.
trap 'cleanup; exit 141' PIPE
trap 'cleanup; exit 129' HUP
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 131' QUIT
trap 'cleanup; exit 143' TERM
mkdir -p "$WORKDIR" || exit 1

usage() {
	# Echo the header comment block; line numbers would drift on every edit.
	awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
	exit "${1:-1}"
}

while getopts 't:r:s:DX:h' opt; do
	case $opt in
	t) NS_TIMEOUT=$OPTARG ;;
	r) NS_RETRY=$OPTARG ;;
	s) DNS_SERVER=$OPTARG ;;
	D) TLS_LIVE=0 ;;
	X) DNS_TOOL=$OPTARG ;;
	h) usage 0 ;;
	*) usage ;;
	esac
done
shift $((OPTIND - 1))

[ $# -gt 0 ] || usage

# Both values reach the DNS tool as numbers, and the dig path also does
# arithmetic on the retry count, where a non-number is a fatal error rather
# than a wrong answer. Reject them here instead.
case $NS_TIMEOUT in
'' | *[!0-9]*)
	printf '%s: -t timeout must be a number: %s\n' "$PROG" "$NS_TIMEOUT" >&2
	exit 2
	;;
esac
case $NS_RETRY in
'' | *[!0-9]*)
	printf '%s: -r retries must be a number: %s\n' "$PROG" "$NS_RETRY" >&2
	exit 2
	;;
esac

# The DNS tool is resolved once, up front, because a tool that is missing or
# unusable makes every query return nothing, which the report would then read
# as a domain with no mail records at all.
case $DNS_TOOL in
auto)
	if command -v nslookup >/dev/null 2>&1; then
		DNS_TOOL=nslookup
	elif command -v dig >/dev/null 2>&1; then
		DNS_TOOL=dig
	else
		printf '%s: neither nslookup nor dig found\n' "$PROG" >&2
		exit 2
	fi
	;;
nslookup | dig) ;;
*)
	printf '%s: not a DNS tool: %s (auto, nslookup or dig)\n' "$PROG" "$DNS_TOOL" >&2
	exit 2
	;;
esac
command -v "$DNS_TOOL" >/dev/null 2>&1 || {
	printf '%s: not installed: %s\n' "$PROG" "$DNS_TOOL" >&2
	exit 2
}

# A resolver that never answers, or a tool whose output the parsers do not
# recognise, would make every record look absent - and a report claiming "no
# SPF, no DMARC, no DKIM" is far worse than no report at all. One root NS query
# through the real query path catches both at once: the answer has to come back
# in the "nameserver = " shape the parsers expect, from the resolver in use.
# Cheap enough to run unconditionally, which is the point - a DNS tool that
# formats its output differently is a silent total false negative otherwise.
preflight_dns() {
	# stderr is kept here, unlike in ns_q, so a resolver or tool that refuses
	# outright is reported with the reason instead of as absent records.
	if [ "$DNS_TOOL" = dig ]; then
		pf=$( { ns_run NS . 2>&1; } | dig_fmt)
	else
		pf=$(ns_run NS . 2>&1)
	fi
	if printf '%s' "$pf" | grep -q 'nameserver = '; then
		return 0
	fi
	if [ -n "$DNS_SERVER" ]; then
		printf '%s: resolver %s did not answer, refusing to report\n' "$PROG" "$DNS_SERVER" >&2
	else
		printf '%s: %s returned no usable root NS answer, refusing to report\n' \
			"$PROG" "$DNS_TOOL" >&2
		printf '  (an unreachable resolver and unparsable %s output look identical\n' "$DNS_TOOL" >&2
		printf '  here, and neither may be reported as "no records at all")\n' >&2
	fi
	[ -n "$pf" ] && printf '%s\n' "$pf" | sed 's/^/  /' >&2
	exit 2
}

if [ -n "$DNS_SERVER" ]; then
	case $DNS_SERVER in
	-*)
		printf '%s: not a resolver address: %s\n' "$PROG" "$DNS_SERVER" >&2
		exit 2
		;;
	esac
fi

# ---------------------------------------------------------------- utilities

ns_run() {
	# The bare query, backend specific. Split out of ns_q so the preflight can
	# have stderr, which ns_q discards.
	# The resolver goes to nslookup as a trailing operand and to dig as an "@"
	# argument, never as an option, so a leading dash in DNS_SERVER cannot
	# inject one.
	if [ "$DNS_TOOL" = dig ]; then
		# +tries counts attempts where nslookup's -retry counts retries.
		if [ -n "$DNS_SERVER" ]; then
			dig -t "$1" +time="$NS_TIMEOUT" +tries="$((NS_RETRY + 1))" \
				"@$DNS_SERVER" "$2"
		else
			dig -t "$1" +time="$NS_TIMEOUT" +tries="$((NS_RETRY + 1))" "$2"
		fi
		return
	fi
	if [ -n "$DNS_SERVER" ]; then
		nslookup -type="$1" -timeout="$NS_TIMEOUT" -retry="$NS_RETRY" \
			"$2" "$DNS_SERVER"
	else
		nslookup -type="$1" -timeout="$NS_TIMEOUT" -retry="$NS_RETRY" \
			"$2"
	fi
}

# dig's "+noall +answer" prints "name ttl IN TYPE rdata", rdata being field 5 on.
# Rewritten into the nslookup layout the parsers below match on, so that
# "mail exchanger = ", "text = ", "Address: ", "nameserver = ", "rdata_43 = "
# and "rdata_52 = " keep working whichever tool ran. A TXT record split into
# several character-strings arrives as adjacent quoted strings, which is the
# shape awk_txt already concatenates. CNAME lines match no branch and drop out.
# The "Non-authoritative answer:" header is what switches sc_addr_lines on.
# Anything that is not an answer record is passed through untouched.
dig_fmt() {
	awk '
		NF < 5 { print; next }
		$4 != "MX" && $4 != "TXT" && $4 != "A" && $4 != "AAAA" \
			&& $4 != "NS" && $4 != "DS" && $4 != "TLSA" { print; next }
		!hdr { print "Non-authoritative answer:"; hdr = 1 }
		{
			r = ""
			for (i = 5; i <= NF; i++) r = r (i > 5 ? " " : "") $i
			if ($4 == "MX") print $1 " mail exchanger = " r
			else if ($4 == "TXT") print $1 " text = " r
			else if ($4 == "A" || $4 == "AAAA") print "Address: " r
			else if ($4 == "NS") print $1 " nameserver = " r
			else if ($4 == "DS") print $1 " rdata_43 = " r
			else print $1 " rdata_52 = " r
		}
	'
}

ns_q() {
	if [ "$DNS_TOOL" = dig ]; then
		# stderr is dropped on purpose: a refused or timed-out query reaches
		# the parsers as an empty answer, which they already read as "no
		# record of this type".
		ns_run "$1" "$2" 2>/dev/null | dig_fmt
	else
		ns_run "$1" "$2" 2>/dev/null
	fi
}

# Held until the query functions above exist, since the preflight calls them.
preflight_dns

# nslookup prints TXT answers as one or more adjacent quoted strings which are
# concatenated verbatim to form the record, so the segments are joined with no
# separator. Anchoring on the key avoids matching the "mail addr = " lines that
# nslookup prints in the authority section.
awk_txt() {
	awk '
		/[[:space:]]text = / {
			s = $0
			sub(/^[^=]*= /, "", s)
			out = ""; inq = 0; i = 1; n = length(s)
			while (i <= n) {
				c = substr(s, i, 1)
				if (inq) {
					if (c == "\\") {
						e = substr(s, i + 1, 1)
						if      (e == "n") out = out "\n"
						else if (e == "t") out = out "\t"
						else               out = out e
						i += 2
						continue
					}
					if (c == "\"") inq = 0
					else out = out c
				} else if (c == "\"") inq = 1
				i++
			}
			print out
		}
	'
}

awk_mx() {
	awk '
		/mail exchanger = / {
			s = $0
			sub(/^[^=]*= /, "", s)
			gsub(/^[ \t]+/, "", s)
			print s
		}
	'
}

# "10 mail1.example.com." -> "mail1.example.com"
split_mx() {
	awk -v OFS=' ' '{ $1 = ""; sub(/^[ \t]+/, ""); print }'
}

# Accept a bare domain or a pasted URL. The scheme, path, userinfo, port,
# trailing dots and a leading "www." are stripped, since mail policy never
# lives on the www host.
normalize_domain() {
	nd=$1
	case $nd in
	*://*) nd=${nd#*://} ;;
	esac
	nd=${nd%%/*}
	nd=${nd#*@}
	nd=${nd%%:*}
	nd=$(printf '%s' "$nd" | tr 'A-Z' 'a-z')
	while :; do
		case $nd in
		?*.) nd=${nd%?} ;;
		*) break ;;
		esac
	done
	while :; do
		case $nd in
		www.*) nd=${nd#www.} ;;
		*) break ;;
		esac
	done
	case $nd in
	'' | .) return 1 ;;
	-*) return 1 ;;
	*..*) return 1 ;;
	*[!a-z0-9._-]*) return 1 ;;
	esac
	printf '%s' "$nd"
	return 0
}

kv() {
	# kv "record" "key"  ->  value of tag key, or empty
	printf '%s' "$1" | tr ';' '\n' | awk -v k="$2" '
		{
			i = index($0, "=")
			if (i < 1) next
			name = substr($0, 1, i - 1)
			gsub(/^[ \t]+|[ \t]+$/, "", name)
			if (name == k) {
				print substr($0, i + 1)
				exit
			}
		}
	'
}

wrap() {
	# Print an indented paragraph, hard-wrapped at $2 columns. The trailing
	# newline must come from printf: fold omits it for a final chunk that
	# cannot be broken, which would run consecutive records together.
	printf '%s\n' "$1" | fold -s -w "${2:-72}" | sed 's/^/  /'
}

# ------------------------------------------------------------------- report

report_domain() {
	d=$1
	printf '\n\033[1m=== %s ===\033[0m\n' "$d"

	report_mx "$d"
	spf_collect "$d"
	report_txt "$d"
	report_spf "$d"
	report_spf_vs_mx "$d"
	report_dmarc "$d"
	report_dkim "$d"
	report_tls "$d"
	report_summary "$d"
}

report_mx() {
	d=$1
	mxfile=$WORKDIR/mx
	ns_q MX "$d" | awk_mx > "$mxfile"
	mxcount=$(wc -l < "$mxfile" | tr -d ' ')

	printf '\n\033[1m-- MX --\033[0m\n'
	if [ "$mxcount" -eq 0 ]; then
		printf '  none found\n'
		return
	fi
	# sort -k1,1n keeps numeric preference order
	sort -k1,1n -k2 "$mxfile" | while read -r pref host; do
		printf '  %-5s %s\n' "$pref" "$host"
	done
}

report_txt() {
	d=$1
	txtfile=$WORKDIR/txt
	ns_q TXT "$d" | awk_txt > "$txtfile"

	printf '\n\033[1m-- TXT --\033[0m\n'
	if [ ! -s "$txtfile" ]; then
		printf '  none found\n'
		return
	fi

	while IFS= read -r rec; do
		[ -n "$rec" ] || continue
		tag=$(classify_txt "$rec")
		if [ "$tag" = 'other' ]; then
			wrap "$rec"
		elif [ "${#rec}" -gt 72 ]; then
			# long records get the tag on its own line so the body can wrap
			printf '  \033[2m[%-9s]\033[0m\n' "$tag"
			wrap "$rec"
		else
			printf '  \033[2m[%-9s]\033[0m %s\n' "$tag" "$rec"
		fi
	done < "$txtfile"
}

classify_txt() {
	cr=$1
	case $cr in
	v=spf1*) printf 'SPF' ; return ;;
	v=DMARC1*) printf 'DMARC' ; return ;;
	v=DKIM1*) printf 'DKIM' ; return ;;
	v=BIMI1*) printf 'BIMI' ; return ;;
	v=MTLSA1* | v=MTA-STS*) printf 'MTA-STS' ; return ;;
	v=TLSRPTv1* | v=TLSRPT*) printf 'TLS-RPT' ; return ;;
	*'-domain-verification='* | google-site-verification=* | *ms=*) printf 'verify' ; return ;;
	*) printf 'other' ;;
	esac
}

report_spf() {
	d=$1
	txtfile=$WORKDIR/txt
	spfcount=$(grep -c '^v=spf1' "$txtfile" 2>/dev/null || true)
	[ -n "$spfcount" ] || spfcount=0

	printf '\n\033[1m-- SPF --\033[0m\n'
	if [ "$spfcount" -eq 0 ]; then
		printf '  \033[31mMISSING\033[0m - no v=spf1 record; nothing authorises this domain\n'
		return
	fi
	if [ "$spfcount" -gt 1 ]; then
		printf '  \033[31mBROKEN\033[0m - %s SPF records published; RFC 7208 allows one (permerror)\n' "$spfcount"
	else
		spf=$(grep '^v=spf1' "$txtfile" | head -1)
		# redirect= delegates the whole policy, so the record legitimately
		# has no "all" mechanism of its own.
		if printf '%s' "$spf" | grep -q 'redirect='; then
			deleg=$(printf '%s' "$spf" | tr ' \t' '\n\n' | \
				awk '/^redirect=/ { sub(/^redirect=/, ""); print; exit }')
			printf '  \033[32mDELEGATED\033[0m - policy is inherited from %s\n' "$deleg"
		else
			last=$(printf '%s' "$spf" | awk '{ print $NF }')
			case $last in
			'-all') printf '  \033[32mOK\033[0m       - %s (hard fail)\n' "$last" ;;
			'~all') printf '  \033[33mSOFTFAIL\033[0m - %s; consider -all\n' "$last" ;;
			'+all') printf '  \033[31mBROKEN\033[0m   - +all authorises every host on the internet\n' ;;
			'?all') printf '  \033[33mNEUTRAL\033[0m  - %s; consider -all\n' "$last" ;;
			*) printf '  \033[33mNO POLICY\033[0m - record has no "all" mechanism\n' ;;
			esac
		fi

		# RFC 7208 caps DNS-querying terms at 10. ip4:/ip6: are direct
		# addresses and do not consume a lookup, so they are not counted.
		lookups=$(printf '%s' "$spf" | tr ' \t' '\n\n' | awk '
			/^include:/ || /^redirect=/ || /^a$/ || /^a[:=]/ \
			|| /^mx$/ || /^mx[:=]/ || /^ptr/ || /^exists:/ { n++ }
			END { print n + 0 }
		')
		if [ "$lookups" -gt 10 ]; then
			printf '  \033[31mBROKEN\033[0m   - %s DNS-querying terms exceed the limit of 10\n' "$lookups"
		else
			printf '           %s of 10 DNS-querying terms used\n' "$lookups"
		fi
	fi
}

# Build the lookup tables the SPF evaluator needs: the SPF record of the domain
# and of everything its includes/redirect reach, plus the A and MX records that
# "a:" and "mx:" mechanisms refer to. Bounded, because RFC 7208 caps a real
# evaluation at 10 DNS lookups anyway.
# Only include: and redirect= continue the SPF policy chain.
spf_policy_targets() {
	printf '%s' "$1" | tr ' \t' '\n\n' | awk '
		{
			t = $0
			# strip any qualifier (+ - ~ ?), not just the ! of exists=, so a
			# negated or soft-failed include is still followed
			sub(/^[+\-~?!]/, "", t)
			if (t ~ /^include:/) print tolower(substr(t, 9))
			else if (t ~ /^redirect=/) print tolower(substr(t, 10))
		}
	' | sed 's/\.$//; /^$/d'
}

# Only a: and mx: targets that name a domain; a bare "a" or "mx" refers to the
# record's own domain and is handled there. These are address lookups, not
# policy, so they are prefetched but never queued as SPF records.
spf_addr_targets() {
	printf '%s' "$1" | tr ' \t' '\n\n' | awk '
		{
			t = $0
			# strip any qualifier (+ - ~ ?), not just !: without this a
			# "-mx:" or "~a:" target was never looked up, so the evaluator
			# found no records and called the name stale
			sub(/^[+\-~?!]/, "", t)
			if (t ~ /^a[:=]/ || t ~ /^mx[:=]/) {
				# mx:/mx= put the name 3 characters in, a:/a= only 2
				a = (t ~ /^mx[:=]/) ? substr(t, 4) : substr(t, 3)
				# a trailing /cidr belongs to the mechanism, not the name, and a
				# bare "/24" leaves nothing to look up
				sub(/\/.*$/, "", a)
				if (a != "" && a !~ /%/) print tolower(a)
			}
		}
	' | sed 's/\.$//; /^$/d'
}

# Fetch the SPF record of the domain and of everything its includes and
# redirect reach, recursively, until the chain is exhausted. Cycles are
# handled by the seen set; the record cap only bounds pathological chains.
spf_collect() {
	sc_dom=$1
	sc_spf=$WORKDIR/spf.db
	sc_a=$WORKDIR/a.db
	sc_mx=$WORKDIR/mx.db
	sc_q=$WORKDIR/spfq
	sc_nq=$WORKDIR/spfnq
	sc_seen=$WORKDIR/spfseen
	: > "$sc_spf"
	: > "$sc_a"
	: > "$sc_mx"
	: > "$sc_seen"
	printf '%s\n' "$sc_dom" > "$sc_q"
	sc_n=0

	while [ -s "$sc_q" ] && [ "$sc_n" -lt "$MAX_SPF_FETCH" ]; do
		: > "$sc_nq"
		while IFS= read -r name; do
			[ -n "$name" ] || continue
			grep -qxF "$name" "$sc_seen" 2>/dev/null && continue
			printf '%s\n' "$name" >> "$sc_seen"
			sc_n=$((sc_n + 1))
			[ "$sc_n" -le "$MAX_SPF_FETCH" ] || break
			rec=$(ns_q TXT "$name" | awk_txt | grep '^v=spf1' | head -1)
			[ -n "$rec" ] && printf '%s\t%s\n' "$name" "$rec" >> "$sc_spf"
			# A and MX for this name, for bare "a" and "mx" mechanisms
			sc_add_a "$name" "$sc_a"
			sc_add_mx "$name" "$sc_mx"
			[ -n "$rec" ] || continue
			# prefetch address lookups named by a:/mx:, without queueing
			# them as policy records
			spf_addr_targets "$rec" | while IFS= read -r t; do
				[ -n "$t" ] || continue
				sc_add_a "$t" "$sc_a"
				sc_add_mx "$t" "$sc_mx"
			done
			# only policy targets continue the recursion
			spf_policy_targets "$rec" >> "$sc_nq"
		done < "$sc_q"
		mv "$sc_nq" "$sc_q"
	done
}

# True when the tab-separated "name<TAB>value" file already has a row for $1.
# An exact field compare, not a grep pattern: these keys are hostnames, and the
# "." in one must not be allowed to match any character in another, or a target
# is silently treated as already resolved.
db_has() {
	awk -F'\t' -v k="$1" '$1 == k { f = 1 } END { exit !f }' "$2" 2>/dev/null
}

# Append name -> comma separated A/AAAA addresses, once per name.
sc_add_a() {
	sa_n=$1
	sa_file=$2
	db_has "$sa_n" "$sa_file" && return 0
	sa_v4=$(ns_q A "$sa_n" | sc_addr_lines)
	sa_v6=$(ns_q AAAA "$sa_n" | sc_addr_lines)
	sa_list=$(printf '%s\n%s\n' "$sa_v4" "$sa_v6" | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -)
	[ -n "$sa_list" ] || sa_list=''
	printf '%s\t%s\n' "$sa_n" "$sa_list" >> "$sa_file"
}

sc_addr_lines() {
	awk '
		/^Non-authoritative answer|^Authoritative answer|^answer:/ { ans = 1 }
		/^Authoritative answers can be found from:/ { ans = 0 }
		ans && /^Address: / { sub(/^Address:[ \t]+/, ""); print }
	'
}

sc_add_mx() {
	sm_n=$1
	sm_file=$2
	db_has "$sm_n" "$sm_file" && return 0
	sm_list=$(ns_q MX "$sm_n" | awk_mx | split_mx | awk '{ print $1 }' | sed 's/\.$//' |
		sort -u | paste -sd, -)
	[ -n "$sm_list" ] || sm_list=''
	printf '%s\t%s\n' "$sm_n" "$sm_list" >> "$sm_file"
	# the mx: mechanism needs the addresses of those hosts as well
	# The trailing newline matters: read drops the final line of a stream that
	# does not end in one, which silently cost us the last MX host's addresses.
	printf '%s\n' "$sm_list" | tr ',' '\n' | while IFS= read -r h || [ -n "$h" ]; do
		[ -n "$h" ] || continue
		sc_add_a "$h" "$WORKDIR/a.db"
	done
}

# Compare every MX address against the published SPF.
report_spf_vs_mx() {
	td=$1
	mxfile=$WORKDIR/mx
	printf '  \033[1mSPF vs MX\033[0m - are the published servers authorised?\n'

	if [ ! -s "$mxfile" ]; then
		printf '    \033[2mno MX records to check\033[0m\n'
		return
	fi
	# spf.db is keyed "domain<TAB>record" and only written when a record was
	# actually found, so the presence of the key is the test.
	if ! db_has "$td" "$WORKDIR/spf.db"; then
		printf '    \033[2mnothing to check: no SPF record\033[0m\n'
		return
	fi

	ips=$WORKDIR/mxips
	# Take the addresses from a.db, which spf_collect already filled for every
	# MX host. Resolving again here could return a different answer than the
	# one the "mx" mechanism was evaluated against, and the comparison would
	# be against a snapshot that no longer contains the address.
	: > "$ips"
	awk '{ print $2 }' "$mxfile" | sort -u | sed 's/\.$//' |
		while IFS= read -r host || [ -n "$host" ]; do
			[ -n "$host" ] || continue
			sa_list=$(awk -F'\t' -v h="$host" '$1 == h { print $2; exit }' "$WORKDIR/a.db" 2>/dev/null)
			if [ -z "$sa_list" ]; then
				# not prefetched: resolve now so the host is still reported
				sa_v4=$(ns_q A "$host" | sc_addr_lines)
				sa_v6=$(ns_q AAAA "$host" | sc_addr_lines)
				sa_list=$(printf '%s\n%s\n' "$sa_v4" "$sa_v6" | sed '/^$/d' |
					awk '!seen[$0]++' | paste -sd, -)
			fi
			printf '%s' "$sa_list" | tr ',' '\n' |
				while IFS= read -r ip || [ -n "$ip" ]; do
					[ -n "$ip" ] || continue
					printf '%s\t%s\n' "$host" "$ip" >> "$ips"
				done
		done
	[ -s "$ips" ] || { printf '    \033[2mno MX addresses resolved\033[0m\n'; return; }

	: > "$WORKDIR/spf-res"
	spf_eval "$ips"

	svf=$(awk -F'\t' '{ c[$1]++ } END { printf "%d %d %d %d", c["pass"]+0, (c["fail"]+c["softfail"]+c["neutral"]), c["unverifiable"]+0, c["nomatch"]+0 }' "$WORKDIR/spf-res")
	okc=${svf%% *}
	sfrest=${svf#* }
	unc=${sfrest%% *}
	sfrest=${sfrest#* }
	unkc=${sfrest%% *}
	badc=${sfrest##* }
	total=$((okc + unc + unkc + badc))
	if [ "$badc" -gt 0 ]; then
		printf '    \033[31m%s of %s MX addresses fail because an include in the chain is broken\033[0m\n' "$badc" "$total"
	elif [ "$unkc" -gt 0 ]; then
		printf '    \033[33m%s of %s could not be decided statically\033[0m\n' "$unkc" "$total"
	elif [ "$unc" -gt 0 ]; then
		printf '    %s of %s MX addresses are covered by the SPF record.\n' "$okc" "$total"
		printf '    \033[2mUncovered is normal: SPF lists who may send as the domain,\033[0m\n'
		printf '    \033[2mMX lists who receives for it. Check only if you send from an MX.\033[0m\n'
	else
		printf '    \033[32mall %s MX addresses are covered by the SPF record\033[0m\n' "$total"
	fi
}

# Static SPF evaluation. Reads "host<TAB>ip" on stdin and writes
# "verdict<TAB>reason" lines, with the matching mechanism named so a pass is
# traceable back to the record that granted it.
spf_eval() {
	awk -v spfdb="$WORKDIR/spf.db" -v adb="$WORKDIR/a.db" -v mxdb="$WORKDIR/mx.db" '
	function decbits(d,   r, i, p) {
		r = ""
		for (i = 0; i < 8; i++) { p = 2 ^ (7 - i); r = r sprintf("%d", int(d / p) % 2) }
		return r
	}
	function hexbits(c,   n, i, p, r) {
		n = index("0123456789abcdef", tolower(c)) - 1
		if (n < 0) return ""
		r = ""
		for (i = 0; i < 4; i++) { p = 2 ^ (3 - i); r = r sprintf("%d", int(n / p) % 2) }
		return r
	}
	function ip4_bin(s,   a, n, i, r) {
		n = split(s, a, ".")
		if (n != 4) return ""
		r = ""
		for (i = 1; i <= 4; i++) {
			if (a[i] !~ /^[0-9]+$/) return ""
			if (a[i] + 0 > 255) return ""
			r = r decbits(a[i] + 0)
		}
		return r
	}
	function bits2hex(b,   v, i, r) {
		v = 0
		for (i = 1; i <= length(b); i++) v = v * 2 + substr(b, i, 1)
		r = sprintf("%x", v)
		while (length(r) < length(b) / 4) r = "0" r
		return r
	}
	# awk has no 128 bit type, so every address becomes a bit string and
	# CIDRs are compared by prefix instead of by arithmetic.
	function ip6_bin(s,   i, p, b, h1, h2, left, right, nl, nr, fill, ng, L, R, G, n, r, g) {
		s = tolower(s)
		if (s !~ /:/) return ""
		if (index(s, ".") > 0) {
			for (i = length(s); i > 0; i--) if (substr(s, i, 1) == ":") break
			if (i == 0) return ""
			b = ip4_bin(substr(s, i + 1))
			if (b == "") return ""
			h1 = bits2hex(substr(b, 1, 16))
			h2 = bits2hex(substr(b, 17, 16))
			s = substr(s, 1, i) h1 ":" h2
		}
		for (i in G) delete G[i]
		ng = 0
		if (index(s, "::") > 0) {
			p = index(s, "::")
			left = substr(s, 1, p - 1); right = substr(s, p + 2)
			nl = 0; if (left != "") nl = split(left, L, ":")
			nr = 0; if (right != "") nr = split(right, R, ":")
			fill = 8 - nl - nr
			if (fill < 0) return ""
			for (i = 1; i <= nl; i++) G[++ng] = L[i]
			for (i = 0; i < fill; i++) G[++ng] = "0"
			for (i = 1; i <= nr; i++) G[++ng] = R[i]
		} else {
			ng = split(s, G, ":")
		}
		if (ng != 8) return ""
		for (i = 1; i <= 8; i++) {
			g = G[i]
			if (g !~ /^[0-9a-f]{1,4}$/) return ""
			while (length(g) < 4) g = "0" g
			G[i] = g
		}
		r = ""
		for (i = 1; i <= 8; i++) r = r G[i]
		b = ""
		for (i = 1; i <= 32; i++) b = b hexbits(substr(r, i, 1))
		return b
	}
	function ip_bin(a,   b) { b = ip4_bin(a); if (b != "") return b; return ip6_bin(a) }
	function cidr_match(ip, cidr,   p, base, plen, ab, bb) {
		p = index(cidr, "/")
		if (p == 0) { base = cidr; plen = (cidr ~ /:/) ? 128 : 32 }
		else { base = substr(cidr, 1, p - 1); plen = substr(cidr, p + 1) + 0 }
		ab = ip_bin(ip); bb = ip_bin(base)
		if (ab == "" || bb == "") return 0
		if (length(ab) != length(bb)) return 0
		if (plen < 0 || plen > length(ab)) return 0
		return substr(ab, 1, plen) == substr(bb, 1, plen)
	}
	function qv(q) {
		if (q == "+") return "pass"
		if (q == "-") return "fail"
		if (q == "~") return "softfail"
		return "neutral"
	}
	function evalrec(dom, ip, depth,   rec, toks, n, i, t, q, arg, r, rr, ips, k, hosts, j, ips2, pre, unv, chk, nchk, s) {
		unv = ""
		# Names whose policy or addresses were fetched and consulted but did
		# not authorise this address. Without this the reason column collapsed
		# to a bare "all", so a record like "include:_spf.example.com ~all"
		# looked as if no mechanism had been examined at all.
		chk = ""
		nchk = 0
		if (depth > 20) return "unverifiable\tinclude chain too deep"
		# The chain is resolved to exhaustion, so a name that is still absent
		# here really has no SPF record. RFC 7208 makes such an include
		# evaluate to "none": it matches nothing and silently stops the
		# enclosing record from authorising anything.
		if (!(dom in SPFDB)) return "nomatch\t" dom " publishes no SPF record"
		rec = SPFDB[dom]
		n = split(rec, toks, /[ \t]+/)
		for (i = 1; i <= n; i++) {
			t = toks[i]
			if (t == "" || t == "v=spf1") continue
			q = "+"
			if (t ~ /^[+\-~?]/) { q = substr(t, 1, 1); t = substr(t, 2) }
			# "=" marks a modifier, except for the cidr form of a: and mx:
			if (t ~ /^redirect=/) {
				arg = tolower(substr(t, 10)); sub(/\.$/, "", arg)
				if (arg ~ /%/) return "unverifiable\tmacro in redirect"
				r = evalrec(arg, ip, depth + 1)
				split(r, rr, "\t")
				if (rr[1] == "pass") return "pass\tredirect=" arg
				if (rr[1] == "nomatch") BROKEN = BROKEN "redirect=" arg " "
				return rr[1] "\tredirect=" arg ": " rr[2]
			}
			if (t ~ /^exp=/) continue
			if (t == "all") {
				# "all" only decides the outcome if every mechanism before it
				# was definitively non-matching. An exists:/macro ahead of it
				# could match, so the policy cannot be called either way.
				if (unv != "") return "unverifiable\t" unv "precede all, so the outcome is undecided"
				if (chk != "") {
					s = chk; sub(/, $/, "", s)
					return qv(q) "\tall (checked: " s ")"
				}
				return qv(q) "\tall"
			}
			# These cannot be decided without a live check, but they must not
			# stop the scan: a later mechanism, or "all", can still give a
			# definite answer, so the limitation is recorded and carried.
			if (t ~ /%/) { unv = unv t " uses a macro; "; continue }
			if (t ~ /^include:/) {
				arg = tolower(substr(t, 9)); sub(/\.$/, "", arg)
				r = evalrec(arg, ip, depth + 1)
				split(r, rr, "\t")
				if (rr[1] == "pass") {
					# a negated include that returns pass matched, which is a
					# non-match, so keep scanning rather than ending here
					if (q == "+") return "pass\tinclude:" arg
					continue
				}
				if (rr[1] == "nomatch") BROKEN = BROKEN "include:" arg " "
				else if (rr[1] == "unverifiable") unv = unv "include:" arg " is not statically decidable; "
				else if (nchk < 3) { chk = chk "include:" arg ", "; nchk++ }
				continue
			}
			if (t ~ /^ip[46]:/) {
				# A mechanism that matches under any qualifier but "+" is a
				# non-match (RFC 7208 4.6), so evaluation continues past it.
				# Returning qv(q) here instead reported "-ip4:" as fail and
				# "?ip4:" as neutral, ending the scan at the wrong mechanism.
				if (cidr_match(ip, substr(t, 5)) && q == "+") return "pass\t" t
				continue
			}
			if (t ~ /^a[:=]/ || t == "a") {
				pre = ""
				arg = (t == "a") ? "" : substr(t, 3)
				# RFC 7208 puts the CIDR after the domain-spec, so it can be
				# written on either side: "a:host/24" or a bare "a:/24".
				# Splitting at the first slash handles both, and a bare
				# prefix leaves an empty name, meaning the domain of the
				# record itself.
				if (index(arg, "/") > 0) {
					pre = substr(arg, index(arg, "/"))
					arg = substr(arg, 1, index(arg, "/") - 1)
				}
				if (arg == "") arg = dom
				arg = tolower(arg); sub(/\.$/, "", arg)
				if (!(arg in ADB) || ADB[arg] == "") { STALE = STALE arg; continue }
				split(ADB[arg], ips, ",")
				for (k = 1; k <= length(ips); k++) {
					if (ips[k] == "") continue
					if (cidr_match(ip, ips[k] pre)) {
						if (q == "+") return "pass\ta:" arg pre
						break   # matched, but negated: a non-match, keep scanning
					}
				}
				if (nchk < 3) { chk = chk "a:" arg ", "; nchk++ }
				continue
			}
			if (t ~ /^mx[:=]/ || t == "mx") {
				pre = ""
				# "mx:" and "mx=" are 3 characters before the name while "a:"
				# and "a=" are only 2, so the name starts one later. Reading
				# from 3 left every mx: target as ":relay.example.test", which
				# matched no MX host, so the mechanism never once matched.
				arg = (t == "mx") ? "" : substr(t, 4)
				if (index(arg, "/") > 0) {
					pre = substr(arg, index(arg, "/"))
					arg = substr(arg, 1, index(arg, "/") - 1)
				}
				if (arg == "") arg = dom
				arg = tolower(arg); sub(/\.$/, "", arg)
				if (!(arg in MXDB) || MXDB[arg] == "") { STALE = STALE arg; continue }
				split(MXDB[arg], hosts, ",")
				for (k = 1; k <= length(hosts); k++) {
					if (hosts[k] == "") continue
					if (!(hosts[k] in ADB)) continue
					split(ADB[hosts[k]], ips2, ",")
					for (j = 1; j <= length(ips2); j++) {
						if (ips2[j] == "") continue
						if (cidr_match(ip, ips2[j] pre)) {
							if (q == "+") return "pass\tmx:" arg pre
							break  # matched, but negated: a non-match
						}
					}
				}
				if (nchk < 3) { chk = chk "mx:" arg ", "; nchk++ }
				continue
			}
			if (t ~ /^exists:/) { unv = unv "exists: needs an envelope sender; "; continue }
			if (t ~ /^ptr/) { unv = unv "ptr: needs reverse DNS; "; continue }
		}
		if (unv != "") return "unverifiable\t" unv "not decidable without a live check"
		if (chk != "") {
			s = chk; sub(/, $/, "", s)
			return "neutral\tno mechanism matched (checked: " s ")"
		}
		return "neutral\tno mechanism matched"
	}
	BEGIN {
		STALE = ""
		BROKEN = ""
		while ((getline ln < spfdb) > 0) { split(ln, p, "\t"); if (p[2] != "") SPFDB[p[1]] = p[2] }
		close(spfdb)
		while ((getline ln < adb) > 0) { split(ln, p, "\t"); ADB[p[1]] = p[2] }
		close(adb)
		while ((getline ln < mxdb) > 0) { split(ln, p, "\t"); MXDB[p[1]] = p[2] }
		close(mxdb)
	}
	{
		host = $1; ip = $2
		r = evalrec(STARTDOM, ip, 0)
		split(r, rr, "\t")
		# An MX address that is simply not covered is normal: SPF describes
		# who may send as the domain, MX lists who receives for it, and the
		# two sets often differ. Only a broken include is an error here.
		if (rr[1] == "pass") c = "32"
		else if (rr[1] == "unverifiable") c = "33"
		else if (rr[1] == "nomatch") c = "31"
		else c = "36"
		rsn = rr[2]
		if (length(rsn) > 66) rsn = substr(rsn, 1, 63) "..."
		printf "    \033[%sm%-13s\033[0m %-26s %-40s %s\n", c, rr[1], host, ip, rsn
		print rr[1] "\t" rr[2] > RESOUT
	}
	END {
		# reported once per name, not once per address evaluated
		n = split(STALE, sl, " ")
		for (i = 1; i <= n; i++) {
			if (sl[i] == "" || sl[i] in SSEEN) continue
			SSEEN[sl[i]] = 1
			printf "    \033[33mstale\033[0m      SPF names %s, which publishes no address records\n", sl[i]
		}
		m = split(BROKEN, bl, " ")
		for (i = 1; i <= m; i++) {
			if (bl[i] == "" || bl[i] in BSEEN) continue
			BSEEN[bl[i]] = 1
			# the token carries the mechanism it came from: a redirect= target
			# is not reached by an include:, and naming it one would misquote
			# the record the reader is looking at
			bname = bl[i]
			bmech = "include:"
			if (sub(/^redirect=/, "", bname)) bmech = "redirect="
			printf "    \033[31mbroken\033[0m     %s%s has no SPF record, so it matches nothing\n", bmech, bname
		}
	}
	' STARTDOM="$td" RESOUT="$WORKDIR/spf-res" "$1"
	return 0
}

# Extract SPF include/redirect/a/mx targets, one per line.
spf_includes() {
	printf '%s' "$1" | tr ' \t' '\n\n' | awk '
		{
			t = $0
			# strip any qualifier (+ - ~ ?), not just !, so a selector behind
			# a negated include is still discovered
			sub(/^[+\-~?!]/, "", t)
			if (t ~ /^include:/)  { print tolower(substr(t, 9)) }
			else if (t ~ /^redirect=/) { print tolower(substr(t, 10)) }
			else if (t ~ /^a:/)    { print tolower(substr(t, 3)) }
			else if (t ~ /^a=/)    { print tolower(substr(t, 3)) }
			else if (t ~ /^mx:/)   { print tolower(substr(t, 4)) }
			else if (t ~ /^mx=/)   { print tolower(substr(t, 4)) }
		}
	' | sed 's/\.$//; /^$/d'
}

report_dmarc() {
	d=$1
	file=$WORKDIR/dmarc
	ns_q TXT "_dmarc.$d" | awk_txt > "$file"

	printf '\n\033[1m-- DMARC --\033[0m\n'
	if [ ! -s "$file" ]; then
		printf '  \033[33mMISSING\033[0m - no _dmarc.%s record\n' "$d"
		return
	fi
	dm=$(grep -i '^v=DMARC1' "$file" | head -1)
	[ -n "$dm" ] || dm=$(head -1 "$file")
	wrap "$dm"
	p=$(kv "$dm" p)
	case $p in
	none)     printf '  \033[33mp=none\033[0m   - monitoring only\n' ;;
	quarantine) printf '  \033[33mp=quarantine\033[0m - senders are asked to quarantine\n' ;;
	reject)   printf '  \033[32mp=reject\033[0m   - strongest policy\n' ;;
	'')       printf '  \033[31mp missing\033[0m - record is invalid (permerror)\n' ;;
	*)        printf '  \033[31mp=%s\033[0m   - unknown policy\n' "$p" ;;
	esac
	[ -z "$(kv "$dm" rua)" ] && printf '  \033[33mno rua=\033[0m  - no aggregate reports will be sent\n'
	return 0
}

# Walk SPF include/redirect chains and turn every target into a DKIM selector
# candidate. Providers overwhelmingly name the selector after the SPF target
# (selector1._domainkey.google.com, s1._domainkey.yahoo.com, ...), so this
# finds the selectors a human would guess at.
#
# The records come from spf.db, which spf_collect has already filled for the
# whole policy chain. Reading them costs no queries and cannot disagree with the
# chain the SPF verdict was computed from, which is what a second traversal with
# its own depth limit did: a selector named only at include depth 4 was invisible
# here while spf_collect had the record, and the report said "no keys found".
# One traversal, one bound (MAX_SPF_FETCH), no MAX_SPF_DEPTH.
spf_discover_selectors() {
	[ -s "$WORKDIR/spf.db" ] || return 0
	awk -F'\t' '{ print $2 }' "$WORKDIR/spf.db" | while IFS= read -r rec; do
		[ -n "$rec" ] || continue
		spf_includes "$rec" | while IFS= read -r t; do
			[ -n "$t" ] || continue
			sel=${t%%.*}
			case $t in
			*._domainkey.*) printf '%s\n' "$sel" ;;
			_*)
				sel=${sel#_}
				[ -n "$sel" ] && printf '%s\n' "$sel"
				;;
			esac
		done
	done
}

dkim_selector_seed() {
	cat <<'EOF'
default
google
selector1
selector2
selector3
s1
s2
s3
k1
k2
key1
key2
mail
mail1
mailer
dkim
dkim1
dkim2
sig
sig1
pm
mandrill
zoho
zoho1
sendgrid
mailjet
mailgun
sparkpost
amazonses
ses
mte1
mte2
mte3
hs1
hs2
protonmail
fastmail
bounce
mx
d
a
1
2
EOF
}

report_dkim() {
	d=$1
	cand=$WORKDIR/dkim-cand
	found=$WORKDIR/dkim-found
	names=$WORKDIR/dkim-names
	missed=$WORKDIR/dkim-missed

	dkim_selector_seed > "$cand"
	spf_discover_selectors "$d" >> "$cand"
	# some providers publish a plain list of selectors at _domainkey
	ns_q TXT "_domainkey.$d" | awk_txt | tr ' \t' '\n' >> "$cand"
	# an MX host's own first label is a common selector name
	ns_q MX "$d" | awk_mx | split_mx | while read -r pref host; do
		case $host in
		*."$d".) printf '%s\n' "${host%%."$d".}" >> "$cand" ;;
		esac
	done

	sort -u "$cand" | sed '/^$/d' > "$cand.u"
	: > "$found"
	: > "$names"
	: > "$missed"
	n=0
	while IFS= read -r sel; do
		[ -n "$sel" ] || continue
		n=$((n + 1))
		[ "$n" -le "$MAX_DKIM_QUERIES" ] || break
		name=$sel._domainkey.$d
		ns_q TXT "$name" | awk_txt > "$WORKDIR/dkim-one"
		if [ -s "$WORKDIR/dkim-one" ]; then
			{
				printf '%s\n' "$name"
				cat "$WORKDIR/dkim-one"
				printf '\n'
			} >> "$found"
			printf '%s\n' "$name" >> "$names"
		else
			printf '%s\n' "$name" >> "$missed"
		fi
	done < "$cand.u"

	printf '\n\033[1m-- DKIM --\033[0m\n'
	if [ ! -s "$found" ]; then
		printf '  \033[31mno keys found\033[0m (%s selectors probed)\n' "$n"
		return
	fi

	# found is a stream of "name / record... / blank line" groups
	while IFS= read -r name; do
		[ -n "$name" ] || continue
		printf '  %s\n' "$name"
		while IFS= read -r rec; do
			[ -n "$rec" ] || break
			verdict=$(dkim_verdict "$rec")
			sev=${verdict%%|*}
			rest=${verdict#*|}
			msg=${rest%%|*}
			fix=${rest#*|}
			[ "$fix" != "$rest" ] || fix=
			case $sev in
			OK) printf '    \033[32m%s\033[0m\n' "$msg" ;;
			WARN) printf '    \033[33m%s\033[0m\n' "$msg" ;;
			*) printf '    \033[31m%s\033[0m\n' "$msg" ;;
			esac
			printf '    p=%s...\n' "$(kv "$rec" p | cut -c1-40)"
			if [ -n "$fix" ]; then
				# deliberately not folded: a paste has to arrive as one
				# character-string, so leave the wrapping to the terminal
				printf '    \033[2mfix:\033[0m %s\n' "$fix"
			fi
		done
	done < "$found"

	printf '  %s key(s) found, %s selector(s) probed without a key\n' \
		"$(wc -l < "$names" | tr -d ' ')" "$(wc -l < "$missed" | tr -d ' ')"
}

dkim_rsa_bits() {
	command -v openssl >/dev/null 2>&1 || return 0
	# base64url differs from base64 only in '-' and '_'; mapping A-Z to a-z
	# positionally (tr '_A-Z' '+/a-z') would shift every letter instead.
	printf '%s' "$1" | tr '\055\137' '+/' | \
		openssl base64 -d -A 2>/dev/null | \
		openssl pkey -pubin -inform DER -noout -text 2>/dev/null | \
		sed -n 's/^Public-Key: (\([0-9]*\) bit.*/\1/p' | head -1
}

dkim_verdict() {
	rec=$1
	p=$(kv "$rec" p)
	k=$(kv "$rec" k)
	t=$(kv "$rec" t)
	[ -n "$k" ] || k=rsa

	if [ -z "$p" ]; then
		printf 'BAD|REVOKED - p= is empty, this selector signs nothing'
		return
	fi

	note=
	weak=
	sev=OK
	case $k in
	rsa)
		bits=$(dkim_rsa_bits "$p")
		[ -n "$bits" ] && note="$bits-bit"
		case $bits in
		*[!0-9]* | '') : ;;
		*)
			if [ "$bits" -lt 2048 ]; then
				sev=WARN
				weak=' (below the 2048-bit minimum)'
			fi
			;;
		esac
		;;
	ed25519 | ed448) note=$k ;;
	esac

	if ! printf '%s' "$rec" | grep -qi 'v=DKIM1'; then
		sev=WARN
		# The third field is the record corrected, so it can be pasted into
		# the zone as it stands. v=DKIM1 goes first because RFC 6376 wants
		# the tag-list in that order; the rest is the published record
		# verbatim, so notes and tags this script ignores are carried over.
		printf '%s|active %s key%s, but the v=DKIM1 tag is missing (RFC 6376 requires it)|v=DKIM1; %s' \
			"$sev" "$note" "$weak" "$rec"
		return
	fi
	case $t in
	y) sev=WARN; printf '%s|active %s key%s, flagged t=y whole-domain test mode' "$sev" "$note" "$weak"; return ;;
	s) sev=WARN; printf '%s|active %s key%s, flagged t=s subdomain test mode' "$sev" "$note" "$weak"; return ;;
	esac
	printf '%s|active %s key%s' "$sev" "$note" "$weak"
}

report_summary() {
	d=$1
	txtfile=$WORKDIR/txt
	spfcount=$(grep -c '^v=spf1' "$txtfile" 2>/dev/null || true)
	[ -n "$spfcount" ] || spfcount=0
	mxcount=$(wc -l < "$WORKDIR/mx" | tr -d ' ')
	dkimcount=$(wc -l < "$WORKDIR/dkim-names" 2>/dev/null | tr -d ' ')
	[ -n "$dkimcount" ] || dkimcount=0
	[ -s "$WORKDIR/dmarc" ] && dmarc=yes || dmarc=no

	stls=$(grep -c '^STARTTLS ok' "$WORKDIR/tls-hosts" 2>/dev/null || true)
	[ -n "$stls" ] || stls=0
	[ -s "$WORKDIR/mtasts" ] && mta=present || mta=absent
	[ -s "$WORKDIR/dane" ] && dane=yes || dane=no

	printf '\n\033[1m-- summary --\033[0m\n'
	printf '  MX: %s  SPF: %s  DMARC: %s  DKIM keys: %s\n' \
		"$mxcount" "$spfcount" "$dmarc" "$dkimcount"
	printf '  TLS: STARTTLS %s  MTA-STS %s  DANE %s\n' \
		"$stls" "$mta" "$dane"
}

# ------------------------------------------------------------------- TLS

fetch_url() {
	# CRLF is normalised away here, at the one place a policy enters the
	# script. Policies are served with CRLF line endings in the wild (Google,
	# for one), and a trailing CR survives into every tag value: "enforce\r"
	# misses the case that recognises it, so a valid enforce policy reads as
	# unknown, and each "mx:" pattern ends up as "host\r", which then matches
	# no MX at all - a false "delivery failure" per host under enforce.
	# Normalising once here is better than trimming \r from each of mode,
	# max_age, id and mx separately, and cannot be forgotten at a new call site.
	if command -v curl >/dev/null 2>&1; then
		curl -sS --max-time "$TLS_PROBE_TIMEOUT" "$1" 2>/dev/null
	elif command -v wget >/dev/null 2>&1; then
		wget -qO- -T "$TLS_PROBE_TIMEOUT" "$1" 2>/dev/null
	else
		return 1
	fi | sed 's/\r$//'
}

# True when $1 (a comma separated list of DNS names) covers $2, honouring a
# single leading wildcard label. Used for both certificate SANs and for the
# mx: patterns of an MTA-STS policy.
name_covers() {
	nc=$(printf '%s' "$1" | tr ',' '\n' | awk -v want="$2" '
		{
			gsub(/^[ \t]+|[ \t]+$/, "")
			sub(/^DNS:/, "")
			if ($0 == want) { print "yes"; exit }
			if (substr($0, 1, 2) == "*.") {
				rest = substr($0, 3)
				# want must end with "." + rest, so that dotted suffix
				# starts len(want) - len(rest) characters in
				if (length(want) > length(rest) + 1 &&
				    substr(want, length(want) - length(rest)) == "." rest) {
					print "yes"
					exit
				}
			}
		}')
	[ "$nc" = 'yes' ]
}

# Run one handshake. $1 = output file, $2 = host, $3 = port,
# $4 = STARTTLS protocol, empty for implicit TLS. Returns the exit status.
tls_handshake() {
	th_out=$1
	th_h=$2
	th_p=$3
	th_proto=$4
	if [ -n "$th_proto" ]; then
		set -- -starttls "$th_proto"
	else
		set --
	fi
	# openssl s_client has no connect timeout, so use timeout(1) when it is
	# present: a firewalled port would otherwise hang for a minute or more.
	if command -v timeout >/dev/null 2>&1; then
		timeout "$TLS_PROBE_TIMEOUT" \
			openssl s_client -connect "$th_h:$th_p" "$@" -showcerts \
			</dev/null > "$th_out" 2>&1
		return $?
	fi
	openssl s_client -connect "$th_h:$th_p" "$@" -showcerts \
		</dev/null > "$th_out" 2>&1
}

# Classify a handshake into: ok, filtered, refused, notls, localtls, badcert,
# nocert, failed.
#
# s_client prints a full session block even when the handshake FAILS, with
# "Protocol: TLSv1.3", "Verification: OK" and "Verify return code: 0 (ok)"
# left at their defaults. Keying off those marks a plaintext port as a
# successful TLS 1.3 connection. The only trustworthy signals are the
# absence of an openssl error and the presence of a real peer certificate.
tls_state() {
	ts_out=$1
	ts_rc=$2
	# A dropped packet and a closed port look alike unless the close arrives
	# as an RST, and an egress firewall produces silence for every port.
	if [ "$ts_rc" -eq 124 ] || [ ! -s "$ts_out" ]; then
		printf 'filtered'
		return
	fi
	if grep -q '^[0-9A-Fa-f]*:error:' "$ts_out"; then
		# Read via tr, not cat: a shell that is not the script's own
		# interpreter warns about a NUL byte in a command substitution.
		case $(tr -d '\000' < "$ts_out") in
		*'Connection refused'*) printf 'refused' ;;
		*'Connection timed out'* | *'no route to host'* | *'Connection reset'*)
			printf 'filtered' ;;
		*'no protocols available'*)
			# the local openssl build will not speak TLS 1.0/1.1 at all, so
			# this says nothing whatsoever about the server
			printf 'localtls' ;;
		# "no starttls" is the OpenSSL 1.1.x wording. 3.x no longer raises
		# that error: it prints "Didn't find STARTTLS in server response,
		# trying anyway..." and then fails with "unexpected eof while
		# reading", which matches none of the patterns below and so used to
		# be reported as a generic handshake failure. A port that greets in
		# plaintext and offers no STARTTLS is exactly what notls means.
		*'wrong version number'* | *'no starttls'* | *'find STARTTLS'* \
			| *'unsupported protocol'*)
			printf 'notls' ;;
		*'certificate verify failed'* | *'self signed'* | *'self-signed'*)
			printf 'badcert' ;;
		*) printf 'failed' ;;
		esac
		return
	fi
	if grep -q -- '-----BEGIN CERTIFICATE-----' "$ts_out"; then
		printf 'ok'
	else
		printf 'nocert'
	fi
}

# Protocol version from a handshake dump, empty when the dump does not say.
# A protocol payload can end up in the dump - a Dovecot POP3 server pads its
# trailing "+OK" with NULs, for one - and grep reads a NUL as "binary": it
# prints "Binary file ... matches" to stderr and drops the -o matches, so a
# port that really speaks TLSv1.3 gets reported as "version unknown". Strip
# the NULs before grep sees them.
tls_proto_ver() {
	tr -d '\000' < "$1" | grep -oE 'TLSv1\.[0-3]' | tail -1
}

# Populate TP_SUBJECT / TP_EXPIRES / TP_SANS from a handshake dump.
tls_cert_fields() {
	awk '/BEGIN CERTIFICATE/ { p = 1 } p { print } /END CERTIFICATE/ { exit }' \
		"$1" > "$WORKDIR/tls-leaf.pem"
	TP_SUBJECT=$(openssl x509 -in "$WORKDIR/tls-leaf.pem" -noout -subject 2>/dev/null |
		sed 's/^subject=[ \t]*//')
	TP_EXPIRES=$(openssl x509 -in "$WORKDIR/tls-leaf.pem" -noout -enddate 2>/dev/null |
		sed 's/^notAfter=//')
	# The prefix must be matched anchored: a greedy ".*DNS:" would eat every
	# entry up to the last one in the list.
	TP_SANS=$(openssl x509 -in "$WORKDIR/tls-leaf.pem" -noout -ext subjectAltName 2>/dev/null |
		tr -d ' \n' | sed 's/^X509v3SubjectAlternativeName://')
}

# Render one non-ok handshake as a single line. The label is already the
# host, host:port or host:port (proto) that was attempted.
tls_report_state() {
	case $1 in
	filtered) trs_c=33; trs_m='no response (filtered, or blocked by your egress)' ;;
	refused) trs_c=31; trs_m='connection refused (nothing listening)' ;;
	notls) trs_c=31; trs_m='port answers but does not speak TLS' ;;
	localtls) trs_c=33; trs_m='untestable: local TLS support too old' ;;
	badcert) trs_c=31; trs_m='certificate chain does not verify' ;;
	nocert) trs_c=33; trs_m='handshake gave no peer certificate' ;;
	*) trs_c=31; trs_m='handshake failed' ;;
	esac
	printf '    \033[%sm%-9s\033[0m %-26s %s\n' "$trs_c" "$1" "$2" "$trs_m"
}

# Addresses a host currently resolves to, IPv4 then IPv6, comma separated.
#
# openssl cannot be asked: -brief prints "Connecting to <ip>", but a filtered
# port produces no output at all, and even for a successful handshake the
# answer can differ from this lookup because the addresses are round-robin.
# So this reports what the host resolves to now, not a guaranteed identity of
# the address openssl dialled. Results are cached because the probe loops
# revisit the same hosts for several ports.
host_addrs() {
	ha_h=$1
	ha_c=$(awk -F'\t' -v h="$ha_h" '$1 == h { print $2; exit }' "$WORKDIR/addr.cache" 2>/dev/null)
	if [ -n "$ha_c" ]; then
		printf '%s' "$ha_c"
		return
	fi
	ha_v4=$(ns_q A "$ha_h" | awk '
		/^Non-authoritative answer|^Authoritative answer|^answer:/ { ans = 1 }
		/^Authoritative answers can be found from:/ { ans = 0 }
		# The resolver banner also prints "Address: <server>#53" before the
		# answer section, so only take addresses once past it.
		ans && /^Address: / { sub(/^Address:[ \t]+/, ""); print }
	')
	ha_v6=$(ns_q AAAA "$ha_h" | awk '
		/^Non-authoritative answer|^Authoritative answer|^answer:/ { ans = 1 }
		/^Authoritative answers can be found from:/ { ans = 0 }
		ans && /^Address: / { sub(/^Address:[ \t]+/, ""); print }
	')
	ha_all=$(printf '%s\n%s\n' "$ha_v4" "$ha_v6" | sed '/^$/d' | awk '!seen[$0]++')
	ha_n=$(printf '%s\n' "$ha_all" | grep -c . )
	[ -n "$ha_n" ] || ha_n=0
	ha_list=$(printf '%s\n' "$ha_all" | head -3 | paste -sd, -)
	if [ "$ha_n" -gt 3 ]; then
		ha_list="$ha_list,+$((ha_n - 3))"
	fi
	[ -n "$ha_list" ] || ha_list='-'
	printf '%s\t%s\n' "$ha_h" "$ha_list" >> "$WORKDIR/addr.cache"
	printf '%s' "$ha_list"
}

# "[185.0.2.1,198.51.100.2]" for display, or nothing when there is no address.
fmt_addrs() {
	case $1 in
	'' | -) return 0 ;;
	esac
	printf ' [%s]' "$1"
}

report_tls() {
	d=$1
	: > "$WORKDIR/tls-hosts"
	: > "$WORKDIR/mtasts"
	: > "$WORKDIR/dane"
	: > "$WORKDIR/addr.cache"
	printf '\n\033[1m-- TLS --\033[0m\n'
	report_tls_starttls "$d"
	report_tls_services "$d"
	report_tls_mtasts "$d"
	report_tls_rpt "$d"
	report_tls_dane "$d"
}

# Live STARTTLS handshake against every MX on port 25.
# Launch a handshake in the background, tagged so results can be collected in
# order. Probes must run concurrently: when a host silently drops packets
# every probe costs the full timeout, and a sequential sweep of 6 services
# across 2 hosts would otherwise take over two minutes.
tls_probe_bg() {
	(
		tls_handshake "$WORKDIR/probe.$1" "$2" "$3" "$4"
		printf '%s\n' "$?" > "$WORKDIR/probe.$1.rc"
	) &
}

report_tls_starttls() {
	td=$1
	mxfile=$WORKDIR/mx
	printf '  \033[1mSTARTTLS\033[0m (port 25)\n'

	if [ ! -s "$mxfile" ]; then
		printf '    no MX records to probe\n'
		return
	fi
	if [ "$TLS_LIVE" -eq 0 ]; then
		printf '    \033[2mskipped (-D)\033[0m\n'
		return
	fi
	if ! command -v openssl >/dev/null 2>&1; then
		printf '    \033[2mskipped: openssl not found\033[0m\n'
		return
	fi

	# Build the target list here, in the parent shell, so the cap applies
	# before any probe starts and "wait" has children to wait for.
	targets=$WORKDIR/targets25
	awk '{ print $2 }' "$mxfile" | sort -u | sed '/^$/d;s/\.$//' |
		head -n "$MAX_TLS_HOSTS" > "$targets"
	total=$(awk '{ print $2 }' "$mxfile" | sort -u | sed '/^$/d' | grep -c . )
	[ -n "$total" ] || total=0

	tag=0
	for h in $(cat "$targets"); do
		tag=$((tag + 1))
		tls_probe_bg "$tag" "$h" 25 smtp
	done
	wait

	tag=0
	for h in $(cat "$targets"); do
		tag=$((tag + 1))
		out=$WORKDIR/probe.$tag
		rc=$(cat "$WORKDIR/probe.$tag.rc" 2>/dev/null || printf '1')
		st=$(tls_state "$out" "$rc")
		ip=$(host_addrs "$h")
		if [ "$st" != ok ]; then
			tls_report_state "$st" "$h$(fmt_addrs "$ip")"
			continue
		fi
		printf 'STARTTLS ok %s\n' "$h" >> "$WORKDIR/tls-hosts"
		proto=$(tls_proto_ver "$out")
		cipher=$(sed -n 's/^New, [^,]*,\{0,1\} *Cipher is //p' "$out" | head -1)
		[ -n "$cipher" ] || cipher=$(sed -n 's/^ *Cipher *: *//p' "$out" | head -1)
		case $proto in
		TLSv1 | TLSv1.1)
			printf '    \033[31m%-6s\033[0m     %s%s (%s) - deprecated by RFC 8997\n' \
				"$proto" "$h" "$(fmt_addrs "$ip")" "$cipher" ;;
		'')
			printf '    \033[33munknown\033[0m   %s%s\n' "$h" "$(fmt_addrs "$ip")" ;;
		*)
			printf '    \033[32m%-6s\033[0m     %s%s (%s)\n' \
				"$proto" "$h" "$(fmt_addrs "$ip")" "$cipher" ;;
		esac

		tls_cert_fields "$out"
		[ -n "$TP_SUBJECT" ] || continue
		printf '             cert %s, expires %s\n' "$TP_SUBJECT" "$TP_EXPIRES"
		# RFC 2818 deprecated CN matching, so only a DNS SAN counts.
		if name_covers "$TP_SANS" "$h"; then
			printf '             \033[32mSAN matches %s\033[0m\n' "$h"
		else
			printf '             \033[31mSAN does not cover %s\033[0m\n' "$h"
		fi
		if ! openssl x509 -in "$WORKDIR/tls-leaf.pem" -noout -checkend 2592000 >/dev/null 2>&1; then
			printf '             \033[33mexpires within 30 days: %s\033[0m\n' "$TP_EXPIRES"
		fi
	done
	if [ "$total" -gt "$MAX_TLS_HOSTS" ]; then
		printf '    \033[2m%s further MX not probed (cap %s, raise with MAILCHECK_MAX_HOSTS)\033[0m\n' \
			"$((total - MAX_TLS_HOSTS))" "$MAX_TLS_HOSTS"
	fi
	return 0
}

# Submission and mailbox ports. Most are implicit TLS, so -starttls must not
# be used for them.
report_tls_services() {
	td=$1
	mxfile=$WORKDIR/mx
	printf '  \033[1mTLS services\033[0m\n'

	if [ ! -s "$mxfile" ]; then
		printf '    \033[2mno MX records to probe\033[0m\n'
		return
	fi
	if [ "$TLS_LIVE" -eq 0 ]; then
		printf '    \033[2mskipped (-D)\033[0m\n'
		return
	fi
	if ! command -v openssl >/dev/null 2>&1; then
		printf '    \033[2mskipped: openssl not found\033[0m\n'
		return
	fi
	if [ -z "$TLS_SERVICES" ]; then
		return
	fi

	# One "host port proto kind" line per probe; proto "-" means implicit
	# TLS. kind is "svc" for a real port and "ctl" for the negative control.
	targets=$WORKDIR/targetssvc
	ctl=$WORKDIR/ctl
	reached=$WORKDIR/reached
	quiet=$WORKDIR/quiet
	: > "$targets"
	: > "$ctl"
	: > "$reached"
	: > "$quiet"
	hosts=$(awk '{ print $2 }' "$mxfile" | sort -u | sed '/^$/d;s/\.$//' | head -n "$MAX_SUB_HOSTS")
	total=$(awk '{ print $2 }' "$mxfile" | sort -u | sed '/^$/d' | grep -c . )
	[ -n "$total" ] || total=0
	for h in $hosts; do
		for spec in $TLS_SERVICES; do
			svc_port=${spec%%/*}
			svc_proto=${spec#*/}
			[ "$svc_proto" = "$spec" ] && svc_proto='-'
			printf '%s %s %s svc\n' "$h" "$svc_port" "$svc_proto" >> "$targets"
		done
		printf '%s %s - ctl\n' "$h" "$CONTROL_PORT" >> "$targets"
	done

	tag=0
	while read -r h svc_port svc_proto kind; do
		[ -n "$h" ] || continue
		tag=$((tag + 1))
		[ "$svc_proto" = '-' ] && svc_proto=''
		tls_probe_bg "$tag" "$h" "$svc_port" "$svc_proto"
	done < "$targets"
	wait

	tag=0
	while read -r h svc_port svc_proto kind; do
		[ -n "$h" ] || continue
		tag=$((tag + 1))
		[ "$svc_proto" = '-' ] && svc_proto='-'
		out=$WORKDIR/probe.$tag
		rc=$(cat "$WORKDIR/probe.$tag.rc" 2>/dev/null || printf '1')
		st=$(tls_state "$out" "$rc")
		# Silence is the only verdict that proves nothing on its own. Every
		# other answer - an RST above all - shows the RST path works for this
		# host, and it is that, not the control port, which makes a "filtered"
		# line below interpretable.
		if [ "$st" = filtered ]; then
			printf '%s\n' "$h" >> "$quiet"
		else
			printf '%s\n' "$h" >> "$reached"
		fi
		if [ "$kind" = ctl ]; then
			if [ "$st" = filtered ]; then
				printf '%s dropped\n' "$h" >> "$ctl"
			else
				printf '%s reaches\n' "$h" >> "$ctl"
			fi
			continue
		fi
		if [ "$svc_proto" = '-' ]; then
			label="$h:$svc_port"
		else
			label="$h:$svc_port ($svc_proto)"
		fi
		label="$label$(fmt_addrs "$(host_addrs "$h")")"
		if [ "$st" != ok ]; then
			tls_report_state "$st" "$label"
			continue
		fi
		proto=$(tls_proto_ver "$out")
		case $proto in
		TLSv1 | TLSv1.1)
			printf '    \033[31m%-9s\033[0m %-26s %s - deprecated by RFC 8997\n' \
				"ok" "$label" "$proto" ;;
		'')
			printf '    \033[33m%-9s\033[0m %-26s handshake ok, version unknown\n' "ok" "$label" ;;
		*)
			printf '    \033[32m%-9s\033[0m %-26s %s\n' "ok" "$label" "$proto" ;;
		esac
		tls_cert_fields "$out"
		if [ -n "$TP_SUBJECT" ]; then
			if ! name_covers "$TP_SANS" "$h"; then
				printf '             \033[31mSAN does not cover %s\033[0m (%s)\n' "$h" "$TP_SUBJECT"
			fi
			if ! openssl x509 -in "$WORKDIR/tls-leaf.pem" -noout -checkend 2592000 >/dev/null 2>&1; then
				printf '             \033[33mexpires within 30 days: %s\033[0m\n' "$TP_EXPIRES"
			fi
		fi
	done < "$targets"

	if [ -s "$ctl" ]; then
		printf '    \033[2megress control, port %s (should be closed):\033[0m\n' "$CONTROL_PORT"
		while read -r h verdict; do
			[ -n "$h" ] || continue
			if [ "$verdict" = dropped ]; then
				# A silent control port is ambiguous on its own, but an RST
				# from any other port settles it: the host answered, so
				# packets reach it and the drop is per-port, not per-host.
				if grep -qxF "$h" "$reached" 2>/dev/null; then
					printf '      \033[33m%-24s\033[0m no RST on this port, but other ports did:\n' "$h"
					printf '        %-24s   the host is reachable, so "filtered" above means dropped there too\n' ''
				else
					printf '      \033[33m%-24s\033[0m no RST came back on any port, so packets are dropped:\n' "$h"
					printf '        %-24s   the "filtered" lines above mean "unknown", not "closed"\n' ''
				fi
			else
				printf '      \033[32m%-24s\033[0m refused or answered, so this host is reachable\n' "$h"
			fi
		done < "$ctl"
	fi
	if [ "$total" -gt "$MAX_SUB_HOSTS" ]; then
		printf '    \033[2m%s further MX not probed (cap %s, raise with MAILCHECK_MAX_SUB_HOSTS)\033[0m\n' \
			"$((total - MAX_SUB_HOSTS))" "$MAX_SUB_HOSTS"
	fi
	return 0
}



report_tls_mtasts() {
	td=$1
	printf '  \033[1mMTA-STS\033[0m\n'
	rec=$(ns_q TXT "_mta-sts.$td" | awk_txt | grep -i '^v=STSv1' | head -1)
	if [ -z "$rec" ]; then
		printf '    \033[2mabsent\033[0m - no _mta-sts.%s record; receivers cannot\n' "$td"
		printf '      enforce TLS, so delivery falls back to opportunistic STARTTLS\n'
		return
	fi
	printf '%s\n' "$rec" > "$WORKDIR/mtasts"
	dns_id=$(printf '%s' "$rec" | tr ';' '\n' | sed -n 's/^[ \t]*id[ \t]*=[ \t]*//p' | tr -d ' \r' | head -1)
	printf '    DNS  %s' "$rec"
	[ -n "$dns_id" ] && printf '  (id=%s)' "$dns_id"
	printf '\n'

	if [ "$TLS_LIVE" -eq 0 ]; then
		printf '    \033[2mpolicy not fetched (-D)\033[0m\n'
		return
	fi
	policy=$(fetch_url "https://mta-sts.$td/.well-known/mta-sts.txt")
	if [ -z "$policy" ]; then
		printf '    \033[33mpolicy not served\033[0m - no usable https://mta-sts.%s/.well-known/mta-sts.txt\n' "$td"
		printf '      receivers treat this domain as having no MTA-STS policy\n'
		return
	fi

	mode=$(printf '%s' "$policy" | sed -n 's/^mode:[ \t]*//p' | head -1)
	maxage=$(printf '%s' "$policy" | sed -n 's/^max_age:[ \t]*//p' | head -1)
	pol_id=$(printf '%s' "$policy" | sed -n 's/^id:[ \t]*//p' | tr -d ' \r' | head -1)
	pats=$(printf '%s' "$policy" | sed -n 's/^mx:[ \t]*//p' | paste -sd, - 2>/dev/null)

	printf '    HTTP mode=%s max_age=%s\n' "${mode:-?}" "${maxage:-?}"
	case $mode in
	enforce) printf '         \033[32mmode enforce\033[0m - receivers must use TLS and validate the cert\n' ;;
	testing) printf '         \033[33mmode testing\033[0m - failures are only reported\n' ;;
	none) printf '         \033[33mmode none\033[0m - policy explicitly disabled\n' ;;
	*) printf '         \033[31munknown or missing mode\033[0m (RFC 8461 requires enforce, none or testing)\n' ;;
	esac

	if [ -z "$pol_id" ]; then
		printf '         \033[31mpolicy has no id tag\033[0m - RFC 8461 requires one; a strict\n'
		printf '           receiver will discard this policy and fall back to STARTTLS\n'
	elif [ "$pol_id" != "$dns_id" ]; then
		printf '         \033[31mSTALE\033[0m - DNS id=%s but policy id=%s; receivers keep the old policy\n' \
			"$dns_id" "$pol_id"
	else
		printf '         \033[32mDNS and policy ids match\033[0m (%s)\n' "$pol_id"
	fi

	# In enforce mode any MX missing from the policy is a delivery failure.
	# Each MX is tested once against the whole pattern list; testing every
	# pattern against every MX would report a failure for each non-matching
	# pairing rather than per host.
	if [ "$mode" = enforce ] && [ -n "$pats" ] && [ -s "$WORKDIR/mx" ]; then
		awk '{ print $2 }' "$WORKDIR/mx" | sort -u | while IFS= read -r mh; do
			[ -n "$mh" ] || continue
			mh=${mh%.}
			name_covers "$pats" "$mh" ||
				printf '         \033[31mMX %s matches no mx: pattern\033[0m\n' "$mh"
		done
	fi
}

report_tls_rpt() {
	td=$1
	rec=$(ns_q TXT "_smtp._tls.$td" | awk_txt | grep -i '^v=TLSRPTv1' | head -1)
	printf '  \033[1mTLS-RPT\033[0m\n'
	if [ -z "$rec" ]; then
		printf '    \033[2mabsent\033[0m - no _smtp._tls.%s record; TLS failures are not reported\n' "$td"
		return
	fi
	rua=$(printf '%s' "$rec" | tr ';' '\n' | sed -n 's/^[ \t]*rua[ \t]*=[ \t]*//p' | head -1)
	printf '    %s\n' "$rec"
	if [ -n "$rua" ]; then
		printf '    \033[32mreports go to %s\033[0m\n' "$rua"
	else
		printf '    \033[31mno rua=\033[0m - nothing to send reports to\n'
	fi
}

report_tls_dane() {
	td=$1
	printf '  \033[1mDANE\033[0m (TLSA)\n'
	if ! ns_q DS "$td" | grep -q 'rdata_43'; then
		printf '    \033[2mno DS record\033[0m - zone is unsigned, so TLSA cannot be validated\n'
	fi
	if [ ! -s "$WORKDIR/mx" ]; then
		return
	fi
	hosts=$(awk '{ print $2 }' "$WORKDIR/mx" | sort -u | sed '/^$/d')
	printf '%s\n' "$hosts" | head -n "$MAX_TLS_HOSTS" | while IFS= read -r host; do
		[ -n "$host" ] || continue
		h=${host%.}
		recs=$(ns_q TLSA "_25._tcp.$h" | awk '
			/rdata_52 = / {
				s = $0
				sub(/^[^=]*= /, "", s)
				n = split(s, f, /[ \t]+/)
				if (n < 3) next
				u = f[1]; sel = f[2]; mt = f[3]
				us = (u == 0 ? "PKIX-EE(0)" : u == 1 ? "PKIX-CA(1)" : u == 2 ? "DANE-TA(2)" : "DANE-EE(3)")
				ss = (sel == 0 ? "full cert" : sel == 1 ? "full cert" : sel == 2 ? "SPKI" : "?" + sel)
				ms = (mt == 0 ? "exact" : mt == 1 ? "SHA-256" : mt == 2 ? "SHA-512" : "?" + mt)
				print us " " ss " " ms
			}')
		if [ -n "$recs" ]; then
			printf '%s\n' "$recs" > "$WORKDIR/dane" 2>/dev/null
			cnt=$(printf '%s\n' "$recs" | wc -l | tr -d ' ')
			printf '    \033[32m%s\033[0m  %s (%s TLSA record(s))\n' "$h" \
				"$(printf '%s\n' "$recs" | head -1)" "$cnt"
		fi
	done
	if [ ! -s "$WORKDIR/dane" ]; then
		printf '    \033[2mno TLSA records\033[0m for any MX - senders cannot pin the certificate\n'
	fi
	return 0
}

for arg in "$@"; do
	if ! domain=$(normalize_domain "$arg"); then
		printf '%s: not a valid domain: %s\n' "$PROG" "$arg" >&2
		continue
	fi
	rm -f "$WORKDIR"/*
	report_domain "$domain"
done

printf '\n'
