#!/bin/ksh
#
# probe-email <address>
#
# One verdict for one mailbox: is the address accepted by the MX of its domain, right now.
# Read-only: QUIT before DATA, no mail is ever sent.
#
# Silent and rc 0 when accepted. rc 1 and one summary line on stderr otherwise: no MX, refused
# or unreachable, missing tool. Exit codes: 0 accepted, 1 usage, 2 no MX, 3 refused or
# unreachable, 4 missing tool.
#
# LOCKSTEP dialogue through a ksh coprocess: every answer is read before the next command goes
# out, so the verdict is the CODE of the RCPT answer. A transcript match would have to name the
# server's wording, and a fixed pause between commands desynchronises the moment the MX is slow.
#
# hn-timeout (sysutils/hn-timeout, timeout(1) of base with its race closed) is the only deadline:
# it puts its command in a process group and signals the GROUP [verifie:openbsd/src
# usr.bin/timeout/timeout.c:226-231, 292-297], so a mute MX cannot hold the probe past it.
#
# Tools: ksh, dig, nc, hostname (base), hn-timeout (sysutils/hn-timeout).
#
# Copyright (c) 2026 Jean-Gerard Pailloncy
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

set -u
export LC_ALL=C
# ksh imports the caller PATH over its own default and never puts it back [verifie:openbsd/src
# bin/ksh/main.c:220-226, :272-274]. /usr/local/bin last, for hn-timeout alone: timeout(1) of
# base blocks its signals after fork(), and a command that exits in between leaves it asleep
# until its deadline. Last, so that no package shadows a base tool.
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin; export PATH

# Whole dialogue. A caller invoking this per address should bound its own pass on what one
# address may cost.
TIMEOUT=20

RCPT=${1:-}
case $RCPT in
*@?*)	;;
*)	echo "usage: ${0##*/} <address>" >&2; exit 1 ;;
esac

# nc last: a resolution test that reads what follows an `nc` as the target of a connection would
# see a tool name there and mistake it for one.
for _t in dig hn-timeout hostname nc; do
	command -v "$_t" >/dev/null 2>&1 || { echo "missing tool: $_t" >&2; exit 4; }
done

DOMAIN=${RCPT##*@}
SRC=$(hostname 2>/dev/null)
[ -n "$SRC" ] || SRC=checker.invalid
FROM="root@$SRC"

# Lowest priority MX, the trailing dot dropped.
DEST=$(dig +short -t MX "$DOMAIN" 2>/dev/null | sort -n | head -1)
DEST=${DEST##* }
DEST=${DEST%.}
[ -n "$DEST" ] || { echo "no MX for $DOMAIN" >&2; exit 2; }

# The connect takes the ADDRESS, resolved here: nc goes through the system resolver, which may
# read a local hosts file before the DNS, and the probe would then measure a private path while
# reporting the state of the MX as the world sees it. Empty falls back on the name.
DEST_IP=$(dig +short A "$DEST" 2>/dev/null | head -1)

CR=$(printf '\r')		# SMTP ends a line with CRLF; print -p adds the LF

# RCODE = final code of an answer ("250-" continues a multi-line one, "250 " ends it). Empty on
# EOF, which is a dead coprocess: refused connection, expired timeout, drop mid-dialogue.
smtp_read_code() {
	RCODE=""
	# shellcheck disable=SC2154  # read -p fills _line from the co-process
	while read -rp _line 2>/dev/null; do
		case $_line in
		[0-9][0-9][0-9]" "*)	RCODE=${_line%"${_line#???}"}; return ;;
		esac
	done
}

# 2>/dev/null on every send: printing to a coprocess whose command has already exited writes
# "no query process", noise over a failure the empty RCODE reports on its own.
bail() {
	print -p "QUIT$CR" 2>/dev/null
	wait "$NC_PID" 2>/dev/null
	echo "$1" >&2
	exit 3
}

hn-timeout "$TIMEOUT" nc "${DEST_IP:-$DEST}" 25 2>/dev/null |&
NC_PID=$!

smtp_read_code
case $RCODE in
2*)	;;
*)	bail "no SMTP greeting from $DEST" ;;
esac

print -p "HELO $SRC$CR" 2>/dev/null
smtp_read_code
case $RCODE in
2*)	;;
*)	bail "HELO refused by $DEST (${RCODE:-no answer})" ;;
esac

print -p "MAIL FROM:<$FROM>$CR" 2>/dev/null
smtp_read_code
case $RCODE in
2*)	;;
*)	bail "MAIL FROM refused by $DEST (${RCODE:-no answer})" ;;
esac

print -p "RCPT TO:<$RCPT>$CR" 2>/dev/null
smtp_read_code
VERDICT=$RCODE

print -p "QUIT$CR" 2>/dev/null
smtp_read_code
wait "$NC_PID" 2>/dev/null

case $VERDICT in
2*)	exit 0 ;;
esac
echo "RCPT refused by $DEST (${VERDICT:-no answer})" >&2
exit 3
