scripts/check-mail-dns.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Check that animegoclub.com is set up to SEND authenticated mail without
# breaking the Cloudflare Email Routing that receives it.
#
# Why a script rather than a checklist: every failure this looks for is
# silent. A second SPF record, a DKIM selector that never got published, an
# MX row clobbered while adding sending records — none of them produce an
# error anywhere. The mail simply stops arriving for the receivers that
# check, which here is most of them: 78% of accounts are on qq.com and
# another 10% on 163.com.
#
# Usage:
# ./scripts/check-mail-dns.sh # SPF/DMARC/MX only
# ./scripts/check-mail-dns.sh resend._domainkey # also check a DKIM selector
#
# Exits non-zero if anything that would stop authenticated delivery is wrong.
set -uo pipefail
ZONE="${MAIL_DOMAIN:-animegoclub.com}"
DKIM_SELECTOR="${1:-}"
RESOLVER="${DNS_RESOLVER:-1.1.1.1}"
fail=0
q() { dig +short "$2" "$1" "@$RESOLVER" 2>/dev/null; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; fail=1; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
echo "域名: $ZONE (解析器 $RESOLVER)"
# ── SPF ─────────────────────────────────────────────────────────────────────
#
# RFC 7208 §4.5: more than one SPF record is a permerror, and a permerror is
# treated as "no SPF at all" by most receivers — so adding a second record to
# "add" a sender silently removes authentication for every sender. This is
# the single most common way this setup gets broken, which is why it is
# checked before the contents.
echo
echo "SPF"
# No arrays, no mapfile: this has to run on macOS's bash 3.2 as well as on
# the VPS. The first draft used `mapfile`, which 3.2 does not have — and the
# script printed "no errors" and exited 0 having never checked SPF at all.
# That is the exact failure it exists to catch, so: plain strings, and a
# verdict that refuses to pass when a check could not run.
spf_all=$(q "$ZONE" TXT | tr -d '"' | grep -i '^v=spf1')
if [ -z "$spf_all" ]; then
spf_n=0
else
spf_n=$(printf '%s\n' "$spf_all" | wc -l | tr -d ' ')
fi
spf_one=$(printf '%s\n' "$spf_all" | head -1)
case "$spf_n" in
0) bad "没有 SPF 记录 —— 发出去的信没有任何 IP 授权" ;;
1) ok "恰好一条: $spf_one" ;;
*) bad "有 $spf_n 条 SPF 记录 —— 这是 permerror,等于完全没有 SPF"
printf ' %s\n' "$spf_all"
bad "修法: 合并成一条,把每个发信方的 include 写进同一行" ;;
esac
if [ "$spf_n" -eq 1 ]; then
# Cloudflare Email Routing's include must survive: dropping it while adding
# a sending provider is how inbound forwarding quietly stops passing SPF.
case "$spf_one" in
*_spf.mx.cloudflare.net*) ok "保留了 Cloudflare Email Routing 的 include(收信不受影响)" ;;
*) warn "SPF 里没有 _spf.mx.cloudflare.net —— 如果收信仍走 CF Routing,这条被删掉了" ;;
esac
# A bare `include:_spf.mx.cloudflare.net` authorises forwarding only; no
# outbound provider is listed, so nothing can send as this domain yet.
if [ "$(printf '%s' "$spf_one" | grep -o 'include:' | wc -l | tr -d ' ')" -le 1 ]; then
warn "只有一个 include —— 还没有任何发信服务被授权,现在发信 SPF 不会通过"
fi
# §4.6.4: >10 DNS-querying mechanisms is also a permerror. Counted loosely
# (nested includes are not expanded) so this is a floor, not the true total.
n=$(printf '%s' "$spf_one" | grep -oE '(include|a|mx|ptr|exists|redirect)[:=]?' | wc -l | tr -d ' ')
[ "$n" -gt 8 ] && warn "顶层机制已有 $n 个,上限是 10(嵌套 include 也算,这里没展开)"
case "$spf_one" in
*" -all"*) ok "以 -all 结尾(严格)" ;;
*" ~all"*) ok "以 ~all 结尾(软失败;先跑一段时间再收紧到 -all)" ;;
*) warn "结尾既不是 ~all 也不是 -all" ;;
esac
fi
# ── DKIM ────────────────────────────────────────────────────────────────────
#
# The provider publishes this; it is the half of DMARC that survives
# forwarding, and the half QQ/163 weigh most heavily.
echo
echo "DKIM"
if [ -z "$DKIM_SELECTOR" ]; then
warn "没传 selector,跳过。装好发信服务后用它给的 selector 再跑一次:"
warn " ./scripts/check-mail-dns.sh resend._domainkey"
else
rec=$(q "${DKIM_SELECTOR}.${ZONE}" TXT; q "${DKIM_SELECTOR}.${ZONE}" CNAME)
if [ -z "$rec" ]; then
bad "${DKIM_SELECTOR}.${ZONE} 解析不到 —— 记录没发布,或者还没生效"
else
ok "${DKIM_SELECTOR}.${ZONE} 有记录"
printf ' %s\n' "$rec" | head -3
fi
fi
# ── DMARC ───────────────────────────────────────────────────────────────────
echo
echo "DMARC"
dmarc=$(q "_dmarc.$ZONE" TXT | tr -d '"' | grep -i '^v=DMARC1' || true)
if [ -z "$dmarc" ]; then
bad "没有 DMARC 记录"
else
ok "$dmarc"
case "$dmarc" in
*p=none*)
# Correct while bringing a new sender up: p=none asks receivers to
# report rather than act, so a misconfiguration costs a report instead
# of a bounced password reset. It buys no enforcement, though.
warn "p=none —— 只监控不拦截。新发信方跑通、rua 报告连续几天全对齐之后再考虑 quarantine" ;;
esac
case "$dmarc" in
*rua=*) ok "配了 rua,对齐失败会有报告寄回来" ;;
*) warn "没有 rua —— 出问题时不会有任何报告,只能靠用户抱怨发现" ;;
esac
fi
# ── MX ──────────────────────────────────────────────────────────────────────
#
# Receiving and sending are independent. Nothing about adding a sender should
# touch MX, so this is here to catch the accident rather than to verify intent.
echo
echo "MX(收信,应保持不变)"
mx=$(q "$ZONE" MX)
if [ -z "$mx" ]; then
bad "没有 MX —— 这个域名收不了信"
else
case "$mx" in
*mx.cloudflare.net*) ok "仍指向 Cloudflare Email Routing" ;;
*) warn "MX 不是 Cloudflare Email Routing,确认是有意为之:"; printf ' %s\n' "$mx" ;;
esac
fi
echo
# `set -u` turns an unset variable into a message on stderr, not an exit —
# which is how the first draft reported success on a section that never ran.
# Anything the script could not evaluate counts as a failure, not a pass.
for v in spf_n dmarc mx; do
eval "[ -n \"\${$v+set}\" ]" || { bad "内部错误: 检查项 $v 没能运行,结论不可信"; }
done
if [ "$fail" -eq 0 ]; then
echo "结论: 没有会阻断认证投递的错误。"
echo "注意这只证明记录对,不证明信能进 QQ/163 的收件箱 —— 那必须真发一封去看。"
else
echo "结论: 上面标 ✗ 的会让认证投递失败,先修那些。"
fi
exit "$fail"