Create Reusable Dynamic Blocks in Twilio SendGrid with this React Application

Developer coding a reusable Dynamic Blog
July 25, 2023
Written by
Twilion
Reviewed by
Twilion
Twilion
Twilion
Twilion

If you are using Twilio SendGrid frequently and creating Dynamic Templates for your emails, you might have wondered: “Is there a way I can create a small, reusable block of components and update multiple templates with it?”

Currently, SendGrid does not offer a console-based capability to do that.

In this post, I will take you through an (unofficial!) application that I hope can assist you with creating a reusable, composable, updatable content block for SendGrid Dynamic Templates. Let’s get started!

A tour of the application

Given the multiple sections of this application, I have created a video tour that hopefully gives a good idea of how to use it. This tour is on a non-technical level so anyone should be able to follow it!

Architecture

Below is a high level overview of the architecture followed in this application.

Architecture to manage reusable content blocks in SendGrid via a React App
  • Hashrouter from react-router-dom is used to handle the client-side navigation. This is because Twilio Serverless won’t be able to handle the client side routing (you will get lots of ‘not found’ in anything other than the main index.html page)
  • When the user logs in using the relevant Twilio Function, a JWT token is generated and placed as an httpOnly cookie in the user’s browser
  • Every component (that corresponds to a logged-in only route) is wrapped with the Auth component that checks the jwt. So, for example, if it has expired, the user will be logged out. You can set the expiry time from the relevant Twilio Function jwt.js
  • When the user takes an action, for example to update a template, the relevant Twilio Function calls the SendGrid APIs and returns the results needed.
  • A Redux store with localStorage is used to maintain the folder/file structure of the dynamic blocks that the user might create. Currently there is no backend to manage this as the purpose of the app at this stage is to deploy within Twilio as quickly as possible
  • HTML sanitization is used throughout the application as it needs to render HTML for the user in multiple components. A reusable component called HTMLSanitize.js does that by leveraging the DOMPurify library

This app is not an official Twilio SendGrid product, but an application provided as-is. If you plan to use this application in production, please make sure you explore and change or improve aspects such as the security handling.

Below we'll explore the prerequisites, setup, and general technical concepts.

Prerequisites

First, if you haven’t yet, you’ll need to sign up for a (free) Twilio SendGrid account. Optionally, if you will send your data to a CDP, sign up for a Twilio Segment account as well.

The main Github repository for this application lives here: https://github.com/evanTheTerribleWarrior/sendgrid-dynamic-blocks

The repository README file provides instructions on how to set it up. The important parts are mentioned below. The app has been built with fast deployment in mind.

  1. Install the Twilio CLI
  2. Install the serverless toolkit
  3. Create a SendGrid API Key. You don't need to give it full permissions. Template Engine should be enough
  4. (Optional) Create a Segment CDP Source. If you want to send data like Web Vitals to Segment CDP, create a Javascript Source - we will use the Write Key below

Excellent – with all the prerequisites in place, we can move on to setting up the code.

Setup

First, clone the repository and enter the folder:

git clone https://github.com/evanTheTerribleWarrior/sendgrid-dynamic-blocks.git
cd sendgrid-dynamic-blocks

Go to frontend directory and run npm install to install the relevant node modules:

cd frontend
npm install

Go to serverless directory, run npm install and create an .env file for all the important environment variables

cd serverless
npm install
cp .env.example .env

The following environment variables exist by default in your .env file that you created in the previous step:

SG_API_KEY=
SEGMENT_WRITE_KEY=
USERNAME=
PASSWORD=
JWT_SECRET=

Ensure that PASSWORD and JWT_SECRET are hard to guess. With the current implementation, the application relies on a single login (no users/signup process)

As part of the setup, there are 3 scripts depending on how you want to use the application:

1. You can deploy via the Twilio Serverless API. This will build your application and will upload the result as Assets and Functions in the Twilio account

zsh setup-remote.sh
# View your app at https://[my-runtime-url].twil.io/index.html

2. You can deploy locally using the Twilio Serverless API. This will build your application and run the Assets and Functions from your local environment

zsh setup-local-same-port.sh
# View your app at http://localhost:3002/index.html (or set the port you want)

3. You can run the frontend and backend locally without building the application. This is best for straightforward development and debugging. Note that the application is configured to proxy requests in the same port that the Functions run, due to the credentials: 'same-origin' used in the requests

