diff --git a/README.md b/README.md index 962a612..67c790c 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ This tranche is read-only. `hostname plan` emits proposed component operations, ## maildesk-cf lifecycle -Use `cfctl maildesk-cf verify|snapshot|diff|plan|provision --plan` with JSON specs under [state/maildesk-cf](state/maildesk-cf) when a maildesk deployment needs Email Routing aliases, Workers, D1, R2, Queues, DNS sender authentication, and outbound identity readiness checked together. +Use `cfctl maildesk-cf verify|snapshot|diff|plan|provision --plan` with JSON specs under [state/maildesk-cf](state/maildesk-cf) when a maildesk deployment needs Email Routing aliases, Workers, D1, R2, Queues, and mode-driven sender readiness checked together. The public template defaults to receive-only outbound mode; Cloudflare Email Service or Resend sender evidence is required only when the spec enables that provider. ```bash cfctl maildesk-cf init --domain example.com @@ -169,7 +169,7 @@ cfctl maildesk-cf diff --file state/maildesk-cf/example.json cfctl maildesk-cf provision --file state/maildesk-cf/example.json --plan ``` -`maildesk-cf provision --plan` emits a local operation id and proposed component operations. `maildesk-cf provision --ack-plan ` is blocked until those component writes are each available through preview-gated public `cfctl` surfaces. The verifier does not perform broad live sends; sender readiness comes from DNS/authentication and provider readback evidence unless a human explicitly asks for targeted delivery proof. +`maildesk-cf provision --plan` emits a local operation id and proposed component operations. `maildesk-cf provision --ack-plan ` is blocked until those component writes are each available through preview-gated public `cfctl` surfaces. The verifier does not perform broad live sends; enabled sender providers use DNS/authentication and provider readback evidence unless a human explicitly asks for targeted delivery proof. Token minting: diff --git a/catalog/standards.json b/catalog/standards.json index cb74753..18fbebe 100644 --- a/catalog/standards.json +++ b/catalog/standards.json @@ -235,19 +235,19 @@ "id": "maildesk-cf.state-owns-complete-path", "level": "required", "title": "One spec owns the maildesk path", - "summary": "A maildesk lifecycle spec should declare domains, role aliases, personal aliases, Workers, D1, R2, Queues, sender authentication, and outbound policy together." + "summary": "A maildesk lifecycle spec should declare domains, role aliases, personal aliases, Workers, D1, R2, Queues, sender mode, and outbound policy together." }, { "id": "maildesk-cf.verify-before-provision", "level": "required", "title": "Verify every dependency before planning provisioning", - "summary": "Maildesk readiness must prove template validation, instance policy, Worker/storage bindings, Email Routing aliases, sender authentication, provider status, and outbound proof posture with evidence artifacts." + "summary": "Maildesk readiness must prove template validation, instance policy, Worker/storage bindings, Email Routing aliases, sender mode, and any enabled provider status with evidence artifacts." }, { "id": "maildesk-cf.component-writes-preview-gated", "level": "required", "title": "Do not composite-apply before component writes are gated", - "summary": "Composite provisioning must remain plan-only until Worker, D1, R2, Queue, Email Routing, DNS authentication, and sender-provider operations are each available through preview-gated public cfctl surfaces." + "summary": "Composite provisioning must remain plan-only until Worker, D1, R2, Queue, Email Routing, and enabled sender-provider operations are each available through preview-gated public cfctl surfaces." }, { "id": "maildesk-cf.targeted-mail-proof-only", diff --git a/catalog/surfaces.json b/catalog/surfaces.json index 59ff1c0..57b3c1b 100644 --- a/catalog/surfaces.json +++ b/catalog/surfaces.json @@ -513,7 +513,7 @@ ] }, "maildesk-cf": { - "description": "Composite maildesk-cf lifecycle readiness across Email Routing aliases, Workers, D1, R2, Queues, sender authentication, and outbound identity evidence.", + "description": "Composite maildesk-cf lifecycle readiness across Email Routing aliases, Workers, D1, R2, Queues, and mode-driven sender evidence.", "standards_ref": "maildesk-cf", "docs_topics": ["email-routing", "email-workers", "d1", "r2", "queues", "api-auth"], "backend": "maildesk_cf_lifecycle", diff --git a/commands/cfctl.sh b/commands/cfctl.sh index 24da27e..233466b 100644 --- a/commands/cfctl.sh +++ b/commands/cfctl.sh @@ -3488,8 +3488,7 @@ cfctl_find_maildesk_cf_plan_receipt_path() { return 1 fi - for candidate in "${runtime_dir}"/*.json; do - [[ -f "${candidate}" ]] || continue + while IFS= read -r candidate; do if jq -e \ --arg ack_plan "${ack_plan}" \ ' @@ -3503,7 +3502,7 @@ cfctl_find_maildesk_cf_plan_receipt_path() { printf '%s\n' "${candidate}" return 0 fi - done + done < <(find "${runtime_dir}" -maxdepth 1 -type f -name 'maildesk-cf-maildesk-cf-*.json' -print | sort -r) return 1 } diff --git a/docs/runbooks/cfctl.md b/docs/runbooks/cfctl.md index 76ec870..b009c84 100644 --- a/docs/runbooks/cfctl.md +++ b/docs/runbooks/cfctl.md @@ -164,7 +164,7 @@ CF_TOKEN_LANE=global cfctl apply edge.certificate order --zone example.com --hos - `apply sync` performs selective desired-state reconciliation on supported surfaces - `hostname verify|diff|plan` checks one YAML hostname lifecycle spec across DNS, TLS, Worker route, Access, Worker script, HTTP response, D1, and R2 - `hostname apply` is blocked until composite mutation is backed by preview-gated component surfaces -- `maildesk-cf init|verify|snapshot|diff|plan|provision --plan` checks one JSON maildesk spec across Email Routing aliases, Workers, D1, R2, Queues, DNS sender authentication, and outbound identity readback +- `maildesk-cf init|verify|snapshot|diff|plan|provision --plan` checks one JSON maildesk spec across Email Routing aliases, Workers, D1, R2, Queues, sender mode, and enabled provider readback - `maildesk-cf provision --ack-plan ` is blocked until composite mutation is backed by preview-gated component surfaces - `maildesk-cf` does not perform broad live sends; targeted delivery proof must be requested explicitly - destructive operations require explicit confirmation such as `--confirm delete` @@ -202,7 +202,7 @@ The current implementation is read-only. It emits evidence for each component su ## maildesk-cf Lifecycle -Use `maildesk-cf` when the question is whether a maildesk deployment is ready across inbound routing, storage, Workers, sender authentication, and outbound identity, not whether one isolated Cloudflare resource exists. +Use `maildesk-cf` when the question is whether a maildesk deployment is ready across inbound routing, storage, Workers, and mode-driven sender posture, not whether one isolated Cloudflare resource exists. ```bash cfctl maildesk-cf init --domain example.com @@ -215,8 +215,9 @@ cfctl maildesk-cf provision --file state/maildesk-cf/example.json --plan The current implementation is read-only. `provision --plan` emits a preview operation id plus proposed component operations; `provision --ack-plan` is blocked until each component mutation is present as a public preview-gated -surface. Sender readiness uses DNS/authentication and provider readback -evidence. Broad live sends are never attempted by default. +surface. The public template defaults to receive-only outbound mode; enabled +sender providers use DNS/authentication and provider readback evidence. Broad +live sends are never attempted by default. ## Result Envelope diff --git a/scripts/cf_maildesk_cf_lifecycle.py b/scripts/cf_maildesk_cf_lifecycle.py index 7018144..85c76e9 100644 --- a/scripts/cf_maildesk_cf_lifecycle.py +++ b/scripts/cf_maildesk_cf_lifecycle.py @@ -96,8 +96,8 @@ def init_spec(domain: str) -> dict[str, Any]: "queue": "maildesk-cf-jobs", }, "sender": { - "mode": "cloudflare_first", - "authenticated_domains": [domain], + "mode": "disabled", + "authenticated_domains": [], }, "verification": { "allow_broad_live_sends": False, @@ -694,95 +694,84 @@ def append_sender_checks( ) return if mode in {"disabled", "receive_only"}: - drifts.append( - drift( - "sender_adapter_receive_only", - "error", - "sender.mode", - "Sender adapter is configured without outbound sending", - "outbound sender provider", - sender_spec.get("mode"), + checks["sender"]["ready"] = True + else: + for domain in [str(value) for value in authenticated if value]: + domain_evidence = (evidence.get("domains") or {}).get(domain) or {} + dns_items = items(domain_evidence.get("dns.record")) + spf_ok = has_txt(dns_items, domain, "v=spf1") + dmarc_ok = has_txt(dns_items, f"_dmarc.{domain}", "v=DMARC1") + domain_status = sender_domain_status(sender_evidence, domain) + provider_status = domain_status or {} + provider_verified = bool( + provider_status.get("verified") + or str(provider_status.get("status") or "").lower() in {"verified", "active", "ready"} ) - ) - checks["sender"]["ready"] = False - return - - for domain in [str(value) for value in authenticated if value]: - domain_evidence = (evidence.get("domains") or {}).get(domain) or {} - dns_items = items(domain_evidence.get("dns.record")) - spf_ok = has_txt(dns_items, domain, "v=spf1") - dmarc_ok = has_txt(dns_items, f"_dmarc.{domain}", "v=DMARC1") - domain_status = sender_domain_status(sender_evidence, domain) - provider_status = domain_status or {} - provider_verified = bool( - provider_status.get("verified") - or str(provider_status.get("status") or "").lower() in {"verified", "active", "ready"} - ) - domain_check = { - "provider": mode, - "provider_readback": provider_status if domain_status else None, - "spf_dns": spf_ok, - "dmarc_dns": dmarc_ok, - "provider_verified": provider_verified, - } - checks["sender"][domain] = domain_check - if not spf_ok: - drifts.append( - drift( - "dns_authentication_drift", - "error", - f"dns.record:{domain}:SPF", - "SPF TXT record is missing for sender authentication", - "TXT v=spf1", - [dns_name(record) for record in dns_items], + domain_check = { + "provider": mode, + "provider_readback": provider_status if domain_status else None, + "spf_dns": spf_ok, + "dmarc_dns": dmarc_ok, + "provider_verified": provider_verified, + } + checks["sender"][domain] = domain_check + if not spf_ok: + drifts.append( + drift( + "dns_authentication_drift", + "error", + f"dns.record:{domain}:SPF", + "SPF TXT record is missing for sender authentication", + "TXT v=spf1", + [dns_name(record) for record in dns_items], + ) ) - ) - if not dmarc_ok: - drifts.append( - drift( - "dns_authentication_drift", - "error", - f"dns.record:_dmarc.{domain}", - "DMARC TXT record is missing for sender authentication", - "TXT v=DMARC1", - [dns_name(record) for record in dns_items], + if not dmarc_ok: + drifts.append( + drift( + "dns_authentication_drift", + "error", + f"dns.record:_dmarc.{domain}", + "DMARC TXT record is missing for sender authentication", + "TXT v=DMARC1", + [dns_name(record) for record in dns_items], + ) ) - ) - if domain_status is None and not sender_readback_available(sender_evidence): - drifts.append( - drift( - "provider_status_unavailable", - "error", - f"sender_domain:{domain}", - "Sender-provider domain status readback is not available", - {"provider": mode, "domain": domain}, - None, + if domain_status is None and not sender_readback_available(sender_evidence): + drifts.append( + drift( + "provider_status_unavailable", + "error", + f"sender_domain:{domain}", + "Sender-provider domain status readback is not available", + {"provider": mode, "domain": domain}, + 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 []}, - sender_domain_enable_plan_command(domain), + 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 []}, + sender_domain_enable_plan_command(domain), + ) ) - ) - elif not provider_verified: - drifts.append( - drift( - "sender_domain_drift", - "error", - f"sender_domain:{domain}", - "Sender-provider domain is not verified", - "verified", - provider_status, - sender_domain_enable_plan_command(domain), + elif not provider_verified: + drifts.append( + drift( + "sender_domain_drift", + "error", + f"sender_domain:{domain}", + "Sender-provider domain is not verified", + "verified", + provider_status, + sender_domain_enable_plan_command(domain), + ) ) - ) verification_spec = spec.get("verification") or {} if verification_spec.get("targeted_send_required"): diff --git a/scripts/verify_maildesk_cf_contract.sh b/scripts/verify_maildesk_cf_contract.sh index cab4ed5..64aadfa 100755 --- a/scripts/verify_maildesk_cf_contract.sh +++ b/scripts/verify_maildesk_cf_contract.sh @@ -22,8 +22,9 @@ require_source_line() { 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")" +cloudflare_sender_spec_file="$(mktemp "${TMPDIR:-/tmp}/maildesk-cf-cloudflare-sender-spec.XXXXXX")" caller_spec_dir="$(mktemp -d "${TMPDIR:-/tmp}/maildesk-cf-caller-spec.XXXXXX")" -trap 'rm -f "${fixture_file}" "${missing_fixture_file}" "${unverified_sender_fixture_file}"; rm -rf "${caller_spec_dir}"' EXIT +trap 'rm -f "${fixture_file}" "${missing_fixture_file}" "${unverified_sender_fixture_file}" "${cloudflare_sender_spec_file}"; rm -rf "${caller_spec_dir}"' EXIT require_source_line "worker evidence lane" '"worker.script": run_cfctl(["list", "worker.script"], lane="global"),' "${ROOT_DIR}/scripts/cf_maildesk_cf_lifecycle.py" require_source_line "d1 evidence lane" '"d1.database": run_cfctl(["list", "d1.database"], lane="global"),' "${ROOT_DIR}/scripts/cf_maildesk_cf_lifecycle.py" @@ -140,6 +141,14 @@ cat >"${unverified_sender_fixture_file}" <<'JSON' } JSON +jq '.sender = {"mode": "cloudflare_email_service", "authenticated_domains": ["example.com"]}' \ + "${ROOT_DIR}/state/maildesk-cf/example.json" >"${cloudflare_sender_spec_file}" + +jq -e ' + .sender.mode == "disabled" + and (.sender.authenticated_domains | length) == 0 +' "${ROOT_DIR}/state/maildesk-cf/example.json" >/dev/null || die "checked-in maildesk-cf example must default to disabled outbound sender mode" + output="$( MAILDESK_CF_EVIDENCE_FILE="${fixture_file}" \ MAILDESK_CF_ACTION=verify \ @@ -154,7 +163,11 @@ jq -e ' and .readiness.instance_ready == true and .readiness.edge_ready == true and .readiness.mail_ready == true + and .checks.sender.mode.normalized == "disabled" + and .checks.sender.ready == true and (.drift_classes | index("provider_status_unavailable")) == null + and (.drift_classes | index("sender_adapter_receive_only")) == null + and (.drift_classes | index("sender_domain_drift")) == null and (.drift_classes | index("optional_live_send_not_requested")) != null and (.plan.operations | length) == 0 ' "${artifact_path}" >/dev/null || die "fixture readiness contract did not match" @@ -172,7 +185,8 @@ jq -e ' .readiness.edge_ready == false and (.drift_classes | index("missing_resource")) != null and (.drift_classes | index("email_routing_alias_drift")) != null - and (.drift_classes | index("dns_authentication_drift")) != null + and (.drift_classes | index("dns_authentication_drift")) == null + and (.drift_classes | index("sender_adapter_receive_only")) == null and any(.plan.operations[]; .surface == "d1.database" and .preview_command == "cfctl wrangler d1 create maildesk-cf-db --plan" and .blocked == null) and any(.plan.operations[]; .surface == "d1.database" and .preview_command == "cfctl wrangler d1 create maildesk-cf-preview-db --plan" and .blocked == null) and any(.plan.operations[]; .surface == "r2.bucket" and .preview_command == "cfctl wrangler r2 bucket create maildesk-cf-raw-mail --plan" and .blocked == null) @@ -183,7 +197,7 @@ jq -e ' unverified_sender_output="$( MAILDESK_CF_EVIDENCE_FILE="${unverified_sender_fixture_file}" \ MAILDESK_CF_ACTION=verify \ - SPEC_FILE="${ROOT_DIR}/state/maildesk-cf/example.json" \ + SPEC_FILE="${cloudflare_sender_spec_file}" \ python3 "${ROOT_DIR}/scripts/cf_maildesk_cf_lifecycle.py" )" unverified_sender_artifact_path="$(printf '%s\n' "${unverified_sender_output}" | tail -n 1)" diff --git a/state/maildesk-cf/README.md b/state/maildesk-cf/README.md index 86729ac..e0c3616 100644 --- a/state/maildesk-cf/README.md +++ b/state/maildesk-cf/README.md @@ -1,8 +1,10 @@ # maildesk-cf State `maildesk-cf` specs describe the Cloudflare account resources needed for one -maildesk deployment: Email Routing aliases, Workers, D1, R2, Queues, sender -authentication, and outbound identity readiness. +maildesk deployment: Email Routing aliases, Workers, D1, R2, Queues, and +mode-driven sender readiness. The public example defaults to receive-only +outbound mode; sender authentication is required only when a provider is +enabled in the spec. Use the composite command when the question is deployment readiness rather than one isolated Cloudflare resource: @@ -19,6 +21,6 @@ operation id. `maildesk-cf provision --ack-plan ` is intentionally blocked until the component write paths are each preview-gated through public `cfctl` surfaces. -The verifier does not perform broad live sends. Sender readiness is based on +The verifier does not perform broad live sends. Enabled sender providers use DNS/authentication and provider readback evidence; targeted send proof remains an explicit human-requested check. diff --git a/state/maildesk-cf/example.json b/state/maildesk-cf/example.json index 144ed59..298d801 100644 --- a/state/maildesk-cf/example.json +++ b/state/maildesk-cf/example.json @@ -41,10 +41,8 @@ "queue": "maildesk-cf-jobs" }, "sender": { - "mode": "cloudflare_first", - "authenticated_domains": [ - "example.com" - ] + "mode": "disabled", + "authenticated_domains": [] }, "verification": { "allow_broad_live_sends": false,