├── .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 | Postmark CLI 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 | CircleCI 8 | License: MIT 9 | npm version 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 |

Postmark Templates

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 |
  1. Create a new folder for your template: 55 |
    56 | cd <%- path %>
    57 | mkdir password-reset
    58 |
  2. 59 |
  3. 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 |
  4. 74 |
  5. Refresh the page
  6. 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 |
6 |
7 | 8 |
9 |

<%- template.Name %>

10 | <%- template.Alias %> 11 |
12 |
13 |
14 | <% if (template.TemplateType === 'Standard') { %> 15 | Layout: 16 | <% if (template.LayoutTemplate) { %> 17 | <%- template.LayoutTemplate %> 18 | <% } else { %> 19 | None 20 | <% } %> 21 | 22 | <% } %> 23 | 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 | <% if (template.TemplateType === 'Standard') {%> 37 |
38 |
39 | Subject 40 | <%- template.Subject || 'No subject' %> 41 |
42 |
Reloaded!
43 |
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 [options]' 20 | export const desc = 'Pull templates from a server to ' 21 | export const builder = { 22 | 'server-token': { 23 | type: 'string', 24 | hidden: true, 25 | }, 26 | 'request-host': { 27 | type: 'string', 28 | hidden: true, 29 | }, 30 | overwrite: { 31 | type: 'boolean', 32 | alias: 'o', 33 | default: false, 34 | describe: 'Overwrite templates if they already exist', 35 | }, 36 | } 37 | 38 | interface TemplatePullArguments { 39 | serverToken: string 40 | requestHost: string 41 | outputdirectory: string 42 | overwrite: boolean 43 | } 44 | export async function handler(args: TemplatePullArguments): Promise { 45 | const serverToken = await validateToken(args.serverToken) 46 | pull(serverToken, args) 47 | } 48 | 49 | /** 50 | * Begin pulling the templates 51 | */ 52 | async function pull( 53 | serverToken: string, 54 | args: TemplatePullArguments, 55 | ): Promise { 56 | const { outputdirectory, overwrite, requestHost } = args 57 | 58 | // Check if directory exists 59 | if (existsSync(untildify(outputdirectory)) && !overwrite) { 60 | return overwritePrompt(serverToken, outputdirectory, requestHost) 61 | } 62 | 63 | return fetchTemplateList({ 64 | sourceServer: serverToken, 65 | outputDir: outputdirectory, 66 | requestHost: requestHost, 67 | }) 68 | } 69 | 70 | /** 71 | * Ask user to confirm overwrite 72 | */ 73 | async function overwritePrompt( 74 | serverToken: string, 75 | outputdirectory: string, 76 | requestHost: string, 77 | ): Promise { 78 | const answer = await confirm({ 79 | default: false, 80 | message: `Overwrite the files in ${outputdirectory}?`, 81 | }) 82 | 83 | if (answer) { 84 | return fetchTemplateList({ 85 | sourceServer: serverToken, 86 | outputDir: outputdirectory, 87 | requestHost: requestHost, 88 | }) 89 | } 90 | } 91 | 92 | interface TemplateListOptions { 93 | sourceServer: string 94 | requestHost: string 95 | outputDir: string 96 | } 97 | /** 98 | * Fetch template list from PM 99 | */ 100 | async function fetchTemplateList(options: TemplateListOptions) { 101 | const { sourceServer, outputDir, requestHost } = options 102 | const spinner = ora('Pulling templates from Postmark...').start() 103 | const client = new ServerClient(sourceServer) 104 | if (requestHost !== undefined && requestHost !== '') { 105 | client.setClientOptions({ requestHost }) 106 | } 107 | 108 | try { 109 | const templates = await client.getTemplates({ count: 300 }) 110 | 111 | if (templates.TotalCount === 0) { 112 | spinner.stop() 113 | return fatalError('There are no templates on this server.') 114 | } else { 115 | await processTemplates({ spinner, client, outputDir, templates }) 116 | } 117 | } catch (err) { 118 | spinner.stop() 119 | return fatalError(err) 120 | } 121 | } 122 | 123 | interface ProcessTemplatesOptions { 124 | spinner: ora.Ora 125 | client: ServerClient 126 | outputDir: string 127 | templates: Templates 128 | } 129 | /** 130 | * Fetch each template’s content from the server 131 | */ 132 | async function processTemplates(options: ProcessTemplatesOptions) { 133 | const { spinner, client, outputDir, templates } = options 134 | 135 | // Keep track of requests 136 | let requestCount = 0 137 | 138 | // keep track of templates downloaded 139 | let totalDownloaded = 0 140 | 141 | // Iterate through each template and fetch content 142 | for (const template of templates.Templates) { 143 | spinner.text = `Downloading template: ${template.Alias || template.Name}` 144 | 145 | // Show warning if template doesn't have an alias 146 | if (!template.Alias) { 147 | requestCount++ 148 | log( 149 | `Template named "${template.Name}" will not be downloaded because it is missing an alias.`, 150 | { warn: true }, 151 | ) 152 | 153 | // If this is the last template 154 | if (requestCount === templates.TotalCount) spinner.stop() 155 | return 156 | } 157 | 158 | // Make request to Postmark 159 | try { 160 | const response = await client.getTemplate(template.Alias) 161 | requestCount++ 162 | 163 | // Save template to file system 164 | await saveTemplate(outputDir, response, client) 165 | totalDownloaded++ 166 | 167 | // Show feedback when finished saving templates 168 | if (requestCount === templates.TotalCount) { 169 | spinner.stop() 170 | 171 | log( 172 | `All finished! ${totalDownloaded} ${pluralize( 173 | totalDownloaded, 174 | 'template has', 175 | 'templates have', 176 | )} been saved to ${outputDir}.`, 177 | { color: 'green' }, 178 | ) 179 | } 180 | } catch (e) { 181 | spinner.stop() 182 | logError(e) 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * Save template 189 | * @return An object containing the HTML and Text body 190 | */ 191 | async function saveTemplate( 192 | outputDir: string, 193 | template: Template, 194 | client: ServerClient, 195 | ) { 196 | invariant( 197 | typeof template.Alias === 'string' && !!template.Alias, 198 | 'Template must have an alias', 199 | ) 200 | 201 | outputDir = 202 | template.TemplateType === 'Layout' ? join(outputDir, '_layouts') : outputDir 203 | 204 | const path: string = untildify(join(outputDir, template.Alias)) 205 | 206 | ensureDirSync(path) 207 | 208 | // Save HTML version 209 | if (template.HtmlBody !== '') { 210 | outputFileSync(join(path, 'content.html'), template.HtmlBody) 211 | } 212 | 213 | // Save Text version 214 | if (template.TextBody !== null && template.TextBody !== '') { 215 | outputFileSync(join(path, 'content.txt'), template.TextBody) 216 | } 217 | 218 | const meta: MetaFile = { 219 | Name: template.Name, 220 | Alias: template.Alias, 221 | ...(template.Subject && { Subject: template.Subject }), 222 | TemplateType: template.TemplateType, 223 | ...(template.TemplateType === 'Standard' && { 224 | LayoutTemplate: template.LayoutTemplate || undefined, 225 | }), 226 | } 227 | 228 | // Save suggested template model 229 | return client 230 | .validateTemplate({ 231 | ...(template.HtmlBody && { HtmlBody: template.HtmlBody }), 232 | ...(template.TextBody && { TextBody: template.TextBody }), 233 | ...meta, 234 | }) 235 | .then(result => { 236 | meta.TestRenderModel = result.SuggestedTemplateModel 237 | }) 238 | .catch(error => { 239 | logError('Error fetching suggested template model') 240 | logError(error) 241 | }) 242 | .then(() => { 243 | // Save the file regardless of success or error when fetching suggested model 244 | outputFileSync(join(path, 'meta.json'), JSON.stringify(meta, null, 2)) 245 | }) 246 | } 247 | -------------------------------------------------------------------------------- /src/commands/templates/push.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import ora from 'ora' 3 | import untildify from 'untildify' 4 | import invariant from 'ts-invariant' 5 | import { existsSync, statSync } from 'fs-extra' 6 | import { find } from 'lodash' 7 | import { confirm } from '@inquirer/prompts' 8 | import { table, getBorderCharacters } from 'table' 9 | import { ServerClient } from 'postmark' 10 | import { TemplateTypes, Templates } from 'postmark/dist/client/models' 11 | 12 | import { TemplateManifest } from '../../types' 13 | import { 14 | pluralize, 15 | log, 16 | validateToken, 17 | fatalError, 18 | logError, 19 | } from '../../utils' 20 | 21 | import { createManifest, sameContent, templatesDiff } from './helpers' 22 | 23 | const debug = require('debug')('postmark-cli:templates:push') 24 | 25 | export const command = 'push [options]' 26 | export const desc = 27 | 'Push templates from to a Postmark server' 28 | 29 | export const builder = { 30 | 'server-token': { 31 | type: 'string', 32 | hidden: true, 33 | }, 34 | 'request-host': { 35 | type: 'string', 36 | hidden: true, 37 | }, 38 | force: { 39 | type: 'boolean', 40 | describe: 'Disable confirmation before pushing templates', 41 | alias: 'f', 42 | }, 43 | all: { 44 | type: 'boolean', 45 | describe: 46 | 'Push all local templates up to Postmark regardless of whether they changed', 47 | alias: 'a', 48 | }, 49 | } 50 | 51 | type MaybeString = string | null | undefined 52 | 53 | type ReviewItem = [string?, string?, string?, string?] 54 | interface TemplatePushReview { 55 | layouts: ReviewItem[] 56 | templates: ReviewItem[] 57 | } 58 | 59 | const STATUS_ADDED = chalk.green('Added') 60 | const STATUS_MODIFIED = chalk.yellow('Modified') 61 | const STATUS_UNMODIFIED = chalk.gray('Unmodified') 62 | 63 | interface TemplatePushArguments { 64 | serverToken: string 65 | requestHost: string 66 | templatesdirectory: string 67 | force: boolean 68 | all: boolean 69 | } 70 | export async function handler(args: TemplatePushArguments): Promise { 71 | const serverToken = await validateToken(args.serverToken) 72 | 73 | try { 74 | validatePushDirectory(args.templatesdirectory) 75 | } catch (e) { 76 | return fatalError(e) 77 | } 78 | 79 | return push(serverToken, args) 80 | } 81 | 82 | /** 83 | * Check if directory exists before pushing 84 | */ 85 | export function validatePushDirectory(dir: string): void { 86 | const fullPath: string = untildify(dir) 87 | 88 | if (!existsSync(fullPath)) { 89 | throw new Error(`The provided path "${dir}" does not exist`) 90 | } 91 | 92 | // check if path is a directory 93 | const stats = statSync(fullPath) 94 | if (!stats.isDirectory()) { 95 | throw new Error(`The provided path "${dir}" is not a directory`) 96 | } 97 | } 98 | 99 | /** 100 | * Push local templates to Postmark 101 | */ 102 | async function push( 103 | serverToken: string, 104 | args: TemplatePushArguments, 105 | ): Promise { 106 | const { templatesdirectory, force, requestHost, all } = args 107 | 108 | const client = new ServerClient(serverToken) 109 | if (requestHost !== undefined && requestHost !== '') { 110 | client.setClientOptions({ requestHost }) 111 | } 112 | 113 | const spinner = ora('Fetching templates...').start() 114 | try { 115 | const manifest = createManifest(templatesdirectory) 116 | 117 | if (manifest.length === 0) { 118 | return fatalError('No templates or layouts were found.') 119 | } 120 | 121 | try { 122 | const templateList = await client.getTemplates({ count: 300 }) 123 | const newList = 124 | templateList.TotalCount === 0 125 | ? [] 126 | : await getTemplateContent(client, templateList, spinner) 127 | 128 | return await processTemplates({ 129 | newList, 130 | manifest, 131 | all, 132 | force, 133 | spinner, 134 | client, 135 | }) 136 | } catch (error) { 137 | return fatalError(error) 138 | } 139 | } finally { 140 | spinner.stop() 141 | } 142 | } 143 | 144 | interface ProcessTemplates { 145 | newList: TemplateManifest[] 146 | manifest: TemplateManifest[] 147 | all: boolean 148 | force: boolean 149 | spinner: ora.Ora 150 | client: ServerClient 151 | } 152 | /** 153 | * Compare templates and CLI flow 154 | */ 155 | async function processTemplates({ 156 | newList, 157 | manifest, 158 | all, 159 | force, 160 | spinner, 161 | client, 162 | }: ProcessTemplates): Promise { 163 | const pushManifest = compareTemplates(newList, manifest, all) 164 | 165 | spinner.stop() 166 | if (pushManifest.length === 0) return log('There are no changes to push.') 167 | 168 | // Show which templates are changing 169 | printReview(prepareReview(pushManifest)) 170 | 171 | // Push templates if force arg is present 172 | if (force || (await confirmation())) { 173 | spinner.text = 'Pushing templates to Postmark...' 174 | spinner.start() 175 | await pushTemplates( 176 | client, 177 | pushManifest, 178 | function handleBeforePush(template) { 179 | spinner.color = 'yellow' 180 | spinner.text = `Pushing template: ${template.Alias}` 181 | }, 182 | function handleError(template, error) { 183 | spinner.stop() 184 | logError(`\n${template.Alias || template.Name}: ${error}`) 185 | spinner.start() 186 | }, 187 | function handleComplete(failed) { 188 | spinner.stop() 189 | log('✅ All finished!', { color: 'green' }) 190 | 191 | if (failed > 0) { 192 | logError( 193 | `⚠️ Failed to push ${failed} ${pluralize(failed, 'template', 'templates')}. Please see the output above for more details.`, 194 | ) 195 | } 196 | }, 197 | ) 198 | } else { 199 | log('Canceling push. Have a good day!') 200 | } 201 | } 202 | 203 | /** 204 | * Gather template content from server to compare against local versions 205 | */ 206 | async function getTemplateContent( 207 | client: ServerClient, 208 | templateList: Templates, 209 | spinner: ora.Ora, 210 | ): Promise { 211 | const result: TemplateManifest[] = [] 212 | 213 | for (const template of templateList.Templates) { 214 | spinner.text = `Comparing template: ${template.Alias}` 215 | const response = await client.getTemplate(template.TemplateId) 216 | result.push({ 217 | ...template, 218 | Alias: response.Alias || undefined, 219 | HtmlBody: response.HtmlBody || undefined, 220 | TextBody: response.TextBody || undefined, 221 | Subject: response.Subject, 222 | TemplateType: response.TemplateType, 223 | LayoutTemplate: response.LayoutTemplate || undefined, 224 | }) 225 | } 226 | 227 | return result 228 | } 229 | 230 | /** 231 | * Ask user to confirm the push 232 | */ 233 | async function confirmation( 234 | message = 'Would you like to continue?', 235 | defaultResponse = false, 236 | ): Promise { 237 | return confirm({ default: defaultResponse, message }) 238 | } 239 | 240 | /** 241 | * Compare templates on server against local 242 | */ 243 | function compareTemplates( 244 | remote: TemplateManifest[], 245 | local: TemplateManifest[], 246 | pushAll: boolean, 247 | ): TemplateManifest[] { 248 | const result: TemplateManifest[] = [] 249 | 250 | for (const template of local) { 251 | const match = find(remote, { Alias: template.Alias }) 252 | template.New = !match 253 | 254 | if (!match) { 255 | template.Status = STATUS_ADDED 256 | result.push(template) 257 | } else { 258 | const modified = wasModified(match, template) 259 | template.Status = modified ? STATUS_MODIFIED : STATUS_UNMODIFIED 260 | 261 | // Push all templates if --all argument is present, 262 | // regardless of whether templates were modified 263 | if (pushAll || modified) { 264 | result.push(template) 265 | } 266 | } 267 | } 268 | 269 | return result 270 | } 271 | 272 | /** 273 | * Check if local template is different than server 274 | */ 275 | function wasModified( 276 | remote: TemplateManifest, 277 | local: TemplateManifest, 278 | ): boolean { 279 | const diff = templatesDiff(remote, local) 280 | const result = diff.size > 0 281 | 282 | debug('Template %o was modified: %o. %o', local.Alias, result, diff) 283 | 284 | return result 285 | } 286 | 287 | function prepareReview(pushManifest: TemplateManifest[]): TemplatePushReview { 288 | const templates: ReviewItem[] = [] 289 | const layouts: ReviewItem[] = [] 290 | 291 | for (const template of pushManifest) { 292 | if (template.TemplateType === TemplateTypes.Layout) { 293 | layouts.push([ 294 | template.Status, 295 | template.Name, 296 | template.Alias || undefined, 297 | ]) 298 | continue 299 | } else { 300 | templates.push([ 301 | template.Status, 302 | template.Name, 303 | template.Alias || undefined, 304 | layoutUsedLabel(template.LayoutTemplate, template.LayoutTemplate), 305 | ]) 306 | } 307 | } 308 | 309 | return { 310 | templates, 311 | layouts, 312 | } 313 | 314 | function layoutUsedLabel( 315 | localLayout: MaybeString, 316 | remoteLayout: MaybeString, 317 | ): string { 318 | let label = localLayout || chalk.gray('None') 319 | 320 | if (!sameContent(localLayout, remoteLayout)) { 321 | label += chalk.red(` ✘ ${remoteLayout || 'None'}`) 322 | } 323 | 324 | return label 325 | } 326 | } 327 | 328 | /** 329 | * Show which templates will change after the publish 330 | */ 331 | function printReview({ templates, layouts }: TemplatePushReview) { 332 | // Table headers 333 | const header = [chalk.gray('Status'), chalk.gray('Name'), chalk.gray('Alias')] 334 | const templatesHeader = [...header, chalk.gray('Layout used')] 335 | 336 | // Labels 337 | const templatesLabel = 338 | templates.length > 0 339 | ? `${templates.length} ${pluralize( 340 | templates.length, 341 | 'template', 342 | 'templates', 343 | )}` 344 | : '' 345 | 346 | const layoutsLabel = 347 | layouts.length > 0 348 | ? `${layouts.length} ${pluralize(layouts.length, 'layout', 'layouts')}` 349 | : '' 350 | 351 | // Log template and layout files 352 | if (templates.length > 0) { 353 | log(`\n${templatesLabel}`) 354 | log( 355 | table([templatesHeader, ...templates], { 356 | border: getBorderCharacters('norc'), 357 | }), 358 | ) 359 | } 360 | if (layouts.length > 0) { 361 | log(`\n${layoutsLabel}`) 362 | log(table([header, ...layouts], { border: getBorderCharacters('norc') })) 363 | } 364 | 365 | // Log summary 366 | log( 367 | chalk.yellow( 368 | `${templatesLabel}${templates.length > 0 && layouts.length > 0 ? ' and ' : ''}${layoutsLabel} will be pushed to Postmark.`, 369 | ), 370 | ) 371 | } 372 | 373 | /** 374 | * Push all local templates 375 | */ 376 | type OnPushTemplateError = (template: TemplateManifest, error: unknown) => void 377 | type OnPushTemplatesComplete = (failed: number) => void 378 | type OnBeforePushTemplate = (template: TemplateManifest) => void 379 | export async function pushTemplates( 380 | client: ServerClient, 381 | localTemplates: TemplateManifest[], 382 | onBeforePush: OnBeforePushTemplate, 383 | onError: OnPushTemplateError, 384 | onComplete: OnPushTemplatesComplete, 385 | ) { 386 | let failed = 0 387 | 388 | // Push first layouts, then standard templates. We're iterating the list twice which is not super efficient, 389 | // but it's easier to read and maintain. 390 | // We need to push layouts first because they can be used by standard templates. 391 | for (const templateType of [TemplateTypes.Layout, TemplateTypes.Standard]) { 392 | for (const template of localTemplates) { 393 | if (template.TemplateType !== templateType) continue 394 | 395 | onBeforePush(template) 396 | try { 397 | await pushTemplate(template) 398 | } catch (error) { 399 | onError(template, error) 400 | failed++ 401 | } 402 | } 403 | } 404 | 405 | onComplete(failed) 406 | 407 | async function pushTemplate(template: TemplateManifest): Promise { 408 | invariant(template.Alias, 'Template alias is required') 409 | if (template.New) { 410 | await client.createTemplate(template) 411 | } else { 412 | await client.editTemplate(template.Alias, template) 413 | } 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from 'chalk' 4 | 5 | require('yargonaut').style('yellow').errorsStyle('red') 6 | 7 | require('yargs/yargs')(process.argv.slice(2)) 8 | .env('POSTMARK') 9 | .commandDir('commands') 10 | .demandCommand() 11 | .help() 12 | .usage( 13 | chalk.yellow(` 14 |               ____ _ _ 15 |  _________ | _ \\ ___ ___| |_ _ __ ___ __ _ _ __| | __ 16 | | \\ / | | |_) / _ \\/ __| __| '_ ' _ \\ / _\` | '__| |/ / 17 | | '...' | | __/ (_) \\__ \\ |_| | | | | | (_| | | | < 18 | |__/___\\__| |_| \\___/|___/\\__|_| |_| |_|\\__,_|_| |_|\\_\\`), 19 | ).argv 20 | -------------------------------------------------------------------------------- /src/types/Email.ts: -------------------------------------------------------------------------------- 1 | interface EmailArguments { 2 | serverToken: string 3 | requestHost: string 4 | from: string 5 | to: string 6 | } 7 | 8 | export interface RawEmailArguments extends EmailArguments { 9 | subject: string 10 | html: string 11 | text: string 12 | } 13 | 14 | export interface TemplatedEmailArguments extends EmailArguments { 15 | id: number 16 | alias: string 17 | model: string 18 | } 19 | -------------------------------------------------------------------------------- /src/types/Server.ts: -------------------------------------------------------------------------------- 1 | export interface ServerListArguments { 2 | accountToken: string 3 | requestHost: string 4 | count: number 5 | offset: number 6 | name: string 7 | json: boolean 8 | showTokens: boolean 9 | } 10 | 11 | export interface ColorMap { 12 | [key: string]: string 13 | } 14 | -------------------------------------------------------------------------------- /src/types/Template.ts: -------------------------------------------------------------------------------- 1 | import { TemplateTypes } from 'postmark/dist/client/models' 2 | 3 | export interface TemplateManifest { 4 | Name?: string 5 | Subject?: string 6 | HtmlBody?: string 7 | TextBody?: string 8 | Alias?: string | null 9 | New?: boolean 10 | Status?: string 11 | TemplateType: TemplateTypes 12 | LayoutTemplate?: string 13 | } 14 | 15 | export interface MetaFile { 16 | Name: string 17 | Alias: string 18 | TemplateType: TemplateTypes 19 | Subject?: string 20 | LayoutTemplate?: string 21 | HtmlBody?: string 22 | TextBody?: string 23 | TestRenderModel?: any 24 | } 25 | 26 | export interface MetaFileTraverse { 27 | path: string 28 | name: string 29 | size: number 30 | extension: string 31 | type: string 32 | } 33 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Email' 2 | export * from './Server' 3 | export * from './Template' 4 | export * from './utils' 5 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export interface CommandOptions { 2 | name: string 3 | command: string 4 | desc: string 5 | builder: any 6 | } 7 | 8 | export interface LogSettings { 9 | error?: boolean 10 | warn?: boolean 11 | color?: 'green' | 'red' | 'blue' | 'yellow' 12 | } 13 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Argv } from 'yargs' 2 | import chalk from 'chalk' 3 | import { password } from '@inquirer/prompts' 4 | import { CommandOptions, LogSettings } from './types/' 5 | import ora = require('ora') 6 | 7 | /** 8 | * Bootstrap commands 9 | * @returns yargs compatible command options 10 | */ 11 | export function cmd(name: string, desc: string): CommandOptions { 12 | return { 13 | name: name, 14 | command: `${name} [options]`, 15 | desc: desc, 16 | builder: (yargs: Argv) => yargs.commandDir(`commands/${name}`), 17 | } 18 | } 19 | 20 | /** 21 | * Pluralize a string 22 | * @returns The proper string depending on the count 23 | */ 24 | export function pluralize( 25 | count: number, 26 | singular: string, 27 | plural: string, 28 | ): string { 29 | return count > 1 || count === 0 ? plural : singular 30 | } 31 | 32 | /** 33 | * Log stuff to the console 34 | * @returns Logging with fancy colors 35 | */ 36 | export function log(text: string, settings?: LogSettings): void { 37 | // Errors 38 | if (settings && settings.error) { 39 | return console.error(chalk.red(text)) 40 | } 41 | 42 | // Warnings 43 | if (settings && settings.warn) { 44 | return console.warn(chalk.yellow(text)) 45 | } 46 | 47 | // Custom colors 48 | if (settings && settings.color) { 49 | return console.log(chalk[settings.color](text)) 50 | } 51 | 52 | // Default 53 | return console.log(text) 54 | } 55 | 56 | export function logError(error: unknown): void { 57 | log(extractErrorMessage(error), { error: true }) 58 | } 59 | 60 | export function fatalError(error: unknown): never { 61 | logError(error) 62 | return process.exit(1) 63 | } 64 | 65 | /** 66 | * Prompt for server or account tokens 67 | */ 68 | async function serverTokenPrompt(forAccount: boolean): Promise { 69 | const tokenType = forAccount ? 'account' : 'server' 70 | const token = await password({ 71 | message: `Please enter your ${tokenType} token`, 72 | mask: '•', 73 | }) 74 | 75 | if (!token) { 76 | return fatalError(`Invalid ${tokenType} token`) 77 | } 78 | 79 | return token 80 | } 81 | 82 | /** 83 | * Validates the presence of a server or account token 84 | */ 85 | export async function validateToken( 86 | token: string, 87 | forAccount = false, 88 | ): Promise { 89 | if (!token) { 90 | return serverTokenPrompt(forAccount) 91 | } 92 | 93 | return token 94 | } 95 | 96 | /** 97 | * Handle starting/stopping spinner and console output 98 | */ 99 | export class CommandResponse { 100 | private spinner: ora.Ora 101 | 102 | public constructor() { 103 | this.spinner = ora().clear() 104 | } 105 | 106 | public initResponse(message: string) { 107 | this.spinner = ora(message).start() 108 | } 109 | 110 | public response(text: string, settings?: LogSettings): void { 111 | this.spinner.stop() 112 | log(text, settings) 113 | } 114 | 115 | public errorResponse(error: unknown): never { 116 | this.spinner.stop() 117 | 118 | return fatalError(error) 119 | } 120 | } 121 | 122 | function extractErrorMessage(error: unknown): string { 123 | if (error instanceof Error) { 124 | return error.toString() 125 | } 126 | 127 | if (typeof error === 'string') { 128 | return error 129 | } 130 | 131 | return `Unknown error: ${error}` 132 | } 133 | -------------------------------------------------------------------------------- /test/config/set_travis_vars.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Clearing Environment Variables" 3 | travis env clear --no-interactive 4 | echo "Setting Variables" 5 | jq -r '. | to_entries[] | "travis env set \(.key) \(.value) --no-interactive --private\n"' testing_keys.json | bash 6 | echo "Variables set:" 7 | travis env list 8 | -------------------------------------------------------------------------------- /test/config/testing_keys.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "SERVER_TOKEN" : "", 3 | "ACCOUNT_TOKEN" : "", 4 | "FROM_ADDRESS" : "", 5 | "TO_ADDRESS" : "" 6 | } 7 | -------------------------------------------------------------------------------- /test/integration/email.template.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | import { serverToken, fromAddress, toAddress, CLICommand } from './shared' 5 | import * as postmark from 'postmark' 6 | 7 | describe('Email send template command', () => { 8 | const options: execa.CommonOptions = { 9 | env: { POSTMARK_SERVER_TOKEN: serverToken }, 10 | } 11 | 12 | const toParameter = `--to=${toAddress}` 13 | const fromParameter = `--from=${fromAddress}` 14 | const baseParameters = ['email', 'template'] 15 | const defaultParameters = baseParameters.concat([toParameter, fromParameter]) 16 | 17 | const client: postmark.ServerClient = new postmark.ServerClient(serverToken) 18 | 19 | describe('not valid', () => { 20 | it('no arguments', () => { 21 | return execa(CLICommand, baseParameters, options).then( 22 | result => { 23 | expect(result).to.equal(null) 24 | }, 25 | error => { 26 | expect(error.message).to.include( 27 | 'Missing required arguments: from, to', 28 | ) 29 | }, 30 | ) 31 | }) 32 | 33 | it('no model', async () => { 34 | const templates: postmark.Models.Templates = await client.getTemplates() 35 | const parameters: string[] = defaultParameters.concat( 36 | `--id=${templates.Templates[0].TemplateId}`, 37 | ) 38 | 39 | try { 40 | await execa(CLICommand, parameters, options) 41 | throw Error('make sure error is thrown') 42 | } catch (error: any) { 43 | expect(error.message).to.include('ApiInputError') 44 | } 45 | }) 46 | 47 | it('no template id', async () => { 48 | try { 49 | await execa(CLICommand, defaultParameters, options) 50 | throw Error('make sure error is thrown') 51 | } catch (error: any) { 52 | expect(error.message).to.include('ApiInputError') 53 | } 54 | }) 55 | }) 56 | 57 | describe('valid', () => { 58 | it('send with template id', async () => { 59 | const templates: postmark.Models.Templates = await client.getTemplates() 60 | const extraParameters: string[] = [ 61 | `--id=${templates.Templates[0].TemplateId}`, 62 | '--m={}', 63 | ] 64 | const parameters: string[] = defaultParameters.concat(extraParameters) 65 | const { stdout } = await execa(CLICommand, parameters, options) 66 | 67 | expect(stdout).to.include('"Message":"OK"') 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /test/integration/email.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | import { serverToken, fromAddress, toAddress, CLICommand } from './shared' 5 | 6 | describe('Email send command', () => { 7 | const options: execa.CommonOptions = { 8 | env: { POSTMARK_SERVER_TOKEN: serverToken }, 9 | } 10 | const textBodyParameter = '--text="test text body"' 11 | const htmlBodyParameter = '--html="test html body"' 12 | const toParameter = `--to=${toAddress}` 13 | const fromParameter = `--from=${fromAddress}` 14 | const baseParameters = ['email', 'raw'] 15 | const defaultParameters = baseParameters.concat([ 16 | toParameter, 17 | fromParameter, 18 | '--subject="test sending"', 19 | ]) 20 | 21 | describe('not valid', () => { 22 | it('no arguments', () => { 23 | return execa(CLICommand, [], options).then( 24 | result => { 25 | expect(result).to.equal(null) 26 | }, 27 | error => { 28 | expect(error.message).to.include( 29 | 'Not enough non-option arguments: got 0, need at least 1', 30 | ) 31 | }, 32 | ) 33 | }) 34 | 35 | describe('no mandatory arguments', () => { 36 | it('missing :to, :from, :subject', () => { 37 | return execa(CLICommand, baseParameters, options).then( 38 | result => { 39 | expect(result).to.equal(null) 40 | }, 41 | error => { 42 | expect(error.message).to.include('Missing required arguments:') 43 | }, 44 | ) 45 | }) 46 | 47 | it('missing :to', () => { 48 | return execa( 49 | CLICommand, 50 | ['email', 'raw', '--subject="test"', fromParameter], 51 | options, 52 | ).then( 53 | result => { 54 | expect(result).to.equal(null) 55 | }, 56 | error => { 57 | expect(error.message).to.include('Missing required argument: to') 58 | }, 59 | ) 60 | }) 61 | 62 | it('missing :subject', () => { 63 | return execa( 64 | CLICommand, 65 | ['email', 'raw', fromParameter, toParameter], 66 | options, 67 | ).then( 68 | result => { 69 | expect(result).to.equal(null) 70 | }, 71 | error => { 72 | expect(error.message).to.include( 73 | 'Missing required argument: subject', 74 | ) 75 | }, 76 | ) 77 | }) 78 | 79 | it('missing :from', () => { 80 | return execa( 81 | CLICommand, 82 | ['email', 'raw', '--subject="hey"', toParameter], 83 | options, 84 | ).then( 85 | result => { 86 | expect(result).to.equal(null) 87 | }, 88 | error => { 89 | expect(error.message).to.include('Missing required argument: from') 90 | }, 91 | ) 92 | }) 93 | }) 94 | 95 | it('no body', () => { 96 | return execa(CLICommand, defaultParameters, options).then( 97 | result => { 98 | expect(result).to.equal(null) 99 | }, 100 | error => { 101 | expect(error.message).to.include('Provide') 102 | }, 103 | ) 104 | }) 105 | }) 106 | 107 | describe('valid', () => { 108 | it('html message', async () => { 109 | const parameters: string[] = defaultParameters.concat(htmlBodyParameter) 110 | const { stdout } = await execa(CLICommand, parameters, options) 111 | 112 | expect(stdout).to.include('"Message":"OK"') 113 | }) 114 | 115 | it('text message', async () => { 116 | const parameters: string[] = defaultParameters.concat(textBodyParameter) 117 | const { stdout } = await execa(CLICommand, parameters, options) 118 | 119 | expect(stdout).to.include('"Message":"OK"') 120 | }) 121 | 122 | it('multipart message', async () => { 123 | const parameters: string[] = defaultParameters.concat([ 124 | textBodyParameter, 125 | htmlBodyParameter, 126 | ]) 127 | const { stdout } = await execa(CLICommand, parameters, options) 128 | 129 | expect(stdout).to.include('"Message":"OK"') 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /test/integration/general.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | import { CLICommand, PackageJson } from './shared' 5 | 6 | describe('Default command', () => { 7 | describe('parameters', () => { 8 | it('help', async () => { 9 | const { stdout } = await execa(CLICommand, ['--help']) 10 | expect(stdout).to.include('Commands:') 11 | expect(stdout).to.include('Options:') 12 | }) 13 | 14 | it('version', async () => { 15 | const { stdout } = await execa(CLICommand, ['--version']) 16 | expect(stdout).to.include(PackageJson.version) 17 | }) 18 | 19 | it('no parameters', async () => { 20 | execa(CLICommand).catch(error => { 21 | expect(error.message).to.include('Not enough non-option arguments') 22 | }) 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/integration/servers.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | 5 | import { accountToken, CLICommand } from './shared' 6 | 7 | describe('Servers list command', () => { 8 | const options: execa.CommonOptions = { 9 | env: { POSTMARK_ACCOUNT_TOKEN: accountToken }, 10 | } 11 | const commandParameters: string[] = ['servers', 'list'] 12 | const JsonCommandParameters: string[] = ['servers', 'list', '-j'] 13 | 14 | it('list - headings', async () => { 15 | const { stdout } = await execa(CLICommand, commandParameters, options) 16 | expect(stdout).to.include('Server') 17 | expect(stdout).to.include('Settings') 18 | expect(stdout).to.include('Server API Tokens') 19 | }) 20 | 21 | it('list - masked token', async () => { 22 | const { stdout } = await execa(CLICommand, commandParameters, options) 23 | expect(stdout).to.include('•'.repeat(36)) 24 | }) 25 | 26 | it('list - JSON arg', async () => { 27 | const { stdout } = await execa(CLICommand, JsonCommandParameters, options) 28 | const servers = JSON.parse(stdout) 29 | expect(servers.TotalCount).to.be.gte(0) 30 | }) 31 | 32 | it('list - invalid token', async () => { 33 | const invalidOptions: execa.CommonOptions = { 34 | env: { POSTMARK_ACCOUNT_TOKEN: 'test' }, 35 | } 36 | 37 | try { 38 | await execa(CLICommand, commandParameters, invalidOptions) 39 | throw Error('make sure error is thrown') 40 | } catch (error: any) { 41 | expect(error.message).to.include('InvalidAPIKeyError') 42 | } 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/integration/shared.ts: -------------------------------------------------------------------------------- 1 | import nconf from 'nconf' 2 | import * as postmark from 'postmark' 3 | 4 | export const testingKeys = nconf 5 | .env() 6 | .file({ file: __dirname + '/../config/testing_keys.json' }) 7 | export const accountToken: string = testingKeys.get('ACCOUNT_TOKEN') 8 | export const serverToken: string = testingKeys.get('SERVER_TOKEN') 9 | export const fromAddress: string = testingKeys.get('FROM_ADDRESS') 10 | export const toAddress: string = testingKeys.get('TO_ADDRESS') 11 | 12 | export const CLICommand: string = './dist/index.js' 13 | export const TestDataFolder: string = './test/data' 14 | export const PackageJson: any = require('../../package.json') 15 | 16 | // In order to test template syncing, data needs to be created by postmark.js client 17 | 18 | const templatePrefix: string = 'testing-template-cli' 19 | 20 | function templateToCreate(templatePrefix: string) { 21 | return new postmark.Models.CreateTemplateRequest( 22 | `${templatePrefix}-${Date.now()}`, 23 | 'Subject', 24 | 'Html body', 25 | 'Text body', 26 | null, 27 | postmark.Models.TemplateTypes.Standard, 28 | ) 29 | } 30 | 31 | function templateLayoutToCreate(templatePrefix: string) { 32 | return new postmark.Models.CreateTemplateRequest( 33 | `${templatePrefix}-${Date.now()}`, 34 | undefined, 35 | 'Html body {{{@content}}}', 36 | 'Text body {{{@content}}}', 37 | null, 38 | postmark.Models.TemplateTypes.Layout, 39 | ) 40 | } 41 | 42 | export const createTemplateData = async () => { 43 | const client = new postmark.ServerClient(serverToken) 44 | await client.createTemplate(templateToCreate(templatePrefix)) 45 | await client.createTemplate(templateLayoutToCreate(templatePrefix)) 46 | } 47 | 48 | export const deleteTemplateData = async () => { 49 | const client = new postmark.ServerClient(serverToken) 50 | const templates = await client.getTemplates({ count: 50 }) 51 | 52 | for (const template of templates.Templates) { 53 | if (template.Name.includes(templatePrefix)) { 54 | await client.deleteTemplate(template.TemplateId) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/integration/templates.pull.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | import * as fs from 'fs-extra' 5 | import { join } from 'path' 6 | 7 | const dirTree = require('directory-tree') 8 | import { 9 | serverToken, 10 | CLICommand, 11 | TestDataFolder, 12 | createTemplateData, 13 | deleteTemplateData, 14 | } from './shared' 15 | 16 | describe('Templates command', () => { 17 | const options: execa.CommonOptions = { 18 | env: { POSTMARK_SERVER_TOKEN: serverToken }, 19 | } 20 | const dataFolder: string = TestDataFolder 21 | const commandParameters: string[] = ['templates', 'pull', dataFolder] 22 | 23 | before(async () => { 24 | await deleteTemplateData() 25 | return createTemplateData() 26 | }) 27 | 28 | after(async () => { 29 | await deleteTemplateData() 30 | }) 31 | 32 | afterEach(() => { 33 | fs.removeSync(dataFolder) 34 | }) 35 | 36 | describe('Pull', () => { 37 | function retrieveFiles(path: string, excludeLayouts?: boolean) { 38 | const folderTree = dirTree( 39 | path, 40 | excludeLayouts && { 41 | exclude: /_layouts$/, 42 | }, 43 | ) 44 | return folderTree.children[0].children 45 | } 46 | 47 | it('console out', async () => { 48 | const { stdout } = await execa(CLICommand, commandParameters, options) 49 | expect(stdout).to.include('All finished') 50 | }) 51 | 52 | describe('Templates', () => { 53 | it('templates', async () => { 54 | await execa(CLICommand, commandParameters, options) 55 | const folderTree = dirTree(dataFolder, { 56 | exclude: /_layouts$/, 57 | }) 58 | expect(folderTree.children.length).to.be.gt(0) 59 | }) 60 | 61 | it('single template - file names', async () => { 62 | await execa(CLICommand, commandParameters, options) 63 | const files = retrieveFiles(dataFolder, true) 64 | const names: string[] = files.map((f: any) => { 65 | return f.name 66 | }) 67 | 68 | expect(names).to.members(['content.txt', 'content.html', 'meta.json']) 69 | }) 70 | 71 | it('single template files - none empty', async () => { 72 | await execa(CLICommand, commandParameters, options) 73 | const files = retrieveFiles(dataFolder) 74 | 75 | let result = files.findIndex((f: any) => { 76 | return f.size <= 0 77 | }) 78 | expect(result).to.eq(-1) 79 | }) 80 | }) 81 | 82 | describe('Layouts', () => { 83 | const filesPath = join(dataFolder, '_layouts') 84 | 85 | it('layouts', async () => { 86 | await execa(CLICommand, commandParameters, options) 87 | const folderTree = dirTree(filesPath) 88 | expect(folderTree.children.length).to.be.gt(0) 89 | }) 90 | 91 | it('single layout - file names', async () => { 92 | await execa(CLICommand, commandParameters, options) 93 | const files = retrieveFiles(filesPath) 94 | 95 | const names: string[] = files.map((f: any) => { 96 | return f.name 97 | }) 98 | 99 | expect(names).to.members(['content.txt', 'content.html', 'meta.json']) 100 | }) 101 | 102 | it('single layout files - none empty', async () => { 103 | await execa(CLICommand, commandParameters, options) 104 | const files = retrieveFiles(filesPath) 105 | 106 | let result = files.findIndex((f: any) => { 107 | return f.size <= 0 108 | }) 109 | expect(result).to.eq(-1) 110 | }) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /test/integration/templates.push.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import execa from 'execa' 4 | import * as fs from 'fs-extra' 5 | import { DirectoryTree } from 'directory-tree' 6 | import { join } from 'path' 7 | 8 | const dirTree = require('directory-tree') 9 | import { 10 | serverToken, 11 | CLICommand, 12 | TestDataFolder, 13 | createTemplateData, 14 | deleteTemplateData, 15 | } from './shared' 16 | 17 | describe('Templates command', () => { 18 | const options: execa.CommonOptions = { 19 | env: { POSTMARK_SERVER_TOKEN: serverToken }, 20 | } 21 | const dataFolder: string = TestDataFolder 22 | const pushCommandParameters: string[] = [ 23 | 'templates', 24 | 'push', 25 | dataFolder, 26 | '--force', 27 | ] 28 | const pullCommandParameters: string[] = ['templates', 'pull', dataFolder] 29 | 30 | before(async () => { 31 | await deleteTemplateData() 32 | return createTemplateData() 33 | }) 34 | 35 | after(async () => { 36 | await deleteTemplateData() 37 | }) 38 | 39 | afterEach(() => { 40 | fs.removeSync(dataFolder) 41 | }) 42 | 43 | describe('Push', () => { 44 | function retrieveFiles(path: string, excludeLayouts?: boolean) { 45 | const folderTree = dirTree( 46 | path, 47 | excludeLayouts && { 48 | exclude: /_layouts$/, 49 | }, 50 | ) 51 | return folderTree.children[0].children 52 | } 53 | 54 | beforeEach(async () => { 55 | await execa(CLICommand, pullCommandParameters, options) 56 | }) 57 | 58 | describe('Templates', () => { 59 | it('console out', async () => { 60 | const files = retrieveFiles(dataFolder, true) 61 | const file: DirectoryTree = files.find((f: DirectoryTree) => { 62 | return f.path.includes('txt') 63 | }) 64 | 65 | fs.writeFileSync( 66 | file.path, 67 | `test data ${Date.now().toString()}`, 68 | 'utf-8', 69 | ) 70 | const { stdout } = await execa( 71 | CLICommand, 72 | pushCommandParameters, 73 | options, 74 | ) 75 | expect(stdout).to.include('All finished!') 76 | }) 77 | 78 | it('file content', async () => { 79 | let files = retrieveFiles(dataFolder, true) 80 | let file: DirectoryTree = files.find((f: DirectoryTree) => { 81 | return f.path.includes('txt') 82 | }) 83 | const contentToPush: string = `test data ${Date.now().toString()}` 84 | 85 | fs.writeFileSync(file.path, contentToPush, 'utf-8') 86 | await execa(CLICommand, pushCommandParameters, options) 87 | 88 | fs.removeSync(dataFolder) 89 | await execa(CLICommand, pullCommandParameters, options) 90 | 91 | files = retrieveFiles(dataFolder, true) 92 | file = files.find((f: DirectoryTree) => { 93 | return f.path.includes('txt') 94 | }) 95 | 96 | const content: string = fs.readFileSync(file.path).toString('utf-8') 97 | expect(content).to.equal(contentToPush) 98 | }) 99 | }) 100 | 101 | describe('Layouts', () => { 102 | const filesPath = join(dataFolder, '_layouts') 103 | 104 | it('console out', async () => { 105 | const files = retrieveFiles(filesPath) 106 | const file: DirectoryTree = files.find((f: DirectoryTree) => { 107 | return f.path.includes('txt') 108 | }) 109 | 110 | fs.writeFileSync( 111 | file.path, 112 | `test data ${Date.now().toString()} {{{@content}}}`, 113 | 'utf-8', 114 | ) 115 | const { stdout } = await execa( 116 | CLICommand, 117 | pushCommandParameters, 118 | options, 119 | ) 120 | expect(stdout).to.include('All finished!') 121 | }) 122 | 123 | it('file content', async () => { 124 | let files = retrieveFiles(filesPath) 125 | let file: DirectoryTree = files.find((f: DirectoryTree) => { 126 | return f.path.includes('txt') 127 | }) 128 | const contentToPush: string = `test data ${Date.now().toString()} {{{@content}}}` 129 | 130 | fs.writeFileSync(file.path, contentToPush, 'utf-8') 131 | await execa(CLICommand, pushCommandParameters, options) 132 | 133 | fs.removeSync(dataFolder) 134 | await execa(CLICommand, pullCommandParameters, options) 135 | 136 | files = retrieveFiles(filesPath) 137 | file = files.find((f: DirectoryTree) => { 138 | return f.path.includes('txt') 139 | }) 140 | 141 | const content: string = fs.readFileSync(file.path).toString('utf-8') 142 | expect(content).to.equal(contentToPush) 143 | }) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/unit/commands/templates/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { expect } from 'chai' 3 | import { TemplateTypes } from 'postmark/dist/client/models' 4 | import { templatesDiff } from '../../../../src/commands/templates/helpers' 5 | import { TemplateManifest } from '../../../../src/types' 6 | 7 | function makeTemplateManifest(): TemplateManifest { 8 | return { 9 | TemplateType: TemplateTypes.Standard, 10 | HtmlBody: undefined, 11 | TextBody: undefined, 12 | Subject: undefined, 13 | Name: undefined, 14 | LayoutTemplate: undefined, 15 | } 16 | } 17 | 18 | describe('comparing templates', () => { 19 | it('detects changes in html body', () => { 20 | const t1: TemplateManifest = { 21 | ...makeTemplateManifest(), 22 | } 23 | const t2: TemplateManifest = { 24 | ...makeTemplateManifest(), 25 | HtmlBody: '

hello

', 26 | } 27 | 28 | const diff = templatesDiff(t1, t2) 29 | 30 | expect(Array.from(diff)).to.eql(['html']) 31 | }) 32 | 33 | it('detects changes in text body', () => { 34 | const t1: TemplateManifest = { 35 | ...makeTemplateManifest(), 36 | } 37 | const t2: TemplateManifest = { 38 | ...makeTemplateManifest(), 39 | TextBody: 'hello', 40 | } 41 | 42 | const diff = templatesDiff(t1, t2) 43 | 44 | expect(Array.from(diff)).to.eql(['text']) 45 | }) 46 | 47 | it('detects changes in subject', () => { 48 | const t1: TemplateManifest = { 49 | ...makeTemplateManifest(), 50 | } 51 | const t2: TemplateManifest = { 52 | ...makeTemplateManifest(), 53 | Subject: 'hello', 54 | } 55 | 56 | const diff = templatesDiff(t1, t2) 57 | 58 | expect(Array.from(diff)).to.eql(['subject']) 59 | }) 60 | 61 | it('detects changes in name', () => { 62 | const t1: TemplateManifest = { 63 | ...makeTemplateManifest(), 64 | } 65 | const t2: TemplateManifest = { 66 | ...makeTemplateManifest(), 67 | Name: 'hello', 68 | } 69 | 70 | const diff = templatesDiff(t1, t2) 71 | 72 | expect(Array.from(diff)).to.eql(['name']) 73 | }) 74 | 75 | it('detects changes in layout', () => { 76 | const t1: TemplateManifest = { 77 | ...makeTemplateManifest(), 78 | } 79 | const t2: TemplateManifest = { 80 | ...makeTemplateManifest(), 81 | LayoutTemplate: 'hello', 82 | } 83 | 84 | const diff = templatesDiff(t1, t2) 85 | 86 | expect(Array.from(diff)).to.eql(['layout']) 87 | }) 88 | 89 | context('when comparing empty strings with undefined values', () => { 90 | it("doesn't detect changes", () => { 91 | const t1: TemplateManifest = { 92 | ...makeTemplateManifest(), 93 | } 94 | const t2: TemplateManifest = { 95 | ...makeTemplateManifest(), 96 | HtmlBody: '', 97 | TextBody: '', 98 | Subject: '', 99 | Name: '', 100 | LayoutTemplate: '', 101 | } 102 | 103 | const diff = templatesDiff(t1, t2) 104 | 105 | expect(Array.from(diff)).to.eql([]) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /test/unit/commands/templates/push.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import sinon from 'sinon' 3 | import { expect } from 'chai' 4 | import { ServerClient } from 'postmark' 5 | import { TemplateTypes } from 'postmark/dist/client/models' 6 | import { pushTemplates } from '../../../../src/commands/templates/push' 7 | import { TemplateManifest } from '../../../../src/types' 8 | 9 | describe('pushing templates', () => { 10 | it('pushes layouts before standard templates', async () => { 11 | const tm1 = makeStandardTemplateManifest({ Alias: 't1' }) 12 | const tm2 = makeLayoutTemplateManifest({ Alias: 'l1' }) 13 | const tm3 = makeStandardTemplateManifest({ Alias: 't2' }) 14 | 15 | const client = { 16 | editTemplate: sinon.stub(), 17 | } 18 | 19 | await pushTemplates( 20 | client as unknown as ServerClient, 21 | [tm1, tm2, tm3], 22 | sinon.stub(), 23 | handleError, 24 | sinon.stub(), 25 | ) 26 | 27 | expect(client.editTemplate.callCount).to.eql(3) 28 | expect(client.editTemplate.getCall(0).args[0]).to.eql('l1') 29 | expect(client.editTemplate.getCall(1).args[0]).to.eql('t1') 30 | expect(client.editTemplate.getCall(2).args[0]).to.eql('t2') 31 | }) 32 | 33 | it('notifies before pushing each template', async () => { 34 | const tm1 = makeStandardTemplateManifest({ Alias: 't1' }) 35 | const tm2 = makeLayoutTemplateManifest({ Alias: 'l1' }) 36 | const tm3 = makeStandardTemplateManifest({ Alias: 't2' }) 37 | 38 | const client = { 39 | editTemplate: sinon.stub(), 40 | } 41 | const beforePush = sinon.stub() 42 | 43 | await pushTemplates( 44 | client as unknown as ServerClient, 45 | [tm1, tm2, tm3], 46 | beforePush, 47 | handleError, 48 | sinon.stub(), 49 | ) 50 | 51 | expect(beforePush.callCount).to.eql(3) 52 | }) 53 | 54 | it('notifies once after pushing all templates', async () => { 55 | const tm1 = makeStandardTemplateManifest({ Alias: 't1' }) 56 | const tm2 = makeLayoutTemplateManifest({ Alias: 'l1' }) 57 | const tm3 = makeStandardTemplateManifest({ Alias: 't2' }) 58 | 59 | const client = { 60 | editTemplate: sinon.stub(), 61 | } 62 | const completePush = sinon.stub() 63 | 64 | await pushTemplates( 65 | client as unknown as ServerClient, 66 | [tm1, tm2, tm3], 67 | sinon.stub(), 68 | handleError, 69 | completePush, 70 | ) 71 | 72 | expect(completePush.callCount).to.eql(1) 73 | expect(completePush.getCall(0).args[0]).to.eql(0) // 0 failures 74 | }) 75 | 76 | it('gracefully handles push errors', async () => { 77 | const tm1 = makeStandardTemplateManifest({ Alias: 't1' }) 78 | const tm2 = makeLayoutTemplateManifest({ Alias: 'l1' }) 79 | const tm3 = makeStandardTemplateManifest({ Alias: 't2' }) 80 | 81 | let callCount = 0 82 | 83 | const client = { 84 | editTemplate: sinon.stub().callsFake(() => { 85 | callCount++ 86 | if (callCount > 1) { 87 | return Promise.reject(new Error('boom')) 88 | } else { 89 | return Promise.resolve() 90 | } 91 | }), 92 | } 93 | const completePush = sinon.stub() 94 | const handleError = sinon.stub() 95 | 96 | await pushTemplates( 97 | client as unknown as ServerClient, 98 | [tm1, tm2, tm3], 99 | sinon.stub(), 100 | handleError, 101 | completePush, 102 | ) 103 | 104 | expect(handleError.callCount).to.eql(2) 105 | 106 | expect(completePush.callCount).to.eql(1) 107 | expect(completePush.getCall(0).args[0]).to.eql(2) // 2 failures 108 | }) 109 | }) 110 | 111 | function handleError(template: TemplateManifest, error: unknown): void { 112 | console.error(`Error pushing template ${template.Alias}: ${error}`) 113 | } 114 | 115 | function makeTemplateManifest(): TemplateManifest { 116 | return { 117 | TemplateType: TemplateTypes.Standard, 118 | HtmlBody: undefined, 119 | TextBody: undefined, 120 | Subject: undefined, 121 | Name: undefined, 122 | LayoutTemplate: undefined, 123 | } 124 | } 125 | 126 | function makeStandardTemplateManifest( 127 | props: Partial, 128 | ): TemplateManifest { 129 | return { 130 | ...makeTemplateManifest(), 131 | ...props, 132 | TemplateType: TemplateTypes.Standard, 133 | } 134 | } 135 | 136 | function makeLayoutTemplateManifest( 137 | props: Partial, 138 | ): TemplateManifest { 139 | return { 140 | ...makeTemplateManifest(), 141 | ...props, 142 | TemplateType: TemplateTypes.Layout, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/unit/servers.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import chalk from 'chalk' 4 | import * as list from '../../src/commands/servers/list' 5 | 6 | describe('Servers', () => { 7 | describe('list', () => { 8 | describe('stateLabel', () => { 9 | it('should return enabled', () => { 10 | const result = list.stateLabel(true) 11 | expect(result).to.eq(chalk.green('Enabled')) 12 | }) 13 | it('should return disabled', () => { 14 | const result = list.stateLabel(false) 15 | expect(result).to.eq(chalk.grey('Disabled')) 16 | }) 17 | }) 18 | 19 | describe('linkTrackingStateLabel', () => { 20 | it('should return HTML', () => { 21 | const result = list.linkTrackingStateLabel('HtmlOnly') 22 | expect(result).to.eq(chalk.green('HTML')) 23 | }) 24 | it('should return Text', () => { 25 | const result = list.linkTrackingStateLabel('TextOnly') 26 | expect(result).to.eq(chalk.green('Text')) 27 | }) 28 | it('should return HTML and Text', () => { 29 | const result = list.linkTrackingStateLabel('HtmlAndText') 30 | expect(result).to.eq(chalk.green('HTML and Text')) 31 | }) 32 | it('should return Disabled', () => { 33 | const result = list.linkTrackingStateLabel('None') 34 | const result2 = list.linkTrackingStateLabel('') 35 | const result3 = list.linkTrackingStateLabel(':(') 36 | const expected = chalk.gray('Disabled') 37 | expect(result).to.eq(expected) 38 | expect(result2).to.eq(expected) 39 | expect(result3).to.eq(expected) 40 | }) 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import * as utils from '../../src/utils' 4 | 5 | describe('Utilities', () => { 6 | describe('cmd', () => { 7 | const name = 'Test' 8 | const desc = 'Description' 9 | const result = utils.cmd(name, desc) 10 | 11 | it('should return yargs keys', () => { 12 | expect(result).to.include.all.keys('name', 'command', 'desc', 'builder') 13 | }) 14 | 15 | it('should contain proper values', () => { 16 | expect(result.name).to.eq(name) 17 | expect(result.command).to.eq(`${name} [options]`) 18 | expect(result.desc).to.eq(desc) 19 | expect(result.builder).to.be.a('function') 20 | }) 21 | }) 22 | 23 | describe('pluralize', () => { 24 | const singular = 'template' 25 | const plural = 'templates' 26 | 27 | it('should return plural', () => { 28 | expect(utils.pluralize(0, singular, plural)).to.eq(plural) 29 | expect(utils.pluralize(2, singular, plural)).to.eq(plural) 30 | expect(utils.pluralize(5, singular, plural)).to.eq(plural) 31 | expect(utils.pluralize(10, singular, plural)).to.eq(plural) 32 | expect(utils.pluralize(100, singular, plural)).to.eq(plural) 33 | }) 34 | 35 | it('should return singular', () => { 36 | expect(utils.pluralize(1, singular, plural)).to.eq(singular) 37 | expect(utils.pluralize(-1, singular, plural)).to.eq(singular) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 4 | "outDir": "./dist" /* Redirect output structure to the directory. */, 5 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 6 | "strict": true /* Enable all strict type-checking options. */, 7 | "strictNullChecks": true, 8 | "module": "commonjs", 9 | "sourceMap": true, 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "lib": ["es5", "es2017", "dom"], 13 | "declaration": true, 14 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["test/**/*", "preview/**/*"], 19 | } 20 | --------------------------------------------------------------------------------