zsh setup-local-diff-port.sh

If all goes well, you should see the Login page!

Code design decisions

The Material UI library is used across the application to provide the UI.

Given the complexity of creating folder trees and multiple levels in a reusable dynamic block (conditions with nested conditions and components), the application uses recursion in multiple places to handle updates such as insertions, deletions, etc. For example, here is a function to rename a folder or file from FolderTree.js:

const handleRenameItemRecursive = (folders, newName) => {

    return folders.map((item) => {
      if (item.id === selectedItem.id) {
        return {
          ...item,
          name: newName
        };
      } else if ( item.children && item.children.length > 0) {
        return {
          ...item,
          children: handleRenameItemRecursive(item.children, newName)
        };
      }
      return item;
    });
  }

The general data model that this uses can be seen in the Redux directory under Redux -> slices -> folderStructureSlice.js

const folderStructureSample = 
[
    { id: uuid(), type: 'folder', name: 'My First Folder', children: []} 
]

This is the initial state. Each item has a unique id generated by the uuid library. If it is a folder, then it can have children (other folders of files). If it is a file, it can’t have children

On the backend (Twilio Functions side), one of the important aspects is authenticating all the requests based on a valid JWT.

So you will see this snippet of code:

const checkAuthPath = Runtime.getFunctions()['check-auth'].path;
const checkAuth = require(checkAuthPath)
let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET);
if(!check.allowed)return callback(null,check.response);
const response = check.response

This uses the concept of Private Functions, so it passes the httpOnly cookie to the check-auth private function that takes care of checking multiple conditions and ultimately verify the JWT token. If any condition fails, it returns Unauthorized to the user.

Code structure decisions

In this section, I will give a quick runthrough of the different components to give you a better understanding on how the current version is structured. The below sections are split per Application UI section.

App.js contains:

return(
    <Provider store={store}> 
        <Layout>      
          <Routes>          
            <Route exact path="/build" element={<Auth><DynamicBlockGenerator/></Auth>} />
            <Route exact path="/update" element={<Auth><TemplateUpdater/></Auth>} />
            <Route exact path="/collection" element={<Auth><SavedCollection /></Auth>} />
            <Route exact path="/upload" element={<Auth><ZipUploader /></Auth>} />
            <Route exact path="/settings" element={<Auth><Settings /></Auth>} />
            <Route exact path="/login" element={<Login />} />
            <Route exact path="/" element={<Login />} />
            <Route exact path="/index.html" element={<Login />} />
          </Routes>
        </Layout>
    </Provider>
  );

You can see that we use the Redux Provider and then the Routes component of react-router-dom to provide the different paths.

All the gated components are wrapped with the Auth component, explained below

Login / Auth

This component, called Auth and its child Login take care of the Login process and page shown here:

SendGrid dynamic blocks content manager login page

When the user tries to log in, the following flow occurs:

Login.js —> authenticateUser.js (in functions.js) → jwt.js (in Twilio Functions)

This returns the httpOnly cookie with an expiration date (5h by default)

Then each time the user tries to access a section, Auth checks the validity of the token with jwt.js.

Upon logout, the cookie is deleted.

Create New Block

Block builder in a SendGrid reusable block manager

The main component in this section is called DynamicBlockGenerator and has several child components. The important one that deals with the main block creation logic is called BlockCreation. This is where several recursive functions are used, as mentioned above, in order to traverse the rows structure.

One of the prominent functions is generateCode. This recursive function takes the rows that get generated and runs through them in order to generate both the steps generated and the html code that will be rendered for the user.

Here is a relevant snippet as an example:

if (row.type === "condition" && row.condition) {

        code += `${indent}${row.condition}`;
        if(row.variable1) code += ` ${row.variable1}`
        if(row.variable2) code += ` ${row.variable2}`
        code += '}}\n'

        const html_condition_value = row.condition + (row.variable1 ? ` ${row.variable1}`: "") + (row.variable2 ? ` ${row.variable2}`: "") + '}}'
        html += `${getCodeBlockObject("code", html_condition_value)}`

      } else if (row.type === "component" && row.component) {
        let fields = row.component.fields ? row.component.fields: null;
        code += `${indent}<${row.component.label}>${fields? fields[0].value : ""}<${row.component.label}>\n`;
        html += `${getCodeBlockObject(row.component.type, fields ? fields: "", row.component.styles)}\n`
        return;
      }

