In Dynamics 365, packaging and promoting email templates across environments are not difficult since this component type is solution aware. The problem however is that these templates can sometime contain bits that are environment-specific, e.g. URLs, and there isn’t a way to deal with this OOTB.
For example, let say you have a ‘Welcome’ email template that is sent to new customers. In this email there is a sign-up link to your Customer Portal. In TEST, you’d want the link to point to the TEST instance of the Portal; and in PROD, you’d want it to point to the PROD instance.
I recently solved this by executing a PowerShell script after each time the email templates are deployed. Using a config file, the script looks through the content of each email template and performs a series of string replacements to update the URLs to suit the target environment (e.g. replace https://customer-portal-dev.com with https://customer-portal-test.com for TEST, and with https://customer-portal.com for PROD). This script is added to the DevOps release pipeline, and therefore eliminates the need for any manual post-deployment tasks for email templates.
The config file
{
"replaceValues": [
{
"find": "customer-portal-dev.com",
"replaceWith": [
{
"environment": "test",
"value": "customer-portal-test.com"
},
{
"environment": "prod",
"value": "customer-portal.com"
}
]
}
]
}
This config file defines a list of tokens to be replaced, and the values to be replaced with for each environment. In the above example, only one token is defined: customer-portal-dev.com, which should be replaced with:
- customer-portal-test.com – when the target environment is test
- customer-portal.com – when the target environment is prod
The script expects this setting file to be named settings.json and be located in the same folder.
The script
param
(
[Parameter(Mandatory)]
[string] $Environment
)
Function GetEmailTemplatesToUpdate()
{
$emailTemplates = Get-CrmRecords -EntityLogicalName "template" -FilterAttribute "title" -FilterOperator "like" -FilterValue "BNH%" -Fields "title", "presentationxml", "body", "safehtml"
WriteInfo "Found $($emailTemplates.Count) email template(s)"
return $emailTemplates.CrmRecords
}
Function ProcessEmailTemplates([System.Collections.Generic.List[Object]] $emailTemplates, [string] $environmentName)
{
Foreach ($template in $emailTemplates)
{
Try
{
WriteInfo "Processing '$($template.title)'"
$presentationXml = $template.presentationxml
$safeHtml = $template.safehtml
$body = $template.body
$tokenFound = $false
Foreach ($replaceValue in $Script:_settings.replaceValues)
{
$matchingReplaceWithForEnvironment = $replaceValue.replaceWith | ? {$_.environment -eq $environmentName}
If ($matchingReplaceWithForEnvironment -ne $null)
{
##PowerShell 5 does not have an overload for String.Contains() that accepts StringComparison so we are using IndexOf instead.
##PowerShell 5 also does not have an overload for String.Replace() that accepts StringComparison. This means the replace will be CASE-SENSITIVE.
If ($presentationXml.IndexOf($replaceValue.find, [System.StringComparison]::InvariantCultureIgnoreCase) -ne -1)
{
$tokenFound = $true
WriteInfo "--- Found token '$($replaceValue.find)' in field 'presentationxml'. Replacing with '$($matchingReplaceWithForEnvironment.value)'..."
$presentationXml = $presentationXml.Replace($replaceValue.find, $matchingReplaceWithForEnvironment.value)
}
If ($safeHtml.IndexOf($replaceValue.find, [System.StringComparison]::InvariantCultureIgnoreCase) -ne -1)
{
$tokenFound = $true
WriteInfo "--- Found token '$($replaceValue.find)' in field 'safehtml'. Replacing with '$($matchingReplaceWithForEnvironment.value)'..."
$safeHtml = $safeHtml.Replace($replaceValue.find, $matchingReplaceWithForEnvironment.value)
}
If ($body.IndexOf($replaceValue.find, [System.StringComparison]::InvariantCultureIgnoreCase) -ne -1)
{
$tokenFound = $true
WriteInfo "--- Found token '$($replaceValue.find)' in field 'body'. Replacing with '$($matchingReplaceWithForEnvironment.value)'..."
$body = $body.Replace($replaceValue.find, $matchingReplaceWithForEnvironment.value)
}
}
}
If ($tokenFound)
{
Set-CrmRecord -EntityLogicalName "template" -Id $template.templateid -Fields @{"presentationxml"=$presentationXml; "safehtml"=$safeHtml; "body"=$body}
}
Else
{
WriteWarning "Did not find any tokens to replace. This template will not be updated."
}
}
Catch
{
WriteError "An error has occurred while processing the template '$($template.title)': $_`n`n$($_.ScriptStackTrace)"
}
}
}
Function ValidateSettingsForEnvironment([string] $environmentName)
{
Foreach ($replaceValue in $Script:_settings.replaceValues)
{
$foundReplaceWithForEnvironment = $false
Foreach ($replaceWith in $replaceValue.replaceWith)
{
If ($replaceWith.environment -eq $environmentName)
{
$foundReplaceWithForEnvironment = $true
Break
}
}
If (-not $foundReplaceWithForEnvironment)
{
WriteWarning "A replace value is not specified for the token '$($replaceValue.find)' for environment '$environmentName'. This token will not be replaced."
}
}
}
Function WriteInfo([string] $message, [string] $foregroundColor = "white")
{
Write-Host $message -ForegroundColor $foregroundColor
}
Function WriteWarning([string] $message)
{
Write-Host "WARNING: $message" -ForegroundColor Yellow
}
Function WriteError([string] $message)
{
Write-Host $message -ForegroundColor Red
}
Function WriteBlankLine()
{
Write-Host "`n"
}
###Main
$ErrorActionPreference = "Stop"
Install-Module Microsoft.Xrm.Data.PowerShell -Scope CurrentUser
WriteBlankLine
$Script:_settings = Get-Content "settings.json" -Raw | ConvertFrom-Json
ValidateSettingsForEnvironment $Environment
$emailTemplates = GetEmailTemplatesToUpdate
ProcessEmailTemplates $emailTemplates $Environment
The script requires one parameter: Environment, which is the target environment and should match one of the environments identified in the config file.
This script uses the Microsoft.Xrm.Data.PowerShell module to retrieve email templates from CRM and update them.
By design, the script only processes specific email templates. This is defined in the GetEmailTemplatesToUpdate function. As written here, it is only processing those templates where title begins with BNH.
Function GetEmailTemplatesToUpdate()
{
$emailTemplates = Get-CrmRecords -EntityLogicalName "template" -FilterAttribute "title" -FilterOperator "like" -FilterValue "BNH%" -Fields "title", "presentationxml", "body", "safehtml"
WriteInfo "Found $($emailTemplates.Count) email template(s)"
return $emailTemplates.CrmRecords
}
IMPORTANT: You will need to update the filtering in the above function to suit your need.
What about the connection to CRM??
You may notice that there is no code to make connection to CRM in the above script. This is because we will make the connection in the DevOps pipeline instead. This will allow us to manage the connection details such as client ID and secret more securely.
The way the Microsoft.Xrm.Data.PowerShell module works, if a connection is not passed when calling Get-CrmRecords or Set-CrmRecord, then it will automatically search for a connection in the current context. This connection will be established by the DevOps pipeline prior to invoking our script.
The build pipeline
Here is the build pipeline:
This pipeline simply exports the CRM solution containing the email templates and publishes that as an artefact. It also publishes a second artefact, which is our script to update the email templates post deployment. Note that the script expects the config file to be in the same location as the script. You therefore should place the script and the config file in the same folder in the repo and publish that folder as an artefact.
The release pipeline
Here is the release pipeline with TEST and PROD configured:
PROD is an exact clone of TEST. Below are the tasks defined for a stage (or environment):
The last task above, Run Scripts to Update Email Templates, is actually defined as a task group so that we can avoid code duplication and reuse it easily across different stages (or environments). We will look at the config for this task group soon, but for now, here are the parameters it requires:
crmClientId, crmClientSecret and crmUrl are used to make connection to the target CRM. environmentName should be one of the environments identified in our config file.
The values for these parameters are defined at the pipeline level and are scoped to each stage. This allows us to clone a stage without needing to update the task that runs our script.
Here is the config for that Run Scripts to Update Email Templates task group:
It has one single task that runs a PowerShell script, which connects to CRM and invokes our script to update the email templates.
Here is that PowerShell:
Install-Module Microsoft.Xrm.Data.PowerShell -Scope CurrentUser -Force
Write-Host "Connecting to $(crmUrl) ($(environmentName))" -ForegroundColor "White"
Connect-CrmOnline -ServerUrl $(crmUrl) -ClientSecret $(crmClientSecret) -OAuthClientId $(crmClientId) | Out-Null
cd $(System.DefaultWorkingDirectory)\_EmailTemplates-Build\update-email-templates-script
. .\UpdateEmailTemplates.ps1 -Environment $(environmentName)
The second last line of the above script contains the location to where our script was published and downloaded as a build artefact. You may need to update this to suit your scenario.
The email template
And for the sake of completeness, here’s my overly simple email template in DEV:
Here’s the same template deployed to PROD:
And there you have it…
With a simple script and config file (and a right DevOps pipeline) you can eliminate post-deployment tasks often associated with email templates and their environment-specific content.