Deploying and updating URLs in Dynamics 365 email templates via DevOps

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:

  1. customer-portal-test.com – when the target environment is test
  2. 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.

About Bernado

Based in Australia, I am a freelance SharePoint and Dynamics CRM developer. I love developing innovative solutions that address business and everyday problems. Feel free to contact me if you think I can help you with your SharePoint or CRM implementation.
This entry was posted in CRM, DevOps. Bookmark the permalink.

Leave a comment