@salesforce/afv-skills 1.41.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -6
- package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +3 -0
- package/skills/automation-sandbox-post-copy-configure/SKILL.md +4 -0
- package/skills/design-systems-slds-validate/SKILL.md +2 -2
- package/skills/dx-app-analytics-query/SKILL.md +1 -3
- package/skills/dx-devops-conflict-resolve/SKILL.md +205 -0
- package/skills/dx-devops-conflict-resolve/examples/conflict-workflows.md +165 -0
- package/skills/dx-devops-conflict-resolve/references/deploy-failure-resolution.md +75 -0
- package/skills/dx-devops-conflict-resolve/references/git-conflict-resolution.md +124 -0
- package/skills/dx-devops-conflict-resolve/scripts/detect-conflicts.sh +91 -0
- package/skills/dx-devops-conflict-resolve/scripts/diagnose-deploy-failure.sh +156 -0
- package/skills/dx-devops-request-status/SKILL.md +160 -0
- package/skills/dx-devops-request-status/examples/polling-workflows.md +101 -0
- package/skills/dx-devops-request-status/references/cli-commands.md +176 -0
- package/skills/dx-devops-request-status/scripts/poll-status.sh +134 -0
- package/skills/dx-devops-test-failures-analyze/SKILL.md +6 -6
- package/skills/dx-devops-test-pipeline-configure/SKILL.md +7 -7
- package/skills/dx-devops-test-suite-assignments-configure/SKILL.md +6 -6
- package/skills/dx-devops-test-suite-run/SKILL.md +6 -6
- package/skills/dx-devops-work-item-manage/SKILL.md +2 -0
- package/skills/dx-org-manage/references/creating-scratch-org.md +5 -2
- package/skills/dx-org-manage/references/creating-snapshot.md +1 -0
- package/skills/dx-org-shape-manage/SKILL.md +152 -0
- package/skills/dx-org-shape-manage/examples/create_error_output.json +9 -0
- package/skills/dx-org-shape-manage/examples/create_success_output.json +9 -0
- package/skills/dx-org-shape-manage/examples/delete_output.json +11 -0
- package/skills/dx-org-shape-manage/examples/list_inactive_output.json +32 -0
- package/skills/dx-org-shape-manage/examples/list_output.json +24 -0
- package/skills/dx-org-shape-manage/references/cli_flags.md +135 -0
- package/skills/dx-org-switch/SKILL.md +2 -2
- package/skills/dx-org-trial-expiration-check/SKILL.md +2 -0
- package/skills/dx-pkg-post-install-configure/SKILL.md +3 -0
- package/skills/education-cloud-multi-campus-configure/SKILL.md +211 -0
- package/skills/education-cloud-multi-campus-configure/examples/hierarchy_visualization.md +174 -0
- package/skills/education-cloud-multi-campus-configure/examples/output_examples.md +54 -0
- package/skills/education-cloud-multi-campus-configure/examples/sample_hierarchy_input.csv +29 -0
- package/skills/education-cloud-multi-campus-configure/examples/sample_hierarchy_input_edgecases.csv +16 -0
- package/skills/education-cloud-multi-campus-configure/references/account_recordtype_prerequisite.md +44 -0
- package/skills/education-cloud-multi-campus-configure/references/delta_computation.md +33 -0
- package/skills/education-cloud-multi-campus-configure/references/error_handling.md +198 -0
- package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +54 -0
- package/skills/education-cloud-multi-campus-configure/references/gotchas.md +16 -0
- package/skills/education-cloud-multi-campus-configure/references/hierarchy_parsing_rules.md +133 -0
- package/skills/education-cloud-multi-campus-configure/references/mcp-invocation.md +75 -0
- package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/SKILL.md +1 -1
- package/skills/experience-cms-brand-apply/SKILL.md +4 -1
- package/skills/experience-cms-brand-create/SKILL.md +197 -0
- package/skills/experience-cms-brand-create/assets/brand-template.json +329 -0
- package/skills/experience-cms-brand-create/references/brand-anatomy.md +120 -0
- package/skills/experience-cms-brand-create/references/disk-contract.md +84 -0
- package/skills/experience-cms-content-generate/SKILL.md +190 -0
- package/skills/experience-cms-content-generate/assets/display-formats.md +84 -0
- package/skills/experience-cms-content-generate/assets/payloads/create-content-bulk.json +11 -0
- package/skills/experience-cms-content-generate/assets/payloads/create-content-single.json +11 -0
- package/skills/experience-cms-content-generate/assets/payloads/publish-content.json +4 -0
- package/skills/experience-cms-content-generate/assets/payloads/update-content.json +9 -0
- package/skills/experience-cms-content-generate/assets/questions.md +241 -0
- package/skills/experience-cms-content-generate/examples/create-content-call.md +81 -0
- package/skills/experience-cms-content-generate/references/bulk-batching.md +56 -0
- package/skills/experience-cms-content-generate/references/content-type-classification.md +88 -0
- package/skills/experience-cms-content-generate/references/content-write-tool.md +95 -0
- package/skills/experience-cms-content-generate/references/delegation-protocol.md +37 -0
- package/skills/experience-cms-content-generate/references/edit-publish-workflow.md +63 -0
- package/skills/experience-cms-content-generate/references/error-recovery.md +72 -0
- package/skills/experience-cms-content-generate/references/identifier-resolution.md +26 -0
- package/skills/experience-cms-content-generate/references/intent-routing.md +45 -0
- package/skills/experience-cms-content-generate/references/principles.md +19 -0
- package/skills/experience-cms-content-generate/references/ux-rules.md +68 -0
- package/skills/experience-cms-content-generate/references/workspace-resolution.md +33 -0
- package/skills/experience-cms-content-type-generate/SKILL.md +449 -0
- package/skills/experience-cms-content-type-generate/assets/discovery-prompts.md +79 -0
- package/skills/experience-cms-content-type-generate/assets/schema-example.json +70 -0
- package/skills/experience-cms-content-type-generate/references/agent-checklist.md +50 -0
- package/skills/experience-cms-content-type-generate/references/anti-patterns.md +39 -0
- package/skills/experience-cms-content-type-generate/references/deployment-errors.md +27 -0
- package/skills/experience-cms-content-type-generate/references/discovery-details.md +106 -0
- package/skills/experience-cms-content-type-generate/references/discovery-query-rules.md +85 -0
- package/skills/experience-cms-content-type-generate/references/edit-fields-loop.md +48 -0
- package/skills/experience-cms-content-type-generate/references/pre-deploy-checklist.md +21 -0
- package/skills/experience-cms-content-type-generate/references/retrieve-and-reconcile.md +105 -0
- package/skills/experience-cms-content-type-generate/references/schema-rules.md +51 -0
- package/skills/experience-cms-content-type-generate/references/schema-summary-format.md +60 -0
- package/skills/experience-lds-graphql-generate/SKILL.md +4 -0
- package/skills/experience-lwc-accessibility-jest-run/SKILL.md +79 -0
- package/skills/experience-lwc-accessibility-jest-run/references/running-sa11y-jest-tests.md +205 -0
- package/skills/experience-lwc-design-generate/SKILL.md +5 -4
- package/skills/experience-lwc-generate/SKILL.md +10 -9
- package/skills/experience-ui-bundle-deploy/references/dev-preview.md +21 -0
- package/skills/experience-ui-bundle-deploy/references/social-login.md +1 -0
- package/skills/experience-ui-bundle-file-upload-generate/SKILL.md +6 -6
- package/skills/experience-ui-bundle-localize/SKILL.md +30 -37
- package/skills/experience-ui-bundle-localize/references/gotchas.md +23 -7
- package/skills/experience-ui-bundle-localize/references/i18n-setup.md +48 -4
- package/skills/experience-ui-bundle-localize/references/label-xml.md +22 -8
- package/skills/experience-ui-bundle-localize/references/verifying.md +39 -11
- package/skills/experience-ui-bundle-localize/scripts/detect-bundle-type.sh +118 -15
- package/skills/experience-ui-bundle-localize/scripts/tests/test-detect-bundle-type.sh +187 -0
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +13 -2
- package/skills/experience-ui-bundle-mfa-configure/references/social-login.md +1 -0
- package/skills/experience-ui-bundle-project-generate/SKILL.md +2 -2
- package/skills/integration-connectivity-generate/SKILL.md +11 -10
- package/skills/integration-eventing-cdc-configure/SKILL.md +6 -6
- package/skills/integration-eventing-subscription-configure/SKILL.md +6 -5
- package/skills/mobile-platform-native-capabilities-integrate/SKILL.md +2 -2
- package/skills/mobile-platform-offline-validate/scripts/package.json +1 -1
- package/skills/platform-agentexchange-partner-offers-configure/SKILL.md +8 -8
- package/skills/platform-custom-application-generate/SKILL.md +2 -0
- package/skills/platform-custom-field-generate/SKILL.md +8 -6
- package/skills/platform-custom-metadata-type-generate/SKILL.md +460 -0
- package/skills/platform-custom-metadata-type-generate/references/cmdt-records.md +251 -0
- package/skills/platform-custom-metadata-type-generate/scripts/sanitize-developer-name.sh +46 -0
- package/skills/platform-custom-setting-generate/SKILL.md +419 -0
- package/skills/platform-dataspace-access-configure/SKILL.md +3 -3
- package/skills/platform-sharing-owd-configure/SKILL.md +3 -0
- package/skills/platform-value-set-generate/SKILL.md +2 -0
- package/skills/service-agentforce-channel-configure/SKILL.md +29 -20
- package/skills/service-agentforce-channel-configure/assets/BotEmailDefinition.botEmailDefinition-meta.xml +36 -0
- package/skills/service-agentforce-channel-configure/assets/email/unfiled$public/AgentforceForServiceEmailTemplate.email +17 -0
- package/skills/service-agentforce-channel-configure/assets/email/unfiled$public/AgentforceForServiceEmailTemplate.email-meta.xml +29 -0
- package/skills/service-agentforce-channel-configure/assets/mdapi-package.xml +25 -0
- package/skills/service-agentforce-channel-configure/assets/settings-mdapi-package.xml +30 -0
- package/skills/service-agentforce-channel-configure/references/agent-wiring.md +20 -0
- package/skills/service-agentforce-channel-configure/references/botemaildefinition.md +107 -0
- package/skills/service-agentforce-channel-configure/references/channel-branch-email.md +203 -50
- package/skills/service-agentforce-channel-configure/references/channel-types.md +30 -3
- package/skills/service-agentforce-channel-configure/references/queue-resolution.md +17 -8
- package/skills/service-agentforce-channel-configure/references/routing-flow.md +14 -3
- package/skills/service-agentforce-channel-configure/scripts/validate-botemaildefinition.py +126 -0
- package/skills/service-agentforce-channel-configure/scripts/validate-emailtemplate.py +133 -0
- package/skills/service-de-channel-activate/SKILL.md +271 -0
- package/skills/service-de-channel-activate/references/gotchas.md +31 -0
- package/skills/service-de-channel-activate/references/phone-verification.md +96 -0
- package/skills/service-de-channel-activate/references/worked-examples.md +14 -0
- package/skills/service-de-channel-consent-configure/SKILL.md +230 -0
- package/skills/service-de-channel-consent-configure/references/gotchas.md +41 -0
- package/skills/service-de-channel-consent-configure/references/language-keywords.md +84 -0
- package/skills/service-de-channel-consent-configure/references/worked-examples.md +134 -0
- package/skills/service-de-channel-create/SKILL.md +369 -0
- package/skills/service-de-channel-create/references/apple.md +59 -0
- package/skills/service-de-channel-create/references/connect-insert.md +84 -0
- package/skills/service-de-channel-create/references/facebook.md +154 -0
- package/skills/service-de-channel-create/references/line.md +66 -0
- package/skills/service-de-channel-create/references/sms.md +98 -0
- package/skills/service-de-channel-create/references/whatsapp.md +98 -0
- package/skills/service-de-channel-create/references/worked-examples.md +70 -0
- package/skills/service-de-channel-routing-configure/SKILL.md +345 -0
- package/skills/service-de-channel-routing-configure/references/asa-routing.md +90 -0
- package/skills/service-de-channel-routing-configure/references/gotchas.md +27 -0
- package/skills/service-de-channel-routing-configure/references/queue-creation.md +123 -0
- package/skills/service-de-channel-routing-configure/references/target-locate.md +98 -0
- package/skills/service-de-channel-routing-configure/references/worked-examples.md +174 -0
- package/skills/service-de-headless-channel-configure/SKILL.md +305 -0
- package/skills/service-de-headless-channel-configure/references/gotchas.md +27 -0
- package/skills/service-de-headless-channel-configure/references/inputs.md +49 -0
- package/skills/service-de-headless-channel-configure/references/output-envelopes.md +53 -0
- package/skills/service-de-headless-channel-configure/references/partial-success.md +24 -0
- package/skills/service-de-headless-channel-configure/references/terms-and-conditions.md +54 -0
- package/skills/service-de-headless-channel-configure/references/worked-examples.md +108 -0
- package/skills/service-de-waba-integrate/SKILL.md +227 -0
- package/skills/service-digital-engagement-channel-configure/scripts/check-api-version.sh +29 -0
- package/skills/service-digital-engagement-messaging-site-integrate/SKILL.md +10 -9
- package/skills/service-digital-engagement-messaging-site-integrate/references/lwr_patch.md +39 -21
- package/skills/service-digital-engagement-messaging-site-integrate/scripts/patch_lwr_bundle.sh +157 -96
- package/skills/service-email-to-case-configure/SKILL.md +190 -0
- package/skills/service-email-to-case-configure/assets/CaseSettings.settings-meta.xml +45 -0
- package/skills/service-email-to-case-configure/examples/CaseSettings-two-addresses.settings-meta.xml +36 -0
- package/skills/service-email-to-case-configure/references/apply-mechanics.md +73 -0
- package/skills/service-email-to-case-configure/references/routing_address_reference.md +73 -0
- package/skills/service-email-to-case-configure/references/troubleshooting.md +25 -0
- package/skills/service-email-to-case-configure/scripts/apply-casesettings.py +1315 -0
- package/skills/service-email-to-case-configure/scripts/check-agent-email-capability.sh +26 -0
- package/skills/service-email-to-case-configure/scripts/tests/__init__.py +0 -0
- package/skills/service-email-to-case-configure/scripts/tests/_bootstrap.py +45 -0
- package/skills/service-email-to-case-configure/scripts/tests/_fakeorg.py +366 -0
- package/skills/service-email-to-case-configure/scripts/tests/_run.py +84 -0
- package/skills/service-email-to-case-configure/scripts/tests/test_apply_org_scenarios.py +655 -0
- package/skills/service-email-to-case-configure/scripts/tests/test_get_session.py +134 -0
- package/skills/service-email-to-case-configure/scripts/tests/test_new_capabilities.py +193 -0
- package/skills/service-email-to-case-configure/scripts/tests/test_validate_casesettings.py +170 -0
- package/skills/service-email-to-case-configure/scripts/validate-casesettings.py +229 -0
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +33 -15
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -47
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +17 -12
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/references/cli-invocation.md +5 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/scripts/classify-enable-plan.mjs +11 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +8 -6
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/references/cli-invocation.md +3 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/scripts/classify-readiness.mjs +11 -3
- package/skills/service-itsm-agentic-setup-configure/SKILL.md +5 -5
- package/skills/service-itsm-agentic-setup-configure/examples/output-templates.md +5 -5
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +2 -2
- package/skills/service-itsm-agentic-setup-employee-agent-configure/references/report-format.md +11 -7
- package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +20 -7
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +10 -6
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/action-availability.md +7 -7
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/report-format.md +11 -7
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/build-create-body.mjs +24 -17
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-action-availability.mjs +98 -58
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +20 -7
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/strip-release-management.mjs +176 -0
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +19 -19
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/cli-invocation.md +3 -4
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/helper-contracts.md +6 -6
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/permset-topology.md +3 -10
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/scripts/classify-permset-availability.mjs +7 -9
- package/skills/service-itsm-incident-mgmt-configure/SKILL.md +2 -0
- package/skills/service-itsm-incident-priority-configure/SKILL.md +15 -12
- package/skills/service-itsm-incident-priority-configure/examples/matrix-operations.md +27 -1
- package/skills/service-itsm-incident-priority-configure/references/sf-cli-invocation.md +37 -6
- package/skills/service-native-voice-recording-transcription-configure/SKILL.md +146 -0
- package/skills/service-native-voice-recording-transcription-configure/assets/confirmation-output.json +16 -0
- package/skills/service-native-voice-recording-transcription-configure/assets/package.xml +22 -0
- package/skills/service-native-voice-recording-transcription-configure/references/thunderbird-voice-settings.md +101 -0
- package/skills/service-native-voice-recording-transcription-configure/scripts/enable-recording-transcription.sh +181 -0
- package/skills/data360-activate/README.md +0 -38
- package/skills/data360-activate/SKILL.md +0 -127
- package/skills/data360-connect/README.md +0 -57
- package/skills/data360-connect/SKILL.md +0 -163
- package/skills/data360-connect/examples/connections/heroku-postgres.json +0 -15
- package/skills/data360-connect/examples/connections/ingest-api-connection.json +0 -5
- package/skills/data360-connect/examples/connections/ingest-api-schema.json +0 -31
- package/skills/data360-connect/examples/connections/redshift.json +0 -16
- package/skills/data360-connect/examples/connections/sharepoint-unstructured.json +0 -20
- package/skills/data360-connect/examples/connections/snowflake-connection.json +0 -42
- package/skills/data360-harmonize/README.md +0 -31
- package/skills/data360-harmonize/SKILL.md +0 -126
- package/skills/data360-orchestrate/README.md +0 -120
- package/skills/data360-orchestrate/SKILL.md +0 -263
- package/skills/data360-orchestrate/assets/definitions/activation-target.template.json +0 -5
- package/skills/data360-orchestrate/assets/definitions/activation.template.json +0 -7
- package/skills/data360-orchestrate/assets/definitions/calculated-insight.template.json +0 -7
- package/skills/data360-orchestrate/assets/definitions/data-action-target.template.json +0 -5
- package/skills/data360-orchestrate/assets/definitions/data-action.template.json +0 -5
- package/skills/data360-orchestrate/assets/definitions/data-graph.template.json +0 -21
- package/skills/data360-orchestrate/assets/definitions/data-stream.template.json +0 -55
- package/skills/data360-orchestrate/assets/definitions/dmo.template.json +0 -17
- package/skills/data360-orchestrate/assets/definitions/identity-resolution.template.json +0 -30
- package/skills/data360-orchestrate/assets/definitions/mapping.template.json +0 -14
- package/skills/data360-orchestrate/assets/definitions/relationship.template.json +0 -12
- package/skills/data360-orchestrate/assets/definitions/search-index.template.json +0 -9
- package/skills/data360-orchestrate/assets/definitions/segment.template.json +0 -16
- package/skills/data360-orchestrate/references/feature-readiness.md +0 -157
- package/skills/data360-orchestrate/references/plugin-setup.md +0 -138
- package/skills/data360-orchestrate/scripts/bootstrap-plugin.sh +0 -53
- package/skills/data360-orchestrate/scripts/diagnose-org.mjs +0 -511
- package/skills/data360-orchestrate/scripts/generate-manifest.mjs +0 -68
- package/skills/data360-orchestrate/scripts/verify-plugin.sh +0 -58
- package/skills/data360-prepare/README.md +0 -50
- package/skills/data360-prepare/SKILL.md +0 -203
- package/skills/data360-prepare/examples/ingestion-api/.env.example +0 -8
- package/skills/data360-prepare/examples/ingestion-api/README.md +0 -48
- package/skills/data360-prepare/examples/ingestion-api/send-data.py +0 -144
- package/skills/data360-query/README.md +0 -43
- package/skills/data360-query/SKILL.md +0 -128
- package/skills/data360-query/examples/search-indexes/hybrid-structured.json +0 -44
- package/skills/data360-query/examples/search-indexes/vector-knowledge.json +0 -43
- package/skills/data360-segment/README.md +0 -35
- package/skills/data360-segment/SKILL.md +0 -123
- package/skills/service-helpagent-coordinate/references/channel-help-portal.md +0 -22
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-1-1-non-text-content.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-i-lists.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-ii-tables.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-iii-form-labels.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-iv-regions.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-v-groups.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-5-identify-input.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-4-3-contrast.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-1-1-keyboard.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-4-4-link-purpose.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-4-6-headings-labels.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-1-pointer-gestures.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-2-pointer-cancellation.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-3-label-in-name.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-7-dragging-movement.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-2-1-on-focus.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-2-2-on-input.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-1-error-identification.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-2-labels-instructions.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-3-error-suggestion.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-i-name.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-ii-role.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-iii-value.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-1-1-non-text-content.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-1-use-of-color.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-10-resize-reflow.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-11-non-text-contrast.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-3-contrast.md +0 -0
- /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/scripts/contrast-ratio.py +0 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Apply Email-to-Case CaseSettings to an org via the Metadata API's CRUD
|
|
3
|
+
updateMetadata call (read -> merge -> update -> verify), in two phases.
|
|
4
|
+
|
|
5
|
+
Why updateMetadata and not a file-based deploy: enabling On-Demand
|
|
6
|
+
Email-to-Case provisions the org's internal email-service infrastructure,
|
|
7
|
+
and a routing address can only bind to it in a SUBSEQUENT operation. A
|
|
8
|
+
single file-based deploy that both enables On-Demand and declares a routing
|
|
9
|
+
address fails on a freshly-configured org ("We couldn't save your routing
|
|
10
|
+
address..."). This script therefore applies changes in two phases:
|
|
11
|
+
|
|
12
|
+
Phase A - prerequisites + toggles (Support Settings owner / automated case
|
|
13
|
+
user, enableEmailToCase, enableOnDemandEmailToCase, plus any other
|
|
14
|
+
emailToCase flags EXCEPT routingAddresses).
|
|
15
|
+
Phase B - routing addresses, appended one updateMetadata call after the
|
|
16
|
+
toggles are live.
|
|
17
|
+
|
|
18
|
+
Each write sends ONLY the top-level CaseSettings fields this skill owns (see
|
|
19
|
+
KEEP_TOP_LEVEL_FIELDS) plus the complete emailToCase block; all other top-level
|
|
20
|
+
fields read from the org are stripped. This is deliberate: the platform
|
|
21
|
+
re-validates any top-level field present in the payload even at an unchanged
|
|
22
|
+
value, and some (e.g. Case Feed) carry dependencies unrelated to Email-to-Case
|
|
23
|
+
(Case Feed needs Chatter), which would fail the call on orgs where that config
|
|
24
|
+
differs. Stripped fields keep their current value via field-level merge, and
|
|
25
|
+
provisioning is driven by the emailToCase block, so the skill needs no Chatter
|
|
26
|
+
prerequisite and is unaffected by unrelated org Case configuration.
|
|
27
|
+
|
|
28
|
+
Authentication uses the Salesforce CLI's existing session for a target-org
|
|
29
|
+
alias (no passwords handled here): `sf org display --target-org <alias> --json`
|
|
30
|
+
supplies the instanceUrl, username, and apiVersion, and
|
|
31
|
+
`sf org auth show-access-token` supplies the live session token. Recent CLIs
|
|
32
|
+
REDACT the token in `sf org display` output (a `[REDACTED ...]` marker, with or
|
|
33
|
+
without `--verbose`), so it is not reused directly. Only older CLIs that predate
|
|
34
|
+
the show-access-token subcommand are served by a fallback, and those omit the
|
|
35
|
+
token from non-verbose `sf org display` entirely — so the fallback issues a
|
|
36
|
+
`--verbose` display to obtain it.
|
|
37
|
+
|
|
38
|
+
Support Settings (Default Case Owner + Automated Case User) are handled so no
|
|
39
|
+
value is ever assumed or guessed:
|
|
40
|
+
* If the org ALREADY has them configured, they are PRESERVED untouched
|
|
41
|
+
(unless --overwrite-support-settings is passed). The platform returns these
|
|
42
|
+
fields null when unset, so a null read is a reliable "not configured"
|
|
43
|
+
signal.
|
|
44
|
+
* If NOT configured, the caller must supply explicit values (the skill
|
|
45
|
+
elicits them):
|
|
46
|
+
- Default Case Owner: --owner-type {User|Queue} + --owner-value. A User
|
|
47
|
+
value must be an active Username; a Queue value must be a real Queue
|
|
48
|
+
(matched on DeveloperName). Invalid input fails closed with an
|
|
49
|
+
actionable message so the skill can re-prompt.
|
|
50
|
+
- Automated Case User: --automated-type {User|System}. User needs
|
|
51
|
+
--automated-value (an active Username); System sets
|
|
52
|
+
useSystemUserAsDefaultCaseUser and needs no user value (optionally
|
|
53
|
+
--system-user-email when the org's automated case user doesn't exist).
|
|
54
|
+
The authenticated CLI user is used ONLY with --use-authenticated-user, and
|
|
55
|
+
only when the operator explicitly asks for it.
|
|
56
|
+
* Routing-address email addresses must be supplied explicitly with
|
|
57
|
+
--routing-email (one per routing address, in document order). The script
|
|
58
|
+
refuses to run if the input declares routing addresses but no
|
|
59
|
+
--routing-email is given.
|
|
60
|
+
|
|
61
|
+
Before mutating a PRODUCTION org (non-sandbox, non-trial), the script fails
|
|
62
|
+
closed unless --confirm-production is passed — enabling Email-to-Case is a
|
|
63
|
+
permanent, org-wide change. Sandboxes and trials deploy without it. The Metadata
|
|
64
|
+
API version is derived from the org and floored at DEFAULT_API_VERSION (67.0);
|
|
65
|
+
pass --api-version to force a specific version.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
python3 apply-casesettings.py --target-org <alias> --input <file> \
|
|
69
|
+
--routing-email support@yourco.com \
|
|
70
|
+
--owner-type Queue --owner-value Support_Queue \
|
|
71
|
+
--automated-type System --system-user-email ops-noreply@yourco.com
|
|
72
|
+
python3 apply-casesettings.py --target-org <alias> --input <file> --verify-only
|
|
73
|
+
# Act 3 — prove inbound email created Cases (read-only; no --input needed):
|
|
74
|
+
python3 apply-casesettings.py --target-org <alias> --verify-cases \
|
|
75
|
+
--supplied-email customer@external.com
|
|
76
|
+
|
|
77
|
+
The --input file is a standard CaseSettings source file (the artifact this
|
|
78
|
+
skill produces). The script parses it, resolves/validates the owner + automated
|
|
79
|
+
user (only when not already configured), substitutes the supplied routing
|
|
80
|
+
email(s), applies phase A then phase B, and re-reads to verify. Prints a JSON
|
|
81
|
+
summary to stdout; exits non-zero on any SOAP fault or failed SaveResult (the
|
|
82
|
+
error text is printed to stderr).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
import argparse
|
|
86
|
+
import json
|
|
87
|
+
import subprocess
|
|
88
|
+
import sys
|
|
89
|
+
import urllib.error
|
|
90
|
+
import urllib.request
|
|
91
|
+
import xml.etree.ElementTree as ET
|
|
92
|
+
|
|
93
|
+
META_NS = "http://soap.sforce.com/2006/04/metadata"
|
|
94
|
+
SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
|
|
95
|
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
96
|
+
# Matches the skill's declared minApiVersion (67.0), which clears the version
|
|
97
|
+
# floors of every emailToCase toggle this skill sets (e.g. showWordCountInComposer)
|
|
98
|
+
# and the Support-Settings System-user fields. The routing-address
|
|
99
|
+
# `botEmailDefinition` child (Agentforce for Service on Email) requires v68.0+.
|
|
100
|
+
DEFAULT_API_VERSION = "67.0"
|
|
101
|
+
# Resolved at runtime by get_session(): an explicit --api-version wins, otherwise
|
|
102
|
+
# it is DERIVED from the org's own apiVersion (as reported by `sf org display`)
|
|
103
|
+
# but floored at DEFAULT_API_VERSION — see resolve_api_version. This lets the
|
|
104
|
+
# skill use the org's newer features (e.g. the v68+ botEmailDefinition binding)
|
|
105
|
+
# with no manual flag, while never dropping below the field version-floors this
|
|
106
|
+
# skill relies on and never outrunning what the org actually supports.
|
|
107
|
+
API_VERSION = DEFAULT_API_VERSION
|
|
108
|
+
# Set True in main() when the operator passed --api-version, so get_session skips
|
|
109
|
+
# the org-derived version and honors the explicit override.
|
|
110
|
+
_API_VERSION_EXPLICIT = False
|
|
111
|
+
# Set by get_session(): the sf CLI alias/username used to run read-only SOQL via
|
|
112
|
+
# `sf data query --json`, so the sf CLI holds the token. The raw session token is
|
|
113
|
+
# only ever put on the wire by the SOAP metadata path (readMetadata/updateMetadata),
|
|
114
|
+
# which has no CLI equivalent.
|
|
115
|
+
TARGET_ORG = None
|
|
116
|
+
|
|
117
|
+
# Platform-managed read-only fields the org mints on a routing address. We never
|
|
118
|
+
# declare these on a write (defensive hygiene). NOTE: stripping them is not what
|
|
119
|
+
# preserves existing addresses on a multi-address write — document ordering is
|
|
120
|
+
# (new addresses before existing ones). See strip_readonly_address_fields.
|
|
121
|
+
READONLY_ADDRESS_FIELDS = {"emailServicesAddress", "isVerified"}
|
|
122
|
+
|
|
123
|
+
# Top-level CaseSettings fields this skill is responsible for — the ONLY
|
|
124
|
+
# top-level children we send in an updateMetadata payload. Every other top-level
|
|
125
|
+
# field the read returns is stripped before writing.
|
|
126
|
+
#
|
|
127
|
+
# Why strip everything else: the platform RE-VALIDATES any field present in the
|
|
128
|
+
# payload, even at an unchanged value. Several top-level Case fields carry
|
|
129
|
+
# dependencies unrelated to Email-to-Case — most notably Case Feed, whose
|
|
130
|
+
# re-validation requires Chatter (feeds) and otherwise fails the whole call with
|
|
131
|
+
# "The setup requirements for Case Feed Items has to be enabled." Sending only
|
|
132
|
+
# the fields we own avoids every such unrelated re-validation (Case Feed,
|
|
133
|
+
# swarming, solutions, suggested articles, web-to-case, ...), so the skill works
|
|
134
|
+
# on orgs regardless of their other Case configuration and needs no Chatter
|
|
135
|
+
# prerequisite.
|
|
136
|
+
#
|
|
137
|
+
# Why this is safe: CaseSettings updateMetadata merges at the field level, so any
|
|
138
|
+
# omitted top-level field keeps its current org value untouched (verified:
|
|
139
|
+
# enableCaseFeed and enableCollapseEmailThread both stay true after a write that
|
|
140
|
+
# omits them). Provisioning of the On-Demand email service is driven entirely by
|
|
141
|
+
# the full `emailToCase` block, NOT by any top-level field (verified from scratch
|
|
142
|
+
# on a Chatter-off org: stripping all 35 other top-level fields still provisioned
|
|
143
|
+
# and bound a routing address). `emailToCase` and `fullName` are always kept in
|
|
144
|
+
# addition to these; support-settings fields (SUPPORT_SETTINGS_FIELDS) are here.
|
|
145
|
+
KEEP_TOP_LEVEL_FIELDS = {
|
|
146
|
+
"fullName",
|
|
147
|
+
"emailToCase",
|
|
148
|
+
"enableDraftEmails",
|
|
149
|
+
"defaultCaseOwner",
|
|
150
|
+
"defaultCaseOwnerType",
|
|
151
|
+
"defaultCaseUser",
|
|
152
|
+
"useSystemUserAsDefaultCaseUser",
|
|
153
|
+
"systemUserEmail",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Top-level CaseSettings fields handled explicitly by the owner / automated-user
|
|
157
|
+
# resolution below (or in their own block, emailToCase). Any OTHER scalar
|
|
158
|
+
# top-level field in the input (e.g. enableDraftEmails, a Support-Settings
|
|
159
|
+
# toggle) is propagated verbatim in Phase A.
|
|
160
|
+
HANDLED_TOP_LEVEL = {"defaultCaseOwner", "defaultCaseOwnerType", "defaultCaseUser",
|
|
161
|
+
"useSystemUserAsDefaultCaseUser", "systemUserEmail",
|
|
162
|
+
"emailToCase"}
|
|
163
|
+
|
|
164
|
+
VALID_OWNER_TYPES = {"User", "Queue"}
|
|
165
|
+
VALID_AUTOMATED_TYPES = {"User", "System"}
|
|
166
|
+
# Support-Settings fields written/read for the owner + automated case user.
|
|
167
|
+
SUPPORT_SETTINGS_FIELDS = ("defaultCaseOwner", "defaultCaseOwnerType",
|
|
168
|
+
"defaultCaseUser", "useSystemUserAsDefaultCaseUser",
|
|
169
|
+
"systemUserEmail")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def fail(message):
|
|
173
|
+
print(message, file=sys.stderr)
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def local_name(tag):
|
|
178
|
+
return tag.split("}", 1)[-1] if "}" in tag else tag
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def qn(name):
|
|
182
|
+
return f"{{{META_NS}}}{name}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# sf CLI session
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def sf_json(args):
|
|
190
|
+
"""Run an `sf` command with --json and return the parsed 'result'."""
|
|
191
|
+
try:
|
|
192
|
+
proc = subprocess.run(
|
|
193
|
+
args, capture_output=True, text=True, check=False
|
|
194
|
+
)
|
|
195
|
+
except FileNotFoundError:
|
|
196
|
+
fail("The Salesforce CLI (`sf`) was not found on PATH. Install it or "
|
|
197
|
+
"authenticate the target org first.")
|
|
198
|
+
if proc.returncode != 0:
|
|
199
|
+
# sf prints a JSON error on stdout even on failure; try to surface it.
|
|
200
|
+
detail = proc.stdout.strip() or proc.stderr.strip()
|
|
201
|
+
fail(f"`{' '.join(args)}` failed: {detail}")
|
|
202
|
+
try:
|
|
203
|
+
return json.loads(proc.stdout)["result"]
|
|
204
|
+
except (json.JSONDecodeError, KeyError) as exc:
|
|
205
|
+
fail(f"Could not parse `{' '.join(args)}` output: {exc}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def sf_json_optional(args):
|
|
209
|
+
"""Like ``sf_json`` but return None instead of exiting when the command is
|
|
210
|
+
unavailable or fails. Used to probe optional subcommands that don't exist on
|
|
211
|
+
every CLI version (e.g. older CLIs lack `sf org auth show-access-token`)."""
|
|
212
|
+
try:
|
|
213
|
+
proc = subprocess.run(
|
|
214
|
+
args, capture_output=True, text=True, check=False
|
|
215
|
+
)
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
return None
|
|
218
|
+
if proc.returncode != 0:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
return json.loads(proc.stdout).get("result")
|
|
222
|
+
except (json.JSONDecodeError, AttributeError):
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _usable_access_token(token):
|
|
227
|
+
"""True if ``token`` looks like a live Salesforce session token.
|
|
228
|
+
|
|
229
|
+
A real token is ``<orgId>!<...>``; the `!` separator is always present.
|
|
230
|
+
Recent CLIs redact the token in `sf org display --json` (returning a
|
|
231
|
+
non-empty `[REDACTED ...]` marker), which must be rejected rather than sent
|
|
232
|
+
as a Bearer token."""
|
|
233
|
+
return isinstance(token, str) and "!" in token and "REDACTED" not in token
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _version_tuple(value):
|
|
237
|
+
"""Parse an 'X.Y' API version string to a (major, minor) int tuple, or None
|
|
238
|
+
if it isn't a recognizable numeric version."""
|
|
239
|
+
if not value:
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
parts = str(value).strip().split(".")
|
|
243
|
+
return (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0)
|
|
244
|
+
except (ValueError, IndexError):
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_api_version(org_api_version):
|
|
249
|
+
"""Return the API version string to use, given the org's own apiVersion
|
|
250
|
+
(as reported by `sf org display`; may be None/blank/garbage).
|
|
251
|
+
|
|
252
|
+
Rule: floor, not "always latest". The effective version is
|
|
253
|
+
max(org_apiVersion, DEFAULT_API_VERSION) — so the skill rides the org's newer
|
|
254
|
+
features (e.g. the v68+ botEmailDefinition binding) automatically, but never
|
|
255
|
+
drops below DEFAULT_API_VERSION (the floor that clears every field this skill
|
|
256
|
+
sets) and never claims a version the org doesn't report. If the org's version
|
|
257
|
+
can't be parsed, fall back to DEFAULT_API_VERSION."""
|
|
258
|
+
org_t = _version_tuple(org_api_version)
|
|
259
|
+
floor_t = _version_tuple(DEFAULT_API_VERSION)
|
|
260
|
+
if org_t is None or org_t < floor_t:
|
|
261
|
+
return DEFAULT_API_VERSION
|
|
262
|
+
return org_api_version.strip() if isinstance(org_api_version, str) else str(
|
|
263
|
+
org_api_version)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_session(target_org):
|
|
267
|
+
"""Return (session_id, metadata_url, instance_url, auth_username).
|
|
268
|
+
|
|
269
|
+
Also resolves the global API_VERSION when it was not set explicitly via
|
|
270
|
+
--api-version: it is derived from the org's reported apiVersion but floored
|
|
271
|
+
at DEFAULT_API_VERSION (see resolve_api_version)."""
|
|
272
|
+
display = sf_json(["sf", "org", "display", "--target-org", target_org,
|
|
273
|
+
"--json"])
|
|
274
|
+
instance_url = display.get("instanceUrl")
|
|
275
|
+
if not instance_url:
|
|
276
|
+
fail(f"No instanceUrl for org '{target_org}'. Is it authenticated?")
|
|
277
|
+
auth_username = display.get("username")
|
|
278
|
+
if not auth_username:
|
|
279
|
+
fail(f"Could not determine the authenticated username for "
|
|
280
|
+
f"org '{target_org}'.")
|
|
281
|
+
# Derive the API version from the org unless the operator forced one with
|
|
282
|
+
# --api-version. `sf org display` reports the CLI's configured apiVersion for
|
|
283
|
+
# the org (the org default unless locally overridden); flooring at
|
|
284
|
+
# DEFAULT_API_VERSION keeps every field this skill sets valid.
|
|
285
|
+
global API_VERSION, TARGET_ORG
|
|
286
|
+
TARGET_ORG = target_org
|
|
287
|
+
if not _API_VERSION_EXPLICIT:
|
|
288
|
+
API_VERSION = resolve_api_version(display.get("apiVersion"))
|
|
289
|
+
# Obtain a live access token. Prefer `sf org auth show-access-token`, which
|
|
290
|
+
# returns the real session token; recent CLIs REDACT the token in
|
|
291
|
+
# `sf org display --json` (a `[REDACTED ...]` marker, verbose or not) so it
|
|
292
|
+
# can't be sent as a Bearer token. Only when that subcommand is unavailable
|
|
293
|
+
# (older CLIs) fall back to a `--verbose` display: those CLIs omit the token
|
|
294
|
+
# from non-verbose output entirely, so a plain `sf org display` never carries
|
|
295
|
+
# it. Either way, accept a value only if it looks like a live token.
|
|
296
|
+
token = None
|
|
297
|
+
at = sf_json_optional(["sf", "org", "auth", "show-access-token",
|
|
298
|
+
"--target-org", target_org, "--no-prompt", "--json"])
|
|
299
|
+
# `result` is an object (`{accessToken}`) on current CLIs, but accept a bare
|
|
300
|
+
# string too in case a CLI version returns the token directly.
|
|
301
|
+
if isinstance(at, str):
|
|
302
|
+
token = at
|
|
303
|
+
elif isinstance(at, dict):
|
|
304
|
+
token = at.get("accessToken")
|
|
305
|
+
if not _usable_access_token(token):
|
|
306
|
+
verbose = sf_json_optional(["sf", "org", "display", "--target-org",
|
|
307
|
+
target_org, "--verbose", "--json"])
|
|
308
|
+
if isinstance(verbose, dict):
|
|
309
|
+
token = verbose.get("accessToken")
|
|
310
|
+
if not _usable_access_token(token):
|
|
311
|
+
fail(f"Could not obtain a usable access token for org '{target_org}'. "
|
|
312
|
+
f"Re-authenticate with `sf org login` and try again.")
|
|
313
|
+
metadata_url = f"{instance_url}/services/Soap/m/{API_VERSION}"
|
|
314
|
+
return token, metadata_url, instance_url, auth_username
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def query_active_user(session_id, instance_url, username):
|
|
318
|
+
"""Return the exact Username of an active User matching `username`
|
|
319
|
+
(case-insensitively), or None if there is no active match."""
|
|
320
|
+
if not username:
|
|
321
|
+
return None
|
|
322
|
+
escaped = username.replace("\\", "\\\\").replace("'", "\\'")
|
|
323
|
+
soql = ("SELECT Username FROM User "
|
|
324
|
+
f"WHERE Username = '{escaped}' AND IsActive = true")
|
|
325
|
+
records = _soql_query(session_id, instance_url, soql).get("records", [])
|
|
326
|
+
return records[0]["Username"] if records else None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _soql_query(session_id, instance_url, soql):
|
|
330
|
+
# Read-only SOQL runs through `sf data query`, so the sf CLI holds the token
|
|
331
|
+
# — the raw session_id is only ever put on the wire by the SOAP metadata path
|
|
332
|
+
# (read/update), which has no CLI equivalent. session_id and instance_url are
|
|
333
|
+
# retained for signature parity with the test seams and other query helpers.
|
|
334
|
+
result = sf_json(["sf", "data", "query", "--target-org", TARGET_ORG,
|
|
335
|
+
"--query", soql, "--json"])
|
|
336
|
+
# `sf data query --json` returns {"records": [...], "totalSize": N, ...} under
|
|
337
|
+
# 'result' — the same shape callers already consume from the REST /query body.
|
|
338
|
+
return result if isinstance(result, dict) else {"records": []}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def query_org_info(session_id, instance_url):
|
|
342
|
+
"""Return (is_sandbox, organization_type, is_trial) for the target org, or
|
|
343
|
+
(None, None, None) if the Organization row can't be read. Used by the
|
|
344
|
+
production-org safety gate."""
|
|
345
|
+
soql = ("SELECT IsSandbox, OrganizationType, TrialExpirationDate "
|
|
346
|
+
"FROM Organization LIMIT 1")
|
|
347
|
+
data = _soql_query(session_id, instance_url, soql)
|
|
348
|
+
records = data.get("records", [])
|
|
349
|
+
if not records:
|
|
350
|
+
return None, None, None
|
|
351
|
+
row = records[0]
|
|
352
|
+
return (row.get("IsSandbox"), row.get("OrganizationType"),
|
|
353
|
+
row.get("TrialExpirationDate") is not None)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def query_email_cases(session_id, instance_url, supplied_email=None):
|
|
357
|
+
"""Return the list of Case records created from inbound email
|
|
358
|
+
(Origin = 'Email') within the last 3 days, optionally narrowed to a
|
|
359
|
+
SuppliedEmail. The 3-day window excludes stale pre-existing email Cases so a
|
|
360
|
+
verify run can't false-pass on an old Case. Used by --verify-cases to prove
|
|
361
|
+
inbound mail created Cases."""
|
|
362
|
+
where = ["Origin = 'Email'"]
|
|
363
|
+
if supplied_email:
|
|
364
|
+
esc = supplied_email.replace("\\", "\\\\").replace("'", "\\'")
|
|
365
|
+
where.append(f"SuppliedEmail = '{esc}'")
|
|
366
|
+
# Fixed SOQL date-literal window (a constant, never operator input) — recent
|
|
367
|
+
# enough to catch a test email sent over a weekend, tight enough to exclude
|
|
368
|
+
# old Cases from a prior setup.
|
|
369
|
+
where.append("CreatedDate >= LAST_N_DAYS:3")
|
|
370
|
+
soql = ("SELECT Id, CaseNumber, Origin, SuppliedEmail, Subject, Status, "
|
|
371
|
+
"CreatedDate FROM Case WHERE " + " AND ".join(where) +
|
|
372
|
+
" ORDER BY CreatedDate DESC LIMIT 50")
|
|
373
|
+
data = _soql_query(session_id, instance_url, soql)
|
|
374
|
+
return data.get("records", [])
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def query_incoming_email_messages(session_id, instance_url, case_ids):
|
|
378
|
+
"""Return incoming EmailMessage records (Incoming = true) whose ParentId is
|
|
379
|
+
one of `case_ids`. Proves the Case has its linked inbound email."""
|
|
380
|
+
if not case_ids:
|
|
381
|
+
return []
|
|
382
|
+
quoted = ", ".join(
|
|
383
|
+
"'" + cid.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
384
|
+
for cid in case_ids)
|
|
385
|
+
soql = ("SELECT Id, ParentId, FromAddress, ToAddress, Subject, Incoming, "
|
|
386
|
+
"MessageDate FROM EmailMessage "
|
|
387
|
+
f"WHERE Incoming = true AND ParentId IN ({quoted}) "
|
|
388
|
+
"ORDER BY MessageDate DESC LIMIT 200")
|
|
389
|
+
data = _soql_query(session_id, instance_url, soql)
|
|
390
|
+
return data.get("records", [])
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def query_queue(session_id, instance_url, name):
|
|
394
|
+
"""Return the DeveloperName of a Queue matching `name` (by DeveloperName,
|
|
395
|
+
which is what CaseSettings.defaultCaseOwner stores for a queue; Name is
|
|
396
|
+
accepted too as a tolerant fallback), or None if there is no match."""
|
|
397
|
+
if not name:
|
|
398
|
+
return None
|
|
399
|
+
escaped = name.replace("\\", "\\\\").replace("'", "\\'")
|
|
400
|
+
soql = ("SELECT DeveloperName FROM Group "
|
|
401
|
+
f"WHERE Type = 'Queue' AND (DeveloperName = '{escaped}' "
|
|
402
|
+
f"OR Name = '{escaped}')")
|
|
403
|
+
data = _soql_query(session_id, instance_url, soql)
|
|
404
|
+
records = data.get("records", [])
|
|
405
|
+
return records[0]["DeveloperName"] if records else None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def resolve_owner(session_id, instance_url, owner_value, owner_type):
|
|
409
|
+
"""Validate a user-supplied Default Case Owner against the org and return
|
|
410
|
+
(resolved_value, resolved_type). Fails closed with an actionable message if
|
|
411
|
+
the type is invalid or the value is not a real active user / queue.
|
|
412
|
+
|
|
413
|
+
Never assumes the authenticated user — the caller must have supplied an
|
|
414
|
+
explicit owner (the skill elicits it)."""
|
|
415
|
+
if owner_type not in VALID_OWNER_TYPES:
|
|
416
|
+
fail(f"defaultCaseOwnerType '{owner_type}' is not valid. It must be "
|
|
417
|
+
f"'User' or 'Queue'. Ask the user for a valid type and value.")
|
|
418
|
+
if not owner_value:
|
|
419
|
+
fail(f"defaultCaseOwnerType is '{owner_type}' but no defaultCaseOwner "
|
|
420
|
+
f"value was provided. Ask the user for the {owner_type} to use.")
|
|
421
|
+
if owner_type == "User":
|
|
422
|
+
resolved = query_active_user(session_id, instance_url, owner_value)
|
|
423
|
+
if not resolved:
|
|
424
|
+
fail(f"Default Case Owner '{owner_value}' is not an active User in "
|
|
425
|
+
f"this org. Ask the user for a valid username (or a Queue).")
|
|
426
|
+
return resolved, "User"
|
|
427
|
+
# Queue
|
|
428
|
+
resolved = query_queue(session_id, instance_url, owner_value)
|
|
429
|
+
if not resolved:
|
|
430
|
+
fail(f"Default Case Owner '{owner_value}' is not a Queue in this org. "
|
|
431
|
+
f"Ask the user for a valid queue DeveloperName (or a User).")
|
|
432
|
+
return resolved, "Queue"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _is_placeholder(value):
|
|
436
|
+
"""A value left as an unfilled template token, e.g. {CASE_OWNER_...}."""
|
|
437
|
+
return (isinstance(value, str) and value.startswith("{")
|
|
438
|
+
and value.endswith("}"))
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def resolve_address_case_owner(session_id, instance_url, addr, label):
|
|
442
|
+
"""Validate the OPTIONAL per-address Default Case Owner on one routing
|
|
443
|
+
address, in place. Rules (applied only when the user opted in to a
|
|
444
|
+
per-address owner, i.e. caseOwner is present):
|
|
445
|
+
* caseOwner must not be an unfilled placeholder.
|
|
446
|
+
* caseOwnerType is required whenever caseOwner is set (platform rejects
|
|
447
|
+
caseOwner without it) and must be 'User' or 'Queue'.
|
|
448
|
+
* The value is validated against the org — an active Username for User, a
|
|
449
|
+
real Queue DeveloperName for Queue — and fails closed if it does not
|
|
450
|
+
exist, so the skill can re-prompt.
|
|
451
|
+
When caseOwner is absent the address is left untouched (cases fall to the
|
|
452
|
+
org Default Case Owner / assignment rules)."""
|
|
453
|
+
case_owner = addr.get("caseOwner")
|
|
454
|
+
case_owner_type = addr.get("caseOwnerType")
|
|
455
|
+
if case_owner is None and case_owner_type is None:
|
|
456
|
+
return # no per-address owner requested
|
|
457
|
+
if case_owner is None:
|
|
458
|
+
fail(f"{label} sets caseOwnerType but no caseOwner value. Either supply "
|
|
459
|
+
f"a caseOwner (a real active Username or Queue DeveloperName) or "
|
|
460
|
+
f"remove caseOwnerType to use the org Default Case Owner.")
|
|
461
|
+
if _is_placeholder(case_owner):
|
|
462
|
+
fail(f"{label} caseOwner is still the placeholder '{case_owner}'. Ask "
|
|
463
|
+
f"the user for a real active Username or Queue DeveloperName for "
|
|
464
|
+
f"this address, or remove caseOwner to use the org Default Case "
|
|
465
|
+
f"Owner.")
|
|
466
|
+
if case_owner_type is None or _is_placeholder(case_owner_type):
|
|
467
|
+
fail(f"{label} sets caseOwner but no valid caseOwnerType. caseOwnerType "
|
|
468
|
+
f"('User' or 'Queue') is required whenever caseOwner is set. Ask "
|
|
469
|
+
f"the user whether '{case_owner}' is a User or a Queue.")
|
|
470
|
+
# resolve_owner validates the type and the value against the org, failing
|
|
471
|
+
# closed with an actionable message; reuse it so per-address and top-level
|
|
472
|
+
# owner validation are identical.
|
|
473
|
+
resolved, resolved_type = resolve_owner(
|
|
474
|
+
session_id, instance_url, case_owner, case_owner_type)
|
|
475
|
+
addr["caseOwner"] = resolved
|
|
476
|
+
addr["caseOwnerType"] = resolved_type
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def resolve_automated_user(session_id, instance_url, automated_type,
|
|
480
|
+
user_value, system_email):
|
|
481
|
+
"""Validate the user-supplied Automated Case User and return a dict of the
|
|
482
|
+
CaseSettings fields to write. 'System' → useSystemUserAsDefaultCaseUser
|
|
483
|
+
(no defaultCaseUser). 'User' → a validated defaultCaseUser username.
|
|
484
|
+
Fails closed with an actionable message on any invalid input."""
|
|
485
|
+
if automated_type not in VALID_AUTOMATED_TYPES:
|
|
486
|
+
fail(f"Automated Case User type '{automated_type}' is not valid. It "
|
|
487
|
+
f"must be 'User' or 'System'. Ask the user for a valid type.")
|
|
488
|
+
if automated_type == "System":
|
|
489
|
+
# System needs no user value; systemUserEmail is required only if the
|
|
490
|
+
# org's automated case user does not exist yet — we pass it through
|
|
491
|
+
# when provided and let the platform validate.
|
|
492
|
+
fields = {"useSystemUserAsDefaultCaseUser": True}
|
|
493
|
+
if system_email:
|
|
494
|
+
fields["systemUserEmail"] = system_email
|
|
495
|
+
return fields, "System"
|
|
496
|
+
# User
|
|
497
|
+
if not user_value:
|
|
498
|
+
fail("Automated Case User type is 'User' but no defaultCaseUser value "
|
|
499
|
+
"was provided. Ask the user for the username to use.")
|
|
500
|
+
resolved = query_active_user(session_id, instance_url, user_value)
|
|
501
|
+
if not resolved:
|
|
502
|
+
fail(f"Automated Case User '{user_value}' is not an active User in "
|
|
503
|
+
f"this org. Ask the user for a valid username (or use System).")
|
|
504
|
+
return {"defaultCaseUser": resolved,
|
|
505
|
+
"useSystemUserAsDefaultCaseUser": False}, "User"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# SOAP plumbing
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def post_soap(url, envelope, soap_action):
|
|
513
|
+
req = urllib.request.Request(
|
|
514
|
+
url, data=envelope.encode("utf-8"), method="POST",
|
|
515
|
+
headers={"Content-Type": "text/xml; charset=UTF-8",
|
|
516
|
+
"SOAPAction": soap_action})
|
|
517
|
+
try:
|
|
518
|
+
with urllib.request.urlopen(req) as resp:
|
|
519
|
+
body = resp.read()
|
|
520
|
+
except urllib.error.HTTPError as e:
|
|
521
|
+
body = e.read() # SOAP faults come back with a non-2xx status + body
|
|
522
|
+
except urllib.error.URLError as e:
|
|
523
|
+
fail(f"Could not reach {url}: {e}")
|
|
524
|
+
return ET.fromstring(body)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def find_fault(root):
|
|
528
|
+
fault = root.find(f".//{{{SOAP_NS}}}Fault")
|
|
529
|
+
if fault is None:
|
|
530
|
+
return None
|
|
531
|
+
fs = fault.find("faultstring")
|
|
532
|
+
return fs.text if fs is not None else ET.tostring(fault, encoding="unicode")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def envelope(session_id, body_inner):
|
|
536
|
+
return (
|
|
537
|
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
|
538
|
+
f'<soapenv:Envelope xmlns:soapenv="{SOAP_NS}" xmlns:met="{META_NS}">'
|
|
539
|
+
f'<soapenv:Header><met:SessionHeader>'
|
|
540
|
+
f'<met:sessionId>{escape_xml(session_id)}</met:sessionId>'
|
|
541
|
+
f'</met:SessionHeader></soapenv:Header>'
|
|
542
|
+
f'<soapenv:Body>{body_inner}</soapenv:Body></soapenv:Envelope>'
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def escape_xml(text):
|
|
547
|
+
return (str(text).replace("&", "&").replace("<", "<")
|
|
548
|
+
.replace(">", ">").replace('"', """))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def read_case_settings(session_id, metadata_url):
|
|
552
|
+
body = (f'<met:readMetadata><met:type>CaseSettings</met:type>'
|
|
553
|
+
f'<met:fullNames>Case</met:fullNames></met:readMetadata>')
|
|
554
|
+
root = post_soap(metadata_url, envelope(session_id, body), "readMetadata")
|
|
555
|
+
fault = find_fault(root)
|
|
556
|
+
if fault:
|
|
557
|
+
fail(f"readMetadata failed: {fault}")
|
|
558
|
+
records = root.find(f".//{qn('records')}")
|
|
559
|
+
if records is None:
|
|
560
|
+
fail("readMetadata returned no CaseSettings record.")
|
|
561
|
+
return records
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def strip_readonly_address_fields(records):
|
|
565
|
+
"""Remove platform-managed read-only fields (emailServicesAddress,
|
|
566
|
+
isVerified) from EVERY routingAddresses element under emailToCase, in place.
|
|
567
|
+
|
|
568
|
+
This is defensive hygiene, NOT the multi-address preservation mechanism. A
|
|
569
|
+
write should never declare platform-managed fields the org owns; stripping
|
|
570
|
+
them keeps our payload clean and avoids relying on the platform to tolerate
|
|
571
|
+
them.
|
|
572
|
+
|
|
573
|
+
It is explicitly NOT what preserves existing addresses: these fields are
|
|
574
|
+
irrelevant to preservation — existing addresses were dropped with the fields
|
|
575
|
+
stripped AND survived with them kept as-read — the deciding factor is document
|
|
576
|
+
ORDER, not these fields. See the ORDERING note in main()'s Phase B (new
|
|
577
|
+
addresses must precede existing ones in the routingAddresses collection) for
|
|
578
|
+
the actual preservation mechanism."""
|
|
579
|
+
e2c = records.find(qn("emailToCase"))
|
|
580
|
+
if e2c is None:
|
|
581
|
+
return
|
|
582
|
+
for addr in e2c.findall(qn("routingAddresses")):
|
|
583
|
+
for field in READONLY_ADDRESS_FIELDS:
|
|
584
|
+
el = addr.find(qn(field))
|
|
585
|
+
if el is not None:
|
|
586
|
+
addr.remove(el)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def update_case_settings(session_id, metadata_url, records):
|
|
590
|
+
metadata_el = ET.Element(qn("metadata"))
|
|
591
|
+
metadata_el.set(f"{{{XSI_NS}}}type", "met:CaseSettings")
|
|
592
|
+
# Defensive hygiene: don't declare platform-managed read-only address fields
|
|
593
|
+
# (emailServicesAddress, isVerified) on a write. This is NOT what preserves
|
|
594
|
+
# existing addresses — that is document ordering (see the Phase B ORDERING
|
|
595
|
+
# note). See strip_readonly_address_fields.
|
|
596
|
+
strip_readonly_address_fields(records)
|
|
597
|
+
# Send ONLY the top-level fields this skill owns; drop every other top-level
|
|
598
|
+
# child the read returned. This avoids the platform re-validating unrelated
|
|
599
|
+
# Case config (Case Feed → Chatter, swarming, solutions, ...). Dropped fields
|
|
600
|
+
# keep their current org value via field-level merge; provisioning is driven
|
|
601
|
+
# by the emailToCase block, not by any top-level field. See
|
|
602
|
+
# KEEP_TOP_LEVEL_FIELDS.
|
|
603
|
+
for child in list(records):
|
|
604
|
+
if local_name(child.tag) in KEEP_TOP_LEVEL_FIELDS:
|
|
605
|
+
metadata_el.append(child)
|
|
606
|
+
update_el = ET.Element(qn("updateMetadata"))
|
|
607
|
+
update_el.append(metadata_el)
|
|
608
|
+
ET.register_namespace("met", META_NS)
|
|
609
|
+
ET.register_namespace("xsi", XSI_NS)
|
|
610
|
+
body = ET.tostring(update_el, encoding="unicode")
|
|
611
|
+
root = post_soap(metadata_url, envelope(session_id, body), "updateMetadata")
|
|
612
|
+
fault = find_fault(root)
|
|
613
|
+
if fault:
|
|
614
|
+
fail(f"updateMetadata failed: {fault}")
|
|
615
|
+
result = root.find(f".//{qn('result')}")
|
|
616
|
+
success_el = result.find(qn("success")) if result is not None else None
|
|
617
|
+
if success_el is None or success_el.text != "true":
|
|
618
|
+
msgs = []
|
|
619
|
+
if result is not None:
|
|
620
|
+
for err in result.findall(qn("errors")):
|
|
621
|
+
m = err.find(qn("message"))
|
|
622
|
+
msgs.append(m.text if m is not None else
|
|
623
|
+
ET.tostring(err, encoding="unicode"))
|
|
624
|
+
fail("updateMetadata reported failure: " +
|
|
625
|
+
("; ".join(msgs) if msgs else "(no error detail returned)"))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
# XML value helpers
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def elem_to_value(elem):
|
|
633
|
+
children = list(elem)
|
|
634
|
+
if not children:
|
|
635
|
+
text = (elem.text or "").strip()
|
|
636
|
+
if text.lower() == "true":
|
|
637
|
+
return True
|
|
638
|
+
if text.lower() == "false":
|
|
639
|
+
return False
|
|
640
|
+
return text
|
|
641
|
+
result = {}
|
|
642
|
+
for child in children:
|
|
643
|
+
tag = local_name(child.tag)
|
|
644
|
+
val = elem_to_value(child)
|
|
645
|
+
if tag in result:
|
|
646
|
+
if not isinstance(result[tag], list):
|
|
647
|
+
result[tag] = [result[tag]]
|
|
648
|
+
result[tag].append(val)
|
|
649
|
+
else:
|
|
650
|
+
result[tag] = val
|
|
651
|
+
return result
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def set_child_text(parent, tag, text):
|
|
655
|
+
"""Set (or create) a single child element's text on parent."""
|
|
656
|
+
el = parent.find(qn(tag))
|
|
657
|
+
if el is None:
|
|
658
|
+
el = ET.SubElement(parent, qn(tag))
|
|
659
|
+
el.text = str(text)
|
|
660
|
+
return el
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def build_address_element(parent, addr_dict):
|
|
664
|
+
"""Append one <routingAddresses> element built from a dict."""
|
|
665
|
+
ra = ET.SubElement(parent, qn("routingAddresses"))
|
|
666
|
+
for key, val in addr_dict.items():
|
|
667
|
+
if key in READONLY_ADDRESS_FIELDS:
|
|
668
|
+
continue # never write platform-managed fields
|
|
669
|
+
child = ET.SubElement(ra, qn(key))
|
|
670
|
+
child.text = "true" if val is True else ("false" if val is False
|
|
671
|
+
else str(val))
|
|
672
|
+
return ra
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
# Parse the input CaseSettings artifact
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
def parse_input(path):
|
|
680
|
+
try:
|
|
681
|
+
tree = ET.parse(path)
|
|
682
|
+
except (ET.ParseError, OSError) as exc:
|
|
683
|
+
fail(f"Could not read input file '{path}': {exc}")
|
|
684
|
+
root = tree.getroot()
|
|
685
|
+
if local_name(root.tag) != "CaseSettings":
|
|
686
|
+
fail(f"Input root must be CaseSettings, found '{local_name(root.tag)}'.")
|
|
687
|
+
return elem_to_value(root)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
# Main flow
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
def existing_address_keys(records_value):
|
|
695
|
+
"""Return (routingNames, emailAddresses) already present, to avoid duplicates."""
|
|
696
|
+
e2c = records_value.get("emailToCase") or {}
|
|
697
|
+
addrs = e2c.get("routingAddresses")
|
|
698
|
+
if addrs is None:
|
|
699
|
+
return set(), set()
|
|
700
|
+
if isinstance(addrs, dict):
|
|
701
|
+
addrs = [addrs]
|
|
702
|
+
names = {a.get("routingName") for a in addrs if a.get("routingName")}
|
|
703
|
+
emails = {a.get("emailAddress") for a in addrs if a.get("emailAddress")}
|
|
704
|
+
return names, emails
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def support_settings_state(records_value):
|
|
708
|
+
"""Report whether Default Case Owner and Automated Case User are already
|
|
709
|
+
configured in the org (from a readMetadata). The platform returns these
|
|
710
|
+
fields null/absent when an admin has not set them, so null is a reliable
|
|
711
|
+
"not configured" signal. Returns a dict with two booleans and the current
|
|
712
|
+
values (for the summary)."""
|
|
713
|
+
owner = records_value.get("defaultCaseOwner")
|
|
714
|
+
owner_type = records_value.get("defaultCaseOwnerType")
|
|
715
|
+
auto_user = records_value.get("defaultCaseUser")
|
|
716
|
+
use_system = records_value.get("useSystemUserAsDefaultCaseUser")
|
|
717
|
+
return {
|
|
718
|
+
"ownerConfigured": bool(owner),
|
|
719
|
+
"automatedUserConfigured": bool(auto_user) or use_system is True,
|
|
720
|
+
"current": {
|
|
721
|
+
"defaultCaseOwner": owner,
|
|
722
|
+
"defaultCaseOwnerType": owner_type,
|
|
723
|
+
"defaultCaseUser": auto_user,
|
|
724
|
+
"useSystemUserAsDefaultCaseUser": use_system,
|
|
725
|
+
},
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _clear_automated_user_fields(records):
|
|
730
|
+
"""Remove any existing Automated Case User children (System and named-User
|
|
731
|
+
are mutually exclusive at the platform layer, so we clear before writing)."""
|
|
732
|
+
for f in ("defaultCaseUser", "useSystemUserAsDefaultCaseUser",
|
|
733
|
+
"systemUserEmail"):
|
|
734
|
+
existing = records.find(qn(f))
|
|
735
|
+
if existing is not None:
|
|
736
|
+
records.remove(existing)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _strip_unwritten_support_fields(records, applied):
|
|
740
|
+
"""Remove any Support-Settings top-level field this run did NOT write, so it
|
|
741
|
+
is omitted from the payload and preserved via field-level merge instead of
|
|
742
|
+
resent verbatim.
|
|
743
|
+
|
|
744
|
+
The five SUPPORT_SETTINGS_FIELDS are in KEEP_TOP_LEVEL_FIELDS so a field we
|
|
745
|
+
DO set survives update_case_settings' strip. The side effect is that a field
|
|
746
|
+
left as-read (preserved, not written) would otherwise be RESENT on the write,
|
|
747
|
+
forcing the platform to re-validate a value nobody asked to touch — and a
|
|
748
|
+
platform-derived `systemUserEmail` resent this way can collide with the
|
|
749
|
+
routing address ("Enter an email address for the system user that's not an
|
|
750
|
+
Email-to-Case routing email address"). Stripping the fields absent from
|
|
751
|
+
`applied` applies the same omit-to-preserve pattern used for every other
|
|
752
|
+
untouched top-level field.
|
|
753
|
+
|
|
754
|
+
Scope: touches ONLY the five SUPPORT_SETTINGS_FIELDS. `emailToCase` (and its
|
|
755
|
+
`routingAddresses`) is never a support field, so routing addresses are
|
|
756
|
+
unaffected."""
|
|
757
|
+
for field in SUPPORT_SETTINGS_FIELDS:
|
|
758
|
+
if field in applied:
|
|
759
|
+
continue
|
|
760
|
+
el = records.find(qn(field))
|
|
761
|
+
if el is not None:
|
|
762
|
+
records.remove(el)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def apply_support_settings(records, args, desired, session_id, instance_url,
|
|
766
|
+
auth_username, state, summary):
|
|
767
|
+
"""Set Default Case Owner and Automated Case User on `records` in Phase A.
|
|
768
|
+
|
|
769
|
+
Default Case Owner and Automated Case User are INDEPENDENT fields — an org
|
|
770
|
+
can have one configured and the other not — so each is preserved or written
|
|
771
|
+
on its own:
|
|
772
|
+
* A field already configured in the org is PRESERVED (left untouched in
|
|
773
|
+
`records`, so it keeps its value via field-level merge) unless
|
|
774
|
+
--overwrite-support-settings is given AND the caller supplied new input
|
|
775
|
+
for THAT field. The overwrite flag is scoped per field: overwriting one
|
|
776
|
+
configured field never forces a rewrite of the other, unrelated field.
|
|
777
|
+
A field the caller supplies for an already-configured setting is ignored
|
|
778
|
+
(preserved) without that flag.
|
|
779
|
+
* A field that is NOT configured must be supplied (via flags, the input
|
|
780
|
+
file, or --use-authenticated-user); values are validated against the org
|
|
781
|
+
and fail closed with an actionable message. Input is required ONLY for
|
|
782
|
+
the field(s) actually being written.
|
|
783
|
+
* The authenticated user is used ONLY with --use-authenticated-user, and
|
|
784
|
+
only for the field(s) being written.
|
|
785
|
+
* Never assume/guess an owner or automated user.
|
|
786
|
+
"""
|
|
787
|
+
ss = {"ownerConfigured": state["ownerConfigured"],
|
|
788
|
+
"automatedUserConfigured": state["automatedUserConfigured"],
|
|
789
|
+
"current": state["current"]}
|
|
790
|
+
summary["supportSettings"] = ss
|
|
791
|
+
|
|
792
|
+
overwrite = args.overwrite_support_settings
|
|
793
|
+
# Detect, per field, whether the caller actually supplied new input for it.
|
|
794
|
+
# --overwrite-support-settings scopes to the field(s) the caller is changing;
|
|
795
|
+
# it must never force a rewrite of an unrelated configured field the caller
|
|
796
|
+
# did not touch. --use-authenticated-user supplies input for BOTH fields (it
|
|
797
|
+
# sets the authenticated user as owner and automated user by design).
|
|
798
|
+
owner_input = bool(
|
|
799
|
+
args.owner_type or args.owner_value
|
|
800
|
+
or desired.get("defaultCaseOwner") or desired.get("defaultCaseOwnerType"))
|
|
801
|
+
automated_input = bool(
|
|
802
|
+
args.automated_type or args.automated_value or args.system_user_email
|
|
803
|
+
or desired.get("defaultCaseUser") or desired.get("systemUserEmail")
|
|
804
|
+
or desired.get("useSystemUserAsDefaultCaseUser") is True)
|
|
805
|
+
if args.use_authenticated_user:
|
|
806
|
+
owner_input = True
|
|
807
|
+
automated_input = True
|
|
808
|
+
|
|
809
|
+
# Decide per field: write it if it is unset (input then required, fails
|
|
810
|
+
# closed), or if the caller opted to overwrite AND supplied input for THAT
|
|
811
|
+
# field. A configured field the caller did not touch is preserved untouched,
|
|
812
|
+
# even when --overwrite-support-settings is set for the other field.
|
|
813
|
+
write_owner = (not state["ownerConfigured"]) or (overwrite and owner_input)
|
|
814
|
+
write_automated = ((not state["automatedUserConfigured"])
|
|
815
|
+
or (overwrite and automated_input))
|
|
816
|
+
|
|
817
|
+
if not write_owner and not write_automated:
|
|
818
|
+
ss["action"] = "preserved-existing"
|
|
819
|
+
ss["ownerAction"] = "preserved"
|
|
820
|
+
ss["automatedUserAction"] = "preserved"
|
|
821
|
+
# Nothing written: omit all Support-Settings fields so they merge-preserve.
|
|
822
|
+
_strip_unwritten_support_fields(records, {})
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
# Resolve the authenticated user once if opted in; used only for fields
|
|
826
|
+
# being written.
|
|
827
|
+
auth_resolved = None
|
|
828
|
+
if args.use_authenticated_user:
|
|
829
|
+
auth_resolved = query_active_user(session_id, instance_url,
|
|
830
|
+
auth_username)
|
|
831
|
+
if not auth_resolved:
|
|
832
|
+
fail(f"The authenticated user '{auth_username}' did not resolve as "
|
|
833
|
+
f"an active user (unexpected).")
|
|
834
|
+
|
|
835
|
+
applied = {}
|
|
836
|
+
|
|
837
|
+
# ---- Default Case Owner ----
|
|
838
|
+
if not write_owner:
|
|
839
|
+
ss["ownerAction"] = "preserved"
|
|
840
|
+
elif auth_resolved is not None:
|
|
841
|
+
set_child_text(records, "defaultCaseOwner", auth_resolved)
|
|
842
|
+
set_child_text(records, "defaultCaseOwnerType", "User")
|
|
843
|
+
applied["defaultCaseOwner"] = auth_resolved
|
|
844
|
+
applied["defaultCaseOwnerType"] = "User"
|
|
845
|
+
ss["ownerAction"] = "set-from-authenticated-user"
|
|
846
|
+
else:
|
|
847
|
+
owner_type = args.owner_type or desired.get("defaultCaseOwnerType")
|
|
848
|
+
owner_value = args.owner_value or desired.get("defaultCaseOwner")
|
|
849
|
+
if not (owner_type or owner_value):
|
|
850
|
+
fail("Default Case Owner is not configured in this org and none was "
|
|
851
|
+
"provided. Ask the user for the Default Case Owner type (User "
|
|
852
|
+
"or Queue) and value, then pass --owner-type/--owner-value (or "
|
|
853
|
+
"--use-authenticated-user if the user asked to use the "
|
|
854
|
+
"authenticated user).")
|
|
855
|
+
resolved_owner, resolved_owner_type = resolve_owner(
|
|
856
|
+
session_id, instance_url, owner_value, owner_type)
|
|
857
|
+
set_child_text(records, "defaultCaseOwner", resolved_owner)
|
|
858
|
+
set_child_text(records, "defaultCaseOwnerType", resolved_owner_type)
|
|
859
|
+
applied["defaultCaseOwner"] = resolved_owner
|
|
860
|
+
applied["defaultCaseOwnerType"] = resolved_owner_type
|
|
861
|
+
ss["ownerAction"] = ("overwrote-existing" if state["ownerConfigured"]
|
|
862
|
+
else "set-from-input")
|
|
863
|
+
|
|
864
|
+
# ---- Automated Case User ----
|
|
865
|
+
if not write_automated:
|
|
866
|
+
ss["automatedUserAction"] = "preserved"
|
|
867
|
+
elif auth_resolved is not None:
|
|
868
|
+
_clear_automated_user_fields(records)
|
|
869
|
+
set_child_text(records, "defaultCaseUser", auth_resolved)
|
|
870
|
+
set_child_text(records, "useSystemUserAsDefaultCaseUser", "false")
|
|
871
|
+
applied["defaultCaseUser"] = auth_resolved
|
|
872
|
+
applied["useSystemUserAsDefaultCaseUser"] = False
|
|
873
|
+
ss["automatedUserAction"] = "set-from-authenticated-user"
|
|
874
|
+
else:
|
|
875
|
+
automated_type = args.automated_type
|
|
876
|
+
automated_value = args.automated_value or desired.get("defaultCaseUser")
|
|
877
|
+
system_email = args.system_user_email or desired.get("systemUserEmail")
|
|
878
|
+
# Infer automated type from the input file if not given via flag.
|
|
879
|
+
if automated_type is None:
|
|
880
|
+
if desired.get("useSystemUserAsDefaultCaseUser") is True:
|
|
881
|
+
automated_type = "System"
|
|
882
|
+
elif desired.get("defaultCaseUser"):
|
|
883
|
+
automated_type = "User"
|
|
884
|
+
if automated_type is None:
|
|
885
|
+
fail("Automated Case User is not configured in this org and none "
|
|
886
|
+
"was provided. Ask the user whether it should be a specific "
|
|
887
|
+
"User (and the username) or System, then pass --automated-type "
|
|
888
|
+
"User --automated-value <username> or --automated-type System "
|
|
889
|
+
"(or --use-authenticated-user).")
|
|
890
|
+
auto_fields, auto_kind = resolve_automated_user(
|
|
891
|
+
session_id, instance_url, automated_type, automated_value,
|
|
892
|
+
system_email)
|
|
893
|
+
_clear_automated_user_fields(records)
|
|
894
|
+
for f, v in auto_fields.items():
|
|
895
|
+
text = "true" if v is True else ("false" if v is False else str(v))
|
|
896
|
+
set_child_text(records, f, text)
|
|
897
|
+
applied[f] = v
|
|
898
|
+
ss["automatedUserKind"] = auto_kind
|
|
899
|
+
ss["automatedUserAction"] = (
|
|
900
|
+
"overwrote-existing" if state["automatedUserConfigured"]
|
|
901
|
+
else "set-from-input")
|
|
902
|
+
|
|
903
|
+
# Roll the two per-field actions up into a single backward-compatible
|
|
904
|
+
# `action` for the summary.
|
|
905
|
+
actions = {ss.get("ownerAction"), ss.get("automatedUserAction")}
|
|
906
|
+
if actions <= {"preserved"}:
|
|
907
|
+
ss["action"] = "preserved-existing"
|
|
908
|
+
elif "overwrote-existing" in actions:
|
|
909
|
+
ss["action"] = "overwrote-existing"
|
|
910
|
+
elif actions <= {"set-from-authenticated-user", "preserved"}:
|
|
911
|
+
ss["action"] = "set-from-authenticated-user"
|
|
912
|
+
else:
|
|
913
|
+
ss["action"] = "set-from-input"
|
|
914
|
+
|
|
915
|
+
if applied:
|
|
916
|
+
ss["applied"] = applied
|
|
917
|
+
summary["phaseA"].update(applied)
|
|
918
|
+
|
|
919
|
+
# Any Support-Settings field NOT written this run must be omitted from the
|
|
920
|
+
# payload (preserved via field-level merge) rather than resent verbatim.
|
|
921
|
+
_strip_unwritten_support_fields(records, applied)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def enforce_production_gate(session_id, instance_url, confirm_production,
|
|
925
|
+
summary):
|
|
926
|
+
"""Fail closed on a PRODUCTION org unless --confirm-production is passed.
|
|
927
|
+
|
|
928
|
+
Email-to-Case is a permanent, org-wide change (enableEmailToCase cannot be
|
|
929
|
+
turned back off), so before mutating a production org we require an explicit
|
|
930
|
+
opt-in. Sandboxes, scratch orgs, and trials deploy without confirmation.
|
|
931
|
+
Records what it found in `summary['org']` so the caller sees the verdict."""
|
|
932
|
+
is_sandbox, org_type, is_trial = query_org_info(session_id, instance_url)
|
|
933
|
+
# A production org is a non-sandbox, non-trial org. If we can't read the
|
|
934
|
+
# Organization row (is_sandbox is None), treat it as production and require
|
|
935
|
+
# confirmation — fail closed rather than silently mutating an unknown org.
|
|
936
|
+
is_production = (is_sandbox is False and not is_trial)
|
|
937
|
+
unknown = is_sandbox is None
|
|
938
|
+
summary["orgInfo"] = {"isSandbox": is_sandbox, "organizationType": org_type,
|
|
939
|
+
"isTrial": is_trial, "isProduction": is_production}
|
|
940
|
+
if (is_production or unknown) and not confirm_production:
|
|
941
|
+
detail = (f"organizationType={org_type}, isSandbox={is_sandbox}, "
|
|
942
|
+
f"isTrial={is_trial}")
|
|
943
|
+
if unknown:
|
|
944
|
+
fail("Could not determine whether this is a production org (the "
|
|
945
|
+
"Organization row was not readable). Refusing to mutate an "
|
|
946
|
+
"org of unknown type. If you are certain this is safe, re-run "
|
|
947
|
+
"with --confirm-production. "
|
|
948
|
+
"Enabling Email-to-Case is a permanent org-wide change.")
|
|
949
|
+
fail(f"Target org appears to be PRODUCTION ({detail}). Enabling "
|
|
950
|
+
"Email-to-Case is a permanent, org-wide change. Confirm with the "
|
|
951
|
+
"user that they want to configure Email-to-Case on this "
|
|
952
|
+
"production org, then re-run with --confirm-production. Sandboxes "
|
|
953
|
+
"and trials do not require this flag.")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def verify_cases(session_id, instance_url, supplied_email):
|
|
957
|
+
"""Prove inbound email created Cases: query Case (Origin='Email', optionally
|
|
958
|
+
a SuppliedEmail, within the last 3 days) and the linked incoming
|
|
959
|
+
EmailMessage rows. Prints a JSON evidence summary and exits non-zero when no
|
|
960
|
+
matching Case (or no linked incoming email) is found — a deploy landing is
|
|
961
|
+
not proof; a Case with its incoming EmailMessage is."""
|
|
962
|
+
cases = query_email_cases(session_id, instance_url, supplied_email)
|
|
963
|
+
case_ids = [c["Id"] for c in cases if c.get("Id")]
|
|
964
|
+
messages = query_incoming_email_messages(session_id, instance_url, case_ids)
|
|
965
|
+
parent_ids_with_msg = {m.get("ParentId") for m in messages}
|
|
966
|
+
result = {
|
|
967
|
+
"mode": "verify-cases",
|
|
968
|
+
"org": instance_url,
|
|
969
|
+
"filter": {"suppliedEmail": supplied_email, "window": "LAST_N_DAYS:3"},
|
|
970
|
+
"caseCount": len(cases),
|
|
971
|
+
"cases": [
|
|
972
|
+
{"caseNumber": c.get("CaseNumber"), "id": c.get("Id"),
|
|
973
|
+
"origin": c.get("Origin"), "suppliedEmail": c.get("SuppliedEmail"),
|
|
974
|
+
"subject": c.get("Subject"), "status": c.get("Status"),
|
|
975
|
+
"createdDate": c.get("CreatedDate"),
|
|
976
|
+
"hasIncomingEmailMessage": c.get("Id") in parent_ids_with_msg}
|
|
977
|
+
for c in cases],
|
|
978
|
+
"incomingEmailMessageCount": len(messages),
|
|
979
|
+
}
|
|
980
|
+
# Proof requires at least one Email-origin Case that has a linked incoming
|
|
981
|
+
# EmailMessage. A Case with no incoming message (or no Case at all) is not
|
|
982
|
+
# proof that the round-trip worked.
|
|
983
|
+
proven = any(c.get("Id") in parent_ids_with_msg for c in cases)
|
|
984
|
+
result["proven"] = proven
|
|
985
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
986
|
+
if not proven:
|
|
987
|
+
if not cases:
|
|
988
|
+
fail("No Cases with Origin='Email' in the last 3 days matched"
|
|
989
|
+
+ (f" SuppliedEmail={supplied_email}" if supplied_email else "")
|
|
990
|
+
+ ". The inbound email may not have arrived yet, the routing "
|
|
991
|
+
"address may not be verified, the test email may be older than "
|
|
992
|
+
"the 3-day window, or the sender differs. "
|
|
993
|
+
"Confirm the user actually sent the test email, wait a moment, "
|
|
994
|
+
"and re-run --verify-cases.")
|
|
995
|
+
fail("Found Email-origin Case(s) but none has a linked incoming "
|
|
996
|
+
"EmailMessage (Incoming=true). The Case may have been created "
|
|
997
|
+
"another way. Re-run once the inbound email has been processed.")
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def main():
|
|
1001
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
1002
|
+
ap.add_argument("--target-org", required=True,
|
|
1003
|
+
help="sf CLI alias/username of the target org")
|
|
1004
|
+
ap.add_argument("--input", default=None,
|
|
1005
|
+
help="Path to the CaseSettings source file to apply. "
|
|
1006
|
+
"Required for the write path; not needed for "
|
|
1007
|
+
"--verify-cases (a read-only proof step).")
|
|
1008
|
+
ap.add_argument("--routing-email", action="append", default=[],
|
|
1009
|
+
metavar="EMAIL",
|
|
1010
|
+
help="Customer-facing email for a routing address. Repeat "
|
|
1011
|
+
"once per routing address, in the order they appear "
|
|
1012
|
+
"in --input. Required if --input declares any "
|
|
1013
|
+
"routing addresses.")
|
|
1014
|
+
# --- Default Case Owner (User or Queue) ---
|
|
1015
|
+
ap.add_argument("--owner-type", choices=sorted(VALID_OWNER_TYPES),
|
|
1016
|
+
default=None,
|
|
1017
|
+
help="Default Case Owner type: User or Queue. Required when "
|
|
1018
|
+
"support settings are not yet configured (unless "
|
|
1019
|
+
"--use-authenticated-user). Falls back to the input "
|
|
1020
|
+
"file's defaultCaseOwnerType.")
|
|
1021
|
+
ap.add_argument("--owner-value", default=None,
|
|
1022
|
+
help="Default Case Owner value: an active Username (for "
|
|
1023
|
+
"--owner-type User) or a Queue DeveloperName (for "
|
|
1024
|
+
"Queue). Validated against the org; the script fails "
|
|
1025
|
+
"if it is not a real active user / queue.")
|
|
1026
|
+
# --- Automated Case User (User or System) ---
|
|
1027
|
+
ap.add_argument("--automated-type", choices=sorted(VALID_AUTOMATED_TYPES),
|
|
1028
|
+
default=None,
|
|
1029
|
+
help="Automated Case User type: User or System. System "
|
|
1030
|
+
"uses the org's automated process user (no value "
|
|
1031
|
+
"needed). Required when support settings are not yet "
|
|
1032
|
+
"configured (unless --use-authenticated-user).")
|
|
1033
|
+
ap.add_argument("--automated-value", default=None,
|
|
1034
|
+
help="Automated Case User username (for --automated-type "
|
|
1035
|
+
"User). Not needed for System. Validated against the "
|
|
1036
|
+
"org.")
|
|
1037
|
+
ap.add_argument("--system-user-email", default=None,
|
|
1038
|
+
help="Email for the automated process user, used with "
|
|
1039
|
+
"--automated-type System when the org's automated "
|
|
1040
|
+
"case user does not exist yet.")
|
|
1041
|
+
# --- Explicit opt-ins ---
|
|
1042
|
+
ap.add_argument("--use-authenticated-user", action="store_true",
|
|
1043
|
+
help="Only when the user running the skill explicitly asks: "
|
|
1044
|
+
"use the authenticated CLI user as both Default Case "
|
|
1045
|
+
"Owner (User) and Automated Case User. Never assumed.")
|
|
1046
|
+
ap.add_argument("--overwrite-support-settings", action="store_true",
|
|
1047
|
+
help="Overwrite an already-configured Default Case Owner or "
|
|
1048
|
+
"Automated Case User. Scoped per field: only the "
|
|
1049
|
+
"field(s) you also supply new input for are rewritten; "
|
|
1050
|
+
"a configured field you don't touch stays preserved. "
|
|
1051
|
+
"Without this, existing support settings are preserved.")
|
|
1052
|
+
ap.add_argument("--verify-only", action="store_true",
|
|
1053
|
+
help="Read and print current settings without writing")
|
|
1054
|
+
# --- Production-org safety gate ---
|
|
1055
|
+
ap.add_argument("--confirm-production", action="store_true",
|
|
1056
|
+
help="Confirm you intend to configure Email-to-Case on a "
|
|
1057
|
+
"PRODUCTION org. Enabling Email-to-Case is permanent "
|
|
1058
|
+
"and org-wide; without this flag the script refuses to "
|
|
1059
|
+
"mutate a production org. Sandboxes and trials do not "
|
|
1060
|
+
"need it.")
|
|
1061
|
+
# --- Act 3: prove inbound email created Cases (read-only) ---
|
|
1062
|
+
ap.add_argument("--verify-cases", action="store_true",
|
|
1063
|
+
help="Read-only proof step: after the user has verified the "
|
|
1064
|
+
"routing address and sent a test email, query the org "
|
|
1065
|
+
"for Cases with Origin='Email' and their linked "
|
|
1066
|
+
"incoming EmailMessage rows, and report the evidence. "
|
|
1067
|
+
"Exits non-zero if no such Case is found.")
|
|
1068
|
+
ap.add_argument("--supplied-email", default=None, metavar="EMAIL",
|
|
1069
|
+
help="With --verify-cases: narrow the Case query to this "
|
|
1070
|
+
"sender address (Case.SuppliedEmail), e.g. the mailbox "
|
|
1071
|
+
"the user sent the test email from.")
|
|
1072
|
+
ap.add_argument("--api-version", default=None,
|
|
1073
|
+
metavar="X.Y",
|
|
1074
|
+
help=f"Explicit Metadata API version override. When omitted, "
|
|
1075
|
+
f"the version is derived from the org and floored at "
|
|
1076
|
+
f"{DEFAULT_API_VERSION}. Set 68.0+ explicitly only to "
|
|
1077
|
+
f"force a version (a botEmailDefinition binding needs "
|
|
1078
|
+
f"v68.0+, but a v68+ org is picked up automatically).")
|
|
1079
|
+
args = ap.parse_args()
|
|
1080
|
+
|
|
1081
|
+
global API_VERSION, _API_VERSION_EXPLICIT
|
|
1082
|
+
_API_VERSION_EXPLICIT = bool(args.api_version)
|
|
1083
|
+
if _API_VERSION_EXPLICIT:
|
|
1084
|
+
API_VERSION = args.api_version
|
|
1085
|
+
# else: get_session derives it from the org, floored at DEFAULT_API_VERSION.
|
|
1086
|
+
|
|
1087
|
+
# --verify-cases is a standalone read-only proof step; it needs a session but
|
|
1088
|
+
# neither the input file nor a write. Handle it before parsing the input so
|
|
1089
|
+
# it works even without a source file on hand.
|
|
1090
|
+
if args.verify_cases:
|
|
1091
|
+
session_id, _metadata_url, instance_url, _auth = get_session(
|
|
1092
|
+
args.target_org)
|
|
1093
|
+
verify_cases(session_id, instance_url, args.supplied_email)
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
if not args.input:
|
|
1097
|
+
fail("--input <CaseSettings source file> is required to apply or verify "
|
|
1098
|
+
"settings. (It is only optional for the read-only --verify-cases "
|
|
1099
|
+
"proof step.)")
|
|
1100
|
+
|
|
1101
|
+
desired = parse_input(args.input)
|
|
1102
|
+
session_id, metadata_url, instance_url, auth_username = get_session(
|
|
1103
|
+
args.target_org)
|
|
1104
|
+
|
|
1105
|
+
if args.verify_only:
|
|
1106
|
+
records = read_case_settings(session_id, metadata_url)
|
|
1107
|
+
print(json.dumps(elem_to_value(records), indent=2, sort_keys=True))
|
|
1108
|
+
return
|
|
1109
|
+
|
|
1110
|
+
# Production-org safety gate: refuse to mutate a production org unless the
|
|
1111
|
+
# operator confirmed. Runs only on the write path (after --verify-only /
|
|
1112
|
+
# --verify-cases, which are read-only). summary is created below; capture the
|
|
1113
|
+
# verdict into a temporary and fold it in.
|
|
1114
|
+
_gate_summary = {}
|
|
1115
|
+
enforce_production_gate(session_id, instance_url, args.confirm_production,
|
|
1116
|
+
_gate_summary)
|
|
1117
|
+
|
|
1118
|
+
desired_e2c = desired.get("emailToCase") or {}
|
|
1119
|
+
desired_addrs = desired_e2c.get("routingAddresses")
|
|
1120
|
+
if desired_addrs and isinstance(desired_addrs, dict):
|
|
1121
|
+
desired_addrs = [desired_addrs]
|
|
1122
|
+
|
|
1123
|
+
# Routing emails must be supplied explicitly, one per declared address,
|
|
1124
|
+
# so no placeholder from the input file ever reaches the org.
|
|
1125
|
+
if desired_addrs:
|
|
1126
|
+
if len(args.routing_email) != len(desired_addrs):
|
|
1127
|
+
fail(f"--input declares {len(desired_addrs)} routing address(es) "
|
|
1128
|
+
f"but {len(args.routing_email)} --routing-email value(s) were "
|
|
1129
|
+
f"given. Provide exactly one --routing-email per address, in "
|
|
1130
|
+
f"order.")
|
|
1131
|
+
|
|
1132
|
+
summary = {"org": instance_url, "phaseA": {}, "phaseB": [],
|
|
1133
|
+
"supportSettings": {}}
|
|
1134
|
+
summary["orgInfo"] = _gate_summary.get("orgInfo")
|
|
1135
|
+
summary["apiVersion"] = API_VERSION
|
|
1136
|
+
|
|
1137
|
+
# ---- Phase A: prerequisites + emailToCase toggles (NOT addresses) ----
|
|
1138
|
+
# Read the current CaseSettings record, merge the desired toggles / Support
|
|
1139
|
+
# Settings into it, and write it back. Writing the COMPLETE emailToCase block
|
|
1140
|
+
# is what actually provisions the org's On-Demand Email-to-Case email-service
|
|
1141
|
+
# infrastructure: a minimal field-level patch that just flips
|
|
1142
|
+
# enableOnDemandEmailToCase true does NOT trigger provisioning, so the routing
|
|
1143
|
+
# address in Phase B then has nothing to bind to ("We couldn't save your
|
|
1144
|
+
# routing address... custom email services named EmailToCase").
|
|
1145
|
+
#
|
|
1146
|
+
# No Chatter prerequisite: update_case_settings sends ONLY the top-level
|
|
1147
|
+
# fields this skill owns (KEEP_TOP_LEVEL_FIELDS) and strips the rest, so the
|
|
1148
|
+
# platform never re-validates unrelated top-level Case config (e.g. Case Feed,
|
|
1149
|
+
# whose re-validation would require Chatter). Stripped fields keep their
|
|
1150
|
+
# current org value via field-level merge. We still read the record first so
|
|
1151
|
+
# the emailToCase block we send carries the platform's existing children
|
|
1152
|
+
# (needed for provisioning) alongside our toggles.
|
|
1153
|
+
records = read_case_settings(session_id, metadata_url)
|
|
1154
|
+
initial_value = elem_to_value(records)
|
|
1155
|
+
state = support_settings_state(initial_value)
|
|
1156
|
+
# Count the addresses the org had BEFORE we touched anything. The
|
|
1157
|
+
# preservation guard baselines against this, not against the post-Phase-A
|
|
1158
|
+
# read: Phase A rewrites the whole emailToCase block, so a read-only field
|
|
1159
|
+
# surviving on a carried-over address would drop it THERE, and a guard that
|
|
1160
|
+
# re-reads after Phase A would see the already-shrunken collection and pass.
|
|
1161
|
+
initial_addr_count = _count_addresses(initial_value)
|
|
1162
|
+
|
|
1163
|
+
# Support Settings (Default Case Owner + Automated Case User): preserve what
|
|
1164
|
+
# the org already has unless the caller explicitly opts to overwrite. The
|
|
1165
|
+
# platform returns these fields null when an admin has not set them, so a
|
|
1166
|
+
# null read is a reliable "not configured" signal. Modifies `records` in place.
|
|
1167
|
+
apply_support_settings(records, args, desired, session_id, instance_url,
|
|
1168
|
+
auth_username, state, summary)
|
|
1169
|
+
|
|
1170
|
+
# Merge any other top-level scalar toggle from the input (e.g.
|
|
1171
|
+
# enableDraftEmails) into the record. Nested/list values are skipped here —
|
|
1172
|
+
# only emailToCase (its own block) and the owner fields above are structured;
|
|
1173
|
+
# everything else at the top level is a scalar toggle. (Fields not in
|
|
1174
|
+
# KEEP_TOP_LEVEL_FIELDS are stripped at write time regardless.)
|
|
1175
|
+
for key, val in desired.items():
|
|
1176
|
+
if key in HANDLED_TOP_LEVEL or isinstance(val, (dict, list)):
|
|
1177
|
+
continue
|
|
1178
|
+
text = "true" if val is True else ("false" if val is False else str(val))
|
|
1179
|
+
set_child_text(records, key, text)
|
|
1180
|
+
summary["phaseA"][key] = val
|
|
1181
|
+
|
|
1182
|
+
# Merge the emailToCase toggles (NOT routingAddresses) into the existing
|
|
1183
|
+
# emailToCase block read from the org.
|
|
1184
|
+
e2c_el = records.find(qn("emailToCase"))
|
|
1185
|
+
if e2c_el is None:
|
|
1186
|
+
e2c_el = ET.SubElement(records, qn("emailToCase"))
|
|
1187
|
+
for key, val in desired_e2c.items():
|
|
1188
|
+
if key == "routingAddresses":
|
|
1189
|
+
continue # phase B
|
|
1190
|
+
text = "true" if val is True else ("false" if val is False else str(val))
|
|
1191
|
+
set_child_text(e2c_el, key, text)
|
|
1192
|
+
summary["phaseA"][f"emailToCase.{key}"] = val
|
|
1193
|
+
|
|
1194
|
+
update_case_settings(session_id, metadata_url, records)
|
|
1195
|
+
|
|
1196
|
+
# ---- Phase B: routing addresses, one call after toggles are live ----
|
|
1197
|
+
if desired_addrs:
|
|
1198
|
+
# Substitute the user-supplied email into each address (positional). The
|
|
1199
|
+
# OPTIONAL per-address Default Case Owner is validated later, only for
|
|
1200
|
+
# addresses that are actually new (see the build loop) — validating a
|
|
1201
|
+
# duplicate that is skipped as already_exists would waste an org round-trip.
|
|
1202
|
+
for addr, email in zip(desired_addrs, args.routing_email):
|
|
1203
|
+
addr["emailAddress"] = email
|
|
1204
|
+
# Re-read the record (now that Phase A's toggles are live and the
|
|
1205
|
+
# email-service infrastructure is provisioned) and add the new
|
|
1206
|
+
# address(es) to the emailToCase block. update_case_settings again
|
|
1207
|
+
# strips top-level fields we don't own, so only emailToCase (+ the fields
|
|
1208
|
+
# we set) is written. The routingAddresses collection is REPLACED (not
|
|
1209
|
+
# field-merged) on update, so the write must carry the existing addresses
|
|
1210
|
+
# alongside the new ones.
|
|
1211
|
+
#
|
|
1212
|
+
# ORDERING IS LOAD-BEARING: when a
|
|
1213
|
+
# newly-added routingAddresses element is placed AFTER the existing,
|
|
1214
|
+
# already-provisioned ones, the platform DROPS an existing address. When
|
|
1215
|
+
# the new address(es) come FIRST and the existing ones LAST, every
|
|
1216
|
+
# address is preserved. This held across writes with the platform-managed
|
|
1217
|
+
# read-only fields (emailServicesAddress/isVerified) stripped, kept, and
|
|
1218
|
+
# kept — so ordering, not those fields, is the deciding factor. We
|
|
1219
|
+
# therefore build the new addresses first, then re-append the existing
|
|
1220
|
+
# ones after them.
|
|
1221
|
+
records = read_case_settings(session_id, metadata_url)
|
|
1222
|
+
existing_names, existing_emails = existing_address_keys(
|
|
1223
|
+
elem_to_value(records))
|
|
1224
|
+
e2c_el = records.find(qn("emailToCase"))
|
|
1225
|
+
if e2c_el is None:
|
|
1226
|
+
e2c_el = ET.SubElement(records, qn("emailToCase"))
|
|
1227
|
+
# Detach the existing address elements so the new ones can be inserted
|
|
1228
|
+
# ahead of them; they are re-appended (unchanged) afterwards.
|
|
1229
|
+
existing_ra_elements = e2c_el.findall(qn("routingAddresses"))
|
|
1230
|
+
for ra in existing_ra_elements:
|
|
1231
|
+
e2c_el.remove(ra)
|
|
1232
|
+
# Build the new addresses FIRST (skip any that already exist by
|
|
1233
|
+
# name/email — those stay only as carried-over existing elements).
|
|
1234
|
+
added = []
|
|
1235
|
+
for i, addr in enumerate(desired_addrs, 1):
|
|
1236
|
+
name = addr.get("routingName")
|
|
1237
|
+
email = addr.get("emailAddress")
|
|
1238
|
+
if name in existing_names or (email and email in existing_emails):
|
|
1239
|
+
summary["phaseB"].append({"routingName": name,
|
|
1240
|
+
"emailAddress": email,
|
|
1241
|
+
"status": "already_exists"})
|
|
1242
|
+
continue
|
|
1243
|
+
# Validate the OPTIONAL per-address Default Case Owner against the org
|
|
1244
|
+
# now that we know this address is genuinely new.
|
|
1245
|
+
resolve_address_case_owner(session_id, instance_url, addr,
|
|
1246
|
+
f"routingAddresses[{i}]")
|
|
1247
|
+
build_address_element(e2c_el, addr)
|
|
1248
|
+
added.append(addr)
|
|
1249
|
+
summary["phaseB"].append({"routingName": name,
|
|
1250
|
+
"emailAddress": email,
|
|
1251
|
+
"status": "created"})
|
|
1252
|
+
# Re-append the existing addresses AFTER the new ones (see ORDERING note).
|
|
1253
|
+
for ra in existing_ra_elements:
|
|
1254
|
+
e2c_el.append(ra)
|
|
1255
|
+
if added:
|
|
1256
|
+
update_case_settings(session_id, metadata_url, records)
|
|
1257
|
+
|
|
1258
|
+
# ---- Verify ----
|
|
1259
|
+
after = elem_to_value(read_case_settings(session_id, metadata_url))
|
|
1260
|
+
after_e2c = after.get("emailToCase") or {}
|
|
1261
|
+
|
|
1262
|
+
# Preservation guard (runtime safety net): every address the org had before
|
|
1263
|
+
# this run, plus every address we newly created, must still be present. A
|
|
1264
|
+
# shortfall means an updateMetadata call REPLACED the routingAddresses
|
|
1265
|
+
# collection and dropped an existing address. Baseline against
|
|
1266
|
+
# initial_addr_count — the count from the very first read, before Phase A
|
|
1267
|
+
# touched the block — so this catches a drop in Phase A as well as Phase B.
|
|
1268
|
+
# Phase B orders new addresses before existing ones to prevent the known
|
|
1269
|
+
# ordering-driven drop; this guard backstops any residual/unknown drop.
|
|
1270
|
+
created_count = sum(1 for entry in summary["phaseB"]
|
|
1271
|
+
if entry.get("status") == "created")
|
|
1272
|
+
expected_count = initial_addr_count + created_count
|
|
1273
|
+
actual_count = _count_addresses(after)
|
|
1274
|
+
if actual_count < expected_count:
|
|
1275
|
+
fail(
|
|
1276
|
+
f"Routing-address write did not preserve existing addresses: "
|
|
1277
|
+
f"expected at least {expected_count} ({initial_addr_count} already "
|
|
1278
|
+
f"in the org + {created_count} newly created), but the org now has "
|
|
1279
|
+
f"{actual_count}. An updateMetadata call replaced the "
|
|
1280
|
+
f"routingAddresses collection and dropped an existing address. No "
|
|
1281
|
+
f"further changes were made; re-run after confirming with the skill "
|
|
1282
|
+
f"maintainer.")
|
|
1283
|
+
verified = {
|
|
1284
|
+
"enableEmailToCase": after_e2c.get("enableEmailToCase"),
|
|
1285
|
+
"enableOnDemandEmailToCase": after_e2c.get("enableOnDemandEmailToCase"),
|
|
1286
|
+
"routingAddressCount": _count_addresses(after),
|
|
1287
|
+
}
|
|
1288
|
+
# Echo back every emailToCase toggle and top-level toggle the input set, so
|
|
1289
|
+
# the caller can confirm each requested flag actually landed in the org.
|
|
1290
|
+
for key in desired_e2c:
|
|
1291
|
+
if key == "routingAddresses":
|
|
1292
|
+
continue
|
|
1293
|
+
verified[f"emailToCase.{key}"] = after_e2c.get(key)
|
|
1294
|
+
for key, val in desired.items():
|
|
1295
|
+
if key in HANDLED_TOP_LEVEL or isinstance(val, (dict, list)):
|
|
1296
|
+
continue
|
|
1297
|
+
verified[key] = after.get(key)
|
|
1298
|
+
# Echo the support-settings values now in the org so the caller can confirm
|
|
1299
|
+
# they were preserved (or set) as intended.
|
|
1300
|
+
for field in SUPPORT_SETTINGS_FIELDS:
|
|
1301
|
+
verified[field] = after.get(field)
|
|
1302
|
+
summary["verified"] = verified
|
|
1303
|
+
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _count_addresses(value):
|
|
1307
|
+
e2c = value.get("emailToCase") or {}
|
|
1308
|
+
addrs = e2c.get("routingAddresses")
|
|
1309
|
+
if addrs is None:
|
|
1310
|
+
return 0
|
|
1311
|
+
return len(addrs) if isinstance(addrs, list) else 1
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
if __name__ == "__main__":
|
|
1315
|
+
main()
|