From 2be87f9531a22cfbfb52437b74c30b8171cc9d31 Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Tue, 30 Jun 2026 17:23:39 -0500 Subject: [PATCH] Read sender-domain status for maildesk maildesk-cf could prove Workers, storage, and Email Routing plans, but outbound readiness still collapsed into provider_status_unavailable because cfctl had no read-only Email Sending sender-domain surface. That made the remaining blocker less precise than the live account evidence allowed. Add a read-only sender_domain surface backed by Cloudflare Email Sending subdomain readback. Wire it into cfctl list/get/verify, permission/profile metadata, capabilities rendering, and the maildesk lifecycle evidence collector. Empty successful readback now reports sender_domain_drift instead of provider_status_unavailable; verified fixture readback can make mail_ready true. No mutation path is added. sender_domain apply remains unsupported, and maildesk composite ack remains blocked. Verified with maildesk contract, static contract, permission catalog check, git diff --check, live sender_domain readback, and private desired-state maildesk provision --plan. --- catalog/permissions.json | 8 +++ catalog/surfaces.json | 34 +++++++++++ docs/capabilities.md | 2 + scripts/cf_inventory_sender_domains.sh | 81 ++++++++++++++++++++++++++ scripts/cf_maildesk_cf_lifecycle.py | 41 ++++++++++++- scripts/lib/cfctl.sh | 22 +++++++ scripts/verify_maildesk_cf_contract.sh | 81 +++++++++++++++++++++++--- scripts/verify_permission_catalog.py | 4 +- scripts/verify_static_contract.sh | 7 +++ 9 files changed, 267 insertions(+), 13 deletions(-) create mode 100755 scripts/cf_inventory_sender_domains.sh diff --git a/catalog/permissions.json b/catalog/permissions.json index 44ed64c..903589f 100644 --- a/catalog/permissions.json +++ b/catalog/permissions.json @@ -44,6 +44,7 @@ "pages.project", "queue", "r2.bucket", + "sender_domain", "tunnel", "turnstile.widget", "vulnerability_scanner.credential_set", @@ -119,6 +120,7 @@ "pages.project", "queue", "r2.bucket", + "sender_domain", "worker.route", "worker.script", "worker.secret", @@ -281,6 +283,12 @@ "surfaces": ["wrangler"], "profiles": ["deploy", "full-operator"] }, + { + "name": "Email Sending Read", + "scope": "zone", + "surfaces": ["sender_domain"], + "profiles": ["read", "deploy", "full-operator"] + }, { "name": "Turnstile Sites Read", "scope": "account", diff --git a/catalog/surfaces.json b/catalog/surfaces.json index da59472..ed4e77f 100644 --- a/catalog/surfaces.json +++ b/catalog/surfaces.json @@ -539,6 +539,40 @@ "./cfctl maildesk-cf provision --file state/maildesk-cf/example.json --plan" ] }, + "sender_domain": { + "description": "Cloudflare Email Sending sender subdomains in a specific zone.", + "backend": "inventory_script", + "inventory_script": "scripts/cf_inventory_sender_domains.sh", + "permission_family": "Email Sending", + "selectors": ["id", "name", "zone"], + "actions": { + "list": { + "supported": true, + "required_selectors": ["zone"] + }, + "get": { + "supported": true, + "required_selectors": ["zone"], + "selectors_any_of": [["id"], ["name"]] + }, + "verify": { + "supported": true, + "required_selectors": ["zone"], + "selectors_any_of": [["id"], ["name"]] + }, + "can": { + "supported": true, + "required_selectors": ["zone"] + }, + "apply": { + "supported": false + } + }, + "examples": [ + "./cfctl list sender_domain --zone example.com", + "./cfctl get sender_domain --zone example.com --name example.com" + ] + }, "d1.database": { "description": "D1 databases visible to the current account token.", "backend": "inventory_script", diff --git a/docs/capabilities.md b/docs/capabilities.md index c960289..2b85e90 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -27,6 +27,7 @@ This table is the operable runtime surface. The standards layer and docs bank in | `queue` | yes | yes | no | yes | no | `-` | `-` | `-` | | `r2.bucket` | yes | yes | no | yes | no | `-` | `-` | `-` | | `security.txt` | yes | yes | yes | yes | yes | `security.txt` | `security-center-securitytxt, api-auth` | `security_txt` | +| `sender_domain` | yes | yes | no | yes | no | `-` | `-` | `-` | | `tunnel` | yes | yes | yes | yes | yes | `tunnel` | `api-auth` | `tunnel` | | `turnstile.widget` | yes | yes | yes | yes | no | `-` | `-` | `-` | | `vulnerability_scanner.credential_set` | yes | yes | no | yes | no | `-` | `api-shield-vulnerability-scanner, api-auth` | `-` | @@ -115,6 +116,7 @@ These surfaces are first-class read surfaces but do not expose `apply` or desire | `pages.project` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_pages.sh` | | `queue` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_queues.sh` | | `r2.bucket` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_r2.sh` | +| `sender_domain` | `list`, `get`, `verify`, `can` | required: zone | `scripts/cf_inventory_sender_domains.sh` | | `vulnerability_scanner.credential_set` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | | `vulnerability_scanner.scan` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | | `vulnerability_scanner.target_environment` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | diff --git a/scripts/cf_inventory_sender_domains.sh b/scripts/cf_inventory_sender_domains.sh new file mode 100755 index 0000000..b7cb449 --- /dev/null +++ b/scripts/cf_inventory_sender_domains.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC1091 +source "${ROOT_DIR}/scripts/lib/cloudflare.sh" + +cf_load_cloudflare_env +cf_require_tools curl jq +cf_require_api_auth +cf_setup_log_pipe "inventory-sender-domains" "build" + +ZONE_NAME="${ZONE_NAME:-}" +ZONE_ID="${ZONE_ID:-}" + +if [[ -z "${ZONE_ID}" ]]; then + if [[ -z "${ZONE_NAME}" ]]; then + echo "ZONE_NAME or ZONE_ID must be set" >&2 + exit 1 + fi + ZONE_ID="$(cf_resolve_zone_id "${ZONE_NAME}")" +fi + +if [[ -z "${ZONE_ID}" || "${ZONE_ID}" == "null" ]]; then + echo "Unable to resolve zone" >&2 + exit 1 +fi + +if [[ -z "${ZONE_NAME}" ]]; then + ZONE_NAME="$(cf_api GET "/zones/${ZONE_ID}" | jq -r '.result.name // empty')" +fi + +SUBDOMAINS_JSON="$(cf_api_capture GET "/zones/${ZONE_ID}/email/sending/subdomains")" +OUTPUT_FILE="$(cf_inventory_file "email-sending" "sender-domains")" + +REPORT_JSON="$( + jq -n \ + --arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg zone_id "${ZONE_ID}" \ + --arg zone_name "${ZONE_NAME}" \ + --argjson subdomains "${SUBDOMAINS_JSON}" \ + ' + ($subdomains.result // []) as $items + | { + generated_at: $generated_at, + zone: { + id: $zone_id, + name: $zone_name + }, + sender_domains: ( + $items + | map( + . + { + zone_id: $zone_id, + zone_name: $zone_name, + domain: (.name // null), + provider: "cloudflare_email_service", + verified: (.enabled // false), + status: (if (.enabled // false) then "verified" else "pending" end) + } + ) + ), + response: $subdomains, + summary: { + readable: ($subdomains.success // false), + sender_domain_count: ($items | length), + verified_count: ($items | map(select(.enabled == true)) | length), + names: ($items | map(.name // .id // null) | map(select(. != null)) | sort), + error_count: (($subdomains.errors // []) | length) + } + } + ' +)" + +cf_write_json_file "${OUTPUT_FILE}" "${REPORT_JSON}" + +echo "Captured Email Sending sender domains for ${ZONE_NAME}." +echo "${REPORT_JSON}" | jq '.summary' +cf_print_log_footer +echo "${OUTPUT_FILE}" diff --git a/scripts/cf_maildesk_cf_lifecycle.py b/scripts/cf_maildesk_cf_lifecycle.py index 6240063..b72234e 100644 --- a/scripts/cf_maildesk_cf_lifecycle.py +++ b/scripts/cf_maildesk_cf_lifecycle.py @@ -256,16 +256,31 @@ def load_fixture_evidence(path: Path, domains: list[dict[str, Any]]) -> dict[str def collect_live_evidence(domains: list[dict[str, Any]]) -> dict[str, Any]: + sender_domains: list[dict[str, Any]] = [] + sender_errors: dict[str, Any] = {} evidence: dict[str, Any] = { "worker.script": run_cfctl(["list", "worker.script"]), "d1.database": run_cfctl(["list", "d1.database"]), "r2.bucket": run_cfctl(["list", "r2.bucket"]), "queue": run_cfctl(["list", "queue"]), "domains": {}, - "sender": {"provider_readback": "not_available"}, + "sender": { + "provider": "cloudflare_email_service", + "domains": sender_domains, + "errors": sender_errors, + }, } for domain_spec in domains: domain = str(domain_spec.get("name") or "") + sender_payload = run_cfctl(["list", "sender_domain", "--zone", domain], lane="global") + if sender_payload.get("ok") is True: + sender_domains.extend(items(sender_payload)) + else: + sender_errors[domain] = { + "command": sender_payload.get("_command"), + "error": sender_payload.get("error"), + "returncode": sender_payload.get("_returncode"), + } evidence["domains"][domain] = { "email.routing_rule": run_cfctl(["list", "email.routing_rule", "--zone", domain], lane="global"), "email.routing": {}, @@ -442,6 +457,13 @@ def sender_domain_status(sender: dict[str, Any], domain: str) -> dict[str, Any] return None +def sender_readback_available(sender: dict[str, Any]) -> bool: + if "domains" in sender or "authenticated_domains" in sender: + return True + provider_readback = str(sender.get("provider_readback") or "").lower() + return provider_readback not in {"", "not_available", "unavailable", "unknown"} + + def normalize_sender_mode(mode: str) -> str: normalized = (mode or "disabled").lower() if normalized in {"cloudflare_first", "cloudflare", "cloudflare_email_service"}: @@ -715,7 +737,7 @@ def append_sender_checks( [dns_name(record) for record in dns_items], ) ) - if domain_status is None: + if domain_status is None and not sender_readback_available(sender_evidence): drifts.append( drift( "provider_status_unavailable", @@ -726,6 +748,17 @@ def append_sender_checks( None, ) ) + elif domain_status is None: + drifts.append( + drift( + "sender_domain_drift", + "error", + f"sender_domain:{domain}", + "Sender-provider domain is not present in readback", + "verified", + {"provider": mode, "domains": sender_evidence.get("domains") or []}, + ) + ) elif not provider_verified: drifts.append( drift( @@ -811,7 +844,9 @@ def planned_operations(drifts: list[dict[str, Any]]) -> list[dict[str, Any]]: operation["blocked"] = "resource creation must use the owning primitive cfctl surface or app deploy lane" elif drift_class == "wrong_binding": operation["blocked"] = "Worker bindings are owned by the app repo config and deploy lane" - elif drift_class in {"dns_authentication_drift", "sender_domain_drift", "provider_status_unavailable"}: + elif drift_class == "sender_domain_drift": + operation["blocked"] = "sender-domain authentication is not yet a cfctl mutation surface" + elif drift_class in {"dns_authentication_drift", "provider_status_unavailable"}: operation["blocked"] = "sender-domain authentication/provider readback is not yet a cfctl mutation surface" elif drift_class == "policy_config_drift": operation["blocked"] = "policy config is owned by the app repo" diff --git a/scripts/lib/cfctl.sh b/scripts/lib/cfctl.sh index 0a51197..54f9108 100644 --- a/scripts/lib/cfctl.sh +++ b/scripts/lib/cfctl.sh @@ -1504,6 +1504,12 @@ cfctl_collect_surface_items() { cfctl_run_backend_script "${script_path}" "ZONE_NAME=${CFCTL_ZONE_NAME}" "ZONE_ID=${CFCTL_ZONE_ID}" CFCTL_COLLECT_BACKEND="inventory_script" ;; + sender_domain) + cfctl_resolve_zone_context + script_path="${CF_REPO_ROOT}/scripts/cf_inventory_sender_domains.sh" + cfctl_run_backend_script "${script_path}" "ZONE_NAME=${CFCTL_ZONE_NAME}" "ZONE_ID=${CFCTL_ZONE_ID}" + CFCTL_COLLECT_BACKEND="inventory_script" + ;; zone.ruleset) cfctl_resolve_zone_context script_path="${CF_REPO_ROOT}/scripts/cf_inventory_zone_security.sh" @@ -1707,6 +1713,9 @@ cfctl_collect_surface_items() { ' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}" )" ;; + sender_domain) + CFCTL_COLLECT_ITEMS_JSON="$(jq -c '.sender_domains // []' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}")" + ;; zone.ruleset) CFCTL_COLLECT_ITEMS_JSON="$( jq -c ' @@ -2002,6 +2011,18 @@ cfctl_filter_surface_items() { ] ' <<< "${items_json}" ;; + sender_domain) + jq -c --arg id "${CFCTL_ID}" --arg name "${CFCTL_NAME}" ' + [ + .[] + | select( + (if $id != "" then .id == $id else true end) + and + (if $name != "" then ((.name // .domain // "") | ascii_downcase) == ($name | ascii_downcase) else true end) + ) + ] + ' <<< "${items_json}" + ;; zone.ruleset) jq -c --arg id "${CFCTL_ID}" --arg name "${CFCTL_NAME}" --arg phase "${CFCTL_PHASE:-}" ' [ @@ -2285,6 +2306,7 @@ cfctl_summary_for_items() { worker.secret) name_field="name" ;; worker.route) name_field="pattern" ;; email.routing_rule) name_field="recipient" ;; + sender_domain) name_field="name" ;; zone.ruleset) name_field="name" ;; security.txt) name_field="zone_name" ;; d1.database) name_field="name" ;; diff --git a/scripts/verify_maildesk_cf_contract.sh b/scripts/verify_maildesk_cf_contract.sh index a383e0c..74d2f07 100755 --- a/scripts/verify_maildesk_cf_contract.sh +++ b/scripts/verify_maildesk_cf_contract.sh @@ -9,9 +9,10 @@ die() { exit 1 } -fixture_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-fixture.XXXXXX.json")" -missing_fixture_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-missing-fixture.XXXXXX.json")" -trap 'rm -f "${fixture_file}" "${missing_fixture_file}"' EXIT +fixture_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-fixture.XXXXXX")" +missing_fixture_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-missing-fixture.XXXXXX")" +unverified_sender_fixture_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-unverified-sender.XXXXXX")" +trap 'rm -f "${fixture_file}" "${missing_fixture_file}" "${unverified_sender_fixture_file}"' EXIT cat >"${fixture_file}" <<'JSON' { @@ -52,7 +53,9 @@ cat >"${fixture_file}" <<'JSON' } }, "sender": { - "provider_readback": "not_available" + "domains": [ + {"domain": "example.com", "name": "example.com", "status": "verified", "enabled": true} + ] } } JSON @@ -76,6 +79,51 @@ cat >"${missing_fixture_file}" <<'JSON' } JSON +cat >"${unverified_sender_fixture_file}" <<'JSON' +{ + "workers": [ + {"id": "maildesk-cf"}, + {"id": "maildesk-cf-router"} + ], + "d1": [ + {"name": "maildesk-cf-db"}, + {"name": "maildesk-cf-preview-db"} + ], + "r2": [ + {"name": "maildesk-cf-raw-mail"}, + {"name": "maildesk-cf-raw-mail-preview"} + ], + "queues": [ + {"queue_name": "maildesk-cf-jobs"} + ], + "domains": { + "example.com": { + "email_routing": {"enabled": true}, + "email_routing_rules": [ + {"recipient": "abuse@example.com", "service": "maildesk-cf-router"}, + {"recipient": "dmarc@example.com", "service": "maildesk-cf-router"}, + {"recipient": "founders@example.com", "service": "maildesk-cf-router"}, + {"recipient": "info@example.com", "service": "maildesk-cf-router"}, + {"recipient": "legal@example.com", "service": "maildesk-cf-router"}, + {"recipient": "noreply@example.com", "service": "maildesk-cf-router"}, + {"recipient": "postmaster@example.com", "service": "maildesk-cf-router"}, + {"recipient": "security@example.com", "service": "maildesk-cf-router"}, + {"recipient": "operator-a@example.com", "service": "maildesk-cf-router"}, + {"recipient": "operator-b@example.com", "service": "maildesk-cf-router"} + ], + "dns_records": [ + {"type": "TXT", "name": "example.com", "content": "v=spf1 include:_spf.mx.cloudflare.net ~all"}, + {"type": "TXT", "name": "_dmarc.example.com", "content": "v=DMARC1; p=none; rua=mailto:dmarc@example.com"} + ] + } + }, + "sender": { + "provider": "cloudflare_email_service", + "domains": [] + } +} +JSON + output="$( MAILDESK_CF_EVIDENCE_FILE="${fixture_file}" \ MAILDESK_CF_ACTION=verify \ @@ -89,10 +137,10 @@ jq -e ' .readiness.template_ready == true and .readiness.instance_ready == true and .readiness.edge_ready == true - and .readiness.mail_ready == false - and (.drift_classes | index("provider_status_unavailable")) != null + and .readiness.mail_ready == true + and (.drift_classes | index("provider_status_unavailable")) == null and (.drift_classes | index("optional_live_send_not_requested")) != null - and (.plan.operations | length) == 1 + and (.plan.operations | length) == 0 ' "${artifact_path}" >/dev/null || die "fixture readiness contract did not match" missing_output="$( @@ -116,6 +164,23 @@ jq -e ' and any(.plan.operations[]; .surface == "queue" and .preview_command == "cfctl wrangler queues create maildesk-cf-jobs --plan" and .blocked == null) ' "${missing_artifact_path}" >/dev/null || die "missing-resource drift classes did not match" +unverified_sender_output="$( + MAILDESK_CF_EVIDENCE_FILE="${unverified_sender_fixture_file}" \ + MAILDESK_CF_ACTION=verify \ + SPEC_FILE="${ROOT_DIR}/state/maildesk-cf/example.json" \ + python3 "${ROOT_DIR}/scripts/cf_maildesk_cf_lifecycle.py" +)" +unverified_sender_artifact_path="$(printf '%s\n' "${unverified_sender_output}" | tail -n 1)" +[[ -f "${unverified_sender_artifact_path}" ]] || die "unverified-sender artifact was not written" + +jq -e ' + .readiness.edge_ready == true + and .readiness.mail_ready == false + and (.drift_classes | index("sender_domain_drift")) != null + and (.drift_classes | index("provider_status_unavailable")) == null + and any(.plan.operations[]; .surface == "sender_domain" and .blocked == "sender-domain authentication is not yet a cfctl mutation surface") +' "${unverified_sender_artifact_path}" >/dev/null || die "unverified sender-domain drift contract did not match" + cfctl_output="$( MAILDESK_CF_EVIDENCE_FILE="${fixture_file}" \ "${ROOT_DIR}/cfctl" maildesk-cf provision --file "${ROOT_DIR}/state/maildesk-cf/example.json" --plan @@ -128,7 +193,7 @@ jq -e ' and .operation == "provision" and .summary.plan_mode == true and .summary.edge_ready == true - and .summary.mail_ready == false + and .summary.mail_ready == true ' <<< "${cfctl_output}" >/dev/null || die "cfctl provision --plan envelope did not match" standards_output="$("${ROOT_DIR}/cfctl" standards maildesk-cf)" diff --git a/scripts/verify_permission_catalog.py b/scripts/verify_permission_catalog.py index 3492205..286dff2 100644 --- a/scripts/verify_permission_catalog.py +++ b/scripts/verify_permission_catalog.py @@ -322,8 +322,8 @@ def validate_command_fixtures(catalog: dict) -> list[dict]: "expected": ( "cfctl token mint --name 'cfctl-deploy-operator' --permission 'Account Settings Read' " "--permission 'D1 Metadata Read' --permission 'D1 Read' --permission 'D1 Write' " - "--permission 'Pages Read' --permission 'Pages Write' --permission 'Queues Read' " - "--permission 'Queues Write' --permission 'Workers R2 Storage Read' " + "--permission 'Email Sending Read' --permission 'Pages Read' --permission 'Pages Write' " + "--permission 'Queues Read' --permission 'Queues Write' --permission 'Workers R2 Storage Read' " "--permission 'Workers R2 Storage Write' --permission 'Workers Routes Read' " "--permission 'Workers Routes Write' --permission 'Workers Scripts Read' " "--permission 'Workers Scripts Write' --permission 'Zone Read' " diff --git a/scripts/verify_static_contract.sh b/scripts/verify_static_contract.sh index 071e8ce..9dd5457 100755 --- a/scripts/verify_static_contract.sh +++ b/scripts/verify_static_contract.sh @@ -101,6 +101,7 @@ bash -n \ "${ROOT_DIR}/scripts/cf_inventory_vulnerability_scanner.sh" \ "${ROOT_DIR}/scripts/cf_inventory_worker_routes.sh" \ "${ROOT_DIR}/scripts/cf_inventory_email_routing_rules.sh" \ + "${ROOT_DIR}/scripts/cf_inventory_sender_domains.sh" \ "${ROOT_DIR}/scripts/cf_inventory_edge_certificates.sh" \ "${ROOT_DIR}/scripts/cf_inventory_zone_settings.sh" \ "${ROOT_DIR}/scripts/cf_inventory_security_txt.sh" \ @@ -717,6 +718,12 @@ assert_jq_file "surface module bindings" ' and .surfaces["maildesk-cf"].actions.provision.required_selectors == ["file"] and .surfaces["maildesk-cf"].actions.apply.supported == false and (.surfaces["maildesk-cf"].docs_topics | index("email-routing")) != null + and .surfaces["sender_domain"].inventory_script == "scripts/cf_inventory_sender_domains.sh" + and .surfaces["sender_domain"].permission_family == "Email Sending" + and .surfaces["sender_domain"].actions.list.required_selectors == ["zone"] + and .surfaces["sender_domain"].actions.get.selectors_any_of == [["id"], ["name"]] + and .surfaces["sender_domain"].actions.verify.selectors_any_of == [["id"], ["name"]] + and .surfaces["sender_domain"].actions.apply.supported == false and .surfaces["worker.route"].module == "worker_route" and .surfaces["worker.route"].standards_ref == "worker.route" and (.surfaces["worker.route"].docs_topics | index("workers-routes")) != null