| 1 | #!/usr/bin/env bash |
| 2 | # Checks Cloudflare Email Sending for the domain Diffinery sends mail from |
| 3 | # (notifications@diffinery.com): that the domain is onboarded and that every DNS |
| 4 | # record Email Sending asks for (the cf-bounce MX and SPF, DKIM and DMARC) is |
| 5 | # published. It changes nothing. Onboard the domain once in the dashboard: |
| 6 | # Compute > Email Service > Email Sending > Onboard Domain. |
| 7 | # |
| 8 | # Requires: CLOUDFLARE_API_TOKEN (zone permissions Email Sending Read or Edit and |
| 9 | # DNS Read on the domain), jq, curl. Optional: DIFFINERY_DOMAIN, MAIL_DOMAIN. |
| 10 | # Always exits 0, warning about anything missing, so a deploy still goes out. |
| 11 | set -euo pipefail |
| 12 | |
| 13 | : "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}" |
| 14 | DOMAIN="${MAIL_DOMAIN:-${DIFFINERY_DOMAIN:-diffinery.com}}" |
| 15 | CF="https://api.cloudflare.com/client/v4" |
| 16 | |
| 17 | cf() { curl -sS "$CF$1" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"; } |
| 18 | warn() { echo "::warning::$*" >&2; } |
| 19 | errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1"; } |
| 20 | |
| 21 | echo "::group::Email Sending for $DOMAIN" |
| 22 | trap 'echo "::endgroup::"' EXIT |
| 23 | |
| 24 | # The zone that holds the domain (the domain itself or a parent). |
| 25 | ZONE_ID="" ; name="$DOMAIN" |
| 26 | while [[ "$name" == *.* ]]; do |
| 27 | ZONE_ID="$(cf "/zones?name=$name" | jq -r '.result[0].id // empty')" |
| 28 | [[ -n "$ZONE_ID" ]] && break |
| 29 | name="${name#*.}" |
| 30 | done |
| 31 | [[ -n "$ZONE_ID" ]] || { warn "Could not find the Cloudflare zone for $DOMAIN (the token needs Zone Read on it)."; exit 0; } |
| 32 | echo "zone $name ($ZONE_ID)" |
| 33 | |
| 34 | # Without Email Sending access on the token, look at the public DNS records onboarding creates instead. |
| 35 | public_check() { |
| 36 | doh() { curl -sS "https://cloudflare-dns.com/dns-query?name=$1&type=$2" -H 'accept: application/dns-json' | jq -r '.Answer[]?.data' 2>/dev/null; } |
| 37 | local ok=1 |
| 38 | if doh "cf-bounce.$DOMAIN" MX | grep -q 'mx.cloudflare.net'; then echo "ok MX cf-bounce.$DOMAIN"; else echo "missing MX cf-bounce.$DOMAIN"; ok=0; fi |
| 39 | if doh "cf-bounce.$DOMAIN" TXT | grep -q 'v=spf1'; then echo "ok TXT cf-bounce.$DOMAIN (SPF)"; else echo "missing TXT cf-bounce.$DOMAIN (SPF)"; ok=0; fi |
| 40 | if doh "_dmarc.$DOMAIN" TXT | grep -qi 'v=DMARC1'; then echo "ok TXT _dmarc.$DOMAIN"; else echo "missing TXT _dmarc.$DOMAIN"; ok=0; fi |
| 41 | [[ "$ok" == 1 ]] && echo "$DOMAIN looks onboarded to Email Sending (public DNS)" || warn "$DOMAIN does not look onboarded to Email Sending, so mail may not be delivered. Onboard it under Compute > Email Service > Email Sending in the Cloudflare dashboard." |
| 42 | } |
| 43 | |
| 44 | LIST="$(cf "/zones/$ZONE_ID/email/sending/subdomains")" |
| 45 | if ! jq -e '.success' >/dev/null <<<"$LIST"; then |
| 46 | echo "can't read Email Sending settings with this token ($(errors "$LIST")), checking public DNS instead" |
| 47 | public_check |
| 48 | exit 0 |
| 49 | fi |
| 50 | TAG="$(jq -r --arg d "$DOMAIN" '.result[]? | select(.name == $d) | .tag' <<<"$LIST" | head -n1)" |
| 51 | if [[ -z "$TAG" ]]; then |
| 52 | warn "$DOMAIN is not onboarded to Email Sending, so no email will be delivered. Onboard it under Compute > Email Service > Email Sending in the Cloudflare dashboard." |
| 53 | exit 0 |
| 54 | fi |
| 55 | cf "/zones/$ZONE_ID/email/sending/subdomains/$TAG" | jq -c '.result | {name, enabled, dkim_selector, return_path_domain}' |
| 56 | |
| 57 | WANT="$(cf "/zones/$ZONE_ID/email/sending/subdomains/$TAG/dns")" |
| 58 | jq -e '.success' >/dev/null <<<"$WANT" || { warn "Could not read the DNS records Email Sending needs ($(errors "$WANT"))."; exit 0; } |
| 59 | norm() { tr -d '"' | tr -s ' ' | sed 's/ *$//'; } |
| 60 | missing=0 |
| 61 | while IFS= read -r rec; do |
| 62 | type="$(jq -r .type <<<"$rec")"; rname="$(jq -r .name <<<"$rec")" |
| 63 | want="$(jq -r .content <<<"$rec" | norm)" |
| 64 | have="$(cf "/zones/$ZONE_ID/dns_records?type=$type&name=$rname&per_page=100")" |
| 65 | if ! jq -e '.success' >/dev/null <<<"$have"; then echo "can't read DNS for $rname ($(errors "$have"))"; continue; fi |
| 66 | if jq -r '.result[].content' <<<"$have" | norm | grep -qxF -- "$want"; then |
| 67 | echo "ok $type $rname" |
| 68 | elif [[ "$type" == TXT && "$rname" == _dmarc.* ]] && jq -r '.result[].content' <<<"$have" | grep -qi 'v=DMARC1'; then |
| 69 | echo "ok TXT $rname (the domain's own DMARC policy)" |
| 70 | else |
| 71 | echo "missing $type $rname"; missing=1 |
| 72 | fi |
| 73 | done < <(jq -c '.result[]?' <<<"$WANT") |
| 74 | [[ "$missing" == 0 ]] && echo "Email Sending is set up for $DOMAIN" || warn "Some Email Sending DNS records for $DOMAIN are missing, so mail may not be delivered. Finish onboarding in the dashboard." |