Showing posts with label EXM. Show all posts
Showing posts with label EXM. Show all posts

Wednesday, March 25, 2026

Sitecore Send transactional mails

Sitecore Send transactional mails

We all get too many emails. But aren't you also frustrated when you don't get a confirmation (mail) after an online purchase? Or even after filling in a form that is important to you. Transactional mails are important for people. And not only as confirmation. They can also be used to inform your organization about events on the website like people making request or even application failures. Sometimes they are also used within the (sales) flow. Just think about codes that are being send to verify your login attempt. 

Plenty of examples and I don't think any organization needs to be convinced that transactional mails are still important. So what happens if you are running on a Sitecore XP and using EXM for those mails... because, well that is what you get with XP.  In my case this means  you do a proof of concept with Sitecore Send to show how this can be handled with a modern platform which feels a bit more reliable. 

The proof-of-concept

The idea for the poc was basically simple:
  • can we send emails from an application based on a template which is maintained by an end user
  • can we transform these emails dynamically:
    • filling specific elements with data provided by the application
    • customizing the template (show/hide areas) as needed

Asking a friend

As this seemed very basic, I asked my good friend Claude to create this for me. Now it could be my mistake and the fact that my prompt of 12 lines was still not accurate enough, but after 4 attempts and a direct link to the documentation he was able to get a function that was almost good. The payload to send to the Sitecore Send API was still wrong so I had to fix that myself. 

As it wasn't right on the spot, this felt like something to share. So here we go. 

Dynamic templates

Before we dive into the code, I would like to talk about the dynamic part. Replacing tokens and personalizing complete blocks of the template.

The Sitecore docs mention:
In the Sitecore Send API we use the Mustache templating language to generate and render personalized content based on the data received via the API. This approach allows you to customize the recipient experience by tailoring specific elements, such as text and images, to meet individual preferences and requirements.
So I checked the documentation for the Mustache templating language and found several interesting things. However (and we will come back to this later) they didn't seem to work. Furthermore, the example from Sitecore a syntax like {{#each ...}} which is actually not basic Mustache. So for the POC I ended up with 2 solutions so we can decide later which will suit us best. 

The first solution will send a request to Send for a transactional mail being send to my recipient with substitution tokens using my template. This is the basic out-of-the-box solution with the least custom coding. Sitecore Send will transform the template and handle the delivery. 

A second solution will also use that template but it will add a custom step. In this approach we first fetch the template from Send, use Mustache ourselves to transform the template and then do a request to Send which includes the resulting html. The request to Send is almost the same as in the first one but by providing the html Send will use this instead of the template. Note that we still mention the template to make sure Send can handle the tracking.

The code

