Tuesday 3 August 2021

Sitecore Forms - Triggered Send Email from Marketing Cloud

Hello Sitecore Folks,

Recently, we had a requirement to Trigger automatic emails from Marketing Cloud using Triggered Send data extensions when a Sitecore gets submitted.

Below code is used to send Triggered Email to the email address specified by the end user submitting a form. It can be News Letter Subscription/Contact Us form.

We first need to get the Authorization Token to access the Trigger send Data Extension to Trigger Email. 

We setup the connection using Rest API URL and by providing Client Id and Client Secret. In Response we get the Access Token.


        private string TokenAuthorization()
        {
            Log.Info($"{_salesForceContactModel.EmailAddress} - Authorization Token Call Started", this);
            try
            {
                System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12 | System.Net.SecurityProtocolType.Tls11 | System.Net.SecurityProtocolType.Tls;

                var client = new HttpClient();

                string clientId = !string.IsNullOrEmpty(ClientId) ? ClientId : DefaultClientId;
                string clientSecret = !string.IsNullOrEmpty(ClientSecret) ? ClientSecret : DefaultClientSecret;

                var dictionaryParam = new Dictionary<string, string>()
                {
                  { "grant_type", "client_credentials" }, { "client_id", clientId}, {"client_secret", clientSecret }
                };

                var content = new FormUrlEncodedContent(dictionaryParam);

                string tokenUri = // TOKEN URL to get the Access Token;

                var response = client.PostAsync(tokenUri, content);

                if (response.Result.IsSuccessStatusCode)
                {
                    var tokenResponse = response.Result.Content.ReadAsStringAsync()?.Result;
                    if (!string.IsNullOrEmpty(tokenResponse))
                    {
                        Log.Info($"{_salesForceContactModel.EmailAddress} - Success : Authorization Token for Trigger Welcome Email from SFMC", this);
                        var objResult = JsonConvert.DeserializeObject<Token>(tokenResponse);
                        return objResult?.access_token;
                    }
                }
            }
            catch (Exception ex)
            {
                Log.Error($"{_salesForceContactModel.EmailAddress} - Failure : Authorization Token Error from SFMC {ex.Message}", this);
            }
            return null;
        }

 

Once we have the Token Authorization, we can make a call to Triggered Send data extension and pass the Subscriber/User data to trigger emails.

 