Here the following takes place:

  • If the row is a “condition”, for example an if statement, then it constructs the code needed, including the relevant angle brackets as required by all conditional statements and the values entered by the user
  • If the row is a component, it takes the relevant value entered by the user (for example an image url) and the styles selected

In order to build the relevant HTML, it uses the getCodeBlockObject function that lives in functions.js. This maps each element to the relevant code block required by the Twilio SendGrid drag and drop editor. This means that when the block is embedded in a template and opened in the Editor, the components can be manipulated correctly, as if they were created in the Editor directly

Update templates

Template updater in a SendGrid content block manager application

The main parent component here is TemplateUpdater. It is split into the 3 steps that the user needs to take.

Some noticeable points are:

In the DynamicTemplateList child component, the Twilio Function get-templates.js is used to pull the relevant Dynamic Templates from the Twilio SendGrid account. You can change the number of templates you want to fetch, as the function implements pagination. The Twilio SendGrid API includes a page_token parameter, so it is checked in the query params passed to it.

const queryParams = {
    "generations": "dynamic",
    "page_size": 10,
    "page_token": event.page_token ? event.page_token : null
  };

Finally, when we use the API, we check to see if the property next is included in the results, as this is a property returned by the API that helps understand the next page we need to retrieve.

if (results_array[0].body._metadata.hasOwnProperty('next')){
      const params = new URLSearchParams(results_array[0].body._metadata.next);
      let page_token = "";
      for (const [key,value] of params) {
        if (key.includes("page_token")) page_token = value;
      }
      res.page_token = page_token;
    }

Another component worth mentioning here is TemplatePrep that essentially helps the user update one or multiple templates.

The logic here is if the user wants to update multiple templates, then they can choose to add them as Footer or Header, as there is no easy way to place the block otherwise – many templates might have a very different structure.

If the user wants to update one template at a time (so they can have more control over where exactly to place the block), then the Dynamic Template that was fetched is broken in “Positions” as shown here

Update blocks in a SendGrid block editor app

This allows the user to choose the exact position.

This section has quite a bit of logic in it, but the main concept is the HTML needs to be manipulated in order to achieve all of the above. As such DOMParser is used (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser) in order to parse the HTML

Here is an example when updating a single template:

   const doc = parser.parseFromString(versionHtmlContent ? versionHtmlContent : selectedTemplateVersion.html_content, 'text/html');
    const preheaderTable = doc.querySelector('table[data-type="preheader"]');
    const siblings = [];
    let currentNode = preheaderTable.nextElementSibling;
      
    while (currentNode) {
      siblings.push(currentNode);
      currentNode = currentNode.nextElementSibling;
    }

This does the following:

  • HTML is parsed using DOMParser
  • The preheader component is retrieved. This is a standard SendGrid table element, after which the actual content of the template is rendered. This content is at the same level as the preheader in the html structure
  • Then we get all the siblings elements that basically represent the actual components we need

Following this logic, the application is able to do several HTML manipulations as needed to accommodate the insertion of the user blocks in the specific places the user chooses.

Saved blocks

Browsing saved blocks in a SendGrid block manager application

The main entry component for this section is SavedCollection.

This component takes care of the user’s stored collection. It allows a user to create folders that include files or other folders. So, essentially, the user can fully shape the structure of their saved blocks.

It leverages one of the most important components in the application, FolderTree, which takes care of the behavior and rendering of the folder structure

Above, I gave an example of renaming a folder. The same recursive approach is used for all different user actions.

This component also allows users to export and import this folder structure from the localStorage. Here is the example of importing and exporting:

const handleExportFolderStructure = () => {
    const jsonData = JSON.stringify(folderStructure.folderStructure, null, 2);
    const blob = new Blob([jsonData], { type: 'application/json' });
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = 'dynamic_blocks_collection.json';
    link.click();
    URL.revokeObjectURL(link.href);
    link.remove();
  }

  const handleImportFolderStructure = (event) => {
    const file = event.target.files[0];
    const reader = new FileReader();
    reader.onload = () => {
      const fileContent = reader.result;
      const importedStructure = JSON.parse(fileContent);
      dispatch(updateFolderStructure(importedStructure))
    };
    reader.readAsText(file);
  }