To keep it simple and basic, let's skip the wrapper code for an Azure function which is the entry point I used to test the code.  Note that this is POC code - and mainly generated - so don't expect high quality code. This is the quick and somehow working version...  but good enough to get you going.
using Microsoft.Extensions.Logging;
using Stubble.Core.Builders;
using Stubble.Core.Interfaces;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace FunctionAppSend
{
    public class SitecoreSendEmailService
    {
        private static readonly IStubbleRenderer stubbleRendered = new StubbleBuilder().Build();
        private readonly ILogger<SitecoreSendEmailService> _logger;
        private readonly HttpClient _httpClient;
        private readonly string _apiKey;
        private const string ApiBaseUrl = "https://api.sitecoresend.io/v3/";

        // Campaign IDs for different languages
        private readonly Dictionary<string, string> _campaignIds = new()
        {
            { "en", "123456be-6e4e-666b-8bd0-1bbbbcc02089" },
            { "de", "YOUR_GERMAN_CAMPAIGN_ID" },
            { "fr", "YOUR_FRENCH_CAMPAIGN_ID" }
        };

        public SitecoreSendEmailService(ILogger<SitecoreSendEmailService> logger, HttpClient httpClient, string apiKey)
        {
            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
            _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
            _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
        }

        public async Task<bool> SendVerificationEmailAsync(string language, string email, string verificationCode, int? payed)
        {
            try
            {
                var campaignId = ResolveCampaignId(language);
                var requestUrl = $"{ApiBaseUrl}campaigns/transactional/send.json?apikey={_apiKey}";
                var substitutions = BuildSubstitutions(email, verificationCode, language, payed);

                var payload = new
                {
                    templateid = campaignId,
                    MailSettings = new
                    {
                        BypassUnsubscribeManagement = new
                        {
                            Enable = true
                        },
                        UnsubscribeLinkManagement = new
                        {
                            IncludeUnsubscribeLink = false
                        }
                    },
                    personalizations = new[]
                    {
                        new
                        {
                            to = new[]
                            {
                                new
                                {
                                    Email = email
                                }
                            },
                            Substitutions = substitutions
                        }
                    }
                };

                return await SendTransactionalAsync(requestUrl, payload, email, "template-based");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Exception occurred while sending verification email to {Email}", email);
                return false;
            }
        }

        public async Task<bool> SendVerificationEmailWithRenderedContentAsync(string language, string email, string verificationCode, int? payed)
        {
            try
            {
                var campaignId = ResolveCampaignId(language);
                var templateHtml = await GetCampaignTemplateHtmlAsync(campaignId);

                if (string.IsNullOrWhiteSpace(templateHtml))
                {
                    _logger.LogError("No HTML template content was found for campaign {CampaignId}", campaignId);
                    return false;
                }

                var substitutions = BuildSubstitutions(email, verificationCode, language, payed);
                var renderedHtml = stubbleRendered.Render(templateHtml, substitutions);
                var requestUrl = $"{ApiBaseUrl}campaigns/transactional/send.json?apikey={_apiKey}";

                var payload = new
                {
                    templateid = campaignId,
                    content = new[]
                    {
                        new
                        {
                            type = "text/html",
                            value = renderedHtml
                        }
                    },
                    MailSettings = new
                    {
                        BypassUnsubscribeManagement = new
                        {
                            Enable = true
                        },
                        UnsubscribeLinkManagement = new
                        {
                            IncludeUnsubscribeLink = false
                        }
                    },
                    personalizations = new[]
                    {
                        new
                        {
                            to = new[]
                            {
                                new
                                {
                                    Email = email
                                }
                            },
                            Substitutions = substitutions
                        }
                    }
                };

                return await SendTransactionalAsync(requestUrl, payload, email, "rendered-content");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Exception occurred while sending rendered verification email to {Email}", email);
                return false;
            }
        }

        private async Task<string?> GetCampaignTemplateHtmlAsync(string campaignId)
        {
            var requestUrl = $"{ApiBaseUrl}campaigns/{campaignId}/view.json?apikey={_apiKey}";

            using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            _logger.LogInformation("Fetching campaign HTML content for campaign {CampaignId}", campaignId);

            using var response = await _httpClient.SendAsync(request);
            var responseBody = await response.Content.ReadAsStringAsync();

            if (!response.IsSuccessStatusCode)
            {
                _logger.LogError("Failed to fetch campaign details for campaign {CampaignId}. Status: {StatusCode}, Error: {ErrorBody}", campaignId, response.StatusCode, responseBody);
                return null;
            }

            using var document = JsonDocument.Parse(responseBody);

            if (!TryGetPropertyIgnoreCase(document.RootElement, "Context", out var context) ||
                !TryGetPropertyIgnoreCase(context, "HTMLContent", out var htmlContentElement) ||
                htmlContentElement.ValueKind != JsonValueKind.String)
            {
                _logger.LogError("Campaign details response did not contain Context.HTMLContent for campaign {CampaignId}", campaignId);
                return null;
            }

            var htmlContent = htmlContentElement.GetString();

            if (string.IsNullOrWhiteSpace(htmlContent))
            {
                _logger.LogError("Campaign details response contained an empty Context.HTMLContent for campaign {CampaignId}", campaignId);
                return null;
            }

            return htmlContent;
        }

        private async Task<bool> SendTransactionalAsync(string requestUrl, object payload, string email, string mode)
        {
            var jsonPayload = JsonSerializer.Serialize(payload);
            using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
            using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl)
            {
                Content = content
            };

            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            _logger.LogInformation("Sending {Mode} verification email to {Email}", mode, email);

            using var response = await _httpClient.SendAsync(request);
            var responseBody = await response.Content.ReadAsStringAsync();

            if (response.IsSuccessStatusCode)
            {
                _logger.LogInformation("Verification email sent successfully to {Email}. Mode: {Mode}. Response: {Response}", email, mode, responseBody);
                return true;
            }

            _logger.LogError("Failed to send verification email to {Email}. Mode: {Mode}. Status: {StatusCode}, Error: {ErrorBody}", email, mode, response.StatusCode, responseBody);
            return false;
        }

        private string ResolveCampaignId(string language)
        {
            if (_campaignIds.TryGetValue(language.ToLowerInvariant(), out var campaignId))
            {
                return campaignId;
            }

            _logger.LogWarning("Language {Language} not supported. Falling back to English", language);
            return _campaignIds["en"];
        }

        private static Dictionary<string, object> BuildSubstitutions(string email, string verificationCode, string language, int? payed)
        {
            var substitutions = new Dictionary<string, object>
            {
                { "Email", email },
                { "VerificationCode", verificationCode },
                { "Language", language },
                { "HasPayed", payed.HasValue && payed.Value > 0 },
                {
                    "order",
                    new
                    {
                        products = new[]
                        {
                            new
                            {
                                name = "Big box",
                                quantity = 3,
                                price = new
                                {
                                    grossValue = 1200,
                                    netValue = 1000
                                },
                                valiant = new
                                {
                                    name = "red",
                                    id = "kev8484j49j9j9"
                                }
                            },
                            new
                            {
                                name = "Small box",
                                quantity = 13,
                                price = new
                                {
                                    grossValue = 120,
                                    netValue = 100
                                },
                                valiant = new
                                {
                                    name = "green",
                                    id = "kev81254"
                                }
                            }
                        }
                    }
                }
            };

            if (payed.HasValue)
            {
                substitutions["Payed"] = payed.Value;
            }

            return substitutions;
        }

        private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
        {
            if (element.ValueKind == JsonValueKind.Object)
            {
                foreach (var property in element.EnumerateObject())
                {
                    if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
                    {
                        value = property.Value;
                        return true;
                    }
                }
            }

            value = default;
            return false;
        }
    }
}

