From ddaac7287ce0a6865a6994126616486e166ce9ff Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Tue, 19 May 2026 19:28:32 -0500 Subject: [PATCH] Add Email Routing rule surface --- catalog/surfaces.json | 48 ++++++ commands/cfctl.sh | 13 ++ docs/capabilities.md | 1 + scripts/cf_inventory_email_routing_rules.sh | 76 ++++++++++ scripts/cf_mutate_email_routing_rule.sh | 158 ++++++++++++++++++++ scripts/lib/cfctl.sh | 37 +++++ 6 files changed, 333 insertions(+) create mode 100755 scripts/cf_inventory_email_routing_rules.sh create mode 100755 scripts/cf_mutate_email_routing_rule.sh diff --git a/catalog/surfaces.json b/catalog/surfaces.json index 7ea5407..2a1f6aa 100644 --- a/catalog/surfaces.json +++ b/catalog/surfaces.json @@ -305,6 +305,54 @@ "./cfctl apply worker.route delete --zone example.com --pattern \"*.example.com/*\" --ack-plan --confirm delete" ] }, + "email.routing_rule": { + "description": "Email Routing rules in a specific zone, including recipient rules that hand mail to an Email Worker.", + "docs_topics": ["email-routing", "api-auth"], + "backend": "inventory_script", + "inventory_script": "scripts/cf_inventory_email_routing_rules.sh", + "apply_script": "scripts/cf_mutate_email_routing_rule.sh", + "permission_family": "Email Routing Rules", + "probe": { + "method": "GET", + "path_template": "/zones/{zone_id}/email/routing/rules" + }, + "selectors": ["zone", "id", "name", "service"], + "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": true, + "operations": { + "upsert": { + "risk": "write", + "required_selectors": ["zone", "name", "service"] + } + } + } + }, + "examples": [ + "./cfctl list email.routing_rule --zone example.com", + "./cfctl get email.routing_rule --zone example.com --name founders@example.com", + "./cfctl apply email.routing_rule upsert --zone example.com --name founders@example.com --service maildesk-cf-router --plan", + "./cfctl apply email.routing_rule upsert --zone example.com --name founders@example.com --service maildesk-cf-router --ack-plan " + ] + }, "d1.database": { "description": "D1 databases visible to the current account token.", "backend": "inventory_script", diff --git a/commands/cfctl.sh b/commands/cfctl.sh index 7cb9479..8c4af97 100644 --- a/commands/cfctl.sh +++ b/commands/cfctl.sh @@ -3537,6 +3537,19 @@ cfctl_handle_apply() { "BODY_JSON=${CFCTL_BODY_JSON}" \ "BODY_FILE=${CFCTL_BODY_FILE}" ;; + email.routing_rule) + cfctl_run_backend_script "${script_path}" \ + "APPLY=$([[ "${CFCTL_PLAN}" == "1" ]] && echo 0 || echo 1)" \ + "OPERATION=${operation}" \ + "ZONE_NAME=${CFCTL_ZONE_NAME}" \ + "ZONE_ID=${CFCTL_ZONE_ID}" \ + "RULE_ID=${id_value}" \ + "RULE_ADDRESS=${CFCTL_NAME}" \ + "WORKER_NAME=${CFCTL_SERVICE}" \ + "PRIORITY=${CFCTL_PRIORITY}" \ + "BODY_JSON=${CFCTL_BODY_JSON}" \ + "BODY_FILE=${CFCTL_BODY_FILE}" + ;; zone.ruleset) cfctl_run_backend_script "${script_path}" \ "APPLY=$([[ "${CFCTL_PLAN}" == "1" ]] && echo 0 || echo 1)" \ diff --git a/docs/capabilities.md b/docs/capabilities.md index 256e30d..2372878 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -17,6 +17,7 @@ This table is the operable runtime surface. The standards layer and docs bank in | `d1.database` | yes | no | no | `-` | `-` | `-` | | `dns.record` | yes | yes | yes | `dns.record` | `api-auth` | `dns_record` | | `edge.certificate` | yes | yes | no | `edge.certificate` | `advanced-certificates, api-auth` | `edge_certificate` | +| `email.routing_rule` | yes | yes | no | `-` | `email-routing, api-auth` | `-` | | `logpush.job` | yes | yes | no | `-` | `-` | `-` | | `pages.project` | yes | no | no | `-` | `-` | `-` | | `queue` | yes | no | no | `-` | `-` | `-` | diff --git a/scripts/cf_inventory_email_routing_rules.sh b/scripts/cf_inventory_email_routing_rules.sh new file mode 100755 index 0000000..dac2e1f --- /dev/null +++ b/scripts/cf_inventory_email_routing_rules.sh @@ -0,0 +1,76 @@ +#!/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 jq +cf_require_api_auth +cf_setup_log_pipe "inventory-email-routing-rules" "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 + +ROUTING_JSON="$(cf_api_capture GET "/zones/${ZONE_ID}/email/routing")" +RULES_JSON="$(cf_api_capture GET "/zones/${ZONE_ID}/email/routing/rules")" +OUTPUT_FILE="$(cf_inventory_file "email-routing" "email-routing-rules")" + +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 routing "${ROUTING_JSON}" \ + --argjson rules "${RULES_JSON}" \ + ' + { + generated_at: $generated_at, + zone: { + id: $zone_id, + name: $zone_name + }, + email_routing: $routing, + rules: $rules, + summary: { + routing_readable: ($routing.success // false), + routing_status: ($routing.result.status // "unknown"), + routing_enabled: ($routing.result.enabled // false), + rules_readable: ($rules.success // false), + rule_count: (($rules.result // []) | length), + recipients: ( + ($rules.result // []) + | map([.matchers[]? | select(.field == "to") | .value][0] // null) + | map(select(. != null)) + | sort + ) + } + } + ' +)" + +cf_write_json_file "${OUTPUT_FILE}" "${REPORT_JSON}" + +echo "Captured Email Routing rules for ${ZONE_NAME}." +echo "${REPORT_JSON}" | jq '.summary' +cf_print_log_footer +echo "${OUTPUT_FILE}" diff --git a/scripts/cf_mutate_email_routing_rule.sh b/scripts/cf_mutate_email_routing_rule.sh new file mode 100755 index 0000000..179b5a4 --- /dev/null +++ b/scripts/cf_mutate_email_routing_rule.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# Mutates Cloudflare Email Routing rules through the zone Email Routing API. +# +# upsert: POST/PUT /zones/:zone_id/email/routing/rules +# +# Required env from cfctl: +# OPERATION upsert +# ZONE_NAME or ZONE_ID +# RULE_ADDRESS literal recipient address, for example founders@example.com +# WORKER_NAME Email Worker script name + +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 jq +cf_require_api_auth +cf_require_backend_dispatch "cfctl apply email.routing_rule ..." + +OPERATION="${OPERATION:-upsert}" +ZONE_NAME="${ZONE_NAME:-}" +ZONE_ID="${ZONE_ID:-}" +RULE_ID="${RULE_ID:-}" +RULE_ADDRESS="${RULE_ADDRESS:-}" +WORKER_NAME="${WORKER_NAME:-}" +PRIORITY="${PRIORITY:-0}" + +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 + +build_payload() { + if [[ -n "${BODY_JSON:-}" || -n "${BODY_FILE:-}" ]]; then + cf_resolve_json_payload "${BODY_JSON:-}" "${BODY_FILE:-}" + return + fi + + if [[ -z "${RULE_ADDRESS}" || -z "${WORKER_NAME}" ]]; then + echo "RULE_ADDRESS and WORKER_NAME must be set when BODY_JSON/BODY_FILE is not provided" >&2 + exit 1 + fi + + jq -n \ + --arg address "${RULE_ADDRESS}" \ + --arg worker_name "${WORKER_NAME}" \ + --arg priority "${PRIORITY}" \ + ' + { + name: ("Maildesk " + $address), + matchers: [ + { + type: "literal", + field: "to", + value: $address + } + ], + actions: [ + { + type: "worker", + value: [$worker_name] + } + ], + enabled: true, + priority: ($priority | tonumber) + } + ' +} + +resolve_rule_id() { + if [[ -n "${RULE_ID}" ]]; then + printf '%s\n' "${RULE_ID}" + return + fi + + if [[ -z "${RULE_ADDRESS}" ]]; then + echo "" + return + fi + + local rules + rules="$(cf_api_capture GET "/zones/${ZONE_ID}/email/routing/rules")" + if jq -e '.success == true' <<< "${rules}" >/dev/null 2>&1; then + jq -r --arg address "${RULE_ADDRESS}" ' + (.result // []) + | map( + select( + any(.matchers[]?; .field == "to" and (.value | ascii_downcase) == ($address | ascii_downcase)) + ) + ) + | if length == 1 then .[0].id else empty end + ' <<< "${rules}" + fi +} + +TARGET_RULE_ID="$(resolve_rule_id)" + +export SURFACE="email-routing-rule" +export OUTPUT_STEM="email-routing-rule-mutation" +export APPLY="${APPLY:-0}" + +case "${OPERATION}" in + upsert) + export BODY_JSON="$(build_payload)" + if [[ -n "${TARGET_RULE_ID}" ]]; then + export REQUEST_METHOD="PUT" + export REQUEST_PATH="/zones/${ZONE_ID}/email/routing/rules/${TARGET_RULE_ID}" + else + export REQUEST_METHOD="POST" + export REQUEST_PATH="/zones/${ZONE_ID}/email/routing/rules" + fi + export VERIFY_PATH="/zones/${ZONE_ID}/email/routing/rules" + ;; + *) + echo "Unsupported OPERATION: ${OPERATION}" >&2 + exit 1 + ;; +esac + +set +e +mutation_report="$("${ROOT_DIR}/scripts/cf_api_apply.sh")" +status=$? +set -e +printf '%s\n' "${mutation_report}" + +report_file="$(printf '%s\n' "${mutation_report}" | tail -n 1)" +if [[ "${APPLY}" == "1" && "${status}" -eq 0 && -f "${report_file}" ]]; then + if jq -e --arg address "${RULE_ADDRESS}" --arg worker_name "${WORKER_NAME}" ' + (.verification.response.result // []) + | any( + .[]?; + ( + any(.matchers[]?; .field == "to" and (.value | ascii_downcase) == ($address | ascii_downcase)) + and any(.actions[]?; .type == "worker" and ((.value // []) | index($worker_name) != null)) + and (.enabled == true) + ) + ) + ' "${report_file}" >/dev/null; then + exit 0 + fi + + echo "Email Routing rule verification failed for ${RULE_ADDRESS} -> ${WORKER_NAME}" >&2 + exit 1 +fi + +exit "${status}" diff --git a/scripts/lib/cfctl.sh b/scripts/lib/cfctl.sh index ecb8bcb..611917c 100644 --- a/scripts/lib/cfctl.sh +++ b/scripts/lib/cfctl.sh @@ -1425,6 +1425,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" ;; + email.routing_rule) + cfctl_resolve_zone_context + script_path="${CF_REPO_ROOT}/scripts/cf_inventory_email_routing_rules.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" @@ -1579,6 +1585,22 @@ cfctl_collect_surface_items() { ' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}" )" ;; + email.routing_rule) + CFCTL_COLLECT_ITEMS_JSON="$( + jq -c ' + . as $root + | [ + (.rules.result // [])[] + | . + { + zone_id: $root.zone.id, + zone_name: $root.zone.name, + recipient: ([.matchers[]? | select(.field == "to") | .value][0] // null), + service: ([.actions[]? | select(.type == "worker") | (.value // [])[]][0] // null) + } + ] + ' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}" + )" + ;; zone.ruleset) CFCTL_COLLECT_ITEMS_JSON="$( jq -c ' @@ -1824,6 +1846,20 @@ cfctl_filter_surface_items() { ] ' <<< "${items_json}" ;; + email.routing_rule) + jq -c --arg id "${CFCTL_ID}" --arg address "${CFCTL_NAME}" --arg service "${CFCTL_SERVICE}" ' + [ + .[] + | select( + (if $id != "" then .id == $id else true end) + and + (if $address != "" then ((.recipient // "") | ascii_downcase) == ($address | ascii_downcase) else true end) + and + (if $service != "" then (.service // "") == $service else true end) + ) + ] + ' <<< "${items_json}" + ;; zone.ruleset) jq -c --arg id "${CFCTL_ID}" --arg name "${CFCTL_NAME}" --arg phase "${CFCTL_PHASE:-}" ' [ @@ -2073,6 +2109,7 @@ cfctl_summary_for_items() { worker.script) name_field="id" ;; worker.secret) name_field="name" ;; worker.route) name_field="pattern" ;; + email.routing_rule) name_field="recipient" ;; zone.ruleset) name_field="name" ;; d1.database) name_field="name" ;; r2.bucket) name_field="name" ;;