├── .circleci
└── config.yml
├── .eslintrc.js
├── .git-blame-ignore-revs
├── .gitignore
├── .mocharc.integration.json
├── .mocharc.unit.json
├── .npmignore
├── .prettierignore
├── .prettierrc.js
├── LICENSE.md
├── README.md
├── media
├── header@2x.png
├── push-confirm.png
├── servers-list.png
└── token-prompt.png
├── package-lock.json
├── package.json
├── preview
├── assets
│ ├── images
│ │ ├── check.svg
│ │ ├── favicon.ico
│ │ ├── folder.svg
│ │ ├── icon.png
│ │ ├── responsive.svg
│ │ └── templates.svg
│ ├── js
│ │ ├── events.js
│ │ ├── popper.2.5.4.min.js
│ │ └── preview.js
│ └── styles
│ │ ├── reset.css
│ │ └── styles.css
├── index.ejs
├── partials
│ ├── head.ejs
│ └── previewScripts.ejs
├── template.ejs
├── template404.ejs
├── templateInvalid.ejs
└── templateText.ejs
├── src
├── commands
│ ├── cheats.ts
│ ├── email.ts
│ ├── email
│ │ ├── raw.ts
│ │ └── template.ts
│ ├── servers.ts
│ ├── servers
│ │ └── list.ts
│ ├── templates.ts
│ └── templates
│ │ ├── helpers.ts
│ │ ├── preview.ts
│ │ ├── pull.ts
│ │ └── push.ts
├── index.ts
├── types
│ ├── Email.ts
│ ├── Server.ts
│ ├── Template.ts
│ ├── index.ts
│ └── utils.ts
└── utils.ts
├── test
├── config
│ ├── set_travis_vars.sh
│ └── testing_keys.json.example
├── integration
│ ├── email.template.test.ts
│ ├── email.test.ts
│ ├── general.test.ts
│ ├── servers.test.ts
│ ├── shared.ts
│ ├── templates.pull.test.ts
│ └── templates.push.test.ts
└── unit
│ ├── commands
│ └── templates
│ │ ├── helpers.test.ts
│ │ └── push.test.ts
│ ├── servers.test.ts
│ └── utils.test.ts
└── tsconfig.json
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | workflows:
4 | node-tests:
5 | jobs:
6 | # The versions are copied from
7 | # https://circleci.com/developer/images/image/cimg/node#image-tags
8 | # End of Life (EOL): https://endoflife.date/nodejs
9 | - unit-tests:
10 | name: node16 # EOL 11 Sep 2023
11 | version: '16.18.1'
12 | - unit-tests:
13 | name: node18 # EOL 30 Apr 2025
14 | version: '18.17.1'
15 | requires:
16 | - node16
17 | - unit-tests:
18 | name: node20 # EOL 30 Apr 2026
19 | version: '20.5.1'
20 | requires:
21 | - node18
22 | jobs:
23 | unit-tests:
24 | parameters:
25 | version:
26 | type: string
27 | docker:
28 | - image: cimg/node:<< parameters.version >>
29 | steps:
30 | - checkout
31 | - run: node --version
32 | - run: npm --version
33 | - run: npm install
34 | - run: npm test
35 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | es6: true,
4 | node: true,
5 | },
6 | extends: [
7 | 'eslint:recommended',
8 | 'plugin:@typescript-eslint/recommended',
9 | 'prettier',
10 | ],
11 | globals: {
12 | Atomics: 'readonly',
13 | SharedArrayBuffer: 'readonly',
14 | },
15 | parserOptions: {
16 | ecmaVersion: 2018,
17 | },
18 | plugins: ['@typescript-eslint'],
19 | rules: {
20 | 'no-console': 'off',
21 | eqeqeq: ['error', 'always'],
22 | 'linebreak-style': ['error', 'unix'],
23 | '@typescript-eslint/indent': 'off',
24 | '@typescript-eslint/member-delimiter-style': [
25 | 'error',
26 | { multiline: { delimiter: 'none' } },
27 | ],
28 | camelcase: 'off',
29 | '@typescript-eslint/explicit-function-return-type': 'off',
30 | '@typescript-eslint/no-use-before-define': [
31 | 'error',
32 | { functions: false, variables: false },
33 | ],
34 | '@typescript-eslint/no-unused-vars': ['error'],
35 | '@typescript-eslint/no-var-requires': 0,
36 | 'prefer-const': 0,
37 | '@typescript-eslint/no-inferrable-types': 0,
38 | 'no-irregular-whitespace': 0,
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # chore: Formatted with Prettier
2 | 4dbedd2136caff76b077d6829be275feab92c82b
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | dist
4 |
5 | .vscode
6 | .idea
7 | *.swp
8 | **/*.DS_Store
9 | **/*.iml
10 | .DS_Store
11 |
12 | dist/test*
13 | src/test*
14 | **/*testing_keys.json
15 | test/data
--------------------------------------------------------------------------------
/.mocharc.integration.json:
--------------------------------------------------------------------------------
1 | {
2 | "diff": true,
3 | "extension": ["test.ts"],
4 | "require": "ts-node/register",
5 | "spec": "test/integration",
6 | "recursive": true,
7 | "retries": 1,
8 | "timeout": 10000
9 | }
10 |
--------------------------------------------------------------------------------
/.mocharc.unit.json:
--------------------------------------------------------------------------------
1 | {
2 | "diff": true,
3 | "extension": ["test.ts"],
4 | "require": "ts-node/register",
5 | "spec": "test/unit",
6 | "recursive": true
7 | }
8 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .circleci
3 | .env*
4 | .eslintrc.js
5 | .git-blame-ignore-revs
6 | .mocharc.*
7 | .prettierignore
8 | .prettierrc.js
9 | .vscode
10 | media
11 | src
12 | test
13 | testing_keys.json
14 | tsconfig.json
15 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | **/*.min.js
3 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | trailingComma: 'all',
4 | arrowParens: 'avoid',
5 | semi: false,
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 [Wildbit](https://wildbit.com)
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 |
2 |
3 |
4 | A CLI tool for managing templates, sending emails, and fetching servers on Postmark.
Nifty for integrating with a CI/CD pipeline.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ## Usage
13 |
14 | After installation, type `postmark` in your command line to see a list of available commands. Check out the [wiki](https://github.com/activecampaign/postmark-cli/wiki) for instructions on how to [send emails](https://github.com/activecampaign/postmark-cli/wiki/email-command), [manage templates](https://github.com/activecampaign/postmark-cli/wiki/templates-command), or [list servers](https://github.com/activecampaign/postmark-cli/wiki/servers-command).
15 |
16 | ```bash
17 | $ postmark
18 |
19 | Commands:
20 | postmark email [options] Send an email
21 | postmark servers [options] Manage your servers
22 | postmark templates [options] Pull and push your templates
23 |
24 | Options:
25 | --version Show version number
26 | --help Show help
27 | ```
28 |
29 | ## Installation
30 |
31 | - Install [Node.js](https://nodejs.org/en/)
32 | - `$ npm i postmark-cli -g`
33 | - `$ postmark` 🌈
34 |
35 | ## Issues & Comments
36 |
37 | Feel free to contact us if you encounter any issues with the library.
38 | Please leave all comments, bugs, requests and issues on the Issues page.
39 |
40 | ## License
41 |
42 | Postmark CLI library is licensed under the **MIT** license. Please refer to the [LICENSE](https://github.com/activecampaign/postmark-cli/blob/master/LICENSE.md) for more information.
43 |
--------------------------------------------------------------------------------
/media/header@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/media/header@2x.png
--------------------------------------------------------------------------------
/media/push-confirm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/media/push-confirm.png
--------------------------------------------------------------------------------
/media/servers-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/media/servers-list.png
--------------------------------------------------------------------------------
/media/token-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/media/token-prompt.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "postmark-cli",
3 | "version": "1.6.19",
4 | "description": "A CLI tool for managing templates, sending emails, and fetching servers on Postmark.",
5 | "main": "./dist/index.js",
6 | "dependencies": {
7 | "@inquirer/prompts": "^3.0.2",
8 | "chalk": "^2.4.2",
9 | "consolidate": "^1.0.0",
10 | "debug": "^4.3.4",
11 | "directory-tree": "^2.2.3",
12 | "ejs": "^3.1.7",
13 | "express": "^4.17.1",
14 | "fs-extra": "^7.0.1",
15 | "lodash": "^4.17.11",
16 | "ora": "^5.4.1",
17 | "postmark": "^4",
18 | "socket.io": "^4",
19 | "table": "^6.8.0",
20 | "traverse": "^0.6.6",
21 | "ts-invariant": "^0.10.3",
22 | "untildify": "^4.0.0",
23 | "watch": "^0.13.0",
24 | "yargonaut": "^1.1.4",
25 | "yargs": "^17.3.1"
26 | },
27 | "devDependencies": {
28 | "@types/chai": "^4.1.4",
29 | "@types/consolidate": "^0.14.0",
30 | "@types/execa": "^0.9.0",
31 | "@types/express": "^4.17.0",
32 | "@types/fs-extra": "^5.0.5",
33 | "@types/lodash": "^4.14.123",
34 | "@types/mocha": "^9.1.0",
35 | "@types/nconf": "^0.10.0",
36 | "@types/sinon": "^10.0.16",
37 | "@types/table": "^4.0.5",
38 | "@types/traverse": "^0.6.32",
39 | "@types/watch": "^1.0.1",
40 | "@types/yargs": "^17.0.8",
41 | "@typescript-eslint/eslint-plugin": "^5.10.0",
42 | "@typescript-eslint/parser": "^5.10.0",
43 | "chai": "^4.1.2",
44 | "concurrently": "^8.2.2",
45 | "eslint": "^8.7.0",
46 | "eslint-config-prettier": "^9.1.0",
47 | "execa": "^1.0.0",
48 | "mocha": "^9.1.4",
49 | "nconf": "^0.11.4",
50 | "pre-commit": "^1.2.2",
51 | "prettier": "^3.2.4",
52 | "sinon": "^17",
53 | "ts-node": "^8.0.3",
54 | "typescript": "^4.5.5"
55 | },
56 | "scripts": {
57 | "start:dev": "watch 'npm run build' ./src ./preview",
58 | "build": "npm run clean && npm run ts && npm run syncPreview && npm run permissions",
59 | "test": "npm run lint && npm run build && npm run test:unit && npm run test:integration",
60 | "test:unit": "mocha --config .mocharc.unit.json",
61 | "test:integration": "mocha --config .mocharc.integration.json",
62 | "clean": "rm -r -f ./dist",
63 | "syncPreview": "cp -R ./preview ./dist/commands/templates/preview",
64 | "ts": "node_modules/.bin/tsc",
65 | "permissions": "chmod +x ./dist/index.js",
66 | "prepublish": "npm run build",
67 | "lint:js": "eslint --ext .ts ./src",
68 | "lint:prettier": "prettier --check .",
69 | "lint": "concurrently \"npm run lint:js\" \"npm run lint:prettier\""
70 | },
71 | "repository": {
72 | "type": "git",
73 | "url": "git+https://github.com/activecampaign/postmark-cli.git"
74 | },
75 | "author": "",
76 | "license": "MIT",
77 | "bugs": {
78 | "url": "https://github.com/activecampaign/postmark-cli/issues"
79 | },
80 | "homepage": "https://github.com/activecampaign/postmark-cli#readme",
81 | "bin": {
82 | "postmark": "./dist/index.js"
83 | },
84 | "precommit": [
85 | "build",
86 | "lint"
87 | ],
88 | "keywords": [
89 | "postmark",
90 | "cli",
91 | "templates",
92 | "email",
93 | "transactional"
94 | ]
95 | }
96 |
--------------------------------------------------------------------------------
/preview/assets/images/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/preview/assets/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/preview/assets/images/favicon.ico
--------------------------------------------------------------------------------
/preview/assets/images/folder.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/preview/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ActiveCampaign/postmark-cli/c88a59d11eaefda69f01cd6a0cd753eee836b2fa/preview/assets/images/icon.png
--------------------------------------------------------------------------------
/preview/assets/images/responsive.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/preview/assets/images/templates.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/preview/assets/js/events.js:
--------------------------------------------------------------------------------
1 | var socket = io()
2 |
3 | socket.on('change', function () {
4 | console.log('Templates changed. Reloading preview.')
5 | document.querySelector('.js-html').contentWindow.location.reload()
6 | document.querySelector('.js-text').contentWindow.location.reload()
7 |
8 | var reloaded = document.querySelector('.js-reloaded')
9 | reloaded.classList.add('is-active')
10 |
11 | setTimeout(function () {
12 | reloaded.classList.remove('is-active')
13 | }, 3000)
14 | })
15 |
16 | socket.on('subject', function (data) {
17 | var subjectEl = document.querySelector('.js-subject')
18 | var subjectErrorTooltip = document.querySelector('.js-subject-error')
19 |
20 | var errorTooltip = Popper.createPopper(subjectEl, subjectErrorTooltip, {
21 | placement: 'top-start',
22 | })
23 |
24 | if (!isEmptyObject(data)) {
25 | if (data.ContentIsValid) {
26 | // Render tooltip if valid
27 | subjectEl.classList.remove('has-error')
28 | subjectEl.textContent = data.RenderedContent
29 |
30 | // Destroy tooltip
31 | errorTooltip.destroy()
32 | } else {
33 | // Show validation error indicator
34 | subjectEl.classList.add('has-error')
35 | subjectEl.innerHTML =
36 | data.rawSubject + ' Syntax error'
37 |
38 | // Append each validation error to tooltip
39 | var subjectError = document.querySelector('.js-subject-error')
40 | subjectError.innerHTML = ''
41 | data.ValidationErrors.forEach(function (error) {
42 | subjectError.innerHTML += '' + error.Message + '
'
43 | })
44 | }
45 | }
46 | })
47 |
48 | function isEmptyObject(value) {
49 | return Object.keys(value).length === 0 && value.constructor === Object
50 | }
51 |
--------------------------------------------------------------------------------
/preview/assets/js/popper.2.5.4.min.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @popperjs/core v2.5.4 - MIT License
3 | */
4 |
5 | "use strict";!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).Popper={})}(this,(function(e){function t(e){return{width:(e=e.getBoundingClientRect()).width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function n(e){return"[object Window]"!==e.toString()?(e=e.ownerDocument)&&e.defaultView||window:e}function r(e){return{scrollLeft:(e=n(e)).pageXOffset,scrollTop:e.pageYOffset}}function o(e){return e instanceof n(e).Element||e instanceof Element}function i(e){return e instanceof n(e).HTMLElement||e instanceof HTMLElement}function a(e){return e?(e.nodeName||"").toLowerCase():null}function s(e){return((o(e)?e.ownerDocument:e.document)||window.document).documentElement}function f(e){return t(s(e)).left+r(e).scrollLeft}function c(e){return n(e).getComputedStyle(e)}function p(e){return e=c(e),/auto|scroll|overlay|hidden/.test(e.overflow+e.overflowY+e.overflowX)}function l(e,o,c){void 0===c&&(c=!1);var l=s(o);e=t(e);var u=i(o),d={scrollLeft:0,scrollTop:0},m={x:0,y:0};return(u||!u&&!c)&&(("body"!==a(o)||p(l))&&(d=o!==n(o)&&i(o)?{scrollLeft:o.scrollLeft,scrollTop:o.scrollTop}:r(o)),i(o)?((m=t(o)).x+=o.clientLeft,m.y+=o.clientTop):l&&(m.x=f(l))),{x:e.left+d.scrollLeft-m.x,y:e.top+d.scrollTop-m.y,width:e.width,height:e.height}}function u(e){return{x:e.offsetLeft,y:e.offsetTop,width:e.offsetWidth,height:e.offsetHeight}}function d(e){return"html"===a(e)?e:e.assignedSlot||e.parentNode||e.host||s(e)}function m(e,t){void 0===t&&(t=[]);var r=function e(t){return 0<=["html","body","#document"].indexOf(a(t))?t.ownerDocument.body:i(t)&&p(t)?t:e(d(t))}(e);e="body"===a(r);var o=n(r);return r=e?[o].concat(o.visualViewport||[],p(r)?r:[]):r,t=t.concat(r),e?t:t.concat(m(d(r)))}function h(e){if(!i(e)||"fixed"===c(e).position)return null;if(e=e.offsetParent){var t=s(e);if("body"===a(e)&&"static"===c(e).position&&"static"!==c(t).position)return t}return e}function g(e){for(var t=n(e),r=h(e);r&&0<=["table","td","th"].indexOf(a(r))&&"static"===c(r).position;)r=h(r);if(r&&"body"===a(r)&&"static"===c(r).position)return t;if(!r)e:{for(e=d(e);i(e)&&0>["html","body"].indexOf(a(e));){if("none"!==(r=c(e)).transform||"none"!==r.perspective||r.willChange&&"auto"!==r.willChange){r=e;break e}e=e.parentNode}r=null}return r||t}function v(e){var t=new Map,n=new Set,r=[];return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||function e(o){n.add(o.name),[].concat(o.requires||[],o.requiresIfExists||[]).forEach((function(r){n.has(r)||(r=t.get(r))&&e(r)})),r.push(o)}(e)})),r}function b(e){var t;return function(){return t||(t=new Promise((function(n){Promise.resolve().then((function(){t=void 0,n(e())}))}))),t}}function y(e){return e.split("-")[0]}function O(e,t){var r,o=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if((r=o)&&(r=o instanceof(r=n(o).ShadowRoot)||o instanceof ShadowRoot),r)do{if(t&&e.isSameNode(t))return!0;t=t.parentNode||t.host}while(t);return!1}function w(e){return Object.assign(Object.assign({},e),{},{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function x(e,o){if("viewport"===o){o=n(e);var a=s(e);o=o.visualViewport;var p=a.clientWidth;a=a.clientHeight;var l=0,u=0;o&&(p=o.width,a=o.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(l=o.offsetLeft,u=o.offsetTop)),e=w(e={width:p,height:a,x:l+f(e),y:u})}else i(o)?((e=t(o)).top+=o.clientTop,e.left+=o.clientLeft,e.bottom=e.top+o.clientHeight,e.right=e.left+o.clientWidth,e.width=o.clientWidth,e.height=o.clientHeight,e.x=e.left,e.y=e.top):(u=s(e),e=s(u),l=r(u),o=u.ownerDocument.body,p=Math.max(e.scrollWidth,e.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),a=Math.max(e.scrollHeight,e.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),u=-l.scrollLeft+f(u),l=-l.scrollTop,"rtl"===c(o||e).direction&&(u+=Math.max(e.clientWidth,o?o.clientWidth:0)-p),e=w({width:p,height:a,x:u,y:l}));return e}function j(e,t,n){return t="clippingParents"===t?function(e){var t=m(d(e)),n=0<=["absolute","fixed"].indexOf(c(e).position)&&i(e)?g(e):e;return o(n)?t.filter((function(e){return o(e)&&O(e,n)&&"body"!==a(e)})):[]}(e):[].concat(t),(n=(n=[].concat(t,[n])).reduce((function(t,n){return n=x(e,n),t.top=Math.max(n.top,t.top),t.right=Math.min(n.right,t.right),t.bottom=Math.min(n.bottom,t.bottom),t.left=Math.max(n.left,t.left),t}),x(e,n[0]))).width=n.right-n.left,n.height=n.bottom-n.top,n.x=n.left,n.y=n.top,n}function M(e){return 0<=["top","bottom"].indexOf(e)?"x":"y"}function E(e){var t=e.reference,n=e.element,r=(e=e.placement)?y(e):null;e=e?e.split("-")[1]:null;var o=t.x+t.width/2-n.width/2,i=t.y+t.height/2-n.height/2;switch(r){case"top":o={x:o,y:t.y-n.height};break;case"bottom":o={x:o,y:t.y+t.height};break;case"right":o={x:t.x+t.width,y:i};break;case"left":o={x:t.x-n.width,y:i};break;default:o={x:t.x,y:t.y}}if(null!=(r=r?M(r):null))switch(i="y"===r?"height":"width",e){case"start":o[r]=Math.floor(o[r])-Math.floor(t[i]/2-n[i]/2);break;case"end":o[r]=Math.floor(o[r])+Math.ceil(t[i]/2-n[i]/2)}return o}function D(e){return Object.assign(Object.assign({},{top:0,right:0,bottom:0,left:0}),e)}function P(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function L(e,n){void 0===n&&(n={});var r=n;n=void 0===(n=r.placement)?e.placement:n;var i=r.boundary,a=void 0===i?"clippingParents":i,f=void 0===(i=r.rootBoundary)?"viewport":i;i=void 0===(i=r.elementContext)?"popper":i;var c=r.altBoundary,p=void 0!==c&&c;r=D("number"!=typeof(r=void 0===(r=r.padding)?0:r)?r:P(r,T));var l=e.elements.reference;c=e.rects.popper,a=j(o(p=e.elements[p?"popper"===i?"reference":"popper":i])?p:p.contextElement||s(e.elements.popper),a,f),p=E({reference:f=t(l),element:c,strategy:"absolute",placement:n}),c=w(Object.assign(Object.assign({},c),p)),f="popper"===i?c:f;var u={top:a.top-f.top+r.top,bottom:f.bottom-a.bottom+r.bottom,left:a.left-f.left+r.left,right:f.right-a.right+r.right};if(e=e.modifiersData.offset,"popper"===i&&e){var d=e[n];Object.keys(u).forEach((function(e){var t=0<=["right","bottom"].indexOf(e)?1:-1,n=0<=["top","bottom"].indexOf(e)?"y":"x";u[e]+=d[n]*t}))}return u}function k(){for(var e=arguments.length,t=Array(e),n=0;n(v.devicePixelRatio||1)?"translate("+e+"px, "+l+"px)":"translate3d("+e+"px, "+l+"px, 0)",d)):Object.assign(Object.assign({},r),{},((t={})[h]=a?l+"px":"",t[m]=u?e+"px":"",t.transform="",t))}function A(e){return e.replace(/left|right|bottom|top/g,(function(e){return G[e]}))}function H(e){return e.replace(/start|end/g,(function(e){return J[e]}))}function R(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function S(e){return["top","right","bottom","left"].some((function(t){return 0<=e[t]}))}var T=["top","bottom","right","left"],q=T.reduce((function(e,t){return e.concat([t+"-start",t+"-end"])}),[]),C=[].concat(T,["auto"]).reduce((function(e,t){return e.concat([t,t+"-start",t+"-end"])}),[]),N="beforeRead read afterRead beforeMain main afterMain beforeWrite write afterWrite".split(" "),V={placement:"bottom",modifiers:[],strategy:"absolute"},I={passive:!0},_={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,r=e.instance,o=(e=e.options).scroll,i=void 0===o||o,a=void 0===(e=e.resize)||e,s=n(t.elements.popper),f=[].concat(t.scrollParents.reference,t.scrollParents.popper);return i&&f.forEach((function(e){e.addEventListener("scroll",r.update,I)})),a&&s.addEventListener("resize",r.update,I),function(){i&&f.forEach((function(e){e.removeEventListener("scroll",r.update,I)})),a&&s.removeEventListener("resize",r.update,I)}},data:{}},U={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state;t.modifiersData[e.name]=E({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})},data:{}},z={top:"auto",right:"auto",bottom:"auto",left:"auto"},F={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,n=e.options;e=void 0===(e=n.gpuAcceleration)||e,n=void 0===(n=n.adaptive)||n,e={placement:y(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:e},null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign(Object.assign({},t.styles.popper),W(Object.assign(Object.assign({},e),{},{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:n})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign(Object.assign({},t.styles.arrow),W(Object.assign(Object.assign({},e),{},{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1})))),t.attributes.popper=Object.assign(Object.assign({},t.attributes.popper),{},{"data-popper-placement":t.placement})},data:{}},X={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach((function(e){var n=t.styles[e]||{},r=t.attributes[e]||{},o=t.elements[e];i(o)&&a(o)&&(Object.assign(o.style,n),Object.keys(r).forEach((function(e){var t=r[e];!1===t?o.removeAttribute(e):o.setAttribute(e,!0===t?"":t)})))}))},effect:function(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach((function(e){var r=t.elements[e],o=t.attributes[e]||{};e=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce((function(e,t){return e[t]="",e}),{}),i(r)&&a(r)&&(Object.assign(r.style,e),Object.keys(o).forEach((function(e){r.removeAttribute(e)})))}))}},requires:["computeStyles"]},Y={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(e){var t=e.state,n=e.name,r=void 0===(e=e.options.offset)?[0,0]:e,o=(e=C.reduce((function(e,n){var o=t.rects,i=y(n),a=0<=["left","top"].indexOf(i)?-1:1,s="function"==typeof r?r(Object.assign(Object.assign({},o),{},{placement:n})):r;return o=(o=s[0])||0,s=((s=s[1])||0)*a,i=0<=["left","right"].indexOf(i)?{x:s,y:o}:{x:o,y:s},e[n]=i,e}),{}))[t.placement],i=o.x;o=o.y,null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=i,t.modifiersData.popperOffsets.y+=o),t.modifiersData[n]=e}},G={left:"right",right:"left",bottom:"top",top:"bottom"},J={start:"end",end:"start"},K={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options;if(e=e.name,!t.modifiersData[e]._skip){var r=n.mainAxis;r=void 0===r||r;var o=n.altAxis;o=void 0===o||o;var i=n.fallbackPlacements,a=n.padding,s=n.boundary,f=n.rootBoundary,c=n.altBoundary,p=n.flipVariations,l=void 0===p||p,u=n.allowedAutoPlacements;p=y(n=t.options.placement),i=i||(p!==n&&l?function(e){if("auto"===y(e))return[];var t=A(e);return[H(e),t,H(t)]}(n):[A(n)]);var d=[n].concat(i).reduce((function(e,n){return e.concat("auto"===y(n)?function(e,t){void 0===t&&(t={});var n=t.boundary,r=t.rootBoundary,o=t.padding,i=t.flipVariations,a=t.allowedAutoPlacements,s=void 0===a?C:a,f=t.placement.split("-")[1];0===(i=(t=f?i?q:q.filter((function(e){return e.split("-")[1]===f})):T).filter((function(e){return 0<=s.indexOf(e)}))).length&&(i=t);var c=i.reduce((function(t,i){return t[i]=L(e,{placement:i,boundary:n,rootBoundary:r,padding:o})[y(i)],t}),{});return Object.keys(c).sort((function(e,t){return c[e]-c[t]}))}(t,{placement:n,boundary:s,rootBoundary:f,padding:a,flipVariations:l,allowedAutoPlacements:u}):n)}),[]);n=t.rects.reference,i=t.rects.popper;var m=new Map;p=!0;for(var h=d[0],g=0;gi[x]&&(O=A(O)),x=A(O),w=[],r&&w.push(0>=j[b]),o&&w.push(0>=j[O],0>=j[x]),w.every((function(e){return e}))){h=v,p=!1;break}m.set(v,w)}if(p)for(r=function(e){var t=d.find((function(t){if(t=m.get(t))return t.slice(0,e).every((function(e){return e}))}));if(t)return h=t,"break"},o=l?3:1;0
2 |
3 | <%- include("partials/head.ejs") %>
4 |
5 |
6 |
7 | <%- path %>
8 |
9 |
10 |
11 | <% if (layouts.length > 0) { %>
12 | <%- layouts.length %> <%- layouts.length > 1 ? 'Layouts' : 'Layout' %>
13 |
23 | <% } %>
24 |
25 |
26 | <% if (templates.length > 0) { %>
27 | <%- templates.length %> <%- templates.length > 1 ? 'Templates' : 'Template' %>
28 |
29 |
43 | <% } else { %>
44 |
45 |
46 |

47 |
No templates were found
48 |
Pull your templates from Postmark:
49 |
postmark templates pull <%- path %>
50 |
51 |
52 |
Or build your templates locally
53 |
54 | - Create a new folder for your template:
55 |
56 | cd <%- path %>
57 | mkdir password-reset
58 |
59 | - Your template folder should contain the following files:
60 |
61 | - content.html - HTML version
62 | - content.txt - Text version
63 | - meta.json - JSON containing the name, alias, subject, and type of template(
Standard
or Layout
).
64 | {
65 | "Name": "Password Reset",
66 | "Alias": "password-reset",
67 | "Subject": "Reset your password",
68 | "TemplateType": "Standard",
69 | "LayoutTemplate": "layout-alias"
70 | }
71 |
72 |
73 |
74 | - Refresh the page
75 |
76 |
77 |
78 | <% } %>
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/preview/partials/head.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Postmark template preview
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/preview/partials/previewScripts.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/preview/template.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%- include("partials/head.ejs") %>
4 |
5 |
35 |
36 | <% if (template.TemplateType === 'Standard') {%>
37 |
44 | <% } %>
45 |
46 |
47 |
48 |
49 |
50 |
Fetching preview...
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | <%- include("partials/previewScripts.ejs") %>
59 |
60 |
61 |
--------------------------------------------------------------------------------
/preview/template404.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
No <%- version %> version
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/preview/templateInvalid.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
There’s an issue with your template
9 |
10 | <% errors.forEach(function(error) { %>
11 | - <%- error.Message %>
12 | <% }) %>
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/preview/templateText.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%- body %>
8 |
9 |
--------------------------------------------------------------------------------
/src/commands/cheats.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk'
2 | import { select } from '@inquirer/prompts'
3 | import { isEqual, last } from 'lodash'
4 |
5 | const debug = require('debug')('postmark-cli:cheats')
6 |
7 | export const desc = false
8 | export const builder = () => {
9 | ask()
10 | }
11 |
12 | async function ask(): Promise {
13 | const enteredCode: string[] = []
14 |
15 | // eslint-disable-next-line no-constant-condition
16 | while (true) {
17 | const code = await cheatInput(enteredCode.length > 0)
18 | enteredCode.push(code)
19 | if (checkAnswer(enteredCode)) break
20 | }
21 | }
22 |
23 | function checkAnswer(answer: string[]): boolean {
24 | if (last(answer) !== 'START') return false
25 |
26 | if (isEqual(answer, superSecretAnswer)) {
27 | const title = chalk.yellow('PROMO CODE UNLOCKED!')
28 | const promoCode = chalk.bgCyan.black(superNotSoSecretPromoCode)
29 |
30 | console.log(
31 | `⭐️ ${title}⭐️\nUse this promo code to receive $5 off at Postmark:\n👉 ${promoCode} 👈\n\nhttps://account.postmarkapp.com/subscription\nhttps://account.postmarkapp.com/billing_settings`,
32 | )
33 | } else {
34 | console.log('Sorry, try again!')
35 | }
36 | return true
37 | }
38 |
39 | async function cheatInput(hideMessage: boolean): Promise {
40 | const choices = ['⬆️', '➡️', '⬇️', '⬅️', 'A', 'B', 'START'].map(value => ({
41 | value,
42 | }))
43 | const answer = await select({
44 | choices,
45 | message: hideMessage ? '\n' : '🔥🔥 ENTER THY CHEAT CODE 🔥🔥\n',
46 | })
47 | debug('answer: %o', answer)
48 | return answer
49 | }
50 |
51 | const superSecretAnswer: string[] = [
52 | '⬆️',
53 | '⬆️',
54 | '⬇️',
55 | '⬇️',
56 | '⬅️',
57 | '➡️',
58 | '⬅️',
59 | '➡️',
60 | 'B',
61 | 'A',
62 | 'START',
63 | ]
64 | const superSecretPromoCode = 'U1VQRVJDTEk1'
65 | const superNotSoSecretPromoCode: string = Buffer.from(
66 | superSecretPromoCode,
67 | 'base64',
68 | ).toString()
69 |
--------------------------------------------------------------------------------
/src/commands/email.ts:
--------------------------------------------------------------------------------
1 | import { cmd } from '../utils'
2 |
3 | export const { command, desc, builder } = cmd('email', 'Send an email')
4 |
--------------------------------------------------------------------------------
/src/commands/email/raw.ts:
--------------------------------------------------------------------------------
1 | import { ServerClient } from 'postmark'
2 | import { validateToken, CommandResponse } from '../../utils'
3 | import { RawEmailArguments } from '../../types'
4 | import { MessageSendingResponse } from 'postmark/dist/client/models'
5 |
6 | export const command = 'raw [options]'
7 | export const desc = 'Send a raw email'
8 | export const builder = {
9 | 'server-token': {
10 | type: 'string',
11 | hidden: true,
12 | },
13 | 'request-host': {
14 | type: 'string',
15 | hidden: true,
16 | },
17 | from: {
18 | type: 'string',
19 | describe:
20 | 'Email address you are sending from. Must be an address on a verified domain or confirmed Sender Signature.',
21 | alias: 'f',
22 | required: true,
23 | },
24 | to: {
25 | type: 'string',
26 | describe: 'Email address you are sending to',
27 | alias: 't',
28 | required: true,
29 | },
30 | subject: {
31 | type: 'string',
32 | describe: 'The subject line of the email',
33 | required: true,
34 | },
35 | html: {
36 | type: 'string',
37 | describe: 'The HTML version of the email',
38 | },
39 | text: {
40 | type: 'string',
41 | describe: 'The text version of the email',
42 | },
43 | }
44 | export const handler = (args: RawEmailArguments): Promise => exec(args)
45 |
46 | /**
47 | * Execute the command
48 | */
49 | const exec = (args: RawEmailArguments): Promise => {
50 | const { serverToken } = args
51 |
52 | return validateToken(serverToken).then(token => {
53 | sendCommand(token, args)
54 | })
55 | }
56 |
57 | /**
58 | * Execute send command in shell
59 | */
60 | const sendCommand = (serverToken: string, args: RawEmailArguments): void => {
61 | const { from, to, subject, html, text, requestHost } = args
62 | const command: CommandResponse = new CommandResponse()
63 | command.initResponse('Sending an email')
64 | const client = new ServerClient(serverToken)
65 | if (requestHost !== undefined && requestHost !== '') {
66 | client.setClientOptions({ requestHost })
67 | }
68 |
69 | sendEmail(client, from, to, subject, html, text)
70 | .then(response => {
71 | command.response(JSON.stringify(response))
72 | })
73 | .catch(error => {
74 | command.errorResponse(error)
75 | })
76 | }
77 |
78 | /**
79 | * Send the email
80 | *
81 | * @return - Promised sending response
82 | */
83 | const sendEmail = (
84 | client: ServerClient,
85 | from: string,
86 | to: string,
87 | subject: string,
88 | html: string | undefined,
89 | text: string | undefined,
90 | ): Promise => {
91 | return client.sendEmail({
92 | From: from,
93 | To: to,
94 | Subject: subject,
95 | HtmlBody: html || undefined,
96 | TextBody: text || undefined,
97 | })
98 | }
99 |
--------------------------------------------------------------------------------
/src/commands/email/template.ts:
--------------------------------------------------------------------------------
1 | import { ServerClient } from 'postmark'
2 | import { validateToken, CommandResponse } from '../../utils'
3 | import { TemplatedEmailArguments } from '../../types'
4 | import { MessageSendingResponse } from 'postmark/dist/client/models'
5 |
6 | export const command = 'template [options]'
7 | export const desc = 'Send a templated email'
8 | export const builder = {
9 | 'source-server': {
10 | type: 'string',
11 | hidden: true,
12 | },
13 | 'request-host': {
14 | type: 'string',
15 | hidden: true,
16 | },
17 | id: {
18 | type: 'string',
19 | describe: 'Template ID. Required if a template alias is not specified.',
20 | alias: 'i',
21 | },
22 | alias: {
23 | type: 'string',
24 | describe: 'Template Alias. Required if a template ID is not specified.',
25 | alias: 'a',
26 | },
27 | from: {
28 | type: 'string',
29 | describe:
30 | 'Email address you are sending from. Must be an address on a verified domain or confirmed Sender Signature.',
31 | alias: 'f',
32 | required: true,
33 | },
34 | to: {
35 | type: 'string',
36 | describe: 'Email address you are sending to',
37 | alias: 't',
38 | required: true,
39 | },
40 | model: {
41 | type: 'string',
42 | describe: '',
43 | alias: 'm',
44 | },
45 | }
46 | export const handler = (args: TemplatedEmailArguments) => exec(args)
47 |
48 | /**
49 | * Execute the command
50 | */
51 | const exec = (args: TemplatedEmailArguments) => {
52 | const { serverToken } = args
53 |
54 | return validateToken(serverToken).then(token => {
55 | sendCommand(token, args)
56 | })
57 | }
58 |
59 | /**
60 | * Execute templated email send command in shell
61 | */
62 | const sendCommand = (serverToken: string, args: TemplatedEmailArguments) => {
63 | const { id, alias, from, to, model, requestHost } = args
64 | const command: CommandResponse = new CommandResponse()
65 | command.initResponse('Sending an email')
66 | const client = new ServerClient(serverToken)
67 | if (requestHost !== undefined && requestHost !== '') {
68 | client.setClientOptions({ requestHost })
69 | }
70 |
71 | sendEmailWithTemplate(client, id, alias, from, to, model)
72 | .then((response: any) => {
73 | command.response(JSON.stringify(response))
74 | })
75 | .catch((error: any) => {
76 | command.errorResponse(error)
77 | })
78 | }
79 |
80 | /**
81 | * Send the email
82 | *
83 | * @return - Promised sending response
84 | */
85 | const sendEmailWithTemplate = (
86 | client: ServerClient,
87 | id: number | undefined,
88 | alias: string | undefined,
89 | from: string,
90 | to: string | undefined,
91 | model: any | undefined,
92 | ): Promise => {
93 | return client.sendEmailWithTemplate({
94 | TemplateId: id || undefined,
95 | TemplateAlias: alias || undefined,
96 | From: from,
97 | To: to,
98 | TemplateModel: model ? JSON.parse(model) : undefined,
99 | })
100 | }
101 |
--------------------------------------------------------------------------------
/src/commands/servers.ts:
--------------------------------------------------------------------------------
1 | import { cmd } from '../utils'
2 |
3 | export const { command, desc, builder } = cmd('servers', 'Manage your servers')
4 |
--------------------------------------------------------------------------------
/src/commands/servers/list.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk'
2 | import { AccountClient } from 'postmark'
3 | import { table, getBorderCharacters } from 'table'
4 | import { validateToken, CommandResponse } from '../../utils'
5 | import { ServerListArguments, ColorMap } from '../../types'
6 | import { Servers, Server } from 'postmark/dist/client/models'
7 |
8 | export const command = 'list [options]'
9 | export const desc = 'List the servers on your account'
10 | export const builder = {
11 | 'account-token': {
12 | type: 'string',
13 | hidden: true,
14 | },
15 | 'request-host': {
16 | type: 'string',
17 | hidden: true,
18 | },
19 | count: {
20 | type: 'number',
21 | describe: 'Number of servers to return',
22 | alias: ['c'],
23 | },
24 | offset: {
25 | type: 'number',
26 | describe: 'Number of servers to skip',
27 | alias: ['o'],
28 | },
29 | name: {
30 | type: 'string',
31 | describe: 'Filter servers by name',
32 | alias: ['n'],
33 | },
34 | json: {
35 | type: 'boolean',
36 | describe: 'Return server list as JSON',
37 | alias: ['j'],
38 | },
39 | 'show-tokens': {
40 | type: 'boolean',
41 | describe: 'Show server tokens with server info',
42 | alias: ['t'],
43 | },
44 | }
45 | export const handler = (args: ServerListArguments): Promise => exec(args)
46 |
47 | /**
48 | * Execute the command
49 | */
50 | const exec = (args: ServerListArguments): Promise => {
51 | const { accountToken } = args
52 |
53 | return validateToken(accountToken, true).then(token => {
54 | listCommand(token, args)
55 | })
56 | }
57 |
58 | /**
59 | * Get list of servers
60 | */
61 | const listCommand = (accountToken: string, args: ServerListArguments): void => {
62 | const { count, offset, name, showTokens, requestHost } = args
63 | const command: CommandResponse = new CommandResponse()
64 | command.initResponse('Fetching servers...')
65 | const client = new AccountClient(accountToken)
66 | if (requestHost !== undefined && requestHost !== '') {
67 | client.setClientOptions({ requestHost })
68 | }
69 |
70 | getServers(client, count, offset, name)
71 | .then(response => {
72 | if (args.json) {
73 | return command.response(serverJson(response, showTokens))
74 | }
75 |
76 | return command.response(serverTable(response, showTokens))
77 | })
78 | .catch(error => {
79 | return command.errorResponse(error)
80 | })
81 | }
82 |
83 | /**
84 | * Fetch servers from Postmark
85 | */
86 | const getServers = (
87 | client: AccountClient,
88 | count: number,
89 | offset: number,
90 | name: string,
91 | ): Promise => {
92 | const options = {
93 | ...(count && { count: count }),
94 | ...(offset && { offset: offset }),
95 | ...(name && { name: name }),
96 | }
97 | return client.getServers(options)
98 | }
99 |
100 | /**
101 | * Return server as JSON
102 | */
103 | const serverJson = (servers: Servers, showTokens: boolean): string => {
104 | if (showTokens) return JSON.stringify(servers, null, 2)
105 |
106 | servers.Servers.forEach(item => {
107 | item.ApiTokens.forEach(
108 | (token, index) => (item.ApiTokens[index] = tokenMask()),
109 | )
110 | return item
111 | })
112 |
113 | return JSON.stringify(servers, null, 2)
114 | }
115 |
116 | /**
117 | * Create a table with server info
118 | */
119 | const serverTable = (servers: Servers, showTokens: boolean): string => {
120 | let headings = ['Server', 'Settings']
121 | let serverTable: any[] = [headings]
122 |
123 | // Create server rows
124 | servers.Servers.forEach(server =>
125 | serverTable.push(serverRow(server, showTokens)),
126 | )
127 | return table(serverTable, { border: getBorderCharacters('norc') })
128 | }
129 |
130 | /**
131 | * Create server row
132 | */
133 | const serverRow = (server: Server, showTokens: boolean): string[] => {
134 | let row = []
135 |
136 | let tokens = ''
137 | server.ApiTokens.forEach((token, index) => {
138 | tokens += showTokens ? token : tokenMask()
139 | if (server.ApiTokens.length > index + 1) tokens += '\n'
140 | })
141 |
142 | // Name column
143 | const name =
144 | chalk.white.bgHex(colorMap[server.Color])(' ') +
145 | ` ${chalk.bold.white(server.Name)}` +
146 | chalk.gray(`\nID: ${server.ID}`) +
147 | `\n${chalk.gray(server.ServerLink)}` +
148 | `\n\n${chalk.bold.white('Server API Tokens')}\n` +
149 | tokens
150 | row.push(name)
151 |
152 | // Settings column
153 | const settings =
154 | `SMTP: ${stateLabel(server.SmtpApiActivated)}` +
155 | `\nOpen Tracking: ${stateLabel(server.TrackOpens)}` +
156 | `\nLink Tracking: ${linkTrackingStateLabel(server.TrackLinks)}` +
157 | `\nInbound: ${stateLabel(server.InboundHookUrl !== '')}`
158 | row.push(settings)
159 |
160 | return row
161 | }
162 |
163 | const tokenMask = (): string => '•'.repeat(36)
164 |
165 | export const stateLabel = (state: boolean | undefined): string => {
166 | return state ? chalk.green('Enabled') : chalk.gray('Disabled')
167 | }
168 |
169 | export const linkTrackingStateLabel = (state: string): string => {
170 | switch (state) {
171 | case 'TextOnly':
172 | return chalk.green('Text')
173 | case 'HtmlOnly':
174 | return chalk.green('HTML')
175 | case 'HtmlAndText':
176 | return chalk.green('HTML and Text')
177 | default:
178 | return chalk.gray('Disabled')
179 | }
180 | }
181 |
182 | const colorMap: ColorMap = {
183 | purple: '#9C73D2',
184 | blue: '#21CDFE',
185 | turquoise: '#52F3ED',
186 | green: '#3BE380',
187 | red: '#F35A3D',
188 | orange: '#FE8421',
189 | yellow: '#FFDE00',
190 | grey: '#929292',
191 | }
192 |
--------------------------------------------------------------------------------
/src/commands/templates.ts:
--------------------------------------------------------------------------------
1 | import { cmd } from '../utils'
2 |
3 | export const { command, desc, builder } = cmd(
4 | 'templates',
5 | 'Manage your templates',
6 | )
7 |
--------------------------------------------------------------------------------
/src/commands/templates/helpers.ts:
--------------------------------------------------------------------------------
1 | import { join, dirname } from 'path'
2 | import { isEmpty } from 'lodash'
3 | import { readJsonSync, readFileSync, existsSync } from 'fs-extra'
4 | import traverse from 'traverse'
5 | import dirTree from 'directory-tree'
6 | import { TemplateManifest, MetaFileTraverse, MetaFile } from '../../types'
7 |
8 | /**
9 | * Parses templates folder and files
10 | */
11 |
12 | export const createManifest = (path: string): TemplateManifest[] => {
13 | let manifest: TemplateManifest[] = []
14 |
15 | // Return empty array if path does not exist
16 | if (!existsSync(path)) return manifest
17 |
18 | // Find meta files and flatten into collection
19 | const list: MetaFileTraverse[] = findMetaFiles(path)
20 |
21 | // Parse each directory
22 | list.forEach(file => {
23 | const item = createManifestItem(file)
24 | if (item) manifest.push(item)
25 | })
26 |
27 | return manifest
28 | }
29 |
30 | /**
31 | * Searches for all metadata files and flattens into a collection
32 | */
33 | export const findMetaFiles = (path: string): MetaFileTraverse[] =>
34 | traverse(dirTree(path)).reduce((acc, file) => {
35 | if (file.name === 'meta.json') acc.push(file)
36 | return acc
37 | }, [])
38 |
39 | /**
40 | * Gathers the template's content and metadata based on the metadata file location
41 | */
42 | export const createManifestItem = (file: MetaFileTraverse): MetaFile | null => {
43 | const { path } = file // Path to meta file
44 | const rootPath = dirname(path) // Folder path
45 | const htmlPath = join(rootPath, 'content.html') // HTML path
46 | const textPath = join(rootPath, 'content.txt') // Text path
47 |
48 | // Check if meta file exists
49 | if (existsSync(path)) {
50 | const metaFile: MetaFile = readJsonSync(path)
51 | const htmlFile: string = existsSync(htmlPath)
52 | ? readFileSync(htmlPath, 'utf-8')
53 | : ''
54 | const textFile: string = existsSync(textPath)
55 | ? readFileSync(textPath, 'utf-8')
56 | : ''
57 |
58 | return {
59 | HtmlBody: htmlFile,
60 | TextBody: textFile,
61 | ...metaFile,
62 | }
63 | }
64 |
65 | return null
66 | }
67 |
68 | type TemplateDifference = 'html' | 'text' | 'subject' | 'name' | 'layout'
69 | type TemplateDifferences = Set
70 |
71 | export function templatesDiff(
72 | t1: TemplateManifest,
73 | t2: TemplateManifest,
74 | ): TemplateDifferences {
75 | const result: TemplateDifferences = new Set()
76 |
77 | if (!sameContent(t1.HtmlBody, t2.HtmlBody)) result.add('html')
78 | if (!sameContent(t1.TextBody, t2.TextBody)) result.add('text')
79 | if (t2.TemplateType === 'Standard' && !sameContent(t1.Subject, t2.Subject))
80 | result.add('subject')
81 | if (!sameContent(t1.Name, t2.Name)) result.add('name')
82 | if (
83 | t2.TemplateType === 'Standard' &&
84 | !sameContent(t1.LayoutTemplate, t2.LayoutTemplate)
85 | )
86 | result.add('layout')
87 |
88 | return result
89 | }
90 |
91 | export function sameContent(
92 | str1: string | null | undefined,
93 | str2: string | null | undefined,
94 | ): boolean {
95 | if (isEmpty(str1) && isEmpty(str2)) {
96 | return true
97 | }
98 |
99 | if (isEmpty(str1) || isEmpty(str2)) {
100 | return false
101 | }
102 |
103 | return str1 === str2
104 | }
105 |
--------------------------------------------------------------------------------
/src/commands/templates/preview.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk'
2 | import path from 'path'
3 | import untildify from 'untildify'
4 | import express from 'express'
5 | import consolidate from 'consolidate'
6 | import { filter, find, replace, debounce } from 'lodash'
7 | import { createMonitor } from 'watch'
8 | import { ServerClient } from 'postmark'
9 | import { TemplateValidationOptions } from 'postmark/dist/client/models'
10 |
11 | import { fatalError, log, validateToken } from '../../utils'
12 |
13 | import { validatePushDirectory } from './push'
14 | import { createManifest } from './helpers'
15 |
16 | const previewPath = path.join(__dirname, 'preview')
17 | const templateLinks = ''
18 |
19 | export const command = 'preview [options]'
20 | export const desc = 'Preview your templates and layouts'
21 | export const builder = {
22 | 'server-token': {
23 | type: 'string',
24 | hidden: true,
25 | },
26 | port: {
27 | type: 'number',
28 | describe: 'The port to open up the preview server on',
29 | default: 3005,
30 | alias: 'p',
31 | },
32 | }
33 |
34 | interface TemplatePreviewArguments {
35 | serverToken: string
36 | templatesdirectory: string
37 | port: number
38 | }
39 | export async function handler(args: TemplatePreviewArguments): Promise {
40 | const serverToken = await validateToken(args.serverToken)
41 |
42 | try {
43 | validatePushDirectory(args.templatesdirectory)
44 | } catch (e) {
45 | return fatalError(e)
46 | }
47 |
48 | return preview(serverToken, args)
49 | }
50 |
51 | /**
52 | * Preview
53 | */
54 | async function preview(
55 | serverToken: string,
56 | args: TemplatePreviewArguments,
57 | ): Promise {
58 | const { port, templatesdirectory } = args
59 | log(`${title} Starting template preview server...`)
60 |
61 | // Start server
62 | const app = express()
63 | const server = require('http').createServer(app)
64 | const io = require('socket.io')(server)
65 |
66 | // Cache manifest and Postmark server
67 | const client = new ServerClient(serverToken)
68 | let manifest = createManifest(templatesdirectory)
69 |
70 | // Static assets
71 | app.use(express.static(`${previewPath}/assets`))
72 |
73 | function updateEvent() {
74 | // Generate new manifest
75 | manifest = createManifest(templatesdirectory)
76 |
77 | // Trigger reload on client
78 | log(`${title} File changed. Reloading browser...`)
79 | io.emit('change')
80 | }
81 |
82 | // Watch for file changes
83 | createMonitor(untildify(templatesdirectory), { interval: 2 }, monitor => {
84 | monitor.on('created', debounce(updateEvent, 1000))
85 | monitor.on('changed', debounce(updateEvent, 1000))
86 | monitor.on('removed', debounce(updateEvent, 1000))
87 | })
88 |
89 | // Template listing
90 | app.get('/', (req, res) => {
91 | manifest = createManifest(templatesdirectory)
92 | const templates = filter(manifest, { TemplateType: 'Standard' })
93 | const layouts = filter(manifest, { TemplateType: 'Layout' })
94 | const path = untildify(templatesdirectory).replace(/\/$/, '')
95 |
96 | consolidate.ejs(
97 | `${previewPath}/index.ejs`,
98 | { templates, layouts, path },
99 | (err, html) => renderTemplateContents(res, err, html),
100 | )
101 | })
102 |
103 | /**
104 | * Get template by alias
105 | */
106 | app.get('/:alias', (req, res) => {
107 | const template = find(manifest, { Alias: req.params.alias })
108 |
109 | if (template) {
110 | consolidate.ejs(
111 | `${previewPath}/template.ejs`,
112 | { template },
113 | (err, html) => renderTemplateContents(res, err, html),
114 | )
115 | } else {
116 | // Redirect to index
117 | return res.redirect(301, '/')
118 | }
119 | })
120 |
121 | /**
122 | * Get template HTML version by alias
123 | */
124 | app.get('/html/:alias', (req, res) => {
125 | const template: any = find(manifest, { Alias: req.params.alias })
126 |
127 | if (template && template.HtmlBody) {
128 | const layout: any = find(manifest, { Alias: template.LayoutTemplate })
129 |
130 | // Render error if layout is specified, but HtmlBody is empty
131 | if (layout && !layout.HtmlBody)
132 | return renderTemplateInvalid(res, layoutError)
133 |
134 | const { TemplateType, TestRenderModel } = template
135 | const payload = {
136 | HtmlBody: getSource('html', template, layout),
137 | TemplateType,
138 | TestRenderModel,
139 | Subject: template.Subject,
140 | }
141 |
142 | return validateTemplateRequest('html', payload, res)
143 | } else {
144 | return renderTemplate404(res, 'HTML')
145 | }
146 | })
147 |
148 | /**
149 | * Get template text version by alias
150 | */
151 | app.get('/text/:alias', (req, res) => {
152 | const template: any = find(manifest, { Alias: req.params.alias })
153 |
154 | if (template && template.TextBody) {
155 | const layout: any = find(manifest, { Alias: template.LayoutTemplate })
156 |
157 | // Render error if layout is specified, but HtmlBody is empty
158 | if (layout && !layout.TextBody)
159 | return renderTemplateInvalid(res, layoutError)
160 |
161 | const { TemplateType, TestRenderModel } = template
162 | const payload = {
163 | TextBody: getSource('text', template, layout),
164 | TemplateType,
165 | TestRenderModel,
166 | }
167 |
168 | return validateTemplateRequest('text', payload, res)
169 | } else {
170 | return renderTemplate404(res, 'Text')
171 | }
172 | })
173 |
174 | server.listen(port, () => {
175 | const url = `http://localhost:${port}`
176 |
177 | log(`${title} Template preview server ready. Happy coding!`)
178 | log(divider)
179 | log(`URL: ${chalk.green(url)}`)
180 | log(divider)
181 | })
182 |
183 | function validateTemplateRequest(
184 | version: 'html' | 'text',
185 | payload: TemplateValidationOptions,
186 | res: express.Response,
187 | ) {
188 | const versionKey = version === 'html' ? 'HtmlBody' : 'TextBody'
189 |
190 | // Make request to Postmark
191 | client
192 | .validateTemplate(payload)
193 | .then(result => {
194 | if (result[versionKey].ContentIsValid) {
195 | const renderedContent =
196 | result[versionKey].RenderedContent + templateLinks
197 | io.emit('subject', {
198 | ...result.Subject,
199 | rawSubject: payload.Subject,
200 | })
201 |
202 | // Render raw source if HTML
203 | if (version === 'html') {
204 | return res.send(renderedContent)
205 | } else {
206 | // Render specific EJS with text content
207 | return renderTemplateText(res, renderedContent)
208 | }
209 | }
210 |
211 | return renderTemplateInvalid(res, result[versionKey].ValidationErrors)
212 | })
213 | .catch(error => {
214 | return res.status(500).send(error)
215 | })
216 | }
217 | }
218 |
219 | function combineTemplate(layout: string, template: string): string {
220 | return replace(layout, /({{{)(.?@content.?)(}}})/g, template)
221 | }
222 |
223 | /* Console helpers */
224 |
225 | const title = `${chalk.yellow('ミ▢ Postmark')}${chalk.gray(':')}`
226 | const divider = chalk.gray('-'.repeat(34))
227 |
228 | /* Render Templates */
229 |
230 | function getSource(version: 'html' | 'text', template: any, layout?: any) {
231 | const versionKey = version === 'html' ? 'HtmlBody' : 'TextBody'
232 |
233 | if (layout) return combineTemplate(layout[versionKey], template[versionKey])
234 |
235 | return template[versionKey]
236 | }
237 |
238 | function renderTemplateText(res: express.Response, body: string) {
239 | return consolidate.ejs(
240 | `${previewPath}/templateText.ejs`,
241 | { body },
242 | (err, html) => renderTemplateContents(res, err, html),
243 | )
244 | }
245 |
246 | function renderTemplateInvalid(res: express.Response, errors: any) {
247 | return consolidate.ejs(
248 | `${previewPath}/templateInvalid.ejs`,
249 | { errors },
250 | (err, html) => renderTemplateContents(res, err, html),
251 | )
252 | }
253 |
254 | function renderTemplate404(res: express.Response, version: string) {
255 | return consolidate.ejs(
256 | `${previewPath}/template404.ejs`,
257 | { version },
258 | (err, html) => renderTemplateContents(res, err, html),
259 | )
260 | }
261 |
262 | function renderTemplateContents(res: express.Response, err: any, html: string) {
263 | if (err) return res.send(err)
264 |
265 | return res.send(html)
266 | }
267 |
268 | const layoutError = [
269 | {
270 | Message:
271 | 'A TemplateLayout is specified, but it is either empty or missing.',
272 | },
273 | ]
274 |
--------------------------------------------------------------------------------
/src/commands/templates/pull.ts:
--------------------------------------------------------------------------------
1 | import ora from 'ora'
2 | import untildify from 'untildify'
3 | import invariant from 'ts-invariant'
4 | import { join } from 'path'
5 | import { outputFileSync, existsSync, ensureDirSync } from 'fs-extra'
6 | import { confirm } from '@inquirer/prompts'
7 | import { ServerClient } from 'postmark'
8 | import type { Template, Templates } from 'postmark/dist/client/models'
9 |
10 | import { MetaFile } from '../../types'
11 | import {
12 | log,
13 | validateToken,
14 | pluralize,
15 | logError,
16 | fatalError,
17 | } from '../../utils'
18 |
19 | export const command = 'pull