How to build a Search and Replace app in Salesforce Marketing Cloud with Vue, SSJS and REST API

How to build a Search and Replace app in Salesforce Marketing Cloud with Vue, SSJS and REST API

This article explains how to build a Search and Replace application in Salesforce Marketing Cloud with the use of Bootstrap, Vue.js, server-side JavaScript and REST API.

The problem

Marketing Cloud is a great tool. It works wonders for Marketing Automation and the interface is clear and simple to use.

But, as our assets folder grows, we all end up facing the same issue: the blocks are harder to find and the content of the blocks can’t be searched and modified in bulk.

Have you ever been assigned a task to find and replace a broken link or any other occurrence in a haystack of countless content blocks?

It turns out, the Content Builder search engine only returns approximate results when it comes to the content and there is no way around it.

Unless… there is another solution: we build this feature ourselves!

The tools

Before we proceed with defining the flow, we first need to define the tools we are going to use:

Keep in mind, that the only reason we use jQuery is because Bootstrap needs it to to run its components. There is nothing jQuery does that plain JavaScript can’t do, but in this case, it’s just easier this way.

The solution

Here is how the Search and Replace application works:

  1. User types the keywords in the search bar and pushes the action button.
  2. AJAX request with the keywords is sent to the Form Handler page.
  3. Form Handler page retrieves all the blocks with approximate occurrences using REST API, then goes through every block, looking for the exact match.
  4. If the application was set to Search, nothing else happens. But if it was set to Search and Replace, for each exact match, a REST API call is sent to update the asset block.
  5. Form Handler page returns the result to the page with the search bar.
  6. Search bar page displays the names and the types of the asset blocks where the keyword was found (and modified).
  7. Download button allows the results to be downloaded in the CSV file format.

Building the application

Before building the application, please make sure to have a couple of content blocks of different type populated with some keywords for testing purposes.

Cloud page

The application is managed by 2 fundamental functionalities: status and action.

Status defines the different steps in the application management:

  • Status = 100: the application is initialized.
  • Status = 101: the request was sent to the Form Handler.
  • Status = 200: the Form Handler returned the results.
  • Status = 400: the Form Handler returned no results.
  • Status = something else: the application failed.

As for the action, it has 2 possible values: search and replace:

  • Search: only the search field is displayed.
  • Replace: both search and replace fields are displayed. Also, the user has to confirm the replacement action before the request is sent to the Form Handler page.

Bootstrap is in charge of the appearance, the confirmation pop-up and the segmented button functionality.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta name="viewport"
        content="width=device-width, height=device-height, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no">
    <title>Search & Replace App</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