The purpose of this is to allow the user to export the structure in JSON format and store it locally. The user is of course free to do any changes locally. When imported, the changes will be reflected, as essentially the folder structure is just recreated by using JSON.parse to read the file and then we use redux in order to save it and import to localStorage.

Upload your HTML

Upload locally stored URL for content blocks in a SendGrid block manager application

The main component for this section is ZipUploader.js. This component uses jsZip in order to read the zip file and list its contents

An example of the main code block is shown below:

try {
        const zip = new JSZip();
        const zipFile = await zip.loadAsync(uploadedFile);   
        const htmlWithCss = await generateHTMLWithCSS(zipFile)
        if(uploadLocalImages) {
          const imagesArrayOfObj = await getImageFiles(zipFile)
          await transformImagesForSendGrid(imagesArrayOfObj)
          console.table(imagesArrayOfObj)
          await uploadImagesToSendGrid(imagesArrayOfObj)
          console.table(imagesArrayOfObj)
          console.log(`Current html: ${htmlWithCss}`)
          const updatedHTML = replaceImageReferences(htmlWithCss, imagesArrayOfObj)
          setProcessedHTML(updatedHTML)
        }
        else{
          setProcessedHTML(htmlWithCss)
        }

As you can see, there are a number of asynchronous operations (using the await keyword to call these functions).

The logic here is as follows:

  • The Zip file is loaded
  • CSS files are discovered and code extracted and placed in the HTML file
  • Image files are discovered and their local paths stored (both from the CSS files and the Image directory)
  • Any image formats that are not valid for SendGrid (for example, svg in this case), get transformed into pngs. At this stage, the code takes care of svg-formatted images,, but if there are different needs in your case you can extend it
  • The images are uploaded to SendGrid using the upload-image.js Twilio Function
  • Finally, the local image paths in the (now assembled) HTML code are replaced by the ones generated from the previous step - basically Twilio SendGrid CDN public URLs

Next, I’ll briefly explain  how this application handles uploading local images to Twilio SendGrid.

The function upload-image.js contains the following code:

const imageBuffer = Buffer.from(imgBase64, 'base64');
  const savedFileName = imgFileName + "_" + timeStamp;
  const savedPath = path.join(tmp_dir, savedFileName)
  
  fs.writeFile(savedPath, imageBuffer, async function(err) {
      if (err) return callback(err);

      const formData = new FormData(); 
      const fileStreamData = fs.createReadStream(savedPath)
      formData.append('upload', fileStreamData);
…
…

This block of code gets the image from the application in base64 encoding and saves it in the /tmp directory under the Function. This directory can’t be used for long term storage, but it’s a good place to do some temporary activity such as storing and fetching the image during the same execution. You can find more information about this here. Then the stored image is read with createReadStream and finally uploaded to SendGrid using the relevant endpoint. At the end of the Function, we retrieve and return back to the application the public CDN URL that SendGrid generates for the uploaded image.

Extensibility

The application has certain areas that could allow you to extend it in the future.

For example, take a look at the variables.js file, specifically where we define a Text component

TEXT: { type: "text", label: "Text", fields: [{name: "text", type: "text", label: "Text", value: ""}],
    styles: [
      {name: "paddingLeft", type: "number", label: "Padding Left", value: ""},
      {name: "paddingRight", type: "number", label: "Padding Right", value: ""},
      {name: "paddingTop", type: "number", label: "Padding Top", value: ""},
      {name: "paddingBottom", type: "number", label: "Padding Bottom", value: ""},
      {name: "bgcolor", type: "text", label: "Background Color", value: ""},

Each component is an object with several fields that can define the type, any fields that the user can control, and styles. You can extend this with other components or extra styles. The important thing is that these need to work well with the SendGrid editor. You can see examples of the components structure in the Twilio SendGrid official documentation

At the time we published this post, there are only a handful components such as ImageText and Columns that have not been implemented yet, so if you need to use those components you’ll know where to start to extend the functionality.

Conclusion

In this blog post we went through an application that can be used in order to create reusable components for Twilio SendGrid dynamic templates. Hopefully you can leverage this app and enhance it to make it truly your own!

Evangelos Resvanis works as a Solution Architect in Twilio. He loves learning about tech and how to put different aspects of it together like lego blocks! He can be reached at eresvanis [at] twilio.com.