Skip to main content

Introduction

Adobe Experience Manager (AEM) is an enterprise content management platform that enables organizations to create, manage, and deliver personalized digital experiences across multiple channels. Sync Experience Fragments and its variations directly as Email Templates in MoEngage. Allows marketing teams to author locale-specific email variations in AEM and have them automatically appear as a grouped, multi-locale email template in MoEngage.

Use Cases

  • Locale-Based Email Campaigns: Author one Experience Fragment with multiple locale variations (e.g., English, German, Italian). Each variation syncs as a child template grouped under a single parent in MoEngage, enabling MoEngage to automatically serve the right locale to each user.
  • Centralised Email Authoring: Keep all email HTML in AEM as the single source of truth. Any publish action automatically pushes the latest content to MoEngage email templates without manual export or copy-paste.
  • Multi-Variation Testing: Manage A/B or regional variants of an email inside a single AEM Experience Fragment, syncing each as a numbered variation in MoEngage’s template group.

How It Works

The sync follows a Parent + Child template model that maps directly to MoEngage’s Email Template grouping structure:
AEM StructureMaps ToNotes
XF Root PageTemplate GroupGroups all locale variations together in MoEngage
First XF Variation (child page)Parent Template (locale: EN)Always synced first. Its external_template_id becomes the group_id for all child templates
Subsequent XF VariationsChild TemplatesLocale detected automatically from the variation’s title or node name (e.g., -de_deDE_DE)

Locale Detection Logic

The workflow automatically detects the locale of each XF variation by matching a [-_][language][-_][country] suffix at the end of the variation’s page title or node name (e.g., promo-email-de_deDE_DE). The following locales are supported out of the box:
Locale CodeLanguage / Region
ENEnglish (default fallback)
DE_DEGerman (Germany)
IT_ITItalian (Italy)
ES_ESSpanish (Spain)
NL_NLDutch (Netherlands)
ID_IDIndonesian (Indonesia)
Locale FallbackIf no valid locale is detected from the variation title or node name, or if the detected locale is not in the supported list above, the workflow defaults the locale to EN and logs a warning. You can extend the VALID_LOCALES set in the implementation to support additional locales as needed.

Step 1: Add the Email Sync Workflow Step

Create a new file at the following path in your AEM project:
core/src/main/java/com/[your-company]/integration/workflow/MoEngageEmailSyncStep.java
Sample ImplementationThe code below is a sample reference implementation intended as a starting point. It covers the core sync flow but may require adjustments to match your AEM project structure, locale list, sender details, or template naming conventions. Review the inline comments carefully and test thoroughly in a non-production environment before deploying. For implementation support or custom requirements, contact MoEngage Support or your Customer Success Manager.
MoEngageEmailSyncStep.java
package com.moengage.integration.workflow;

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.engine.SlingRequestProcessor;
import com.day.cq.contentsync.handler.util.RequestResponseFactory;
import com.day.cq.wcm.api.Page;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Iterator;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.json.JSONObject;

@Component(
    service = WorkflowProcess.class,
    property = { "process.label=MoEngage Email Sync (Parent + Child - Stable)" }
)
public class MoEngageEmailSyncStep implements WorkflowProcess {
    private static final Logger log = LoggerFactory.getLogger(MoEngageEmailSyncStep.class);
    private static final String EMAIL_API_URL = "https://api-%s.moengage.com/v1.0/custom-templates/email";

    // Extend this set to support additional locales required by your markets
    private static final java.util.Set<String> VALID_LOCALES = new java.util.HashSet<>(java.util.Arrays.asList(
        "IT_IT", "DE_DE", "ES_ES", "NL_NL", "ID_ID", "EN"
    ));

    @Reference
    private RequestResponseFactory requestResponseFactory;

    @Reference
    private SlingRequestProcessor requestProcessor;

