From 5b4f1acb64f2adf4f87bf624557aab5e75236ab3 Mon Sep 17 00:00:00 2001 From: rogu3bear Date: Wed, 1 Jul 2026 13:58:20 -0500 Subject: [PATCH] fix maildesk receive-only sender lifecycle Align the cfctl maildesk-cf lifecycle with the public template contract by making generated and checked-in specs default to disabled outbound sender mode. Disabled/receive-only mode now proves sender readiness without creating sender-provider drift, while provider-enabled specs still exercise DNS and provider readback paths. The contract test splits those cases so Milestone 1 can prove provisioning shape without pretending outbound sending is configured. The only runtime helper change bounds maildesk ack receipt lookup to maildesk envelopes so the blocked ack proof remains fast even with a large evidence directory. --- README.md | 4 +- catalog/standards.json | 6 +- catalog/surfaces.json | 2 +- commands/cfctl.sh | 5 +- docs/runbooks/cfctl.md | 9 +- scripts/cf_maildesk_cf_lifecycle.py | 159 ++++++++++++------------- scripts/verify_maildesk_cf_contract.sh | 20 +++- state/maildesk-cf/README.md | 8 +- state/maildesk-cf/example.json | 6 +- 9 files changed, 111 insertions(+), 108 deletions(-) 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,