Let's digest what is happening here. 

Solution 1: using Send's dynamic templates

As you can see we are testing various substitutions here. Some were taken from the Sitecore example, some are more towards what we are actually looking for. 

Our first solution is actually pretty simple if you know what you need to do. We need to send a request to the Send API : POST /campaigns/transactional/send.json and attach our API key in the querystring. 
The payload is the data that we send to this API and that will determine what actually happens. We are sending only what is needed for our solution so it will not include a subject or a from address as those come from the template. We are including:
  • templateId: statistics will be registered to the campaign with this ID so although it is not mandatory for the api you probably do want this - and in this scenario it is needed as we use the template to send the mail
  • mailSettings: you can handle unsubscribe settings and scheduled dispatching with these settings
  • personalizations: required and rather important part. Here you define your recipient(s) for the mail and also the substitutions. Substitutions are the values to substitute in the message content and subject for the current recipient (as key/value pairs)
We created all the values we need in a payload object, serialize it and send it with a post request to the Send API.  A few seconds later our mail arrives... 


Solution 2: custom transformation 

In a second solution we first fetch the campaign details with GET /campaigns/{CampaignID}/view.json and filter the HtmlContent from the response. We use this html content and the substitutions to render the final html using Stubble which is an implementation of the Mustache template system in C#.  

We now have the html for the mail - let's send this to Send. We use the same API as in the first solution and almost the same payload. We do add one more value called Content.  Note that this Content is an array although it should only contain one value and you do need to specify both type and value.

After posting this to Send a few seconds later we also get our mail.


The Sitecore Send campaign

I will not discuss the whole process of creating a transactional campaign in Send as that is quite straight forward (and documented). What I do want to show in order to get to our conclusion is what was included in the template. 

Simple substitutions

First of all we have the simple substitutions like "{{VerificationCode}}". Note that these can be placed both in the content of the mail as in the subject. They are replaced perfectly in both places in both solutions (to get this working in solution 2 do not forget to still send the substitutions even though you already used them for the content).

These can be very handy to place personalized data in your mail.

If (not)

