├── .babelrc
├── .circleci
└── config.yml
├── .env.json.example
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .storybook
├── addons.js
├── config.js
└── webpack.config.js
├── .travis.yml
├── LICENSE
├── README.md
├── functions
├── userInvitation
│ ├── .apexignore
│ ├── .gitignore
│ ├── schema.json
│ ├── src
│ │ ├── index.js
│ │ ├── index.test.js
│ │ ├── template.js
│ │ ├── template.test.js
│ │ └── template.text.js
│ └── stories.js
└── userReminder
│ ├── .apexignore
│ ├── .gitignore
│ ├── schema.json
│ ├── src
│ ├── index.js
│ ├── index.test.js
│ ├── template.js
│ ├── template.test.js
│ └── template.text.js
│ └── stories.js
├── impraise-logo.png
├── jest.config.js
├── package.json
├── project.json
├── shared
├── components
│ ├── CallToAction.js
│ ├── EmailContent.js
│ ├── EmailWrapper.js
│ ├── Footer.js
│ └── Header.js
├── constants
│ ├── colors.js
│ ├── css.js
│ ├── site.js
│ ├── textVersion.js
│ └── url.js
├── context.js
├── email.js
├── email.test.js
└── images
│ └── logo.png
├── utils
├── ci
│ └── deploy
└── tests
│ ├── disableStyleWarnings.js
│ ├── fileMock.js
│ ├── setupEnzyme.js
│ └── shims.js
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "env",
5 | {
6 | "targets": {
7 | "node": "6.10"
8 | }
9 | }
10 | ],
11 | "react",
12 | "stage-3"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: node:6
6 | steps:
7 | - checkout
8 | - run:
9 | name: Install dependencies
10 | command: yarn install --pure-lockfile
11 | - run:
12 | name: Run tests
13 | environment:
14 | JEST_JUNIT_OUTPUT: reports/junit/js-test-results.xml
15 | command: yarn run test --ci --testResultsProcessor="jest-junit" --coverage --verbose
16 | - store_test_results:
17 | path: reports/junit
18 | - store_artifacts:
19 | path: coverage
20 | destination: test-coverage
21 | - run:
22 | name: Install awscli
23 | command: apt-get update && apt-get install -yy awscli
24 | - deploy:
25 | command: ./utils/ci/deploy
26 |
--------------------------------------------------------------------------------
/.env.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "POSTMARK_KEY": "paste_key_here"
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /functions/**/index.js
2 | /coverage
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | "standard",
4 | "prettier",
5 | "plugin:react/recommended",
6 | "plugin:jest/recommended"
7 | ],
8 | plugins: ["prettier", "react", "jest"],
9 | rules: {
10 | strict: 0,
11 | "prettier/prettier": "error"
12 | },
13 | parser: "babel-eslint",
14 | env: {
15 | "jest/globals": true
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /.env.json
3 | /coverage
4 | /build
5 | /functions/**/*.png
6 | .DS_Store
7 | /storybook-static
8 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /package.json
2 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import "@storybook/addon-options/register";
2 | import "@storybook/addon-knobs/register";
3 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from "@storybook/react";
2 | import { setOptions } from "@storybook/addon-options";
3 |
4 | setOptions({
5 | name: "React-MailMerge",
6 | url: "https://github.com/impraise/react-mailmerge/",
7 | });
8 |
9 | function loadStories() {
10 | require("../functions/userInvitation/stories.js");
11 | require("../functions/userReminder/stories.js");
12 | // You can require as many stories as you need.
13 | }
14 |
15 | configure(loadStories, module);
16 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | // module.exports = (baseConfig, env, defaultConfig) => {
4 | // defaultConfig.resolve = defaultConfig.resolve || {};
5 | // defaultConfig.resolve.alias["shared"] = path.resolve(__dirname, "../shared");
6 | // return defaultConfig;
7 | // };
8 |
9 | module.exports = {
10 | module: {
11 | rules: [
12 | {
13 | test: /\.(png|jpg|gif)$/,
14 | use: {
15 | loader: "file-loader",
16 | options: {
17 | publicPath: process.env.ASSETS_BASE_URL
18 | }
19 | }
20 | }
21 | ]
22 | },
23 | resolve: {
24 | alias: {
25 | shared: path.resolve(__dirname, "../shared")
26 | }
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js: "node"
4 |
5 | install:
6 | - yarn install
7 | - yarn global add codecov
8 |
9 | script:
10 | - yarn test --coverage
11 |
12 | after_success:
13 | - codecov
14 |
15 | before_deploy:
16 | - yarn build-storybook
17 |
18 | deploy:
19 | provider: pages
20 | skip_cleanup: true
21 | github_token: $GITHUB_TOKEN
22 | local-dir: storybook-static
23 | on:
24 | branch: master
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Impraise
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React-MailMerge
2 |
3 | [](https://travis-ci.org/impraise/react-mailmerge) [](https://codecov.io/gh/impraise/react-mailmerge)
4 |
5 | Rendering templates is slow. If your website sends transactional email,
6 | it's probably rendering a lot of templates, since every email uses at least
7 | one template.
8 |
9 | Rather than making your backend servers render all those templates,
10 | React-MailMerge allows you to offload that work to AWS. React-MailMerge runs on
11 | [AWS Lambda](https://aws.amazon.com/lambda/), and exposes an API for each
12 | email template. Your backend servers can call these APIs with batches of
13 | user data, and React-MailMerge will render the templates on Lambda
14 | and pass the rendered versions to your mail delivery system.
15 |
16 | # AWS Lambda
17 |
18 | This project is designed to be deployed onto
19 | [AWS Lambda](https://aws.amazon.com/lambda) using the
20 | [Apex](http://apex.run) framework.
21 |
22 | ## AWS profile and credentials
23 |
24 | To deploy templates you will need an AWS account with access to
25 | lambda functions. The Apex documentation details the [minimum required
26 | IAM policy](http://apex.run/#aws-credentials) for this account.
27 |
28 | You should store your AWS credentials in the `~/.aws/credentials` file,
29 | like this:
30 |
31 | ```
32 | [mailmerge]
33 | aws_access_key_id = xxxxxxxx
34 | aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxx
35 | ```
36 |
37 | # Project Structure
38 |
39 | - `/functions` — Email templates. One template per function.
40 | - `/shared` — Code that is shared between templates.
41 | - `/utils` — Utility functions.
42 |
43 | # Credits
44 |
45 | **React-MailMerge** is developed and maintained by
46 | [Impraise](http://www.impraise.com/).
47 | Issue reports and pull requests are greatly appreciated!
48 |
49 | 
50 |
--------------------------------------------------------------------------------
/functions/userInvitation/.apexignore:
--------------------------------------------------------------------------------
1 | /src
2 | schema.json
3 |
--------------------------------------------------------------------------------
/functions/userInvitation/.gitignore:
--------------------------------------------------------------------------------
1 | /index.js
2 |
--------------------------------------------------------------------------------
/functions/userInvitation/schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "object",
3 | "properties": {
4 | "baseUrl": {
5 | "description": "Links in the email should point to this base URL",
6 | "type": "string"
7 | },
8 | "user": {
9 | "description": "The person receiving this email",
10 | "type": "object",
11 | "properties": {
12 | "name": {
13 | "type": "string"
14 | },
15 | "email": {
16 | "type": "string"
17 | },
18 | "invitationToken": {
19 | "type": "string"
20 | }
21 | },
22 | "required": ["email", "invitationToken"]
23 | },
24 | "invitedBy": {
25 | "description": "The existing user who invited the person receiving this email",
26 | "type": "object",
27 | "properties": {
28 | "name": {
29 | "type": "string"
30 | }
31 | },
32 | "required": ["name"]
33 | },
34 | "group": {
35 | "description": "The group that the person is being invited to join",
36 | "type": "object",
37 | "properties": {
38 | "name": {
39 | "type": "string"
40 | }
41 | },
42 | "required": ["name"]
43 | }
44 | },
45 | "required": ["baseUrl", "user", "invitedBy", "group"]
46 | }
47 |
--------------------------------------------------------------------------------
/functions/userInvitation/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { renderEmail } from "react-html-email";
3 | import Ajv from "ajv";
4 |
5 | import schema from "../schema.json";
6 | import Template from "./template";
7 | import textTemplate from "./template.text";
8 | import Client from "shared/email";
9 | import * as site from "shared/constants/site";
10 |
11 | const client = new Client(process.env.POSTMARK_TOKEN);
12 |
13 | export const handle = (data, context, callback) => {
14 | const ajv = new Ajv({ allErrors: true });
15 | const isValid = ajv.validate(schema, data);
16 | if (!isValid) {
17 | return callback(`Validation errors: ${ajv.errorsText()}`); // eslint-disable-line
18 | }
19 | console.log(
20 | `Validated data. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
21 | );
22 |
23 | const html = renderEmail();
24 | console.log(
25 | `HTML template rendered. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
26 | );
27 |
28 | const text = textTemplate(data);
29 | console.log(
30 | `Text template rendered. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
31 | );
32 |
33 | client.sendEmail(
34 | {
35 | From: site.fromEmail,
36 | To: data.user.email,
37 | Subject: `You've been invited to join ${data.group.name} on ${
38 | site.name
39 | }!`,
40 | TextBody: text,
41 | HtmlBody: html,
42 | Tag: "invite"
43 | },
44 | err => callback(JSON.stringify(err))
45 | );
46 | console.log(
47 | `Email sent to Postmark. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/functions/userInvitation/src/index.test.js:
--------------------------------------------------------------------------------
1 | import MockContext from "shared/context";
2 |
3 | describe("userInvitation handler", () => {
4 | let mockClient, mockSendEmail, handle;
5 |
6 | beforeEach(() => {
7 | jest.mock("postmark", () => {
8 | mockClient = jest.fn().mockReturnThis();
9 | mockSendEmail = jest.fn((payload, callback) =>
10 | callback(null, { origin: "test" })
11 | );
12 | mockClient.sendEmail = mockSendEmail;
13 | return { Client: jest.fn(() => mockClient) };
14 | });
15 | global.console.log = jest.fn(); // disable logging in tests
16 | handle = require("./index").handle;
17 | });
18 |
19 | it("returns an error when called without arguments", () => {
20 | const data = {};
21 | const context = new MockContext();
22 | const callback = jest.fn();
23 | handle(data, context, callback);
24 |
25 | const errorsText = callback.mock.calls[0][0];
26 | expect(errorsText.startsWith("Validation errors: ")).toBe(true);
27 | expect(errorsText).toContain("baseUrl");
28 | expect(errorsText).toContain("user");
29 | expect(errorsText).toContain("invitedBy");
30 | expect(errorsText).toContain("group");
31 | });
32 |
33 | it("sends an email when called with correct arguments", () => {
34 | const data = {
35 | baseUrl: "example-website.com",
36 | user: {
37 | email: "foo@example.com",
38 | invitationToken: "abc123"
39 | },
40 | invitedBy: {
41 | name: "Sam Smith"
42 | },
43 | group: {
44 | name: "Cool Kids"
45 | }
46 | };
47 | const callback = jest.fn();
48 | const context = new MockContext();
49 | handle(data, context, callback);
50 |
51 | expect(mockSendEmail).toHaveBeenCalledWith(
52 | {
53 | From: "hi@example-website.com",
54 | To: "foo@example.com",
55 | Subject: "You've been invited to join Cool Kids on Example Website!",
56 | TextBody: expect.any(String),
57 | HtmlBody: expect.any(String),
58 | Tag: "invite"
59 | },
60 | expect.any(Function)
61 | );
62 | expect(callback).toHaveBeenCalled();
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/functions/userInvitation/src/template.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { Box, Item, A } from "react-html-email";
4 | import EmailWrapper from "shared/components/EmailWrapper";
5 | import CallToAction from "shared/components/CallToAction";
6 | import { linkColor } from "shared/constants/colors";
7 | import * as site from "shared/constants/site";
8 |
9 | const Template = ({ baseUrl, user, invitedBy, group }) => {
10 | const url = `https://${baseUrl}/invitations/${user.invitationToken}`;
11 | const greeting = user.name ? `Hey, ${user.name}!` : "Hey!";
12 | return (
13 |
14 | -
15 |
16 |
- {greeting}
17 | -
18 | {invitedBy.name} has invited you to join the {group.name}{" "}
19 | group on{" "}
20 |
21 | {site.name}!
22 |
23 |
24 | -
25 | {site.name} is the best place to frobnicate all your
26 | whatsits, and it's free! We'd love to get you on board!
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | Template.propTypes = {
36 | baseUrl: PropTypes.string.isRequired,
37 | user: PropTypes.object.isRequired,
38 | invitedBy: PropTypes.object.isRequired,
39 | group: PropTypes.object.isRequired
40 | };
41 |
42 | export default Template;
43 |
--------------------------------------------------------------------------------
/functions/userInvitation/src/template.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow, mount } from "enzyme";
3 |
4 | import Template from "./template";
5 |
6 | describe("userInvitation template", () => {
7 | const data = {
8 | baseUrl: "example-website.com",
9 | user: {
10 | email: "foo@example.com",
11 | invitationToken: "abc123"
12 | },
13 | invitedBy: {
14 | name: "Sam Smith"
15 | },
16 | group: {
17 | name: "Cool Kids"
18 | }
19 | };
20 |
21 | it("renders without crashes", () => {
22 | const wrapper = shallow(
23 |
24 | );
25 |
26 | expect(wrapper.exists()).toBe(true);
27 | });
28 | it("displays the organization name", () => {
29 | const wrapper = mount(
30 |
31 | );
32 |
33 | expect(wrapper.text()).toContain("Example");
34 | });
35 | it("includes a link with the activation URL", () => {
36 | const wrapper = mount(
37 |
38 | );
39 |
40 | const invitationLinks = wrapper.find(
41 | 'a[href="https://example-website.com/invitations/abc123"]'
42 | );
43 | expect(invitationLinks.exists()).toBe(true);
44 | expect(invitationLinks.at(0).text()).toBe("Example Website!");
45 | expect(invitationLinks.at(1).text()).toBe("Accept your invitation");
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/functions/userInvitation/src/template.text.js:
--------------------------------------------------------------------------------
1 | import * as site from "shared/constants/site";
2 | import { footer } from "shared/constants/textVersion";
3 |
4 | const textTemplate = ({ baseUrl, user, invitedBy, group }) => {
5 | const url = `https://${baseUrl}/invitations/${user.invitationToken}`;
6 | const greeting = user.name ? `Hey, ${user.name}!` : "Hey!";
7 |
8 | return `
9 | ${greeting}
10 |
11 | You've been invited to join ${group.name} on ${
12 | site.name
13 | }! To accept your invitation, visit this webpage:
14 |
15 | ${url}
16 |
17 | ${footer}`.trim();
18 | };
19 |
20 | export default textTemplate;
21 |
--------------------------------------------------------------------------------
/functions/userInvitation/stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { storiesOf } from "@storybook/react";
3 | import { withKnobs, text } from "@storybook/addon-knobs/react";
4 | import Template from "./src/template";
5 | import textTemplate from "./src/template.text";
6 |
7 | const dataFactory = () => ({
8 | baseUrl: text("baseUrl", "example-website.com"),
9 | user: {
10 | email: "foo@example.com",
11 | name: text("user name", "Frodo Baggins"),
12 | invitationToken: text("token", "secondbreakfast")
13 | },
14 | invitedBy: {
15 | name: text("invitedBy name", "Gandalf")
16 | },
17 | group: {
18 | name: text("group name", "Fellowship of the Ring")
19 | }
20 | });
21 |
22 | storiesOf("userInvitation", module)
23 | .addDecorator(withKnobs)
24 | .add("HTML", () => {
25 | const data = dataFactory();
26 | return ;
27 | })
28 | .add("text", () => {
29 | const data = dataFactory();
30 | return
{textTemplate(data)}
;
31 | });
32 |
--------------------------------------------------------------------------------
/functions/userReminder/.apexignore:
--------------------------------------------------------------------------------
1 | /src
2 | schema.json
3 |
--------------------------------------------------------------------------------
/functions/userReminder/.gitignore:
--------------------------------------------------------------------------------
1 | /index.js
2 |
--------------------------------------------------------------------------------
/functions/userReminder/schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "object",
3 | "properties": {
4 | "baseUrl": {
5 | "description": "Links in the email should point to this base URL",
6 | "type": "string"
7 | },
8 | "user": {
9 | "description": "The person receiving this email",
10 | "type": "object",
11 | "properties": {
12 | "firstName": {
13 | "type": "string"
14 | },
15 | "email": {
16 | "type": "string"
17 | },
18 | "timeZone": {
19 | "description":
20 | "The user's local timezone. Must be a name from the IANA time zone database.",
21 | "type": "string"
22 | },
23 | "locale": {
24 | "description": "Language/region identifier",
25 | "type": "string"
26 | },
27 | "invitationToken": {
28 | "type": ["string", "null"]
29 | }
30 | },
31 | "required": ["firstName", "email", "locale", "timeZone"]
32 | },
33 | "event": {
34 | "description": "The event that the user is being reminded of",
35 | "type": "object",
36 | "properties": {
37 | "title": {
38 | "type": "string"
39 | },
40 | "id": {
41 | "type": "number"
42 | },
43 | "startAt": {
44 | "type": "string",
45 | "format": "date-time"
46 | },
47 | "endAt": {
48 | "type": "string",
49 | "format": "date-time"
50 | }
51 | },
52 | "required": ["title", "id", "startAt"]
53 | }
54 | },
55 | "required": ["baseUrl", "user", "event"]
56 | }
57 |
--------------------------------------------------------------------------------
/functions/userReminder/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { renderEmail } from "react-html-email";
3 | import Ajv from "ajv";
4 | import { IntlProvider } from "react-intl";
5 |
6 | import schema from "../schema.json";
7 | import Template from "./template";
8 | import textTemplate from "./template.text";
9 | import Client from "shared/email";
10 | import * as site from "shared/constants/site";
11 |
12 | const client = new Client(process.env.POSTMARK_TOKEN);
13 |
14 | export const handle = (data, context, callback) => {
15 | const ajv = new Ajv({ allErrors: true });
16 | const isValid = ajv.validate(schema, data);
17 | if (!isValid) {
18 | return callback(`Validation errors: ${ajv.errorsText()}`); // eslint-disable-line
19 | }
20 | console.log(
21 | `Validated data. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
22 | );
23 |
24 | const html = renderEmail(
25 |
26 |
27 |
28 | );
29 | console.log(
30 | `HTML template rendered. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
31 | );
32 |
33 | const text = textTemplate(data);
34 | console.log(
35 | `Text template rendered. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
36 | );
37 |
38 | client.sendEmail(
39 | {
40 | From: site.fromEmail,
41 | To: data.user.email,
42 | Subject: `Looking forward to seeing you at ${data.event.title}!`,
43 | TextBody: text,
44 | HtmlBody: html,
45 | Tag: "reminder"
46 | },
47 | err => callback(JSON.stringify(err))
48 | );
49 | console.log(
50 | `Email sent to Postmark. ${context.getRemainingTimeInMillis()}ms remaining until timeout.`
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/functions/userReminder/src/index.test.js:
--------------------------------------------------------------------------------
1 | import MockContext from "shared/context";
2 |
3 | describe("userReminder handler", () => {
4 | let mockClient, mockSendEmail, handle;
5 |
6 | beforeEach(() => {
7 | jest.mock("postmark", () => {
8 | mockClient = jest.fn().mockReturnThis();
9 | mockSendEmail = jest.fn((payload, callback) =>
10 | callback(null, { origin: "test" })
11 | );
12 | mockClient.sendEmail = mockSendEmail;
13 | return { Client: jest.fn(() => mockClient) };
14 | });
15 | global.console.log = jest.fn(); // disable logging in tests
16 | handle = require("./index").handle;
17 | });
18 |
19 | it("returns an error when called without arguments", () => {
20 | const data = {};
21 | const context = new MockContext();
22 | const callback = jest.fn();
23 | handle(data, context, callback);
24 |
25 | const errorsText = callback.mock.calls[0][0];
26 | expect(errorsText.startsWith("Validation errors: ")).toBe(true);
27 | expect(errorsText).toContain("baseUrl");
28 | expect(errorsText).toContain("user");
29 | expect(errorsText).toContain("event");
30 | });
31 |
32 | it("sends an email when called with correct arguments", () => {
33 | const data = {
34 | baseUrl: "example-website.com",
35 | user: {
36 | email: "foo@example.com",
37 | firstName: "Test",
38 | locale: "en",
39 | timeZone: "America/New_York"
40 | },
41 | event: {
42 | id: 1,
43 | title: "Pool Party",
44 | startAt: "2017-02-01T10:00:00Z",
45 | endAt: "2017-02-01T22:00:00Z"
46 | }
47 | };
48 | const context = new MockContext();
49 | const callback = jest.fn();
50 | handle(data, context, callback);
51 |
52 | expect(mockSendEmail).toHaveBeenCalledWith(
53 | {
54 | From: "hi@example-website.com",
55 | To: "foo@example.com",
56 | Subject: "Looking forward to seeing you at Pool Party!",
57 | TextBody: expect.any(String),
58 | HtmlBody: expect.any(String),
59 | Tag: "reminder"
60 | },
61 | expect.any(Function)
62 | );
63 | expect(callback).toHaveBeenCalled();
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/functions/userReminder/src/template.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { FormattedDate, FormattedTime } from "react-intl";
4 | import { Item } from "react-html-email";
5 | import EmailWrapper from "shared/components/EmailWrapper";
6 | import CallToAction from "shared/components/CallToAction";
7 | import generateUrl from "shared/constants/url";
8 |
9 | const Template = ({ baseUrl, user, event }) => {
10 | let eventUrl = generateUrl(
11 | baseUrl,
12 | `events/${event.id}`,
13 | user.invitationToken
14 | );
15 | let eventCta = user.invitationToken ? "Register and RSVP Now" : "RSVP Now";
16 |
17 | return (
18 |
19 | - {user.firstName},
20 | -
21 | Are you attending {event.title}? It's starting on{" "}
22 |
23 |
29 | {" at "}
30 |
35 |
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | Template.propTypes = {
43 | baseUrl: PropTypes.string.isRequired,
44 | user: PropTypes.object.isRequired,
45 | event: PropTypes.object.isRequired
46 | };
47 |
48 | export default Template;
49 |
--------------------------------------------------------------------------------
/functions/userReminder/src/template.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 | import { mountWithIntl } from "enzyme-react-intl";
4 |
5 | import Template from "./template";
6 | import textTemplate from "./template.text";
7 |
8 | describe("userReminder template", () => {
9 | const data = {
10 | baseUrl: "example-website.com",
11 | user: {
12 | firstName: "Test",
13 | email: "foo@example.com",
14 | locale: "en",
15 | timeZone: "America/New_York",
16 | },
17 | event: {
18 | id: 2,
19 | name: "Lemonade Stand",
20 | startAt: "2017-02-01T22:00:00Z",
21 | }
22 | };
23 |
24 | it("renders without crashes", () => {
25 | const wrapper = shallow(
26 |
27 | );
28 |
29 | expect(wrapper.exists()).toBe(true);
30 | });
31 | it("displays the review information", () => {
32 | const wrapper = mountWithIntl(
33 |
34 | );
35 |
36 | expect(wrapper.text()).toContain("Test,");
37 | expect(wrapper.text()).toContain("RSVP Now");
38 | });
39 | it("includes a link with the activation URL", () => {
40 | const dataWithInvitation = {
41 | ...data,
42 | user: {
43 | ...data.user,
44 | invitationToken: "xyz1234",
45 | }
46 | }
47 |
48 | const wrapper = mountWithIntl(
49 |
50 | );
51 |
52 | expect(wrapper.html()).toContain("xyz1234");
53 | expect(wrapper.text()).toContain(
54 | "Register and RSVP Now"
55 | );
56 | });
57 | it("renders the timezone correctly", () => {
58 | const wrapper = mountWithIntl(
59 |
60 | );
61 |
62 | expect(wrapper.text()).toContain("February 1 at 5:00 PM EST");
63 | });
64 | });
65 |
66 | describe("selfAssessmentReminder text template", () => {
67 | const data = {
68 | baseUrl: "example-website.com",
69 | user: {
70 | firstName: "Test",
71 | email: "foo@example.com",
72 | locale: "en",
73 | timeZone: "America/New_York",
74 | },
75 | event: {
76 | id: 2,
77 | name: "Lemonade Stand",
78 | startAt: "2017-02-01T22:00:00Z",
79 | }
80 | };
81 |
82 | it("renders timezone corectly", () => {
83 | const text = textTemplate(data);
84 |
85 | expect(text).toContain("February 1 at 5:00 PM EST");
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/functions/userReminder/src/template.text.js:
--------------------------------------------------------------------------------
1 | import globalFormatMessage from "format-message";
2 | import generateUrl from "shared/constants/url";
3 | import { footer } from "shared/constants/textVersion";
4 |
5 | const textTemplate = ({ baseUrl, user, event }) => {
6 | const formatMessage = globalFormatMessage.namespace();
7 | formatMessage.setup({
8 | locale: user.locale,
9 | formats: {
10 | date: {
11 | local: {
12 | month: "long",
13 | day: "numeric",
14 | timeZone: user.timeZone
15 | }
16 | },
17 | time: {
18 | local: {
19 | hour: "numeric",
20 | minute: "2-digit",
21 | timeZone: user.timeZone,
22 | timeZoneName: "short"
23 | }
24 | }
25 | }
26 | });
27 |
28 | const eventUrl = generateUrl(
29 | baseUrl,
30 | `events/${event.id}`,
31 | user.invitationToken
32 | );
33 | const eventCta = user.invitationToken ? "Register and RSVP Now" : "RSVP Now";
34 |
35 | return `
36 | ${user.firstName},
37 |
38 | ${formatMessage(
39 | "Are you attending {event}? It's starting on {startAt, date, local} at {startAt, time, local}.",
40 | {
41 | event: event.title,
42 | startAt: new Date(event.startAt)
43 | }
44 | )}
45 |
46 | ${eventCta} by visiting this link:
47 | ${eventUrl}
48 |
49 | ${footer}`.trim();
50 | };
51 |
52 | export default textTemplate;
53 |
--------------------------------------------------------------------------------
/functions/userReminder/stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { storiesOf } from "@storybook/react";
3 | import { withKnobs, text, date } from "@storybook/addon-knobs/react";
4 | import { IntlProvider } from "react-intl";
5 | import Template from "./src/template";
6 | import textTemplate from "./src/template.text";
7 |
8 | function isoDate(name, defaultValue) {
9 | const stringTimestamp = date(name, new Date(defaultValue));
10 | return new Date(stringTimestamp).toISOString();
11 | }
12 |
13 | const dataFactory = () => ({
14 | baseUrl: text("baseUrl", "example-website.com"),
15 | user: {
16 | email: "foo@example.com",
17 | firstName: text("user firstName", "Frodo"),
18 | locale: "en",
19 | timeZone: text("user timeZone", "America/New_York")
20 | },
21 | event: {
22 | id: 1,
23 | title: text("event title", "Bilbo's Farewell Birthday Party"),
24 | startAt: isoDate("event startAt", "3001-10-22T11:00:00-05:00"),
25 | endAt: isoDate("event endAt", "3001-10-22T22:00:00-05:00")
26 | }
27 | });
28 |
29 | storiesOf("userReminder", module)
30 | .addDecorator(withKnobs)
31 | .add("HTML", () => {
32 | const data = dataFactory();
33 | return (
34 |
35 |
36 |
37 | );
38 | })
39 | .add("text", () => {
40 | const data = dataFactory();
41 | return {textTemplate(data)}
;
42 | });
43 |
--------------------------------------------------------------------------------
/impraise-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/impraise/react-mailmerge/772626d5759426750bab9602a95a0b3b1f0ecc58/impraise-logo.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: true,
3 | setupFiles: [
4 | "./utils/tests/shims.js",
5 | "./utils/tests/setupEnzyme.js",
6 | "./utils/tests/disableStyleWarnings.js"
7 | ],
8 | snapshotSerializers: ["enzyme-to-json/serializer"],
9 | moduleNameMapper: {
10 | "\\.(jpg|png|gif)$": "/utils/tests/fileMock.js",
11 | "\\.(css|less)$": "identity-obj-proxy",
12 | "^shared/(.*)$": "/shared/$1"
13 | },
14 | collectCoverageFrom: [
15 | "functions/**/*.{js,jsx}",
16 | "shared/**/*.{js,jsx}",
17 | "!**/stories.js"
18 | ]
19 | };
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "React-MailMerge",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "devDependencies": {
7 | "@storybook/addon-knobs": "^3.3.10",
8 | "@storybook/addon-options": "^3.3.13",
9 | "@storybook/react": "^3.3.10",
10 | "babel-core": "^6.26.0",
11 | "babel-eslint": "^8.0.1",
12 | "babel-jest": "^22.1.0",
13 | "babel-loader": "^7.1.2",
14 | "babel-preset-env": "^1.6.0",
15 | "babel-preset-react": "^6.24.1",
16 | "babel-preset-stage-3": "^6.24.1",
17 | "enzyme": "^3.1.0",
18 | "enzyme-adapter-react-16": "^1.0.1",
19 | "enzyme-react-intl": "^1.4.5",
20 | "enzyme-to-json": "^3.1.4",
21 | "eslint": "^4.8.0",
22 | "eslint-config-prettier": "^2.6.0",
23 | "eslint-config-standard": "^11.0.0-beta.0",
24 | "eslint-plugin-import": "^2.7.0",
25 | "eslint-plugin-jest": "^21.2.0",
26 | "eslint-plugin-node": "^5.2.0",
27 | "eslint-plugin-prettier": "^2.3.1",
28 | "eslint-plugin-promise": "^3.5.0",
29 | "eslint-plugin-react": "^7.4.0",
30 | "eslint-plugin-standard": "^3.0.1",
31 | "file-loader": "^1.1.5",
32 | "html-webpack-plugin": "^2.30.1",
33 | "husky": "^0.14.3",
34 | "identity-obj-proxy": "^3.0.0",
35 | "jest": "^22.1.4",
36 | "jest-junit": "^3.1.0",
37 | "lint-staged": "^6.0.1",
38 | "prettier": "^1.7.4",
39 | "react-router-dom": "^4.2.2",
40 | "react-test-renderer": "16",
41 | "webpack": "^3.6.0",
42 | "webpack-dev-server": "^2.9.1"
43 | },
44 | "dependencies": {
45 | "ajv": "^6.0.1",
46 | "format-message": "^5.2.1",
47 | "postmark": "^1.5.0",
48 | "prop-types": "^15.6.0",
49 | "react": "^16.2.0",
50 | "react-dom": "^16.2.0",
51 | "react-html-email": "^3.0.0",
52 | "react-intl": "^2.4.0"
53 | },
54 | "scripts": {
55 | "storybook": "start-storybook -p 9001 -c .storybook",
56 | "precommit": "lint-staged",
57 | "lint": "eslint --fix --max-warnings 0 ./",
58 | "test": "jest",
59 | "build": "NODE_ENV=production yarn webpack"
60 | },
61 | "lint-staged": {
62 | "*.{json,css}": [
63 | "prettier --write",
64 | "git add"
65 | ],
66 | "*.js": [
67 | "prettier --write",
68 | "eslint --fix --no-ignore --max-warnings 0",
69 | "git add"
70 | ]
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "React-MailMerge",
3 | "description": "",
4 | "runtime": "nodejs6.10",
5 | "memory": 128,
6 | "timeout": 5,
7 | "role": "arn:aws:iam::###########:role/mailmerge",
8 | "environment": {},
9 | "hooks": {
10 | "build": "yarn build --context $(pwd) --output-path $(pwd)"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/shared/components/CallToAction.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { Box, Item, A } from "react-html-email";
4 | import { linkColor } from "../constants/colors";
5 |
6 | const CallToAction = ({ url, text }) => [
7 | ,
8 | ,
9 | -
10 |
18 |
-
19 |
25 | {text}
26 |
27 |
28 |
29 | ,
30 | ,
31 |
32 | ];
33 |
34 | CallToAction.propTypes = {
35 | url: PropTypes.string.isRequired,
36 | text: PropTypes.string.isRequired
37 | };
38 |
39 | export default CallToAction;
40 |
--------------------------------------------------------------------------------
/shared/components/EmailContent.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box, Item } from "react-html-email";
3 | import PropTypes from "prop-types";
4 |
5 | const EmailContent = ({ children }) => (
6 |
14 | -
15 | {children}
16 |
17 |
18 | );
19 |
20 | EmailContent.propTypes = {
21 | children: PropTypes.node.isRequired
22 | };
23 |
24 | export default EmailContent;
25 |
--------------------------------------------------------------------------------
/shared/components/EmailWrapper.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Email, Item } from "react-html-email";
3 | import PropTypes from "prop-types";
4 | import Header from "shared/components/Header";
5 | import EmailContent from "shared/components/EmailContent";
6 | import Footer from "shared/components/Footer";
7 | import { textColor, emailBackgroundColor } from "shared/constants/colors";
8 | import { headCSS } from "shared/constants/css";
9 |
10 | const fontFamily = "Helvetica, Arial, sans-serif";
11 |
12 | const EmailWrapper = ({ title, children }) => (
13 |
24 | -
25 |
26 |
27 | -
28 | {children}
29 |
30 | -
31 |
32 |
33 |
34 | );
35 |
36 | EmailWrapper.propTypes = {
37 | title: PropTypes.string.isRequired,
38 | children: PropTypes.node.isRequired
39 | };
40 |
41 | export default EmailWrapper;
42 |
--------------------------------------------------------------------------------
/shared/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box, Item, A } from "react-html-email";
3 | import { footerTextColor } from "shared/constants/colors";
4 | import * as site from "shared/constants/site";
5 |
6 | const Footer = () => (
7 |
13 |
14 | -
15 |
37 |
38 |
39 |
40 | );
41 |
42 | export default Footer;
43 |
--------------------------------------------------------------------------------
/shared/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box, Item, Image } from "react-html-email";
3 | import logo from "../images/logo.png";
4 | import { name } from "shared/constants/site";
5 |
6 | const Header = () => (
7 |
8 | -
9 |
10 |
11 |
12 | );
13 |
14 | export default Header;
15 |
--------------------------------------------------------------------------------
/shared/constants/colors.js:
--------------------------------------------------------------------------------
1 | export const textColor = "#464646";
2 | export const footerTextColor = "#b0b0b0";
3 | export const linkColor = "#ffa837";
4 | export const linkVisitedColor = "#2ba6cb";
5 | export const rulerColor = "#d5e0e5";
6 | export const emailBackgroundColor = "#f5f3f0";
7 |
--------------------------------------------------------------------------------
/shared/constants/css.js:
--------------------------------------------------------------------------------
1 | import { linkVisitedColor, footerTextColor } from "./colors";
2 |
3 | export const headCSS = `
4 | * {
5 | font-size: 14px;
6 | }
7 | .footer a, .footer td {
8 | font-size: 12px;
9 | color: ${footerTextColor};
10 | }
11 | @media only screen and (max-width: 600px) {
12 | img {
13 | max-width: 600px !important;
14 | width: 100% !important;
15 | height: auto !important;
16 | }
17 | .main-content {
18 | border-radius: 0 !important;
19 | }
20 | .legalese td {
21 | display: block !important;
22 | text-align: center !important;
23 | padding: 5px;
24 | }
25 | }
26 | a:hover {
27 | text-decoration: underline !important;
28 | }
29 | .button a:hover {
30 | text-decoration: none !important;
31 | }
32 | a:visited {
33 | color: ${linkVisitedColor};
34 | }
35 | `.trim();
36 |
--------------------------------------------------------------------------------
/shared/constants/site.js:
--------------------------------------------------------------------------------
1 | export const name = "Example Website";
2 | export const baseUrl = "example-website.com";
3 | export const fromEmail = "hi@example-website.com";
4 |
--------------------------------------------------------------------------------
/shared/constants/textVersion.js:
--------------------------------------------------------------------------------
1 | import { name } from "./site";
2 |
3 | export const footer = `
4 | ${name} © ${new Date().getFullYear()}
5 | `.trim();
6 |
--------------------------------------------------------------------------------
/shared/constants/url.js:
--------------------------------------------------------------------------------
1 | const generateUrl = (baseUrl, path, invitationToken) => {
2 | let finalPath = invitationToken
3 | ? `invitations/${invitationToken}?after=${encodeURIComponent("/" + path)}`
4 | : path;
5 |
6 | return `https://${baseUrl}/${finalPath}`;
7 | };
8 |
9 | export default generateUrl;
10 |
--------------------------------------------------------------------------------
/shared/context.js:
--------------------------------------------------------------------------------
1 | class MockContext {
2 | getRemainingTimeInMillis() {
3 | // return a random integer between 0 and 5000
4 | return Math.floor(Math.random() * 5000);
5 | }
6 | }
7 |
8 | export default MockContext;
9 |
--------------------------------------------------------------------------------
/shared/email.js:
--------------------------------------------------------------------------------
1 | import { Client as PostmarkClient } from "postmark";
2 |
3 | class Client {
4 | constructor(token) {
5 | this.client = new PostmarkClient(token);
6 | }
7 |
8 | sendEmail(payload, callback) {
9 | if (process.env.ENVIRONMENT === "staging") {
10 | // To avoid spamming people with emails sent on staging servers,
11 | // we override the recipient, and put them in the subject instead.
12 | payload.Subject = `'${payload.To}': ${payload.Subject}`;
13 | payload.To = "mailmerge@example.com";
14 | }
15 | this.client.sendEmail(payload, (error, result) => {
16 | if (error) {
17 | console.error(`Unable to send via postmark: ${JSON.stringify(error)}`);
18 | }
19 | if (callback) {
20 | callback(error, result);
21 | }
22 | });
23 | }
24 | }
25 |
26 | export default Client;
27 |
--------------------------------------------------------------------------------
/shared/email.test.js:
--------------------------------------------------------------------------------
1 | describe("email client", () => {
2 | let consoleErr, environmentEnv, mockClient, mockSendEmail, Client;
3 |
4 | beforeEach(() => {
5 | consoleErr = global.console.error;
6 | environmentEnv = process.env.ENVIRONMENT;
7 |
8 | jest.mock("postmark", () => {
9 | mockClient = jest.fn().mockReturnThis();
10 | mockSendEmail = jest.fn((payload, callback) =>
11 | callback(null, { origin: "test" })
12 | );
13 | mockSendEmail.returnError = err => {
14 | mockSendEmail.mockImplementation((payload, callback) => callback(err));
15 | };
16 | mockClient.sendEmail = mockSendEmail;
17 | return { Client: jest.fn(() => mockClient) };
18 | });
19 | Client = require("./email").default;
20 | });
21 |
22 | afterEach(() => {
23 | global.console.error = consoleErr;
24 | process.env.ENVIRONMENT = environmentEnv;
25 | });
26 |
27 | it("does not override properties in production environment", () => {
28 | const payload = {
29 | To: "foo@example.com",
30 | Subject: "Test"
31 | };
32 | process.env.ENVIRONMENT = "production";
33 | const client = new Client("token");
34 | client.sendEmail(payload);
35 |
36 | expect(mockSendEmail).toHaveBeenCalledWith(
37 | {
38 | To: "foo@example.com",
39 | Subject: "Test"
40 | },
41 | expect.any(Function)
42 | );
43 | });
44 |
45 | it("override properties in staging environment", () => {
46 | const payload = {
47 | To: "foo@example.com",
48 | Subject: "Test"
49 | };
50 | process.env.ENVIRONMENT = "staging";
51 | const client = new Client("token");
52 | client.sendEmail(payload);
53 |
54 | expect(mockSendEmail).toHaveBeenCalledWith(
55 | {
56 | To: "mailmerge@example.com",
57 | Subject: "'foo@example.com': Test"
58 | },
59 | expect.any(Function)
60 | );
61 | });
62 |
63 | it("logs errors", () => {
64 | const payload = {
65 | To: "foo@example.com",
66 | Subject: "Test"
67 | };
68 | process.env.ENVIRONMENT = "staging";
69 | global.console.error = jest.fn();
70 | mockSendEmail.returnError({ message: "whoops" });
71 | const client = new Client("token");
72 | client.sendEmail(payload);
73 |
74 | expect(mockSendEmail).toHaveBeenCalledWith(
75 | {
76 | To: "mailmerge@example.com",
77 | Subject: "'foo@example.com': Test"
78 | },
79 | expect.any(Function)
80 | );
81 | expect(global.console.error).toHaveBeenCalledWith(
82 | 'Unable to send via postmark: {"message":"whoops"}'
83 | );
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/shared/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/impraise/react-mailmerge/772626d5759426750bab9602a95a0b3b1f0ecc58/shared/images/logo.png
--------------------------------------------------------------------------------
/utils/ci/deploy:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | branch=$(git rev-parse --abbrev-ref HEAD)
6 |
7 | # Figure out the apex env name and assets CDN endpoint from the current
8 | # branch name.
9 | case $branch in
10 | master)
11 | env="production"
12 | postmarkToken="$POSTMARK_TOKEN_PRODUCTION"
13 | cdn="d2hs2ei1xhklop.cloudfront.net"
14 | ;;
15 | *)
16 | env="staging"
17 | postmarkToken="$POSTMARK_TOKEN_STAGING"
18 | cdn="d2o9yho397pljd.cloudfront.net"
19 | esac
20 |
21 | if [ "$postmarkToken" == "" ]; then
22 | echo "Missing postmark token for '$env', set POSTMARK_TOKEN_ env variable"
23 | exit 1
24 | fi
25 |
26 | # The S3 bucket to which assets will be uploaded
27 | bucket="s3://react-mailmerge-assets-$env"
28 |
29 | # Export things that will be used by `apex build` (and passed to webpack) when building
30 | # the final bundle file:
31 | export ASSETS_BASE_URL="https://$cdn/"
32 | export NODE_ENV=production
33 |
34 | # Base arguments used when running `apex deploy`
35 | deployArgs="--env $env -s POSTMARK_TOKEN=$postmarkToken -s ASSETS_BASE_URL=$ASSETS_BASE_URL -s ENVIRONMENT=$env -s BRANCH=$branch"
36 |
37 | echo "=== Using variables:"
38 | echo " - Branch: $branch"
39 | echo " - Environment: $env"
40 | echo " - CDN: $cdn"
41 | echo " - S3 Bucket: $bucket"
42 |
43 | echo "=== Downloading and install Apex binary if not present"
44 |
45 | command -v apex >/dev/null 2>&1 && echo "apex installed, skipping..." || {
46 | echo "apex not found, installing..."
47 | curl https://raw.githubusercontent.com/apex/apex/master/install.sh | sh
48 | }
49 |
50 | echo "=== Listing functions to be deployed"
51 |
52 | functionNames=$(ls -1 functions/)
53 |
54 | echo -e "-- Function directories: \n$functionNames"
55 | echo "-- Running 'apex list':"
56 | apex list
57 |
58 | echo "=== Generating assets list into build/ directory"
59 |
60 | # To save round-trips to S3, we move all assets to a directory, and then
61 | # upload everything inside it. Similar assets will simply override eachother.
62 | if [ ! -d build ]; then
63 | mkdir build
64 | fi
65 |
66 | rm -rf build/*
67 |
68 | # Dry-run deploys, and build out a full list of assets to upload:
69 | for func in $functionNames; do
70 | # Dry-run and build assets for this function:
71 | apex deploy --dry-run $deployArgs $func
72 |
73 | # Move assets to build/ dir
74 | # This could be written in a much simpler way, but for explicit-ness and error-handling
75 | # sake, we do it in a more methodic manner.
76 | for asset in ls -1 functions/$func/*{.png,.gif,.jpg}; do
77 | if [ -f $asset ]; then
78 | echo "Copying generated '$asset' to build/"
79 | mv "$asset" build/
80 | fi
81 | done
82 | done
83 |
84 | echo "=== Uploading assets to S3"
85 | aws s3 cp build/ "$bucket/" --recursive
86 |
87 | echo "=== Deploying functions to Lambda"
88 |
89 | for func in $functionNames; do
90 | apex deploy $deployArgs $func
91 | done
92 |
93 | echo "=== All done! :)"
94 |
--------------------------------------------------------------------------------
/utils/tests/disableStyleWarnings.js:
--------------------------------------------------------------------------------
1 | import ReactHTMLEmail from "react-html-email";
2 |
3 | ReactHTMLEmail.configStyleValidator({
4 | strict: false,
5 | warn: false
6 | });
7 |
--------------------------------------------------------------------------------
/utils/tests/fileMock.js:
--------------------------------------------------------------------------------
1 | module.exports = "test-file-stub";
2 |
--------------------------------------------------------------------------------
/utils/tests/setupEnzyme.js:
--------------------------------------------------------------------------------
1 | import Enzyme from "enzyme";
2 | import Adapter from "enzyme-adapter-react-16";
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/utils/tests/shims.js:
--------------------------------------------------------------------------------
1 | // Mock requestAnimationFrame sinse React 16 needs it
2 | /* istanbul ignore next */
3 | global.requestAnimationFrame = function(callback) {
4 | setTimeout(callback, 0);
5 | };
6 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | module.exports = {
4 | entry: "./src/index.js",
5 | output: {
6 | filename: "index.js",
7 | libraryTarget: "commonjs2"
8 | },
9 | module: {
10 | rules: [
11 | {
12 | test: /\.js$/,
13 | exclude: /node_modules/,
14 | use: {
15 | loader: "babel-loader"
16 | }
17 | },
18 | {
19 | test: /\.(png|jpg|gif)$/,
20 | use: {
21 | loader: "file-loader",
22 | options: {
23 | publicPath: process.env.ASSETS_BASE_URL
24 | }
25 | }
26 | }
27 | ]
28 | },
29 | resolve: {
30 | alias: {
31 | shared: path.resolve(__dirname, "shared")
32 | }
33 | },
34 | target: "node"
35 | };
36 |
--------------------------------------------------------------------------------