├── templates ├── common │ ├── mainbrand │ │ ├── brand_header.html │ │ └── brand_footer.html │ ├── subbrand │ │ ├── brand_header.html │ │ └── brand_footer.html │ ├── footer.html │ └── header.html └── emails │ └── ice_cream.html ├── .gitignore ├── lambda ├── example-invoked-lambda │ ├── index.js │ └── package.json ├── template-engine │ ├── Cache.js │ ├── package.json │ └── index.js └── campaign-hook │ ├── package.json │ └── index.js └── README.md /templates/common/mainbrand/brand_header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 |
5 |

Big MAIN BRAND Header

6 |
9 | 10 | -------------------------------------------------------------------------------- /templates/common/mainbrand/brand_footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 |
5 |

© My Main Brand. All Rights Reserved

6 |
9 | 10 | -------------------------------------------------------------------------------- /templates/common/subbrand/brand_header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 |
5 |

Big SUB BRAND Header

6 |
9 | 10 | -------------------------------------------------------------------------------- /templates/common/subbrand/brand_footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 |
5 |

© My Sub Brand. All Rights Reserved

6 |
9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | **/dist 3 | **/.zip 4 | **/tmp 5 | **/out-tsc 6 | 7 | # dependencies 8 | **/node_modules 9 | 10 | # e2e 11 | **/e2e/*.js 12 | **/e2e/*.map 13 | 14 | # misc 15 | **/npm-debug.log 16 | **/testem.log 17 | **/package-lock.json 18 | **/.vscode/settings.json 19 | 20 | # System Files 21 | **/.DS_Store 22 | **/.vscode 23 | 24 | example-import.csv 25 | -------------------------------------------------------------------------------- /lambda/example-invoked-lambda/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async (event) => { 2 | // Imagine this is a database call, or a webservice call, or anything! 3 | // event.Endpoint would contain the full Endpoint object as stored in Pinpoint 4 | return { 5 | "image": "https://images.unsplash.com/photo-1580750603266-cae8b4b9f72a", 6 | "name": "Bananas", 7 | "price": "$12.00", 8 | "link": "http://www.amazon.com" 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /lambda/template-engine/Cache.js: -------------------------------------------------------------------------------- 1 | const Cache = function(){ 2 | 3 | this.items = []; 4 | 5 | }; 6 | 7 | 8 | Cache.prototype.put = function(key, value, timeout = null) { 9 | 10 | if (value !== undefined && value !== null) { 11 | const item = { 12 | key: key, 13 | value: value, 14 | expiredWhen: timeout ? Date.now() + timeout : null 15 | }; 16 | this.items[key] = item; 17 | } 18 | 19 | }; 20 | 21 | Cache.prototype.get = function(key) { 22 | 23 | const item = this.items[key]; 24 | if (item) { 25 | if (item.expiredWhen === null || Date.now() <= item.expiredWhen) { 26 | return item.value; 27 | } 28 | } 29 | return null; 30 | }; 31 | 32 | 33 | module.exports = new Cache(); 34 | -------------------------------------------------------------------------------- /lambda/campaign-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": { 10 | "name": "Ryan Lowe" 11 | }, 12 | "license": "Apache 2.0", 13 | "scripts": { 14 | "pretest": "npm install", 15 | "build:init": "rm -rf package-lock.json && rm -rf dist && rm -rf node_modules", 16 | "build:zip": "rm -rf package-lock.json && zip -rq --exclude=*tests* --exclude=*template.yml campaign-hook.zip .", 17 | "build:dist": "mkdir dist && mv campaign-hook.zip dist/", 18 | "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist", 19 | "coverage": "nyc npm test" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lambda/example-invoked-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-invoked-lambda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": { 10 | "name": "Ryan Lowe" 11 | }, 12 | "license": "Apache 2.0", 13 | "scripts": { 14 | "pretest": "npm install", 15 | "build:init": "rm -rf package-lock.json && rm -rf dist && rm -rf node_modules", 16 | "build:zip": "rm -rf package-lock.json && zip -rq --exclude=*tests* --exclude=*template.yml campaign-hook.zip .", 17 | "build:dist": "mkdir dist && mv campaign-hook.zip dist/", 18 | "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist", 19 | "coverage": "nyc npm test" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lambda/template-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template-engine", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "npm install", 8 | "build:init": "rm -rf package-lock.json && rm -rf dist && rm -rf node_modules", 9 | "build:zip": "rm -rf package-lock.json && zip -rq --exclude=*tests* --exclude=*template.yml template-engine.zip .", 10 | "build:dist": "mkdir dist && mv template-engine.zip dist/", 11 | "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist", 12 | "coverage": "nyc npm test" 13 | }, 14 | "author": { 15 | "name": "Ryan Lowe" 16 | }, 17 | "license": "Apache 2.0", 18 | "dependencies": { 19 | "dateformat": "^3.0.3", 20 | "handlebars": "^4.7.3", 21 | "promised-handlebars": "^2.0.1", 22 | "q": "^1.5.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /templates/emails/ice_cream.html: -------------------------------------------------------------------------------- 1 | 2 | {{content-block "common/header.html"}} 3 | 4 |