We also tried to get the "if" syntax from Mustache to work. It looks like this:
    {{#Payed}} Thank you for your payment of {{Payed}}&euro; {{/Payed}}
    {{^Payed}} Don't forget to pay. {{/Payed}}

Everything inside the {{# part should only be displayed if Payed is present in the substitutions. The {{^ syntax is the negation so that will be displayed if it is not present.  Actually, if the key exists and has a non-false value, the html between the pound and slash will be rendered and displayed one or more times so it can also be used to display lists.

This is standard Mustache but it will not work if you let Send do the transformation as we did in solution 1. It will work in solution 2 as that uses all Mustache transformations.

Each

We also tried the "each" syntax as mentioned in the Sitecore Send documentation. This looks something like:
    {{#each order.products}}
    {{this.name}}
    Quantity: {{this.quantity}} - Total {{this.price.grossValue}}
    {{/each order.products}} 

This works fine in our first solution where Send does the transformation but it does not in our 2nd where we use the actual Mustache library.


Conclusion

It became clear to us that Sitecore Send is probably using their own interpretation of Mustache. There are several of those available online. To be honest I would have liked that the default (and full) mustache language was available as that would be much easier so that feedback will be going towards the Send team. 

But after all - solutions are possible. And both solutions provided here have their use cases. If you don't need more than what is provided by substitutions out-of-the-box the first solution is a very easy way to get your transactional mails out. 
If you do need more flexibility and more complex logic in your templates, the second solution also works fine and can get your mails to their recipients with tracking in Send.


Let's start Send-ing those mails!
 

Wednesday, October 25, 2023

Sitecore EXM - issue with long name emails

 Sitecore EXM - emails with long names

Our customer is using Sitecore Email Experience Manager to send campaigns (e-mails) to their contacts.  We got notified that they had an issue with a specific campaign that was not sending. 

First of all we noticed that the email was completely blocked in the EXM management tool. We could not de-activate it to try to change anything which was pretty weird. Luckily the logs showed us some meaningful information.
ERROR Exception: Sitecore.Exceptions.InvalidItemNameException
Message: An item name lenght should be less or equal to 100.
Source: Sitecore.Kernel
   ....
   at Sitecore.EmailCampaign.Cm.Pipelines.DispatchNewsletter.DeployAnalytics.AddCampaignItem(MessageItem message)

 

An item name lenght should be less or equal to 100

Yes, item length - that is the issue. (including the typo in the exception message 😊)

But what item name? Yes, the name of the email was long, but not that long. EXM does check the length of the name - just try to type more than 100 characters in the name field of a new regular mail and it will turn red. 

Let's have another look at the trace in the error message - it seems to be from "AddCampaign" and that rings a bell. When a regular email is created, Sitecore also creates a campaign item in /sitecore/system/Marketing Control Panel/Campaigns. When we check the names of the items there it seems to be the name of the mail campaign with the item id (guid). This means that this item is 36 characters longer than the mail name - leaving us with only 64 characters for the mail name. 

Which is fine - if you know it. And yes, that should be checked in EXM. You can consider this a bug -it is- but I'm not going to patch a solution for it. 

Fix ?

We could try and patch it ourselves, but it's actually not worth the effort. We could also increase the MaxItemNameLength but again - don't seem worth it. Note that we are talking about the name of the mail campaign, not the subject or anything the end customer would see.

So we decided to just fix the impacted mail and inform the customer to use shorter names in future (until we move the solution to Sitecore Send 😛)

Fixing locked mail items

Fixing the item seemed to be not that easy actually as it was really locked. I could not deactivate it to edit it. An old trick to the rescue, described on Sitecore Stack Exchange (of course... ) in another situation but still very useful. 

Go to the content editor and locate the mail item. Unprotect it and make the changes you want - in this case shorten the title.  Protect the item again - use the "Open EXM" button to go to the item where you now can de-active and activate again.  And that should fix your mail.

Conclusion


One more reason to start moving the mail part of the solution to Send...




Monday, January 22, 2018

Sitecore Forms Send Email Campaign message

Sitecore Forms 9.0 Update-1 rev. 171219

Sitecore release it's Update-1 version of the platform. In this version they included EXM (Email Experience Manager) as out-of-the-box part of the product. No more separate module..  like they did with Forms before. This change also had some (expected) changes to the Forms module. In the initial release version there was no submit action that could send an email. We expected this to be introduced together with EXM and that was a correct assumption.



So.. we have a "Send Email Campaign message" submit action now.

Send Email Campaign message submit action


I tried to do a quick test on a vanilla install of the platform:
  1. Create an automated campaign in EXM (how-to)
  2. Test the campaign - yes, the test mail arrived perfectly.
  3. Create a form (any form will do), added a few fields and all wanted save actions, amongst which the "Send Email Campaign Message" - place it before the redirect ;)
    The save action will allow you to select a campaign - my newly created campaign was in the list so I selected that one.
  4. Add the form to a page (we had to create/generate an mvc layout for this*)
  5. Publish everything and let's try this...
Submit the form with some test data.. and.. damn..  "Failed to send email!
In the Sitecore logs I found more information:  ERROR Contact id is null.

As I am not that familiar with EXM (yet), as probably quite a lot of others, I was not aware that I could only send a mail to a known contact. Sitecore Support helped me on this one, so now I figured out that I do need to identify my contact first.
To send an email with the "Send Email Campaign Message" action, your contact needs to be identified.
Sound reasonable, but the (first) problem is that there is no out-of-the-box submit action to do this. Luckily all you need to do this yourself has been documented on the official doc site.  (still weird that they can document it, but not put it in the product...).

Documentation on the submit action could have saved me some time, so hopefully this small post will help someone.

Further usage

We did not find a way to send the form data in the message (without customizing the save action) - unless the form data is all in the contact data, which probably is not the case.

We also haven't found a solution to send the email to someone else - not the person who submitted the form. The mail will be send to the identified contact.

Conclusion

I must admit I was hoping Update-1 would have more impact on the Forms part of the product. I was also hoping the "Send Email" functionality would be in there. One could say it is, but without custom code is useless. Let's get our hopes up for Update-2...

Questions?

For questions on the topic, please find me (and many other Sitecore folks) on Sitecore Slack or Stack Exchange.

Is your custom automated campaign is not showing in the Send Email Campaign Message Action? See https://sitecore.stackexchange.com/a/11511/237


* Sitecore Forms is MVC only - and the vanilla setup of Sitecore still comes with a default WebForms homepage 😞