@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.
Files changed (288) hide show
  1. package/package.json +2 -6
  2. package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +3 -0
  3. package/skills/automation-sandbox-post-copy-configure/SKILL.md +4 -0
  4. package/skills/design-systems-slds-validate/SKILL.md +2 -2
  5. package/skills/dx-app-analytics-query/SKILL.md +1 -3
  6. package/skills/dx-devops-conflict-resolve/SKILL.md +205 -0
  7. package/skills/dx-devops-conflict-resolve/examples/conflict-workflows.md +165 -0
  8. package/skills/dx-devops-conflict-resolve/references/deploy-failure-resolution.md +75 -0
  9. package/skills/dx-devops-conflict-resolve/references/git-conflict-resolution.md +124 -0
  10. package/skills/dx-devops-conflict-resolve/scripts/detect-conflicts.sh +91 -0
  11. package/skills/dx-devops-conflict-resolve/scripts/diagnose-deploy-failure.sh +156 -0
  12. package/skills/dx-devops-request-status/SKILL.md +160 -0
  13. package/skills/dx-devops-request-status/examples/polling-workflows.md +101 -0
  14. package/skills/dx-devops-request-status/references/cli-commands.md +176 -0
  15. package/skills/dx-devops-request-status/scripts/poll-status.sh +134 -0
  16. package/skills/dx-devops-test-failures-analyze/SKILL.md +6 -6
  17. package/skills/dx-devops-test-pipeline-configure/SKILL.md +7 -7
  18. package/skills/dx-devops-test-suite-assignments-configure/SKILL.md +6 -6
  19. package/skills/dx-devops-test-suite-run/SKILL.md +6 -6
  20. package/skills/dx-devops-work-item-manage/SKILL.md +2 -0
  21. package/skills/dx-org-manage/references/creating-scratch-org.md +5 -2
  22. package/skills/dx-org-manage/references/creating-snapshot.md +1 -0
  23. package/skills/dx-org-shape-manage/SKILL.md +152 -0
  24. package/skills/dx-org-shape-manage/examples/create_error_output.json +9 -0
  25. package/skills/dx-org-shape-manage/examples/create_success_output.json +9 -0
  26. package/skills/dx-org-shape-manage/examples/delete_output.json +11 -0
  27. package/skills/dx-org-shape-manage/examples/list_inactive_output.json +32 -0
  28. package/skills/dx-org-shape-manage/examples/list_output.json +24 -0
  29. package/skills/dx-org-shape-manage/references/cli_flags.md +135 -0
  30. package/skills/dx-org-switch/SKILL.md +2 -2
  31. package/skills/dx-org-trial-expiration-check/SKILL.md +2 -0
  32. package/skills/dx-pkg-post-install-configure/SKILL.md +3 -0
  33. package/skills/education-cloud-multi-campus-configure/SKILL.md +211 -0
  34. package/skills/education-cloud-multi-campus-configure/examples/hierarchy_visualization.md +174 -0
  35. package/skills/education-cloud-multi-campus-configure/examples/output_examples.md +54 -0
  36. package/skills/education-cloud-multi-campus-configure/examples/sample_hierarchy_input.csv +29 -0
  37. package/skills/education-cloud-multi-campus-configure/examples/sample_hierarchy_input_edgecases.csv +16 -0
  38. package/skills/education-cloud-multi-campus-configure/references/account_recordtype_prerequisite.md +44 -0
  39. package/skills/education-cloud-multi-campus-configure/references/delta_computation.md +33 -0
  40. package/skills/education-cloud-multi-campus-configure/references/error_handling.md +198 -0
  41. package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +54 -0
  42. package/skills/education-cloud-multi-campus-configure/references/gotchas.md +16 -0
  43. package/skills/education-cloud-multi-campus-configure/references/hierarchy_parsing_rules.md +133 -0
  44. package/skills/education-cloud-multi-campus-configure/references/mcp-invocation.md +75 -0
  45. package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/SKILL.md +1 -1
  46. package/skills/experience-cms-brand-apply/SKILL.md +4 -1
  47. package/skills/experience-cms-brand-create/SKILL.md +197 -0
  48. package/skills/experience-cms-brand-create/assets/brand-template.json +329 -0
  49. package/skills/experience-cms-brand-create/references/brand-anatomy.md +120 -0
  50. package/skills/experience-cms-brand-create/references/disk-contract.md +84 -0
  51. package/skills/experience-cms-content-generate/SKILL.md +190 -0
  52. package/skills/experience-cms-content-generate/assets/display-formats.md +84 -0
  53. package/skills/experience-cms-content-generate/assets/payloads/create-content-bulk.json +11 -0
  54. package/skills/experience-cms-content-generate/assets/payloads/create-content-single.json +11 -0
  55. package/skills/experience-cms-content-generate/assets/payloads/publish-content.json +4 -0
  56. package/skills/experience-cms-content-generate/assets/payloads/update-content.json +9 -0
  57. package/skills/experience-cms-content-generate/assets/questions.md +241 -0
  58. package/skills/experience-cms-content-generate/examples/create-content-call.md +81 -0
  59. package/skills/experience-cms-content-generate/references/bulk-batching.md +56 -0
  60. package/skills/experience-cms-content-generate/references/content-type-classification.md +88 -0
  61. package/skills/experience-cms-content-generate/references/content-write-tool.md +95 -0
  62. package/skills/experience-cms-content-generate/references/delegation-protocol.md +37 -0
  63. package/skills/experience-cms-content-generate/references/edit-publish-workflow.md +63 -0
  64. package/skills/experience-cms-content-generate/references/error-recovery.md +72 -0
  65. package/skills/experience-cms-content-generate/references/identifier-resolution.md +26 -0
  66. package/skills/experience-cms-content-generate/references/intent-routing.md +45 -0
  67. package/skills/experience-cms-content-generate/references/principles.md +19 -0
  68. package/skills/experience-cms-content-generate/references/ux-rules.md +68 -0
  69. package/skills/experience-cms-content-generate/references/workspace-resolution.md +33 -0
  70. package/skills/experience-cms-content-type-generate/SKILL.md +449 -0
  71. package/skills/experience-cms-content-type-generate/assets/discovery-prompts.md +79 -0
  72. package/skills/experience-cms-content-type-generate/assets/schema-example.json +70 -0
  73. package/skills/experience-cms-content-type-generate/references/agent-checklist.md +50 -0
  74. package/skills/experience-cms-content-type-generate/references/anti-patterns.md +39 -0
  75. package/skills/experience-cms-content-type-generate/references/deployment-errors.md +27 -0
  76. package/skills/experience-cms-content-type-generate/references/discovery-details.md +106 -0
  77. package/skills/experience-cms-content-type-generate/references/discovery-query-rules.md +85 -0
  78. package/skills/experience-cms-content-type-generate/references/edit-fields-loop.md +48 -0
  79. package/skills/experience-cms-content-type-generate/references/pre-deploy-checklist.md +21 -0
  80. package/skills/experience-cms-content-type-generate/references/retrieve-and-reconcile.md +105 -0
  81. package/skills/experience-cms-content-type-generate/references/schema-rules.md +51 -0
  82. package/skills/experience-cms-content-type-generate/references/schema-summary-format.md +60 -0
  83. package/skills/experience-lds-graphql-generate/SKILL.md +4 -0
  84. package/skills/experience-lwc-accessibility-jest-run/SKILL.md +79 -0
  85. package/skills/experience-lwc-accessibility-jest-run/references/running-sa11y-jest-tests.md +205 -0
  86. package/skills/experience-lwc-design-generate/SKILL.md +5 -4
  87. package/skills/experience-lwc-generate/SKILL.md +10 -9
  88. package/skills/experience-ui-bundle-deploy/references/dev-preview.md +21 -0
  89. package/skills/experience-ui-bundle-deploy/references/social-login.md +1 -0
  90. package/skills/experience-ui-bundle-file-upload-generate/SKILL.md +6 -6
  91. package/skills/experience-ui-bundle-localize/SKILL.md +30 -37
  92. package/skills/experience-ui-bundle-localize/references/gotchas.md +23 -7
  93. package/skills/experience-ui-bundle-localize/references/i18n-setup.md +48 -4
  94. package/skills/experience-ui-bundle-localize/references/label-xml.md +22 -8
  95. package/skills/experience-ui-bundle-localize/references/verifying.md +39 -11
  96. package/skills/experience-ui-bundle-localize/scripts/detect-bundle-type.sh +118 -15
  97. package/skills/experience-ui-bundle-localize/scripts/tests/test-detect-bundle-type.sh +187 -0
  98. package/skills/experience-ui-bundle-mfa-configure/SKILL.md +13 -2
  99. package/skills/experience-ui-bundle-mfa-configure/references/social-login.md +1 -0
  100. package/skills/experience-ui-bundle-project-generate/SKILL.md +2 -2
  101. package/skills/integration-connectivity-generate/SKILL.md +11 -10
  102. package/skills/integration-eventing-cdc-configure/SKILL.md +6 -6
  103. package/skills/integration-eventing-subscription-configure/SKILL.md +6 -5
  104. package/skills/mobile-platform-native-capabilities-integrate/SKILL.md +2 -2
  105. package/skills/mobile-platform-offline-validate/scripts/package.json +1 -1
  106. package/skills/platform-agentexchange-partner-offers-configure/SKILL.md +8 -8
  107. package/skills/platform-custom-application-generate/SKILL.md +2 -0
  108. package/skills/platform-custom-field-generate/SKILL.md +8 -6
  109. package/skills/platform-custom-metadata-type-generate/SKILL.md +460 -0
  110. package/skills/platform-custom-metadata-type-generate/references/cmdt-records.md +251 -0
  111. package/skills/platform-custom-metadata-type-generate/scripts/sanitize-developer-name.sh +46 -0
  112. package/skills/platform-custom-setting-generate/SKILL.md +419 -0
  113. package/skills/platform-dataspace-access-configure/SKILL.md +3 -3
  114. package/skills/platform-sharing-owd-configure/SKILL.md +3 -0
  115. package/skills/platform-value-set-generate/SKILL.md +2 -0
  116. package/skills/service-agentforce-channel-configure/SKILL.md +29 -20
  117. package/skills/service-agentforce-channel-configure/assets/BotEmailDefinition.botEmailDefinition-meta.xml +36 -0
  118. package/skills/service-agentforce-channel-configure/assets/email/unfiled$public/AgentforceForServiceEmailTemplate.email +17 -0
  119. package/skills/service-agentforce-channel-configure/assets/email/unfiled$public/AgentforceForServiceEmailTemplate.email-meta.xml +29 -0
  120. package/skills/service-agentforce-channel-configure/assets/mdapi-package.xml +25 -0
  121. package/skills/service-agentforce-channel-configure/assets/settings-mdapi-package.xml +30 -0
  122. package/skills/service-agentforce-channel-configure/references/agent-wiring.md +20 -0
  123. package/skills/service-agentforce-channel-configure/references/botemaildefinition.md +107 -0
  124. package/skills/service-agentforce-channel-configure/references/channel-branch-email.md +203 -50
  125. package/skills/service-agentforce-channel-configure/references/channel-types.md +30 -3
  126. package/skills/service-agentforce-channel-configure/references/queue-resolution.md +17 -8
  127. package/skills/service-agentforce-channel-configure/references/routing-flow.md +14 -3
  128. package/skills/service-agentforce-channel-configure/scripts/validate-botemaildefinition.py +126 -0
  129. package/skills/service-agentforce-channel-configure/scripts/validate-emailtemplate.py +133 -0
  130. package/skills/service-de-channel-activate/SKILL.md +271 -0
  131. package/skills/service-de-channel-activate/references/gotchas.md +31 -0
  132. package/skills/service-de-channel-activate/references/phone-verification.md +96 -0
  133. package/skills/service-de-channel-activate/references/worked-examples.md +14 -0
  134. package/skills/service-de-channel-consent-configure/SKILL.md +230 -0
  135. package/skills/service-de-channel-consent-configure/references/gotchas.md +41 -0
  136. package/skills/service-de-channel-consent-configure/references/language-keywords.md +84 -0
  137. package/skills/service-de-channel-consent-configure/references/worked-examples.md +134 -0
  138. package/skills/service-de-channel-create/SKILL.md +369 -0
  139. package/skills/service-de-channel-create/references/apple.md +59 -0
  140. package/skills/service-de-channel-create/references/connect-insert.md +84 -0
  141. package/skills/service-de-channel-create/references/facebook.md +154 -0
  142. package/skills/service-de-channel-create/references/line.md +66 -0
  143. package/skills/service-de-channel-create/references/sms.md +98 -0
  144. package/skills/service-de-channel-create/references/whatsapp.md +98 -0
  145. package/skills/service-de-channel-create/references/worked-examples.md +70 -0
  146. package/skills/service-de-channel-routing-configure/SKILL.md +345 -0
  147. package/skills/service-de-channel-routing-configure/references/asa-routing.md +90 -0
  148. package/skills/service-de-channel-routing-configure/references/gotchas.md +27 -0
  149. package/skills/service-de-channel-routing-configure/references/queue-creation.md +123 -0
  150. package/skills/service-de-channel-routing-configure/references/target-locate.md +98 -0
  151. package/skills/service-de-channel-routing-configure/references/worked-examples.md +174 -0
  152. package/skills/service-de-headless-channel-configure/SKILL.md +305 -0
  153. package/skills/service-de-headless-channel-configure/references/gotchas.md +27 -0
  154. package/skills/service-de-headless-channel-configure/references/inputs.md +49 -0
  155. package/skills/service-de-headless-channel-configure/references/output-envelopes.md +53 -0
  156. package/skills/service-de-headless-channel-configure/references/partial-success.md +24 -0
  157. package/skills/service-de-headless-channel-configure/references/terms-and-conditions.md +54 -0
  158. package/skills/service-de-headless-channel-configure/references/worked-examples.md +108 -0
  159. package/skills/service-de-waba-integrate/SKILL.md +227 -0
  160. package/skills/service-digital-engagement-channel-configure/scripts/check-api-version.sh +29 -0
  161. package/skills/service-digital-engagement-messaging-site-integrate/SKILL.md +10 -9
  162. package/skills/service-digital-engagement-messaging-site-integrate/references/lwr_patch.md +39 -21
  163. package/skills/service-digital-engagement-messaging-site-integrate/scripts/patch_lwr_bundle.sh +157 -96
  164. package/skills/service-email-to-case-configure/SKILL.md +190 -0
  165. package/skills/service-email-to-case-configure/assets/CaseSettings.settings-meta.xml +45 -0
  166. package/skills/service-email-to-case-configure/examples/CaseSettings-two-addresses.settings-meta.xml +36 -0
  167. package/skills/service-email-to-case-configure/references/apply-mechanics.md +73 -0
  168. package/skills/service-email-to-case-configure/references/routing_address_reference.md +73 -0
  169. package/skills/service-email-to-case-configure/references/troubleshooting.md +25 -0
  170. package/skills/service-email-to-case-configure/scripts/apply-casesettings.py +1315 -0
  171. package/skills/service-email-to-case-configure/scripts/check-agent-email-capability.sh +26 -0
  172. package/skills/service-email-to-case-configure/scripts/tests/__init__.py +0 -0
  173. package/skills/service-email-to-case-configure/scripts/tests/_bootstrap.py +45 -0
  174. package/skills/service-email-to-case-configure/scripts/tests/_fakeorg.py +366 -0
  175. package/skills/service-email-to-case-configure/scripts/tests/_run.py +84 -0
  176. package/skills/service-email-to-case-configure/scripts/tests/test_apply_org_scenarios.py +655 -0
  177. package/skills/service-email-to-case-configure/scripts/tests/test_get_session.py +134 -0
  178. package/skills/service-email-to-case-configure/scripts/tests/test_new_capabilities.py +193 -0
  179. package/skills/service-email-to-case-configure/scripts/tests/test_validate_casesettings.py +170 -0
  180. package/skills/service-email-to-case-configure/scripts/validate-casesettings.py +229 -0
  181. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +33 -15
  182. package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -47
  183. package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +17 -12
  184. package/skills/service-itsm-agentic-setup-agentforce-studio-configure/references/cli-invocation.md +5 -3
  185. package/skills/service-itsm-agentic-setup-agentforce-studio-configure/scripts/classify-enable-plan.mjs +11 -3
  186. package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +8 -6
  187. package/skills/service-itsm-agentic-setup-agentforce-studio-validate/references/cli-invocation.md +3 -3
  188. package/skills/service-itsm-agentic-setup-agentforce-studio-validate/scripts/classify-readiness.mjs +11 -3
  189. package/skills/service-itsm-agentic-setup-configure/SKILL.md +5 -5
  190. package/skills/service-itsm-agentic-setup-configure/examples/output-templates.md +5 -5
  191. package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +2 -2
  192. package/skills/service-itsm-agentic-setup-employee-agent-configure/references/report-format.md +11 -7
  193. package/skills/service-itsm-agentic-setup-employee-agent-configure/scripts/render-report.mjs +20 -7
  194. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +10 -6
  195. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/action-availability.md +7 -7
  196. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/references/report-format.md +11 -7
  197. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/build-create-body.mjs +24 -17
  198. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/classify-action-availability.mjs +98 -58
  199. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/render-report.mjs +20 -7
  200. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/scripts/strip-release-management.mjs +176 -0
  201. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +19 -19
  202. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/cli-invocation.md +3 -4
  203. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/helper-contracts.md +6 -6
  204. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/references/permset-topology.md +3 -10
  205. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/scripts/classify-permset-availability.mjs +7 -9
  206. package/skills/service-itsm-incident-mgmt-configure/SKILL.md +2 -0
  207. package/skills/service-itsm-incident-priority-configure/SKILL.md +15 -12
  208. package/skills/service-itsm-incident-priority-configure/examples/matrix-operations.md +27 -1
  209. package/skills/service-itsm-incident-priority-configure/references/sf-cli-invocation.md +37 -6
  210. package/skills/service-native-voice-recording-transcription-configure/SKILL.md +146 -0
  211. package/skills/service-native-voice-recording-transcription-configure/assets/confirmation-output.json +16 -0
  212. package/skills/service-native-voice-recording-transcription-configure/assets/package.xml +22 -0
  213. package/skills/service-native-voice-recording-transcription-configure/references/thunderbird-voice-settings.md +101 -0
  214. package/skills/service-native-voice-recording-transcription-configure/scripts/enable-recording-transcription.sh +181 -0
  215. package/skills/data360-activate/README.md +0 -38
  216. package/skills/data360-activate/SKILL.md +0 -127
  217. package/skills/data360-connect/README.md +0 -57
  218. package/skills/data360-connect/SKILL.md +0 -163
  219. package/skills/data360-connect/examples/connections/heroku-postgres.json +0 -15
  220. package/skills/data360-connect/examples/connections/ingest-api-connection.json +0 -5
  221. package/skills/data360-connect/examples/connections/ingest-api-schema.json +0 -31
  222. package/skills/data360-connect/examples/connections/redshift.json +0 -16
  223. package/skills/data360-connect/examples/connections/sharepoint-unstructured.json +0 -20
  224. package/skills/data360-connect/examples/connections/snowflake-connection.json +0 -42
  225. package/skills/data360-harmonize/README.md +0 -31
  226. package/skills/data360-harmonize/SKILL.md +0 -126
  227. package/skills/data360-orchestrate/README.md +0 -120
  228. package/skills/data360-orchestrate/SKILL.md +0 -263
  229. package/skills/data360-orchestrate/assets/definitions/activation-target.template.json +0 -5
  230. package/skills/data360-orchestrate/assets/definitions/activation.template.json +0 -7
  231. package/skills/data360-orchestrate/assets/definitions/calculated-insight.template.json +0 -7
  232. package/skills/data360-orchestrate/assets/definitions/data-action-target.template.json +0 -5
  233. package/skills/data360-orchestrate/assets/definitions/data-action.template.json +0 -5
  234. package/skills/data360-orchestrate/assets/definitions/data-graph.template.json +0 -21
  235. package/skills/data360-orchestrate/assets/definitions/data-stream.template.json +0 -55
  236. package/skills/data360-orchestrate/assets/definitions/dmo.template.json +0 -17
  237. package/skills/data360-orchestrate/assets/definitions/identity-resolution.template.json +0 -30
  238. package/skills/data360-orchestrate/assets/definitions/mapping.template.json +0 -14
  239. package/skills/data360-orchestrate/assets/definitions/relationship.template.json +0 -12
  240. package/skills/data360-orchestrate/assets/definitions/search-index.template.json +0 -9
  241. package/skills/data360-orchestrate/assets/definitions/segment.template.json +0 -16
  242. package/skills/data360-orchestrate/references/feature-readiness.md +0 -157
  243. package/skills/data360-orchestrate/references/plugin-setup.md +0 -138
  244. package/skills/data360-orchestrate/scripts/bootstrap-plugin.sh +0 -53
  245. package/skills/data360-orchestrate/scripts/diagnose-org.mjs +0 -511
  246. package/skills/data360-orchestrate/scripts/generate-manifest.mjs +0 -68
  247. package/skills/data360-orchestrate/scripts/verify-plugin.sh +0 -58
  248. package/skills/data360-prepare/README.md +0 -50
  249. package/skills/data360-prepare/SKILL.md +0 -203
  250. package/skills/data360-prepare/examples/ingestion-api/.env.example +0 -8
  251. package/skills/data360-prepare/examples/ingestion-api/README.md +0 -48
  252. package/skills/data360-prepare/examples/ingestion-api/send-data.py +0 -144
  253. package/skills/data360-query/README.md +0 -43
  254. package/skills/data360-query/SKILL.md +0 -128
  255. package/skills/data360-query/examples/search-indexes/hybrid-structured.json +0 -44
  256. package/skills/data360-query/examples/search-indexes/vector-knowledge.json +0 -43
  257. package/skills/data360-segment/README.md +0 -35
  258. package/skills/data360-segment/SKILL.md +0 -123
  259. package/skills/service-helpagent-coordinate/references/channel-help-portal.md +0 -22
  260. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-1-1-non-text-content.md +0 -0
  261. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-i-lists.md +0 -0
  262. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-ii-tables.md +0 -0
  263. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-iii-form-labels.md +0 -0
  264. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-iv-regions.md +0 -0
  265. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-1-v-groups.md +0 -0
  266. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-3-5-identify-input.md +0 -0
  267. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-1-4-3-contrast.md +0 -0
  268. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-1-1-keyboard.md +0 -0
  269. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-4-4-link-purpose.md +0 -0
  270. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-4-6-headings-labels.md +0 -0
  271. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-1-pointer-gestures.md +0 -0
  272. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-2-pointer-cancellation.md +0 -0
  273. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-3-label-in-name.md +0 -0
  274. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-2-5-7-dragging-movement.md +0 -0
  275. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-2-1-on-focus.md +0 -0
  276. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-2-2-on-input.md +0 -0
  277. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-1-error-identification.md +0 -0
  278. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-2-labels-instructions.md +0 -0
  279. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-3-3-3-error-suggestion.md +0 -0
  280. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-i-name.md +0 -0
  281. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-ii-role.md +0 -0
  282. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/reviewers/sc-4-1-2-iii-value.md +0 -0
  283. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-1-1-non-text-content.md +0 -0
  284. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-1-use-of-color.md +0 -0
  285. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-10-resize-reflow.md +0 -0
  286. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-11-non-text-contrast.md +0 -0
  287. /package/skills/{experience-lwc-accessibility-validate → experience-accessibility-validate}/references/vision/sc-1-4-3-contrast.md +0 -0
  288. /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("&", "&amp;").replace("<", "&lt;")
548
+ .replace(">", "&gt;").replace('"', "&quot;"))
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()