Here is my Ice Cream promotion email. I am so happy you are here {{properCase Attributes.FirstName}}

5 | 6 |

Your price is {{currencyFormat Attributes.Price Attributes.Locale Attributes.Currency}} 7 | 8 |

Your Deal expires {{dateFormat Attributes.ExpireDate "dddd, mmmm d, yyyy"}} 9 | 10 |

11 | {{#invokeLambda "example-invoked-lambda"}} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
Product Image from Lambda:
Product Name{{{name}}}
Price:{{{price}}}
26 | {{/invokeLambda}} 27 |

28 | 29 |

30 | {{#translate-text "en" Attributes.PreferredLanguage.[0]}} 31 | This is English that will be translated into the preferred language of the current user by Amazon Translate. 32 | {{/translate-text}} 33 |

34 | 35 | {{content-block "common/footer.html"}} 36 | -------------------------------------------------------------------------------- /templates/common/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{#ifEquals Attributes.brand "mainbrand"}} 10 | {{content-block "common/mainbrand/brand_footer.html"}} 11 | {{/ifEquals}} 12 | {{#ifEquals Attributes.brand "subbrand"}} 13 | {{content-block "common/subbrand/brand_footer.html"}} 14 | {{/ifEquals}} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /lambda/campaign-hook/index.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({ 3 | region: process.env.AWS_REGION 4 | }); 5 | 6 | const pinpoint = new AWS.Pinpoint(); 7 | const lambda = new AWS.Lambda(); 8 | 9 | exports.handler = async (event) => { 10 | 11 | console.log(JSON.stringify(event)); 12 | 13 | return pinpoint.getCampaign({ 14 | ApplicationId: event.ApplicationId, 15 | CampaignId: event.CampaignId 16 | }).promise() 17 | .then((response) => { 18 | 19 | const TemplateName = response.CampaignResponse.TemplateConfiguration.EmailTemplate.Name; 20 | return pinpoint.getEmailTemplate({ TemplateName }).promise(); 21 | }) 22 | .then((response) => { 23 | 24 | // Parse the BaseTemplate name out of the Pinpoint Template and construct a content-block for it 25 | const regex = new RegExp('{{!.* *BaseTemplate=([^ ]*) *.*}}'); 26 | const foundEmail = response.EmailTemplateResponse.HtmlPart.match(regex); 27 | 28 | const h = '{{content-block "' + foundEmail[1] + '"}}'; 29 | // console.log('Found HTML: ' + h); 30 | 31 | let attribute = 'html'; 32 | const regexAttr = new RegExp('{{!.* ?TargetAttribute=([^ ]*) *.*}}'); 33 | const foundAttr = response.EmailTemplateResponse.HtmlPart.match(regexAttr); 34 | 35 | if (foundAttr.length > 0) { 36 | attribute = foundAttr[1]; 37 | // console.log('Found Attr: ' + attribute); 38 | } 39 | 40 | const promises = []; 41 | Object.keys(event.Endpoints).forEach((endpointId, ind) => { 42 | const endpoint = event.Endpoints[endpointId]; 43 | 44 | const params = { 45 | ContentString: h, 46 | DataRoot: endpoint 47 | }; 48 | 49 | // Call our Template-engine Lambda function 50 | const promise = lambda.invoke({ 51 | FunctionName: process.env.TEMPLATE_ENGINE_LAMBDA, 52 | InvocationType: "RequestResponse", 53 | Payload: Buffer.from(JSON.stringify(params)) 54 | }).promise() 55 | .then((response) => { 56 | return JSON.parse(response.Payload.toString()).html; 57 | }) 58 | .then((html) => { 59 | endpoint.Id = endpointId; 60 | if (!endpoint.Attributes) { 61 | endpoint.Attributes = {}; 62 | } 63 | 64 | endpoint.Attributes[attribute] = [html]; 65 | return endpoint; 66 | }); 67 | 68 | promises.push(promise); 69 | }); 70 | 71 | return Promise.all(promises) 72 | .then((endpoints) => { 73 | const returnObject = {}; 74 | endpoints.forEach((endpoint) => { 75 | returnObject[endpoint.Id] = endpoint; 76 | delete returnObject[endpoint.Id].Id; 77 | }); 78 | 79 | // console.log(JSON.stringify(returnObject)); 80 | return returnObject; 81 | }); 82 | 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /lambda/template-engine/index.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({ 3 | region: process.env.AWS_REGION 4 | }); 5 | const dateFormat = require('dateformat'); 6 | const promisedHandlebars = require('promised-handlebars'); 7 | const Q = require('q'); 8 | const Handlebars = promisedHandlebars(require('handlebars'), { Promise: Q.Promise }); 9 | 10 | const lambda = new AWS.Lambda(); 11 | const s3 = new AWS.S3(); 12 | const translate = new AWS.Translate(); 13 | 14 | Handlebars.registerHelper('content-block', function(s3Key, context) { 15 | 16 | const params = { 17 | ContentBlock: s3Key, 18 | DataRoot: context.data.root 19 | }; 20 | 21 | return lambda.invoke({ 22 | FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME, 23 | InvocationType: "RequestResponse", 24 | Payload: Buffer.from(JSON.stringify(params)) 25 | }).promise() 26 | .then((response) => { 27 | return new Handlebars.SafeString(JSON.parse(response.Payload.toString()).html); 28 | }); 29 | }); 30 | 31 | Handlebars.registerHelper('ifEquals', function(arg1, arg2, options) { 32 | return (arg1 == arg2) ? options.fn(this) : options.inverse(this); 33 | }); 34 | 35 | Handlebars.registerHelper('currencyFormat', (priceArr, format, currency) => { 36 | return [].concat(priceArr).map((price) => { 37 | return new Intl.NumberFormat(format, { style: 'currency', currency: currency }).format(price); 38 | }); 39 | }); 40 | 41 | Handlebars.registerHelper('dateFormat', (dateArr, format) => { 42 | return [].concat(dateArr).map((date) => { 43 | return dateFormat(new Date(date), format); 44 | }); 45 | }); 46 | 47 | Handlebars.registerHelper('invokeLambda', (lambdaName, context) => { 48 | const params = { 49 | Endpoint: context.data.root 50 | }; 51 | 52 | console.log('LambdaName: ' + lambdaName); 53 | 54 | return lambda.invoke({ 55 | FunctionName: lambdaName, 56 | InvocationType: "RequestResponse", 57 | Payload: Buffer.from(JSON.stringify(params)) 58 | }).promise() 59 | .then((response) => { 60 | const json = JSON.parse(response.Payload.toString()); 61 | console.log('RESPONSE: ' + response.Payload.toString()); 62 | return context.fn(json); 63 | }); 64 | }); 65 | 66 | Handlebars.registerHelper('upperCase', (strArr) => { 67 | return [].concat(strArr).map((str) => { 68 | return str.toUpperCase(); 69 | }); 70 | }); 71 | 72 | Handlebars.registerHelper('lowerCase', (strArr) => { 73 | return [].concat(strArr).map((str) => { 74 | return str.toLowerCase(); 75 | }); 76 | }); 77 | 78 | Handlebars.registerHelper('properCase', (strArr) => { 79 | 80 | return [].concat(strArr).map((str) => { 81 | return str.toLowerCase().split(' ').map(function(word) { 82 | return (word.charAt(0).toUpperCase() + word.slice(1)); 83 | }).join(' '); 84 | }); 85 | }); 86 | 87 | Handlebars.registerHelper('urlencode', (strArr) => { 88 | return [].concat(strArr).map((str) => { 89 | return encodeURIComponent(str); 90 | }); 91 | }); 92 | 93 | Handlebars.registerHelper('translate-text', function(source, target, options) { 94 | return translate.translateText({ 95 | SourceLanguageCode: source, 96 | TargetLanguageCode: target, 97 | Text: options.fn(this) 98 | }).promise() 99 | .then((response) => { 100 | return response.TranslatedText; 101 | }); 102 | }); 103 | 104 | 105 | exports.handler = async (event) => { 106 | 107 | console.log(JSON.stringify(event)); 108 | if (event.ContentBlock) { 109 | return processContentBlock(event); 110 | } else if (event.ContentString) { 111 | return processString(event); 112 | } 113 | }; 114 | 115 | const processContentBlock = function(event) { 116 | return s3.getObject({ 117 | Bucket: process.env.TEMPLATE_BUCKET, 118 | Key: event.ContentBlock 119 | }).promise() 120 | .then((response) => { 121 | const h = response.Body.toString(); 122 | return processHandlebars(h, event.DataRoot); 123 | }); 124 | }; 125 | 126 | const processString = function(event) { 127 | return processHandlebars(event.ContentString, event.DataRoot); 128 | }; 129 | 130 | const processHandlebars = function(s, c) { 131 | const compiled = Handlebars.compile(s); 132 | return compiled(c).then((html) => { 133 | return { html }; 134 | }); 135 | }; 136 | -------------------------------------------------------------------------------- /templates/common/header.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 32 | 33 | 45 | 46 | 59 | 67 | Template Email 68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 | 76 |
77 | 78 | 79 |
80 | 81 | 82 |
83 | 84 | 85 |
86 | 87 | 88 | 89 | {{#ifEquals Attributes.brand "mainbrand"}} 90 | {{content-block "common/mainbrand/brand_header.html"}} 91 | {{/ifEquals}} 92 | {{#ifEquals Attributes.brand "subbrand"}} 93 | {{content-block "common/subbrand/brand_header.html"}} 94 | {{/ifEquals}} 95 | 96 | 97 | 98 |
99 | 100 | 101 |
102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lambda Powered Amazon Pinpoint Templates 2 | Amazon Pinpoint allows you to manage Email, SMS, Push, and Voice templates via the AWS Console and APIs. Pinpoint is also extremely extensible and does not require you to use these templates. This is a POC built to showcase how you can retrieve and render templates outside of Pinpoint for use in Pinpoint campaigns using Lambda functions. 3 | 4 | This example uses MustacheJS with custom Helpers to render email templates stored in S3 using Pinpoint Endpoint Attributes AND data returned from invoking other Lambda functions. In this way, data can be rendered in templates that exist in other RDS, DynamoDB, on-premises systems, Salesforce CRM, or wherever! 5 | 6 | 7 | ## Example Email Template 8 | ``` 9 | 10 | {{content-block "common/header.html"}} 11 | 12 |

Here is my Ice Cream promotion email. I am so happy you are here {{properCase Attributes.FirstName}}

13 | 14 |

Your price is {{currencyFormat Attributes.Price Attributes.Locale Attributes.Currency}} 15 | 16 |

Your Deal expires {{dateFormat Attributes.ExpireDate "dddd, mmmm d, yyyy"}} 17 | 18 |

19 | {{#invokeLambda "example-invoked-lambda"}} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
Product Image from Lambda:
Product Name{{{name}}}
Price:{{{price}}}
34 | {{/invokeLambda}} 35 |

36 | 37 |

38 | {{#translate-text "en" Attributes.PreferredLanguage.[0]}} 39 | This is English that will be translated into the preferred language of the current user by Amazon Translate. 40 | {{/translate-text}} 41 |

42 | 43 | 44 | {{content-block "common/footer.html"}} 45 | 46 | ``` 47 | 48 | Note that the content-block "common/header.html" is also parsed for MustacheJS expressions and even more content-blocks. Each content-block is therefore recursively processed: 49 | 50 | ``` 51 | ... 52 |
53 | 54 | 55 |
56 | 57 | 58 | 59 | {{#ifEquals Attributes.brand "mainbrand"}} 60 | {{content-block "common/mainbrand/brand_header.html"}} 61 | {{/ifEquals}} 62 | {{#ifEquals Attributes.brand "subbrand"}} 63 | {{content-block "common/subbrand/brand_header.html"}} 64 | {{/ifEquals}} 65 | 66 | 67 | 68 |
69 | 70 | 71 |
72 | ... 73 | ``` 74 | 75 | ## MustacheJS Registered Helpers 76 | | Helper | Description | 77 | | ---- | ---- | 78 | | content-block | fetches a templatized content block by S3 key and recursively processes it | 79 | | ifEquals | compares two values together | 80 | | currencyFormat | formats a string of numbers for currency display | 81 | | dateFormat | formats a string into a date string | 82 | | invokeLambda | block invokes another lambda and makes the return object available inside the block | 83 | | translate-text | translates the given text using Amazon Translate | 84 | | upperCase | RETURNS AN UPPERCASED VERSION OF THE STRING | 85 | | lowerCase | returns a lowercased version of the string | 86 | | properCase | Returns A String Where Every Word Starts With A Capital Letter | 87 | | urlencode | Encodes a string to be placed in a URL | 88 | 89 | ## Repository content 90 | Main files: 91 | ``` 92 | . 93 | ├── README.MD <-- This instructions file 94 | ├── templates <-- Folder for email templates, sync to S3 95 | ├── lambda <-- Folder for the AWS Lambdas 96 | │ └── campaign-hook 97 | │ └── index.js <-- Function called by Pinpoint, Campaign Filter Lambda Hook 98 | │ └── template-engine 99 | │ └── index.js <-- Function to recursively render templates from S3 100 | │ └── example-invoked-lambda 101 | │ └── index.js <-- Example function that returns values used in blocks 102 | ``` 103 | 104 | ## Example Segment 105 | Save the following as a CSV file to test the example templates located in this repository. It has the necessary attributes to render the ice_cream email. 106 | ``` 107 | ChannelType,Id,Address,User.UserId,Attributes.FirstName,Attributes.brand,Attributes.Price,Attributes.Locale,Attributes.Currency,Attributes.ExpireDate 108 | EMAIL,1,EMAIL_ADDRESS1_HERE,1,Ryan,mainbrand,125.32,en-US,USD,2020-03-10T10:00:00 109 | EMAIL,2,EMAIL_ADDRESS2_HERE,2,JOHN,subbrand,321.45,zh-CN,JPY,2020-05-10T10:00:00 110 | ``` 111 | 112 | ## Set up 113 | 1. Deploy the Templates in an S3 bucket 114 | `aws s3 templates/ s3://[BUCKET NAME]` 115 | 2. Deploy Lambdas 116 | * Run `npm run build` in each Lambda folder to build a deployable Zip file 117 | * Ensure Lambda functions have IAM permissions to call Pinpoint, invoke other Lambda functions, and access files in S3 118 | * Ensure Lambda functions have environment variables defined 119 | 120 | | Lambda Function | Environment Variable Name | Value | 121 | | --------------- | --------------- | --------------- | 122 | | campaign-hook | TEMPLATE_ENGINE_LAMBDA | [ARN of the Template Lambda Function] 123 | | template-engine | TEMPLATE_BUCKET | [Template S3 Bucket Name] | 124 | 125 | 3. Create a Pinpoint Project 126 | 4. Connect the campaign hook Lambda to a Pinpoint Project 127 | [Full Lambda Hook Documentation for](https://docs.aws.amazon.com/pinpoint/latest/developerguide/segments-dynamic.html) 128 | ``` 129 | aws lambda add-permission \ 130 | --function-name [CAMPAIGN_HOOK LAMBDA ARN] \ 131 | --statement-id lambdaHookStatement1 \ 132 | --action lambda:InvokeFunction \ 133 | --principal pinpoint.[REGION].amazonaws.com \ 134 | --source-arn arn:aws:mobiletargeting:[REGION]:[AWS_ACCOUNT_ID]:/apps/[PINPOINT_PROJECT_ID]/* 135 | 136 | aws lambda add-permission \ 137 | --function-name [CAMPAIGN_HOOK LAMBDA ARN] \ 138 | --statement-id lambdaHookStatement12 \ 139 | --action lambda:InvokeFunction \ 140 | --principal pinpoint.[REGION].amazonaws.com \ 141 | --source-arn arn:aws:mobiletargeting:[REGION]:[AWS_ACCOUNT_ID]:/apps/[PINPOINT_PROJECT_ID] 142 | 143 | aws pinpoint update-application-settings \ 144 | --application-id [PINPOINT_PROJECT_ID] \ 145 | --write-application-settings-request 'CampaignHook={LambdaFunctionName="[CAMPAIGN_HOOK LAMBDA ARN]",Mode="FILTER"}' 146 | ``` 147 | 148 | 5. Create a Pinpoint Template with the following Message Body 149 | 150 | ``` 151 | 152 | {{! BaseTemplate=emails/ice_cream.html TargetAttribute=html_ice }} 153 | {{Attributes.html_ice}} 154 | ``` 155 | 156 | 6. Import segment from above 157 | 158 | 7. Create a campaign using the segment and the created Pinpoint Template and schedule for immediate send 159 | --------------------------------------------------------------------------------