private void CallTriggeredSendDefinition()
        {
            try
            {
                Log.Info($"{_salesForceContactModel.EmailAddress} - Starting Trigger Welcome Email from SFMC", this);

                var accessToken = TokenAuthorization();

                if (!string.IsNullOrEmpty(accessToken))
                {
                    var data = GetSubscriberData();

                    if (data != null)
                    {
                        var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json");

                        Log.Info($"{_salesForceContactModel.EmailAddress} - Success : Subscriber Data Call", this);

                        var TriggerName = // Email Trigger Name (External API Key on Marketing Cloud Email Trigger)

                        string baseUri = // REST API URL
                        HttpClient client = new HttpClient
                        {
                            BaseAddress = new Uri(string.Format(@"{0}/key:{1}/send", baseUri, currentTriggerName)),
                            DefaultRequestHeaders = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
                        };

                        var response = client.PostAsync(client.BaseAddress, content);

                        if (response.Result.IsSuccessStatusCode)
                        {
                            Log.Info($"{_salesForceContactModel.EmailAddress} - Ending Trigger Welcome Email SFMC using : {currentTriggerName}", this);
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                Log.Error("Trigger Send Email exception: " + ex.Message, ex, this);
            }
        }
 

        private object GetSubscriberData()
        {
            SubscriberAttributes sb = new SubscriberAttributes
            {
                sfid = _salesForceContactModel.EmailAddress,
                SubscriberKey = _salesForceContactModel.EmailAddress,
                EmailAddress = _salesForceContactModel.EmailAddress,
                first_name = _salesForceContactModel.FirstName,
                last_name = _salesForceContactModel.LastName
            };
            ContactAttributes ct = new ContactAttributes
            {
                SubscriberAttributes = sb
            };
            To toData = new To
            {
                Address = _salesForceContactModel.EmailAddress,
                SubscriberKey = _salesForceContactModel.EmailAddress,
                ContactAttributes = ct
            };
            PostData data = new PostData
            {
                To = toData
            };

            return data;
        }

 

Below are the classes to access the JSON data.

public class Token
 {
        public string access_token { get; set; }   
 }

 public class SubscriberAttributes
    {
        public string SubscriberKey { get; set; }
        public string EmailAddress { get; set; }
        public string first_name { get; set; }
        public string last_name { get; set; }
    }

    public class ContactAttributes
    {
        public SubscriberAttributes SubscriberAttributes { get; set; }
    }

    public class To
    {
        public string Address { get; set; }
        public string SubscriberKey { get; set; }
        public ContactAttributes ContactAttributes { get; set; }
    }

    public class PostData
    {
        public To To { get; set; }
    }

Thanks !!!

Sitecore - Custom Language Resolver

Hello Sitecore Folks,

If we want to resolve our site with the default context language even though it's not explicitly specified in the URL, then you have arrived at the right place.

e.g. www.site.com or www.site.com/ both the URLs would redirect to www. site.com/en, en is your context language.  

The custom pipeline code will set the context language.


Custom Language Resolver Pipeline:-


public class ContextLanguageResolver : HttpRequestProcessor
    {
        public override void Process(HttpRequestArgs args)
        {
            try
            {
                StartProfilingOperation("Resolve Context Language and strip trailing Slash", args);

                if (Context.Database == null
                    || Context.Database.Name == "core"
                    || !Context.PageMode.IsNormal
                    || Context.Item == null || Context.Language == null)
                    return;

                // Get out if this item if it isn't in the context site
                if (!Context.Item.Paths.FullPath.StartsWith(Context.Site.StartPath, StringComparison.OrdinalIgnoreCase))
                    return;

                string path = args.HttpContext.Request.RawUrl;
                if (!path.StartsWith($"/{Context.Language.Name.ToLower()}"))
                {
                    Log.Info($"[{this.GetType().Name}.{nameof(Process)}] Referrer URL of the Current Request is `{HttpContext.Current.Request.UrlReferrer}`", this);
                    string url = GetCanonicalUrlWithQuerystring(path);
                    args.HttpContext.Response.Redirect(url);
                }
                else if (path.EndsWith("/"))
                {
                    Log.Info($"[{this.GetType().Name}.{nameof(Process)}] Referrer URL of the Current Request is `{HttpContext.Current.Request.UrlReferrer}`", this);
                    string url = GetCanonicalUrlWithQuerystring(path);
                    args.HttpContext.Response.RedirectPermanent(url);
                }
            }
            finally
            {
                EndProfilingOperation(null, args);
            }
        }

        private static string GetCanonicalUrlWithQuerystring(string path)
        {
            var query = path.IndexOf('?');
            return $"{LinkManager.GetItemUrl(Context.Item)}{(query == -1 ? string.Empty : path.Substring(query))}";
        }
    }

Configuration Patch:-


<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:env="http://www.sitecore.net/xmlconfig/env/">
    <sitecore>
        <pipelines>
            <httpRequestBegin>
                <processor type=ProjectName.Website.Pipelines.HttpRequestBegin.ContextLanguageResolver,
ProjectName.Website" patch:after="processor[@type='Sitecore.Pipelines.HttpRequest.ItemResolver, Sitecore.Kernel']">
                </processor>
            </httpRequestBegin>
        </pipelines>
    </sitecore>
</configuration>

 

Tuesday 1 June 2021

Sitecore - Create first item version in default language

Recently, we had a request from client wherein they asked us to force the content authors to create items in English version first, if the item doesn't have one. In short, content authors should create item in English version first, than create versions in different languages.

The issue was, accidentally content authors were creating items in different language versions rather than the default English language which is the Fallback language. This was happening mainly on the Media Items where they were creating Media Items like images,PDFs in non-English language due to which they were facing content related issues where image was displaying on X language version page but not on Y language version page.

To Solve the issue, we decided to create a custom validator.

Validator checks the newly created item whether it has the default language version (i.e en).

If Yes, allow content authors to create the language version.

If No, show the validation message.


    [Serializable]
    public class CreateDefaultItemVersion : StandardValidator
    {
        public CreateDefaultItemVersion() { }

        public CreateDefaultItemVersion(SerializationInfo info, StreamingContext context) : base(info, context) { }

        protected override ValidatorResult Evaluate()
        {
            Item item = this.GetItem();
            if (item == null)
            {
                return ValidatorResult.Valid;
            }
            string name = item.Name;

            if (item.Language.Name.Equals("en"))
            {
                return ValidatorResult.Valid;
            }
            else
            {
                Language language = Language.Parse("en");
                var currentItem = Factory.GetDatabase("master").GetItem(item.Paths.FullPath, language);
                if (currentItem?.Versions?.Count > 0)
                {
                    return ValidatorResult.Valid;
                }
                else
                {
                    base.Text = this.GetText(Translate.Text($"The item name `{name}` doesn't have a default English Langauge version. Please create English Version first."));
                    return base.GetFailedResult(ValidatorResult.CriticalError);
                }
            }
        }

        protected override ValidatorResult GetMaxValidatorResult()
        {
            return this.GetFailedResult(ValidatorResult.Error);
        }

        public override string Name => "Always Create Items in English Version First";
    }

 

Once the code is added to the solution, 

Create a validator item under the path /sitecore/system/Settings/Validation Rules. Provide the custom validator class name and the assembly of the class we created and apply the Validation rule.

That's it !!!

 


 

Wednesday 10 February 2021

Integrate Kakao API with Sitecore

Hello Sitecore Developers,

We recently came across a requirement where client wanted to use Kakao API to get Latitude and Longitude for South Korean Addresses in Sitecore.

Kakao is an API service specifically used in South Korea which provide similar kind of services as Google or Bing maps.

To Learn more about Kakao:

https://developers.kakao.com/

We need to create an App to use the functionalities of Kakao. Since we are using Sitecore we would be using Web platform in Kakao app which provides Javascript/Rest API support. Once the App is created it would provide with an API key which we can use in our code to use Kakao services.

Below article provides information on how to create an app in Kakao

https://developers.kakao.com/docs/latest/ko/getting-started/app                                          

We were having fields like Address, City, State, Postal Code etc. typically used to get the Latitude and Longitude values.

But the challenge with Kakao API is the Language of the address to pass, it only allows Korean address for conversion.

Content Items in our system were having Shared fields and had addresses in English Language only.

We decided to use google translate api to send the English addresses and convert them to Korean and pass it through Kakao to get the Lat and Long values.

Here is the link to setup Google Translate API.

https://cloud.google.com/translate/docs

 

Code:

We have integrated Kakao Api with Data Exchange Framework where our custom pipeline executes and collects data from external system and create items in Sitecore. We pass Address, City, StateOrProvince, Country etc. to

Google Translate -> Translate the Address values to Korean Language - > Sends the Korean address to Kakao -> Sends Lat and Long values in response and update item fields.

 

public async Task<Geolocation> GetLatLongfromAddressViaKakaoAsync(string address, string city, string stateOrProvince, string country)

        {

            Geolocation latlong = null;

            double propertyLatidude = 0.0;

            double propertyLongitude = 0.0;

            var addressQuery = string.Join(" ", new[] { address, city, stateOrProvince, country }.Where(s => !string.IsNullOrEmpty(s)));

            if (string.IsNullOrEmpty(addressQuery))

            {

                Log.Warn($"[{GetType().FullName}.{nameof(GetLatLongfromAddressViaKakaoAsync)}] Could not get geolocation data because no address parameters were populated", this);

            }

            else

            {

                var cleanAddress = Regex.Replace(addressQuery, @"[^0-9a-zA-Z-:, ]+", "");

                string koreanAddress =TranslateAddressToKoreanViaGoogleTranslate(cleanAddress);

 

                if (string.IsNullOrEmpty(koreanAddress))

                {

          Log.Warn($"[{GetType().FullName}.{nameof(GetLatLongfromAddressViaKakaoAsync)}] Could not get Korean Address Via Google Translate API", this);

                }

                else

                {

                    HttpWebRequest request = KakaoRequest(koreanAddress);

 

                    var content = string.Empty;

 

                    using (var response = (HttpWebResponse)request?.GetResponse())

                    {

                        if (response?.StatusCode == HttpStatusCode.OK)

                        {

                            using (var stream = response?.GetResponseStream())

                            {

                                if (stream != null && stream != Stream.Null)

                                {

                                    using (var sr = new StreamReader(stream))

                                    {

                                        content = await sr?.ReadToEndAsync();

                                        var jsonData = JsonConvert.DeserializeObject<KakaoGeoCodeResults>(content);

                                        if (jsonData?.documents != null && jsonData?.documents?.Count > 0)

                                        {

                                            double.TryParse(jsonData?.documents?.FirstOrDefault()?.y, out propertyLatidude);

                                            double.TryParse(jsonData?.documents?.FirstOrDefault()?.x, out propertyLongitude);

                                            latlong = new Geolocation

                                            {

                                                Latitude = propertyLatidude,

                                                Longitude = propertyLongitude

                                            };

                                        }

                                        else

                                        {

                                            Log.Warn($"[{GetType().FullName}.{nameof(GetLatLongfromAddressViaKakaoAsync)}] Could not get geolocation data for address query via Kakao Geocoding service for: English : {addressQuery} - Korea: {koreanAddress}", this);

                                        }

                                        sr?.Dispose();

                                    }

                                }

                                else

                                {

                                    Log.Warn($"[{GetType().FullName}.{nameof(GetLatLongfromAddressViaKakaoAsync)}] Response Stream is null from Kakao Response for: English : {addressQuery} - Korea: {koreanAddress}", this);

                                }

                            }

                        }

                        else

                        {

                            Log.Error($"[{GetType().FullName}.{nameof(GetLatLongfromAddressViaKakaoAsync)}] Error(s) received from Kakao Geocode Provider for: English : {addressQuery} - Korea: {koreanAddress} having Errors : {response?.StatusDescription}", this);

                        }

                        response?.Dispose();

                    }

                }

            }

            return latlong;

        }

private HttpWebRequest KakaoRequest(string koreaAddress)

        {

            var request = (HttpWebRequest)WebRequest.Create("https://dapi.kakao.com//v2/local/search/address.json?query=" + koreaAddress);

            request.Headers["Authorization"] = "KakaoAK " + KakaoApiKey;

            request.Method = "GET";

            request.ContentType = "application/json;charset=UTF-8";

            return request;

        }

        private string TranslateAddressToKoreanViaGoogleTranslate(string address)

        {

            var url = "https://translation.googleapis.com/language/translate/v2?key=" + GoogleApiKey + "&source=en&target=ko&q=" + address;

            using (var client = new WebClient())

            {

                client.Encoding = Encoding.UTF8;

                var translatedData = client?.DownloadString(url);

                if (string.IsNullOrEmpty(translatedData))

                    return null;

                var jsonTranslatedData = JsonConvert.DeserializeObject<GoogleTranslateData>(translatedData);

                return jsonTranslatedData?.Data?.Translations?.FirstOrDefault()?.TranslatedText;

            }

        }

 

Below are the model classes used to get response from Google Translate and kakao Rest API

 

public class GoogleTranslationsList

    {

        public List<GoogleTranslateText> Translations { get; set; }

    }

public class GoogleTranslateText

    {

        public string TranslatedText { get; set; }

    }

public class GoogleTranslateText

    {

        public string TranslatedText { get; set; }

    }

 

public class KakaoDocument

    {

        public string x { get; set; }

        public string y { get; set; }

    }

public class KakaoDocument

    {

        public string x { get; set; }

        public string y { get; set; }

    }

public class KakaoDocument

    {

        public string x { get; set; }

        public string y { get; set; }

    }