</head>
<body>
<div id="app" class="container">
    <div class="mx-auto my-5">
        <template>
            <h2>Search & Replace</h2>
            <form class="needs-validation my-4" ref="form" action="{{ YOUR FORM HANDLER URL }}" method="post">
                <div class="input-group input-group-lg">
                    <input 
                        class="form-control"
                        v-model="form.search"
                        @keyup="status = 100"
                        type="text"
                        minlength="3"
                        maxlength="254"
                        placeholder="Search..."
                        required
                    >
                    <div class="invalid-feedback order-last">
                        Your text should be at least 3 characters long.
                    </div>
                    <input 
                        class="form-control"
                        v-model="form.replace"
                        v-if="action == 'replace'"
                        @keyup="status = 100"
                        type="text"
                        minlength="3"
                        maxlength="254"
                        placeholder="Replace by..."
                        required
                    >
                    <div v-if="action == 'replace'" class="invalid-feedback order-last">
                        Your text should be at least 3 characters long.
                    </div>
                    <div class="input-group-append">
                        <button 
                            @click.prevent="takeAction()"
                            type="button" 
                            class="btn btn-primary"
                            :disabled="(status == 101)"
                        >
                        {{ button }}
                        </button>
                        <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                            <span class="sr-only">Toggle Dropdown</span>
                        </button>
                        <div class="dropdown-menu">
                            <a class="dropdown-item" href="#" @click.prevent="action = 'search'">Search</a>
                            <a class="dropdown-item" href="#" @click.prevent="action = 'replace'">Search & replace</a>
                        </div>
                    </div>
                </div>
            </form>
        </template>
        <template v-if="status == 100"></template>
        <template v-else-if="status == 101">
            <div class="text-center text-primary">
                <div class="spinner-border" role="status">
                    <span class="sr-only"></span>
                </div>
            </div>
        </template>
        <template v-else-if="status == 200">
            <h3 v-if="action == 'search'" class="mb-4">Search results for "{{ form.search }}":</h3>
            <h3 v-if="action == 'replace'" class="mb-4">Replaced "{{ form.search }}" by "{{ form.replace }}" in:</h3>
            <ul class="list-group mt-2">
                <li class="list-group-item" v-for="result in results">
                    <strong>{{ result.name }}</strong> 
                    <small class="float-right text-info">{{ result.type }}</small>
                </li>
            </ul>
            <br>
            <button @click.prevent="downloadResults()" class="btn btn-primary">Download results</button>
        </template>
        <template v-else-if="status == 404">
            <div class="text-center">
                <h3>Sorry but nothing was found.</h3>
            </div>
        </template>
        <template v-else>
            <div class="text-center text-danger">
                <h3>Huston, we have a problem.</h3>
            </div>
        </template>
        <div class="modal fade" id="confirmModal" tabindex="-1" role="dialog" aria-labelledby="confirmModal" aria-hidden="true">
            <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="exampleModalLabel">Are you sure?</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                    </div>
                    <div class="modal-body">
                        This operation can't be reverted. Proceed at your own risk.
                    </div>
                    <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Abort</button>
                    <button type="button" class="btn btn-primary" @click="sendFormData(); confirmModal();">Replace all</button>
                </div>
            </div>
            </div>
        </div>
    </div>
</div>
<script src="{{ YOUR JS FOLDER URL }}/app.js"></script>
</body>
</html>

Vue.js application

Please refer to the comments in the code to get a better understanding.

Fire up Vue.js DevTools Chrome extension to observe how the application manages the data.

new Vue({
    el: '#app',
    data: {
        status: 100,
        form: {
            'search': '',
            'replace': ''
        },
        button: 'Choose an action',
        action: '',
        endpoint: '',
        results: {}
    },
    watch: {
        action: function () {
			/* When the action changes, remove the validation
			highlights, hide the search results and change the
			label of the search button */
			
            this.$refs.form.classList.remove('was-validated');
            this.status = 100;

            switch (this.action) {
                case 'search':
                    this.button = 'Search'
                    this.form.replace = ''
                    break;
                case 'replace':
                    this.button = 'Search and replace'
                    break;    
                default:
                    this.button = 'Choose an action'
                    break;
            }
        }
    },
    mounted: function() {
		// Get the URL from the form tag action attribute
        this.endpoint = this.$refs.form.getAttribute('action');
    },
    methods: {

        sendFormData: function() {
			/* Send form data to the Form Handler, set the status
			to "awaiting response", then change it according to
			the request status and return the results */

            this.status = 101;
            var $this = this;

            axios({
                method: 'POST',
                url: this.endpoint,
                data: this.form,
                validateStatus: function() { return true }
            }).then(function(result) {
                $this.status = result.data.Status;
                $this.results = result.data.Results;
                if($this.status == 500) {
                    console.error(result.data.Message);
                }
            }).catch(function(error) {
                console.error(error);
            });
        },

        validateForm: function() {
			/* If the form is valid and the action is set to "replace"
			prompt the user with the pop-up window, then send the data.
			If the action is set to "search" and the form is valid,
			send the data right away */

            if (this.$refs.form.checkValidity() !== false) {
                if(this.action == 'replace') this.confirmModal(); 
				else this.sendFormData();
            }
            this.$refs.form.classList.add('was-validated');

        },

        confirmModal: function() {
			// Toggle the Bootstrap Modal component
            $('#confirmModal').modal('toggle');
        },

        takeAction: function() {
			// Reset the status and validate the form
            if(this.action == 'search' || this.action == 'replace') {
                this.status = 100;
                this.validateForm();
            } else {
                return false;
            }
        },

        downloadResults: function() {
			/* Compile the results sent from the Form Handler
			in a CSV file and trigger the download action */
			
            var csv = 'Id,Name,Type\n';
            var list = JSON.parse(JSON.stringify(this.results));

            for(k in list) {
                csv += (list[k].id + ",");
                csv += (list[k].name + ",");
                csv += (list[k].type + ",");
                csv += "\n";
            }
        
            var el = document.createElement('a');
            el.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
            el.target = '_blank';
            el.download = this.action + '-results.csv';
            el.click();
        }
    }
})

