So, I solved this with a VF email plus custom component with component controller
Here's the component controller class
/**
* Component controller for various VF Components that need dynamic buttons
* Use cases to date
* 1 - Dynamic button in a VF email to a Help_Center Experience Cloud page
*/
public inherited sharing class ButtonComponentCtrl {
public String siteDeveloperName {get; set;}
/**
* experienceCloudBaseUrl
* reference: https://salesforce.stackexchange.com/a/247372/2602
* @return baseURL of an experience cloud site which is independent of org (PROD, QA, dev, etc)
*/
public String experienceCloudBaseUrl {
get {
if (experienceCloudBaseUrl == null) {
Site[] sites = [SELECT Id FROM Site WHERE Name = :siteDeveloperName];
if (!sites.isEmpty()) {
SiteDetail siteDetail = [SELECT SecureURL FROM SiteDetail WHERE DurableId = :sites[0].Id];
experienceCloudBaseUrl = siteDetail.SecureUrl+
(siteDetail.SecureUrl.endsWith('/') // PROD behaves differently than test
? 's'
: '/s') ;
}
}
return experienceCloudBaseUrl;
}
private set;
}
}
and the VF component (to display a clickable button)
<!--
- Designed to support button images with dynamic links to experience cloud sites
Reference: https://help.salesforce.com/s/articleView?id=000384022&type=1
-->
<apex:component id="ExperienceCloudButton" controller="ButtonComponentCtrl" access="global">
<apex:attribute name="buttonContentAssetDevName" type="String" description="Content Asset developer name for the button image"/>
<apex:attribute name="buttonWidth" type="String" description="CSS width for button"/>
<apex:attribute name="experienceSiteApiName" type="String" assignTo="{!siteDeveloperName}" description="Site Api name used to get baseUrl independent of org"/>
<apex:attribute name="orgBaseUrl" type="String" description="Org's BaseUrl"/>
<apex:attribute name="pathToExperiencePage" type="String" description="Path (relative) to page including any params"/>
<apex:outputLink value="{!experienceCloudBaseUrl}{!pathToExperiencePage}">
<apex:image id="clickableButton"
width="{!buttonWidth}"
value="{!orgBaseUrl}/file-asset-public/{!buttonContentAssetDevName}?oid={!$Organization.Id}" alt="clickableButton"/>
</apex:outputLink>
</apex:component>
and usage in the VF email template
...
<c:ExperienceCloudButton buttonContentAssetDevName="ClickHereButton"
buttonWidth="300px"
experienceSiteApiName="MySiteApiName"
orgBaseUrl="{!relatedTo.Base_Url__c}"
pathToExperiencePage="/myPage?param1={!relatedTo.MyField1__c}¶m2={!relatedTo.MyField2__c}"/>
...
and a convenience field on the relatedTo Object
Base_Url__c (Text)
Data Type Formula
LEFT($Api.Enterprise_Server_URL_570, FIND('/services',$Api.Enterprise_Server_URL_570))