Xperience by Kentico's Email Builder functionality, enabled through email channels is a great experience for marketers. They can author emails, use the structured content and visual builder features they're familiar with in the website Page Builder, and send email campaigns to recipients without having to write any code!

We love when marketers can do everything themselves. But, what if we have a more advanced scenario - like sending an email to a recipient when a webhook in our solution is called from an external service, some data is imported through an integration, or some customer data is submitted to a website as part of a complex business process?

Thankfully, we can use Xperience's APIs to send out an autoresponder email to a recipient while also using an email already authored by a marketer! We can even include dynamic data using tokens that a marketer places in the email content. 😲

Defining our dependencies

Let's assume we trigger the email send through ASP.NET Core API Endpoint. First, we'll create a Controller and action method:

[ApiController]
[Route("[controller]/[action]")]
public class EmailController : Controller
{
    public EmailController(
        IInfoProvider<EmailConfigurationInfo> emailConfigurationProvider,
        IInfoProvider<ContentItemInfo> contentItems,
        IInfoProvider<EmailChannelInfo> emailChannels,
        IInfoProvider<EmailChannelSenderInfo> emailChannelSenders,  
        IInfoProvider<ContactInfo> contactProvider,
      
        IEmailContentResolverFactory emailContentResolverFactory,
        IEmailService emailService,
        IEmailMarkupBuilderFactory markupBuilderFactory,
        IEmailChannelLanguageRetriever languageRetriever,
        IContentItemDataInfoRetriever dataRetriever,
        IEmailChannelSenderEmailProvider senderInfoProvider)
    { }

Now that we have all the required services available, we can start composing our email.

[HttpGet]
public async Task<IActionResult> Index()
{
    /*
     * Retrieve these values from the endpoint request or db
     */
    var recipient = new Recipient
    {
        FirstName = "Dana",
        LastName = "Scully",
        Email = "[email protected]"
    };

    var dataContext = new CustomTokenValueDataContext
    {
        Recipient = recipient,
        Items = new()
        {
            /*
             * Define any tokens you want to replace
             */
            {
                "TOKEN_DynamicGatedAssetURL", 
                "https://www.youtube.com/watch?v=TYdC1uwO4UI" 
            }
        }
    };

Our CustomTokenValueDataContext is a simple class that inherits from FormAutoresponderEmailDataContext since our email will be an autoresponder email.

public class CustomTokenValueDataContext : FormAutoresponderEmailDataContext
{
    public Dictionary<string, string> Items { get; set; } = new();
}

It has a collection of key/value pairs that we can populate with whatever we want. This is what lets us have dynamic email content that we define. For our use-case, we're doing to replace a TOKEN_DynamicGatedAssetURL token in the email with a hard-coded URL, but this URL could come from the database and be based on the information submitted to the API endpoint.

We'll return later to this data context object to define how the token replacement works.

Generating an email programmatically

Now, we can begin to use all our dependencies to generate the email body and retrieve the sender information.

var emailConfig = await emailConfigurationProvider
    .GetAsync("DancingGoatAutoresponder-v6dz12bu");

We first retrieve our EmailConfigurationInfo which we are calling emailConfig.

Don't confuse this with an EmailInfo... in Xperience by Kentico, EmailInfo represents a packaged email to a specific recipient ready to be sent (typically from the email queue).

So what is an EmailConfigurationInfo? Well, that represents the email that a marketer creates in an email channel. For example, in the Dancing Goat sample solution email channel, it represents one of the items in this image.

Next, we retrieve the current contact by email or generate a new one.

var currentContact = ContactManagementContext.GetCurrentContact(true)
    ?? (await contactProvider.Get()
          .WhereEquals(nameof(ContactInfo.ContactEmail), recipient.Email)
          .GetEnumerableTypedResultAsync())
        .FirstOrDefault()
    ?? new ContactInfo { ContactEmail = recipient.Email };

We'll pass this contact as context when rendering the email to ensure email activity tracking and personalization works as the marketer expects.

Rendering the email with recipient context

We then use the IEmailMarkupBuilderFactory to merge the email recipient and contact, with the email, its structured content, and Email Builder template. We end up with a string that includes our non-personalized or dynamic email body.

var markupBuilder = await markupBuilderFactory.Create(emailConfig);
string mergedTemplate = await markupBuilder
    .BuildEmailForSending(
      emailConfig, 
      SetEmailContext(
        dataContext.MailoutGuid, 
        currentContact, 
        recipient.Email, 
        emailConfig));

SetEmailContext is a method that returns a function (delegate) and is a key part of email widget personalization.

private static Func<IServiceProvider, Task> SetEmailContext(Guid mailoutGuid, ContactInfo currentContact, string contactEmail, EmailConfigurationInfo emailConfiguration) =>
    async (serviceProvider) =>
    {
        var emailRecipientContextProvider = serviceProvider.GetRequiredService<IEmailRecipientContextProvider>();
        var recipientContextAccessor = serviceProvider.GetRequiredService<IEmailRecipientContextAccessor>();
        var recipientContactGroupAccessor = serviceProvider.GetRequiredService<IEmailRecipientContactGroupContextAccessor>();

        var emailRecipientContext = await emailRecipientContextProvider
            .Get(currentContact.ContactID, contactEmail, emailConfiguration, mailoutGuid, default);

        recipientContextAccessor.SetContext(emailRecipientContext);

        if (recipientContactGroupAccessor is not EmailRecipientContactGroupContextAccessor accessor)
        {
            return;
        }

        var groupNames = currentContact.ContactGroups
            .Select(g => g.ContactGroupName)
            .ToList();
        var recipientContactGroupContext = new EmailRecipientContactGroupContext 
        { 
          ContactGroupNames = groupNames 
        };
        accessor.Context = recipientContactGroupContext;
    };

Then we use the IEmailContentResolverFactory to create an IEmailContentResolver which resolves all the email content with built-in filters (e.g. tracking links/pixels) and our registered filters (which we will see later) that do token replacement (like replacing TOKEN_DynamicGatedAssetURL). Note, this only runs filters on the email HTML body.

var contentResolver = await emailContentResolverFactory.Create(emailConfig, default);
string emailBody = await contentResolver.Resolve(
    emailConfig,
    mergedTemplate,
    EmailContentFilterType.Sending,
    dataContext);

Next, we need to get all the related email configuration that is part of our email channel configuration. This requires a few steps, but it ensures that our email sending address works exactly how a marketer would expect it to.

  var contentItem = await contentItems
      .GetAsync(emailConfig.EmailConfigurationContentItemID);
  var contentLanguage = await languageRetriever
      .GetEmailChannelLanguageInfoOrThrow(emailConfig.EmailConfigurationEmailChannelID);
  var data = await dataRetriever
      .GetContentItemData(contentItem, contentLanguage.ContentLanguageID, false);
  var emailFieldValues = new EmailContentTypeSpecificFieldValues(data);

What we end up with here is an Xperience EmailContentTypeSpecificFieldValues object that looks like this:

public sealed class EmailContentTypeSpecificFieldValues 
    : IEmailContentTypeSpecificFieldValues
{
    public string EmailSubject { get; }
    public string EmailPreviewText { get; }
    public string EmailPlainText { get; }
    public int EmailSenderID { get; }
    public int EmailTemplateID { get; }
}
var tokenFilter = new CustomTokenValueEmailContentFilter();
string subject = await tokenFilter.Apply(
  emailFieldValues.EmailSubject, 
  emailConfig, 
  dataContext);
string plainTextBody = await tokenFilter.Apply(
  emailFieldValues.EmailPlainText, 
  emailConfig, 
  dataContext);

We use CustomTokenValueEmailContentFilter which is a simple, custom type we'll soon create that does the work of actually replacing tokens with values.

Retrieving the sender email configuration

The EmailContentTypeSpecificFieldValues instance includes our EmailSenderID which is what we use to get the rest of our email configuration:

var emailChannel = (await emailChannels.Get()
    .WhereEquals(
        nameof(EmailChannelInfo.EmailChannelID),
        emailConfig.EmailConfigurationEmailChannelID)
    .GetEnumerableTypedResultAsync())
  .FirstOrDefault() 
  ?? throw new Exception($"There is not email channel for the email configuration [{emailConfig.EmailConfigurationID}]");

var sender = await emailChannelSenders
    .GetAsync(emailFieldValues.EmailSenderID);
string senderEmail = await senderInfoProvider
    .GetEmailAddress(emailChannel.EmailChannelID, sender.EmailChannelSenderName);

Our senderEmail is the full email address for the sender configured for this autoresponder email.

We're finally ready to author and send the email message!

  var emailMessage = new EmailMessage
  {
      From = $"""{sender.EmailChannelSenderDisplayName}" <{senderEmail}>""",
      Recipients = recipient.Email,
      Subject = subject,
      Body = emailBody,
      PlainTextBody = plainTextBody,
      EmailConfigurationID = emailConfig.EmailConfigurationID,
      MailoutGuid = dataContext.MailoutGuid
  };
  
  await emailService.SendEmail(emailMessage);

    return Ok();
}

We use the address specification for the From property so that we can include the email address and display name.

If we were to make a request to our HTTP endpoint now, we'd successfully send an email to our recipient, but our dynamic content would not be inserted into the email. For that, we need to create a custom IEmailContentFilter.

Email content filters

To transform our token value TOKEN_DynamicGatedAssetURL into the URL value we provided to our CustomTokenValueDataContext requires we define and register an IEmailContentFilter.

First, we'll create a CustomTokenValueEmailContentFilter class:

public class CustomTokenValueEmailContentFilter : IEmailContentFilter
{
    public Task<string> Apply(
        string text, 
        EmailConfigurationInfo email, 
        IEmailDataContext dataContext)
    {
        if (dataContext is CustomTokenValueDataContext customValueContext)
        {
            foreach (var (key, val) in customValueContext.Items)
            {
                text = text.Replace(key, val);
            }
        }

        return Task.FromResult(text);
    }
}

This class is really simple! It will be instantiated and the Apply() method will be executed for each email sent out by Xperience.

We have access to the EmailConfigurationInfo and IEmailDataContext which means we can make sure our filter only does its work for emails that can use it. By patterning matching on dataContext we can get access to a strongly typed CustomTokenValueDataContext.

We then loop through all the keys in the Items dictionary, doing a simple string replace for the values.

The last step is to register our filter with Xperience, which we need to do in a custom module class:

[assembly: RegisterModule(typeof(DancingGoatModule))]

namespace DancingGoat;

public class DancingGoatModule : Module
{
    public DancingGoatModule() : base(nameof(DancingGoatModule)) { }

    protected override void OnInit(ModuleInitParameters parameters)
    {
        /**
        * This filter enables token replacement in Email Builder email content
        * _before_ Xperience's URL replacement for URL click tracking.
        * This is important for emails to recipients that include dynamically generated
        * and recipient-specific URLs, like password recovery and registration confirmation
        */
        EmailContentFilterRegister.EmailBuilderInstance
            .Register(
            () => new CustomTokenValueEmailContentFilter(),
            EmailContentFilterType.Sending,
            100);

        base.OnInit(parameters);
    }
}

With our filter registered, a marketer can now insert the dynamic token value anywhere in the email content.

Pretty neat!

Now when we hit our API endpoint, we'll see a link in our email that is tailored for our recipient!

If you have any ideas on how to build on this idea and help marketers achieve even more, share your thoughts in the comments!