Form Handler page

In order to use REST API in Marketing Cloud, you first need to create a server-to-server API integration and fetch the API endpoint URL, ClientId and ClientSecret credentials.

The process of the Form Handler page works as follows:

  1. Retrieve a token for performing the REST API requests.
  2. Make the first request to retrieve all the blocks of type HTML of FreeForm, but filter on 1 result per page. From this operation we can make an estimation of how many pages there are in total if we decide to retrieve 100 results per page (using Math!).
  3. Create a loop that runs as many times as there are pages and retrieves 100 results at a time.
  4. Retrieve the content of the block from each result and look for the exact match for the search keyword.
  5. If the action is set to “Replace“, replace the keyword in the retrieved content and perform a REST API update request to change the content of the block.
  6. Write the results of the operation in JSON format.

The reason behind the estimation and loop processes is merely the fact that REST API only returns paginated results and the number of results is limited by page.

<script runat="server">
    Platform.Load("Core", "1.1.1");

    var postData = Platform.Request.GetPostData();

    var parsedData = Platform.Function.ParseJSON(postData);

    var searchNeedle = parsedData.search;
    var replaceBy = parsedData.replace;

    var config = {
        endpoint: "https://{{ YOUR BASE URL }}.auth.marketingcloudapis.com/v2/token",
        contentType: "application/json",
        credentials: {
            client_id: "{{ YOUR CLIENT ID }}",
            client_secret: "{{ YOUR CLIENT SECRET }}",
            grant_type: "client_credentials"
        }
    }

    try {

        if(searchNeedle == null || searchNeedle.length === 0) throw "No search requested."

        var accessTokenRequest = HTTP.Post(config.endpoint, config.contentType, Stringify(config.credentials));

        if (accessTokenRequest.StatusCode == 200) {
            var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
            var accessToken = tokenResponse.access_token;
            var restInstanceUrl = tokenResponse.rest_instance_url
        } else {
            throw "Access token request has failed."
        }

        if (accessToken != null) {

            var requestUrl = restInstanceUrl + "asset/v1/content/assets/query";
            var headerNames = ["Authorization"];
            var headerValues = ["Bearer " + accessToken];
            var jsonBody = {
                "page": {
                    "page": 1,
                    "pageSize": 1
                },
                "query": {
                    "leftOperand":
                    {
                        "property": "assetType.name",
                        "simpleOperator": "equal",
                        "value": "freeformblock"
                    },
                    "logicalOperator": "OR",
                    "rightOperand":
                    {
                        "property": "assetType.name",
                        "simpleOperator": "equal",
                        "value": "htmlblock"
                    }
                },
                "fields": [
                    "content",
                    "category",
                    "name"
                ]
            };

            /////////////////////////////////////////////////////
            ////// ESTIMATION
            /////////////////////////////////////////////////////

            var res = HTTP.Post(requestUrl, config.contentType, Stringify(jsonBody), headerNames, headerValues);

            if(res.StatusCode != 200) throw "Estimation request has failed."

            var resultJSON = Platform.Function.ParseJSON(String(res.Response));
            var count = resultJSON.count;
            var pageSize = 100;

            /////////////////////////////////////////////////////
            ////// SEARCH
            /////////////////////////////////////////////////////

            var totalPages = Math.round(count / pageSize) + 1;
            var searchResults = [];

            for(var i = 1; i <= totalPages; i++) {

                jsonBody.page.page = i;
                jsonBody.page.pageSize = pageSize;

                var res = HTTP.Post(requestUrl, config.contentType, Stringify(jsonBody), headerNames, headerValues);

                var resultJSON = Platform.Function.ParseJSON(String(res.Response));

                var items = resultJSON.items;

                for(k in items) {

                    var item = items[k];

                    if (String(item.content).indexOf(searchNeedle) != -1) {

                        if(replaceBy != null && replaceBy.length > 0) {

                            /////////////////////////////////////////////////////
                            ////// REPLACE
                            /////////////////////////////////////////////////////

                            var updateUrl = restInstanceUrl + "asset/v1/content/assets/" + item.id;

                            var newContent = replaceAll(String(item.content), searchNeedle, replaceBy)

                            var req = new Script.Util.HttpRequest(updateUrl);
                            req.emptyContentHandling = 0;
                            req.retries = 2;
                            req.continueOnError = true;
                            req.setHeader("Authorization", "Bearer " + accessToken);
                            req.method = "PATCH";
                            req.contentType = config.contentType
                            req.postData = Stringify({ Content: newContent });

                            var resp = req.send();

                            if(resp.statusCode == 200) searchResults.push({
                                id: item.id,
                                name: item.name,
                                type: item.assetType.name
                            });

                        } else {
                            searchResults.push({
                                id: item.id,
                                name: item.name,
                                type: item.assetType.name
                            });
                        }
                    };
                }
            }

            var output = {
                Status: (searchResults.length > 0) ? 200 : 404,
                Results: searchResults
            }

            Write(Stringify(output));

        }

    } catch (error) {
        Write(Stringify({ Status: 500, Message: error }));
    }

    function replaceAll(str, find, replace) {
        var target = str;
        return target.split(find).join(replace);
    }