    @Override
    public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap args)
            throws WorkflowException {
        ResourceResolver resolver = null;
        try {
            String processArgs = args.get("PROCESS_ARGS", "");
            Map<String, String> config = parseArgs(processArgs);

            String apiKey     = config.get("moengage.api.key");
            String apiSecret  = config.get("moengage.api.secret");
            String dataCenter = config.getOrDefault("moengage.datacenter", "02");

            String publishUrl = config.getOrDefault("aem.publish.url", "").trim();
            if (publishUrl.endsWith("/")) publishUrl = publishUrl.substring(0, publishUrl.length() - 1);
            boolean debugMode = Boolean.parseBoolean(config.get("moengage.debug"));
            String debugUrl   = config.getOrDefault("moengage.debug.url", "");

            String payloadPath = getCleanPath(workItem.getWorkflowData().getPayload().toString());
            resolver = workflowSession.adaptTo(ResourceResolver.class);

            if (resolver == null) {
                log.error("Could not obtain ResourceResolver");
                return;
            }

            Page xfRoot = getXfRootPage(payloadPath, resolver);
            if (xfRoot == null) return;

            List<EmailTemplate> templates = discoverAllVariations(xfRoot, resolver, publishUrl);
            if (templates.isEmpty()) return;

            // Sync the first variation as the parent (locale: EN)
            EmailTemplate parentTemplate = templates.get(0);
            String parentExternalId = syncParentTemplate(
                parentTemplate, resolver, dataCenter, apiKey, apiSecret,
                workItem.getWorkflow().getInitiator(), debugMode, debugUrl
            );

            if (parentExternalId == null || parentExternalId.isEmpty()) {
                log.error("Failed to create parent template, aborting child sync");
                return;
            }

            // Sync remaining variations as children, linked via group_id
            for (int i = 1; i < templates.size(); i++) {
                EmailTemplate childTemplate = templates.get(i);
                try {
                    syncChildTemplate(childTemplate, parentExternalId, i + 1, dataCenter,
                        apiKey, apiSecret, workItem.getWorkflow().getInitiator(),
                        debugMode, debugUrl);
                } catch (Exception e) {
                    log.error("Failed to sync child template: {}. Skipping.", childTemplate.subject, e);
                }
            }

        } catch (Exception e) {
            log.error("Email Sync Failed. Caught exception to prevent AEM retry loop.", e);
        }
    }

    // Resolves the XF root page from the workflow payload path
    private Page getXfRootPage(String payloadPath, ResourceResolver resolver) {
        String cleanPath = payloadPath.replaceAll("\\.html$", "").replaceAll("/jcr:content.*$", "");
        Resource resource = resolver.getResource(cleanPath);
        if (resource == null) return null;
        Page page = resource.adaptTo(Page.class);
        if (page == null) return null;
        Page parent = page.getParent();
        // If this is a variation page (no children), walk up to the XF root
        if (parent != null && parent.getPath().contains("/experience-fragments/")
                && !page.listChildren().hasNext()) {
            return parent;
        }
        return page;
    }

    // Iterates XF children and builds an EmailTemplate list.
    // First child = parent (EN); remaining children = locale variants.
    private List<EmailTemplate> discoverAllVariations(Page xfRoot, ResourceResolver resolver, String publishUrl) {
        List<EmailTemplate> templates = new ArrayList<>();
        try {
            Iterator<Page> children = xfRoot.listChildren();
            boolean isParent = true;

            while (children.hasNext()) {
                Page child = children.next();
                String title = child.getTitle() != null ? child.getTitle() : child.getName();
                String html  = renderHtml(child.getPath(), resolver);

                if (html.isEmpty()) {
                    log.warn("HTML is empty for {}, skipping.", title);
                    continue;
                }

                html = processHtmlForEmail(html, publishUrl);

                if (isParent) {
                    // Parent template always uses EN as the default locale
                    templates.add(new EmailTemplate("master", title, html, title, "EN"));
                    isParent = false;
                } else {
                    // Detect locale from title or node name suffix, e.g. "-de_de" or "_it_it"
                    String localeCode = null;
                    Matcher m = Pattern.compile("[-_]([a-zA-Z]{2}[-_][a-zA-Z]{2})$").matcher(title);
                    if (m.find()) {
                        localeCode = m.group(1).toUpperCase().replace("-", "_");
                    } else {
                        Matcher mNode = Pattern.compile("[-_]([a-zA-Z]{2}[-_][a-zA-Z]{2})$")
                            .matcher(child.getName());
                        if (mNode.find()) {
                            localeCode = mNode.group(1).toUpperCase().replace("-", "_");
                        }
                    }
                    // Fall back to EN if locale is unrecognised
                    if (localeCode == null || !VALID_LOCALES.contains(localeCode)) {
                        log.warn("Locale '{}' not valid for '{}', defaulting to EN", localeCode, title);
                        localeCode = "EN";
                    }
                    templates.add(new EmailTemplate(localeCode, title, html, title, localeCode));
                }
            }
        } catch (Exception e) {
            log.error("Error discovering variations", e);
        }
        return templates;
    }

    private String processHtmlForEmail(String html, String publishUrl) {
        String cleanHtml = cleanContentAsIs(html);
        if (publishUrl != null && !publishUrl.isEmpty()) {
            cleanHtml = externalizeLinks(cleanHtml, publishUrl);
        }
        return cleanHtml;
    }

    // Creates the parent email template; returns external_template_id
    private String syncParentTemplate(EmailTemplate template, ResourceResolver resolver,
            String dataCenter, String apiKey, String apiSecret, String initiator,
            boolean dbg, String dbgUrl) throws Exception {

        String templateId = "email_" + template.templateName + "_" + System.currentTimeMillis();
        String endpointUrl = String.format(EMAIL_API_URL, dataCenter);
        JSONObject payload = new JSONObject();

        JSONObject basicDetails = new JSONObject();
        basicDetails.put("subject", template.subject);
        basicDetails.put("email_content", template.htmlContent);
        basicDetails.put("sender_name", "Brand Communications"); // TODO: customise as needed

        JSONObject metaInfo = new JSONObject();
        metaInfo.put("template_id", templateId);
        metaInfo.put("template_name", template.templateName);
        metaInfo.put("template_version", "1.0");
        metaInfo.put("created_by", initiator);
        metaInfo.put("variation", 1);
        metaInfo.put("locale", template.locale); // Always "EN" for parent

        payload.put("basic_details", basicDetails);
        payload.put("meta_info", metaInfo);

        String response = sendToMoEngageAndGetResponse(endpointUrl, apiKey, apiSecret,
            payload.toString(), dbg, dbgUrl);

        try {
            JSONObject responseJson = new JSONObject(response);
            String externalId = responseJson.optString("external_template_id", null);
            if (externalId != null && !externalId.isEmpty()) {
                return externalId;
            }
            return null;
        } catch (Exception e) {
            return null;
        }
    }

    // Creates a child template linked to the parent via group_id
    private void syncChildTemplate(EmailTemplate template, String parentGroupId, int variationNumber,
            String dataCenter, String apiKey, String apiSecret, String initiator,
            boolean dbg, String dbgUrl) throws Exception {

        String templateId = "email_" + template.templateName + "_" + template.locale
            + "_" + System.currentTimeMillis();
        String endpointUrl = String.format(EMAIL_API_URL, dataCenter);
        JSONObject payload = new JSONObject();

        JSONObject basicDetails = new JSONObject();
        basicDetails.put("subject", template.subject);
        basicDetails.put("email_content", template.htmlContent);
        basicDetails.put("sender_name", "Brand Communications"); // TODO: customise as needed

        JSONObject metaInfo = new JSONObject();
        metaInfo.put("template_id", templateId);
        metaInfo.put("template_name", template.templateName);
        metaInfo.put("template_version", "1.0");
        metaInfo.put("created_by", initiator);
        metaInfo.put("variation", variationNumber);
        metaInfo.put("locale", template.locale);
        metaInfo.put("group_id", parentGroupId); // Links child to parent group

        payload.put("basic_details", basicDetails);
        payload.put("meta_info", metaInfo);

        sendToMoEngageAndGetResponse(endpointUrl, apiKey, apiSecret,
            payload.toString(), dbg, dbgUrl);
    }

    // HTML cleaning: extracts <style> blocks + <body> content,
    // strips AEM-specific script tags and data attributes
    private String cleanContentAsIs(String html) {
        if (html == null || html.isEmpty()) return "";
        StringBuilder result = new StringBuilder();

        Matcher headMatcher = Pattern.compile("<head[^>]*>(.*?)</head>",
            Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(html);
        if (headMatcher.find()) {
            String headContent = headMatcher.group(1);
            Matcher styleMatcher = Pattern.compile("<style[^>]*>.*?</style>",
                Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(headContent);
            while (styleMatcher.find()) result.append(styleMatcher.group()).append("\n");
        }

        String bodyContent = html;
        Matcher bodyMatcher = Pattern.compile("<body[^>]*>(.*?)</body>",
            Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(html);
        if (bodyMatcher.find()) {
            bodyContent = bodyMatcher.group(1);
        } else {
            bodyContent = html
                .replaceAll("(?is)<!DOCTYPE[^>]*>", "")
                .replaceAll("(?is)<html[^>]*>", "")
                .replaceAll("(?is)</html>", "")
                .replaceAll("(?is)<head[^>]*>.*?</head>", "");
        }

        bodyContent = bodyContent
            .replaceAll("(?is)<script[^>]*>\\s*\\(function\\(\\)\\s*\\{\\s*var imageDiv[^}]+\\}\\s*\\)\\(\\);\\s*</script>", "")
            .replaceAll("(?is)<script[^>]*>.*?CQ_Analytics.*?</script>", "")
            .replaceAll("(?is)<script[^>]*src=\"/etc\\.clientlibs/[^\"]*\"[^>]*></script>", "")
            .replaceAll("\\s*data-cmp-[^=]*=\"[^\"]*\"", "")
            .replaceAll("\\s*data-sly-[^=]*=\"[^\"]*\"", "");

        result.append(bodyContent);
        return result.toString().trim();
    }

    private String externalizeLinks(String content, String domain) {
        if (content == null || domain == null || domain.isEmpty()) return content;
        return content
            .replaceAll("(src|href)=\"(/content/[^\"]+)\"", "$1=\"" + domain + "$2\"")
            .replaceAll("(src|href)=\"(/etc\\.clientlibs/[^\"]+)\"", "$1=\"" + domain + "$2\"")
            .replaceAll("(src|href)=\"(/libs/[^\"]+)\"", "$1=\"" + domain + "$2\"");
    }

    private String renderHtml(String path, ResourceResolver resolver) throws Exception {
        HttpServletRequest req = requestResponseFactory.createRequest("GET", path + ".html");
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        HttpServletResponse resp = requestResponseFactory.createResponse(out);
        requestProcessor.processRequest(req, resp, resolver);
        return out.toString(StandardCharsets.UTF_8.name());
    }

    private String sendToMoEngageAndGetResponse(String endpointUrl, String key, String secret,
            String json, boolean dbg, String dbgUrl) throws Exception {
        if (dbg && dbgUrl != null && !dbgUrl.isEmpty()) {
            sendDebug(json, "POST", dbgUrl);
        }
        URL url = new URL(endpointUrl);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setRequestProperty("Content-Type", "application/json");
        conn.setRequestProperty("MOE-APPKEY", key);
        String auth = Base64.getEncoder().encodeToString((key + ":" + secret)
            .getBytes(StandardCharsets.UTF_8));
        conn.setRequestProperty("Authorization", "Basic " + auth);
        conn.setConnectTimeout(30000);
        conn.setReadTimeout(30000);
        conn.setDoOutput(true);
        try (OutputStream os = conn.getOutputStream()) {
            os.write(json.getBytes(StandardCharsets.UTF_8));
        }
        int code = conn.getResponseCode();
        if (code >= 400) throw new Exception("MoEngage Error Code: " + code);
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
            return reader.lines().collect(Collectors.joining("\n"));
        }
    }

    private void sendDebug(String payload, String method, String debugUrl) {
        try {
            URL url = new URL(debugUrl + "?method=" + method);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            conn.setDoOutput(true);
            try (OutputStream os = conn.getOutputStream()) {
                os.write(payload.getBytes(StandardCharsets.UTF_8));
            }
            conn.getResponseCode();
        } catch (Exception e) { /* Non-critical */ }
    }

    private String getCleanPath(String path) {
        if (path == null) return "";
        if (path.endsWith("/jcr:content")) return path.substring(0, path.indexOf("/jcr:content"));
        if (path.endsWith("/jcr:content/metadata")) return path.substring(0, path.indexOf("/jcr:content/metadata"));
        return path;
    }

    private Map<String, String> parseArgs(String args) {
        Map<String, String> map = new HashMap<>();
        if (args == null || args.isEmpty()) return map;
        for (String pair : args.split(",")) {
            String[] kv = pair.split("=", 2);
            if (kv.length == 2) map.put(kv[0].trim(), kv[1].trim());
        }
        return map;
    }

    private static class EmailTemplate {
        String type;
        String subject;
        String htmlContent;
        String templateName;
        String locale;

        EmailTemplate(String type, String subject, String htmlContent,
                      String templateName, String locale) {
            this.type = type;
            this.subject = subject;
            this.htmlContent = htmlContent;
            this.templateName = templateName;
            this.locale = locale;
        }
    }
}

Step 2: Create the Email Sync Workflow Model

  1. Navigate to Tools > Workflow > Models in AEM Author.
  2. Click Create > Create Model and enter the following details:
    1. Title: MoEngage Email Sync
    2. Name: moengage-email-sync
  3. Open the workflow for editing and delete the default step.
  4. Drag a Process Step from the sidebar onto the canvas.
  5. Double-click the Process Step to configure it.
  6. In the Process dropdown, select MoEngage Email Sync (Parent + Child - Stable).
  7. In the Arguments field, enter:
    moengage.api.key=YOUR_WORKSPACE_ID,moengage.api.secret=YOUR_API_SECRET,moengage.datacenter=YOUR_DATACENTRE_VALUE,aem.publish.url=https://your-publish-domain.com
    
  8. Click OK to save, then click Sync to activate the workflow model.

Configuration Parameters

ParameterDescriptionRequired
moengage.api.keyYour MoEngage Workspace IDYes
moengage.api.secretYour MoEngage Campaign API Secret (used for Basic Auth)Yes
moengage.datacenterMoEngage data center code (e.g., 02, 03, 04)Yes
aem.publish.urlBase URL of your AEM Publish instance, used to externalize relative asset linksYes
moengage.debugEnable debug mode — sends payloads to the debug webhook before the MoEngage API call (true/false)No
moengage.debug.urlWebhook URL for payload inspection (e.g., webhook.site)No

Step 3: Configure the Workflow Launcher

Navigate to Tools → Workflow → Launchers and create a launcher for Experience Fragments:
Launcher NameEvent TypeNodetypePathConditionWorkflow
moengage-xf-email-syncModifiedcq:PageContent/content/experience-fragments/[your-site]cq:lastReplicationAction==Activatemoengage-email-sync
Important
  • Set Run Modes: author
  • Set Enabled: true
  • Replace [your-site] with your actual site path
  • If you are already using the MoEngage Content Sync launcher on the same XF path, ensure both launchers are scoped correctly to avoid double-processing. You can use path conditions or separate XF sub-folders to differentiate content-block XFs from email-template XFs.

Step 4: Test the Email Template Sync

Enable Debug Mode (Recommended for First Run)
For initial testing, add debug parameters to workflow arguments:
moengage.debug=true,moengage.debug.url=https://webhook.site/your-unique-id
Get a free webhook URL from webhook.site.
  1. In AEM Author, navigate to the Experience Fragment you want to sync as an email template. Ensure it has at least one variation (child page). For locale-based grouping, ensure each variation’s title or node name ends with a locale suffix (e.g., promo-email-de_de).
  2. Select the page information icon and click Start Workflow.
  3. Select MoEngage Email Sync from the dropdown and click Start.
  4. Monitor crx-quickstart/logs/error.log for log entries prefixed with MoEngageEmailSyncStep to confirm parent and child template sync.
  5. In MoEngage, navigate to Content > Email Templates to confirm the template group was created with the correct locales.

Content Sync vs. Email Template Sync — Key Differences

Content Sync (MoEngageContentSyncStep)Email Sync (MoEngageEmailSyncStep)
MoEngage destinationContent BlocksEmail Templates
API endpoint/v1/external/campaigns/content-blocks/v1.0/custom-templates/email
Supported content typesExperience Fragments, Content Fragments, DAM AssetsExperience Fragments only
Locale / groupingNot applicable — each XF variation syncs as an independent blockVariations grouped as parent + children with locale codes
Update behaviourSearch by name → PUT if exists, POST if newAlways POST — creates a new template version each sync
Support and CustomisationThe sample code covers the core sync flow. Common customisations include adding more locales to VALID_LOCALES, customising the sender_name field per brand, or adjusting the locale detection regex to match your AEM naming conventions. For implementation assistance, contact MoEngage Support or your Customer Success Manager.