</script>

See it in action

Conclusion and considerations

Et voilà! Try it out! This application can work miracles in the right hands.

Although, please consider the following:

  • Make sure all the security measures are implemented on the Form Handler page. I didn’t include them in this article to save space.
  • Please make sure to test this application before using, preferably on the test asset blocks created for this purpose.
  • Run a Search request before Search and Replace, as there will be no going back once the asset blocks are updated.
  • This code requires a lot of REST API calls, dont abuse it, as they are limited in the number of use according to your contract with Salesforce.
  • The more asset blocks you query, the slower the application will work.
    Be patient.

Notes and props

In case you were wondering, I could have indeed used jQuery for AJAX calls, but I deeply dislike this framework and I prefer to use it as less as possible. And I dislike LESS as well, because SASS is just better.

This article was made possible thanks to the awesome article from Zuzanna Jarczynska about file uploads with REST API.

Have I missed anything?

Please poke me with a sharp comment below or use the contact form.

  1. It is wonderful!!!

    i have use case-
    if say search results shows up 10 content blocks, i need only specific content blocks to be replaced, how to acheive this ? this would be great help.

    Like say having checkbox to resultant content block, if checked box ticked then replace all to the checked content box.

    Awaiting for your thoughts on it!!

  2. You’ll need to rewrite the app for this, but it’s totally possible. After the search result, you get the id’s of all the found blocks. Using those ids you can filter on the blocks you want to be affected by the replacement method.


    var ids_of_blocks = [12345,45678,91011];
    function inArray(arr, k) {
    var out = -1;
    for (var i in arr) {
    if (arr[i] == k) out = i;
    }
    return out;
    }
    if (String(item.content).indexOf(searchNeedle) != -1 && !!inArray(ids_of_blocks,item.id) ...

  3. Thank you for turn around.

    I there way to search other than content block, say paste html or template based email.

    Thanks,
    Praveen

  4. HTML blocks yes, that’s what I do in the app. An email… haven tried that yet, but I assume an email is just “template + ampscript references to the content blocks it uses”. Otherwise these are the asset types you can try to query: templatebasedemail, htmlemail and textonlyemail.

  5. I’m not able to retrieve templatebasedemail search text, could you please help me out in this.

    That would be great help.

  6. Do you really need it? templatebasedemail is related to Classic emails and Classic emails were removed from SFMC recently.

  7. Yes, if there is turn around that might help!

    Please let me know.

  8. Hey Ian, thanks for updating the code to search ‘html’ type. For others reading this comment, in the /assets/query, you can use one of the asset type values as in https://developer.salesforce.com/docs/atlas.en-us.mc-apis.meta/mc-apis/base-asset-types.htm.

    Ian – one question. When I try this, the search and replace works fine (the html gets replaced just fine). But the handler code throws error. The response bascially says this.

    Post Data ==> {“search”:”latest_”,”replace”:”latest”}https://…rest.marketingcloudapis.com/asset/v1/content/assets/query {“page”:{“page”:1,”pageSize”:1},”query”:{“leftOperand”:{“property”:”assetType.name”,”simpleOperator”:”equal”,”value”:”freeformblock”},”logicalOperator”:”OR”,”rightOperand”:{“property”:”assetType.name”,”simpleOperator”:”equal”,”value”:”htmlblock”}},”fields”:[“content”,
    “category”,
    “name”]}Response ==> {“Status”:200,”Results”:[{“id”:7264,”name”:”201812_LLate_Payment_Cell_ID1″,”type”:”htmlblock”}]}
    (function(a,m,i,g,o,s){o=a.createElement(g);s=a.getElementsByTagName(i)[0];o.src=m.origin+m.pathname+”/_t?eventType=CLOUDPAGESVISIT”;o.width=0;o.height=0;o.style.display=”none”;s.appendChild(o);})(document,window.location,”body”,”img”);

    if (_etmc && typeof _etmc.push === ‘function’) {
    _etmc.push([‘setOrgId’, ‘1234’]);
    _etmc.push([‘trackPageView’]);
    }

    The first part is something that I’ve added to debug. Any thoughts on what the error is about? Should I be running this in parent BU and not child BU?

  9. hey Matheswaran, you created your Form Handler page using Classic editor and what you are seeing is the tracking code that is automatically added to the HTML of the page. Try creating the page using Content Builder option and everything should work fine.

  10. Hi Ivan,

    Is there any way to retrieve the folder path of resultant block?

    Appreciate your help on this!!!

  11. Thanks this is really helpful, definitely will save me and the team a lot of time! I’m just getting stuck on one thing I’m trying to do, I want to include codesnippetsblock in the search query as well but I can’t seem to figure out how to add a third condition. I tried nesting a query and adding another query object but to no avail. Can you point me in the right direction?

  12. You’re welcome mate 😉 I spent so many sleepless nights that one day I started this blog, so people don’t get blocked all the time by the lack of documentation ^^

  13. Hi Ivan,

    I tried to create this page today and there seems to be a change in response of asset/v1/content/assets/query. So, I made two changes.

    1. Change query in jsonBody as
    “query”: {
    “property”: “content”,
    “simpleOperator”: “mustcontain”,
    “value”: searchNeedle
    }

    Since ‘content’ with ‘mustcontain’ used, this does a ‘partial search’ for any given keyword in any of the asset type (email, html, codesnippet).

    2. The API doesn’t seem to return item.content from below line.

    if (String(item.content).indexOf(searchNeedle) != -1) {

    So, I change that to

    if (String(item.name)) {

    I didn’t try out the replace function yet – as we didn’t wanted it 🙂

  14. Hi Ivan, how did you manage to link external files in MC Cloud Page (ex. Bootstrap, jQuery, etc.). I need to like one external JS file, but MC is just ignoring it. Thanks a lot!

  15. yes, you need to put it in the Code View, like you would normally do it on a regular HTML page

  16. Hi, I’m fairly entry level at this and I tried to follow your instructions, but I’m getting the following errors. I put the vue script in as the “{{ YOUR JS FOLDER URL }}/app.js”, but it wasn’t clear if that was correct to me or if something else would go there?

    Failed to load resource: the server responded with a status of 400 (Bad Request)
    vue:61 Object
    (anonymous) @ vue:61
    DevTools failed to load source map: Could not load content for chrome-extension://makmhllelncgkglnpaipelogkekggpio/nonESPInject.js.map: HTTP error: status code 404, net::ERR_UNKNOWN_URL_SCHEME

  17. Liz, please replace every time your see something like {{ blab bla }} by whatever is indicated. So {{ YOUR JS FOLDER URL }} could be wherever you store your JS file. It could be a Marketing Cloud URL, it could be somewhere else. It’s a common knowledge for coders when you see something like {{ … }} or [[ .. ]] that it’s a placeholder.

  18. I replaced the missing data, now I don’t get any errors, but it doesn’t find anything. Does this work when you have multiple business units? I set this up in our main one so it still should find things, but maybethere’s something different about the locations?

    Thank you so much!

  19. What does REST API return? There might be an issue with your credentials or the API integration. You have to run your app on the parent business unit and provide the app with the credentials for each child business unit. You also need to create the integration either from the parent BU or the BU the data of which you are trying to access. Otherwise, indeed it will return nothing and give you no error. SFMC is tricky that way.

  20. Thank you for replying. I’ve checked and the package seems fine, all the credentials are set right and it correctly references both the vue and form handler. For whatever reason it isn’t finding anything (whether on the same BU or the parent) and I’m not able to debug API calls so I don’t know what is happening. I do want to say thank you for building this, as it clearly is helping others. Maybe someday someone will package it up for the appexchange or (dare I hope) Salesforce will build it into SFMC. I really appreciate your time & help!

  21. No worries 🙂 The thing is, if a package seems fine, it doesn’t mean that it was created in the right BU. Could you create a new package from the parent BU, grant it access to all the child BUs and make sure it has access rights to the Content Builder?

  22. Hi
    I have a query:
    Bootstrap code in cloudpage landing page
    Script in cloudpage js
    vue.js in ????

  23. Hi Ivan, I am a beginner and found your post interesting…
    I have made the changes in the code with I need to do {{ … }} and added a script tag to the vue.js and copied it to a code resource page in MC. But I can kind some error on the console
    You are running Vue in development mode.
    ***Make sure to turn on production mode when deploying for production.
    See more tips at https://vuejs.org/guide/deployment.html
    ykhrgn5byap:2 Uncaught SyntaxError: Unexpected token ‘<'
    favicon.ico:1 Failed to load resource: the server responded with a status of 400 (Bad Request)***

    And return an empty web page.

    I have tried using virtual studio code and the Form Handler page is not linking.

  24. Hi Ivan
    I have tried to replicate what you have done and everything runs good but the handler pages throws an error.

    When I try to search something this error occurs.
    {“Status”:500,”Message”:”No search requested.”}

  25. Hi Dhanush,

    That’s because your form handler doesn’t receive the data you are sending to it from the Cloud page. Make sure both the Cloud page and the Form Handler are on the same domain and use the same HTTP or HTTPS protocol.

  26. Dhanush,

    Please try to learn HTML first before trying to implement something this complex.

    This error is a simple copy-paste gone wrong.

Comments are closed.

Up Next:

How to use Vue, Bootstrap and reCAPTCHA to build forms for Salesforce Marketing Cloud

How to use Vue, Bootstrap and reCAPTCHA to build forms for Salesforce Marketing Cloud