├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── ---1-report-an-issue.md │ ├── ---2-feature-request.md │ └── config.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── release-automated.yml ├── .gitignore ├── .nycrc ├── .releaserc.js ├── .releaserc ├── commit.hbs ├── footer.hbs ├── header.hbs └── template.hbs ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── changelogs ├── CHANGELOG_alpha.md ├── CHANGELOG_beta.md └── CHANGELOG_release.md ├── demo └── index.js ├── package-lock.json ├── package.json ├── spec ├── .eslintrc.json ├── ApiMailAdapter.spec.js ├── Errors.spec.js ├── MailAdapter.spec.js ├── helper.js ├── support │ └── jasmine.json └── templates │ ├── custom_email.html │ ├── custom_email.txt │ ├── custom_email_subject.txt │ ├── de-AT │ ├── custom_email.html │ ├── custom_email.txt │ └── custom_email_subject.txt │ ├── de │ ├── custom_email.html │ ├── custom_email.txt │ └── custom_email_subject.txt │ ├── password_reset_email.html │ ├── password_reset_email.txt │ ├── password_reset_email_subject.txt │ ├── verification_email.html │ ├── verification_email.txt │ └── verification_email_subject.txt ├── src ├── ApiMailAdapter.js ├── ApiPayloadConverter.js ├── Errors.js ├── MailAdapter.js └── index.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-proposal-object-rest-spread" 4 | ], 5 | "presets": [ 6 | ["@babel/preset-env", { 7 | "targets": { 8 | "node": "14" 9 | } 10 | }] 11 | ], 12 | "sourceMaps": "inline", 13 | "retainLines": true 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "eslint:recommended", 4 | "env": { 5 | "node": true, 6 | "es6": true 7 | }, 8 | "parser": "babel-eslint", 9 | "plugins": [ 10 | "flowtype" 11 | ], 12 | "parserOptions": { 13 | "ecmaVersion": 6, 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "indent": ["error", 2, { "SwitchCase": 1 }], 18 | "linebreak-style": ["error", "unix"], 19 | "no-trailing-spaces": 2, 20 | "eol-last": 2, 21 | "space-in-parens": ["error", "never"], 22 | "no-multiple-empty-lines": 1, 23 | "prefer-const": "error", 24 | "space-infix-ops": "error", 25 | "no-useless-escape": "off", 26 | "require-atomic-updates": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---1-report-an-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Report an issue" 3 | about: A feature is not working as expected. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### New Issue Checklist 11 | 16 | 17 | - [ ] I am not disclosing a [vulnerability](https://github.com/mtrezza/parse-server-api-mail-adapter/security/policy). 18 | - [ ] I am not just asking a [question](https://github.com/mtrezza/parse-server-api-mail-adapter#need-help). 19 | - [ ] I have searched through [existing issues](https://github.com/mtrezza/parse-server-api-mail-adapter/issues?q=is%3Aissue). 20 | - [ ] I can reproduce the issue with the [latest version](https://github.com/mtrezza/parse-server-api-mail-adapter/releases). 21 | 22 | ### Issue Description 23 | 24 | 25 | ### Steps to reproduce 26 | 27 | 28 | ### Actual Outcome 29 | 30 | 31 | ### Expected Outcome 32 | 33 | 34 | ### Failing Test Case / Pull Request 35 | 40 | 41 | ### Environment 42 | 43 | 44 | - API Mail Adapter version: `FILL_THIS_OUT` 45 | - Parse Server version: `FILL_THIS_OUT` 46 | 47 | ### Logs 48 | 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4A1 Request a feature" 3 | about: Suggest new functionality or an enhancement of existing functionality. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### New Feature / Enhancement Checklist 11 | 16 | 17 | - [ ] I am not disclosing a [vulnerability](https://github.com/mtrezza/parse-server-api-mail-adapter/security/policy). 18 | - [ ] I am not just asking a [question](https://github.com/mtrezza/parse-server-api-mail-adapter#need-help). 19 | - [ ] I have searched through [existing issues](https://github.com/mtrezza/parse-server-api-mail-adapter/issues?q=is%3Aissue). 20 | 21 | ### Current Limitation 22 | 23 | 24 | ### Feature / Enhancement Description 25 | 26 | 27 | ### Example Use Case 28 | 29 | 30 | ### Alternatives / Workarounds 31 | 32 | 33 | ### 3rd Party References 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🙋🏽♀️ Getting help with code 4 | url: https://stackoverflow.com/questions/tagged/parse-platform 5 | about: Get help with code-level questions on Stack Overflow. 6 | - name: 🙋 Getting general help 7 | url: https://community.parseplatform.org 8 | about: Get help with other questions on our Community Forum. 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### New Pull Request Checklist 2 | 7 | 8 | - [ ] I am not disclosing a [vulnerability](https://github.com/mtrezza/parse-server-api-mail-adapter/security/policy). 9 | - [ ] I am creating this PR in reference to an [issue](https://github.com/mtrezza/parse-server-api-mail-adapter/issues?q=is%3Aissue). 10 | 11 | ### Issue Description 12 | 13 | 14 | Related issue: FILL_THIS_OUT 15 | 16 | ### Approach 17 | 18 | 19 | ### TODOs before merging 20 | 24 | 25 | - [ ] Add test cases 26 | - [ ] Add entry to changelog 27 | - [ ] Add changes to documentation (guides, repository pages, in-code descriptions) -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - '**' 9 | env: 10 | NODE_VERSION: 14.16.1 11 | jobs: 12 | check-circular: 13 | name: Circular Dependencies 14 | timeout-minutes: 5 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.NODE_VERSION }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.NODE_VERSION }} 22 | - name: Cache Node.js modules 23 | uses: actions/cache@v2 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- 29 | - name: Install dependencies 30 | run: npm ci 31 | - run: npm run madge:circular 32 | check-lint: 33 | name: Lint 34 | timeout-minutes: 5 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Use Node.js ${{ matrix.NODE_VERSION }} 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | - name: Cache Node.js modules 43 | uses: actions/cache@v2 44 | with: 45 | path: ~/.npm 46 | key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} 47 | restore-keys: | 48 | ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- 49 | - name: Install dependencies 50 | run: npm ci 51 | - run: npm run lint 52 | check-tests: 53 | strategy: 54 | matrix: 55 | include: 56 | - name: Node 22 57 | NODE_VERSION: 22.4.1 58 | - name: Node 20 59 | NODE_VERSION: 20.15.1 60 | - name: Node 18 61 | NODE_VERSION: 18.20.4 62 | fail-fast: false 63 | name: Tests 64 | timeout-minutes: 5 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v2 68 | - name: Use Node.js 69 | uses: actions/setup-node@v1 70 | with: 71 | node-version: ${{ matrix.NODE_VERSION }} 72 | - name: Cache Node.js modules 73 | uses: actions/cache@v2 74 | with: 75 | path: ~/.npm 76 | key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} 77 | restore-keys: | 78 | ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- 79 | - run: npm ci 80 | - run: npm test 81 | env: 82 | CI: true 83 | - name: Upload code coverage 84 | uses: codecov/codecov-action@v4 85 | with: 86 | fail_ci_if_error: false 87 | token: ${{ secrets.CODECOV_TOKEN }} 88 | env: 89 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 90 | concurrency: 91 | group: ${{ github.workflow }}-${{ github.ref }} 92 | cancel-in-progress: true 93 | -------------------------------------------------------------------------------- /.github/workflows/release-automated.yml: -------------------------------------------------------------------------------- 1 | name: release-automated 2 | on: 3 | push: 4 | branches: [ main, master, release, alpha, beta, next-major, 'release-[0-9]+.x.x' ] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v4 11 | with: 12 | persist-credentials: false 13 | - name: Setup Node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | cache: 'npm' 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Run semantic-release 21 | run: npx semantic-release 22 | env: 23 | GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 24 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Coverage 12 | lib-cov 13 | coverage 14 | .nyc_output 15 | 16 | # Node 17 | .lock-wscript 18 | build/Release 19 | node_modules 20 | .node_repl_history 21 | .npm 22 | 23 | # Temporary 24 | .idea 25 | .DS_Store 26 | lib 27 | 28 | # Visual Studio Code 29 | .vscode 30 | 31 | # Demo configuration 32 | mailgun.json -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": [ 3 | "lcov", 4 | "text-summary" 5 | ], 6 | "exclude": [ 7 | "**/spec/**" 8 | ] 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Semantic Release Config 3 | */ 4 | 5 | // For CommonJS use: 6 | const { readFile } = require('fs').promises; 7 | const { resolve } = require('path'); 8 | 9 | // For ES6 modules use: 10 | // import { readFile } from 'fs/promises'; 11 | // import { resolve, dirname } from 'path'; 12 | // import { fileURLToPath } from 'url'; 13 | 14 | // Get env vars 15 | const ref = process.env.GITHUB_REF; 16 | const serverUrl = process.env.GITHUB_SERVER_URL; 17 | const repository = process.env.GITHUB_REPOSITORY; 18 | const repositoryUrl = serverUrl + '/' + repository; 19 | 20 | // Declare params 21 | const resourcePath = './.releaserc/'; 22 | const templates = { 23 | main: { file: 'template.hbs', text: undefined }, 24 | header: { file: 'header.hbs', text: undefined }, 25 | commit: { file: 'commit.hbs', text: undefined }, 26 | footer: { file: 'footer.hbs', text: undefined }, 27 | }; 28 | 29 | // Declare semantic config 30 | async function config() { 31 | 32 | // Get branch 33 | const branch = ref?.split('/')?.pop()?.split('-')[0] || '(current branch could not be determined)'; 34 | console.log(`Running on branch: ${branch}`); 35 | 36 | // Set changelog file 37 | //const changelogFile = `./changelogs/CHANGELOG_${branch}.md`; 38 | const changelogFile = `./CHANGELOG.md`; 39 | console.log(`Changelog file output to: ${changelogFile}`); 40 | 41 | // Load template file contents 42 | await loadTemplates(); 43 | 44 | const config = { 45 | branches: [ 46 | 'main', 47 | 'master', 48 | 'release', 49 | { name: 'alpha', prerelease: true }, 50 | { name: 'beta', prerelease: true }, 51 | 'next-major', 52 | // Long-Term-Support branches 53 | // { name: 'release-1', range: '1.x.x', channel: '1.x' }, 54 | // { name: 'release-2', range: '2.x.x', channel: '2.x' }, 55 | // { name: 'release-3', range: '3.x.x', channel: '3.x' }, 56 | // { name: 'release-4', range: '4.x.x', channel: '4.x' }, 57 | ], 58 | dryRun: false, 59 | debug: true, 60 | ci: true, 61 | tagFormat: '${version}', 62 | plugins: [ 63 | ['@semantic-release/commit-analyzer', { 64 | preset: 'angular', 65 | releaseRules: [ 66 | { type: 'docs', scope: 'README', release: 'patch' }, 67 | { scope: 'no-release', release: false }, 68 | ], 69 | parserOpts: { 70 | noteKeywords: [ 'BREAKING CHANGE' ], 71 | }, 72 | }], 73 | ['@semantic-release/release-notes-generator', { 74 | preset: 'angular', 75 | parserOpts: { 76 | noteKeywords: [ 'BREAKING CHANGE' ] 77 | }, 78 | writerOpts: { 79 | commitsSort: ['subject', 'scope'], 80 | mainTemplate: templates.main.text, 81 | headerPartial: templates.header.text, 82 | commitPartial: templates.commit.text, 83 | footerPartial: templates.footer.text, 84 | }, 85 | }], 86 | ['@semantic-release/changelog', { 87 | 'changelogFile': changelogFile, 88 | }], 89 | ['@semantic-release/npm', { 90 | 'npmPublish': true, 91 | }], 92 | ['@semantic-release/git', { 93 | assets: [changelogFile, 'package.json', 'package-lock.json'], 94 | }], 95 | ['@semantic-release/github', { 96 | successComment: getReleaseComment(), 97 | labels: ['type:ci'], 98 | releasedLabels: ['state:released<%= nextRelease.channel ? `-\${nextRelease.channel}` : "" %>'] 99 | }], 100 | ], 101 | }; 102 | 103 | return config; 104 | } 105 | 106 | async function loadTemplates() { 107 | for (const template of Object.keys(templates)) { 108 | // For ES6 modules use: 109 | // const fileUrl = import.meta.url; 110 | // const __dirname = dirname(fileURLToPath(fileUrl)); 111 | 112 | const filePath = resolve(__dirname, resourcePath, templates[template].file); 113 | const text = await readFile(filePath, 'utf-8'); 114 | templates[template].text = text; 115 | } 116 | } 117 | 118 | function getReleaseComment() { 119 | const url = repositoryUrl + '/releases/tag/${nextRelease.gitTag}'; 120 | let comment = '🎉 This change has been released in version [${nextRelease.version}](' + url + ')'; 121 | return comment; 122 | } 123 | 124 | // For CommonJS use: 125 | module.exports = config(); 126 | 127 | // For ES6 modules use: 128 | // export default config(); 129 | -------------------------------------------------------------------------------- /.releaserc/commit.hbs: -------------------------------------------------------------------------------- 1 | *{{#if scope}} **{{scope}}:** 2 | {{~/if}} {{#if subject}} 3 | {{~subject}} 4 | {{~else}} 5 | {{~header}} 6 | {{~/if}} 7 | 8 | {{~!-- commit link --}} {{#if @root.linkReferences~}} 9 | ([{{shortHash}}]( 10 | {{~#if @root.repository}} 11 | {{~#if @root.host}} 12 | {{~@root.host}}/ 13 | {{~/if}} 14 | {{~#if @root.owner}} 15 | {{~@root.owner}}/ 16 | {{~/if}} 17 | {{~@root.repository}} 18 | {{~else}} 19 | {{~@root.repoUrl}} 20 | {{~/if}}/ 21 | {{~@root.commit}}/{{hash}})) 22 | {{~else}} 23 | {{~shortHash}} 24 | {{~/if}} 25 | 26 | {{~!-- commit references --}} 27 | {{~#if references~}} 28 | , closes 29 | {{~#each references}} {{#if @root.linkReferences~}} 30 | [ 31 | {{~#if this.owner}} 32 | {{~this.owner}}/ 33 | {{~/if}} 34 | {{~this.repository}}#{{this.issue}}]( 35 | {{~#if @root.repository}} 36 | {{~#if @root.host}} 37 | {{~@root.host}}/ 38 | {{~/if}} 39 | {{~#if this.repository}} 40 | {{~#if this.owner}} 41 | {{~this.owner}}/ 42 | {{~/if}} 43 | {{~this.repository}} 44 | {{~else}} 45 | {{~#if @root.owner}} 46 | {{~@root.owner}}/ 47 | {{~/if}} 48 | {{~@root.repository}} 49 | {{~/if}} 50 | {{~else}} 51 | {{~@root.repoUrl}} 52 | {{~/if}}/ 53 | {{~@root.issue}}/{{this.issue}}) 54 | {{~else}} 55 | {{~#if this.owner}} 56 | {{~this.owner}}/ 57 | {{~/if}} 58 | {{~this.repository}}#{{this.issue}} 59 | {{~/if}}{{/each}} 60 | {{~/if}} 61 | 62 | -------------------------------------------------------------------------------- /.releaserc/footer.hbs: -------------------------------------------------------------------------------- 1 | {{#if noteGroups}} 2 | {{#each noteGroups}} 3 | 4 | ### {{title}} 5 | 6 | {{#each notes}} 7 | * {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}} ([{{commit.shortHash}}]({{commit.shortHash}})) 8 | {{/each}} 9 | {{/each}} 10 | 11 | {{/if}} 12 | -------------------------------------------------------------------------------- /.releaserc/header.hbs: -------------------------------------------------------------------------------- 1 | {{#if isPatch~}} 2 | ## 3 | {{~else~}} 4 | # 5 | {{~/if}} {{#if @root.linkCompare~}} 6 | [{{version}}]( 7 | {{~#if @root.repository~}} 8 | {{~#if @root.host}} 9 | {{~@root.host}}/ 10 | {{~/if}} 11 | {{~#if @root.owner}} 12 | {{~@root.owner}}/ 13 | {{~/if}} 14 | {{~@root.repository}} 15 | {{~else}} 16 | {{~@root.repoUrl}} 17 | {{~/if~}} 18 | /compare/{{previousTag}}...{{currentTag}}) 19 | {{~else}} 20 | {{~version}} 21 | {{~/if}} 22 | {{~#if title}} "{{title}}" 23 | {{~/if}} 24 | {{~#if date}} ({{date}}) 25 | {{/if}} 26 | -------------------------------------------------------------------------------- /.releaserc/template.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | {{#each commitGroups}} 4 | 5 | {{#if title}} 6 | ### {{title}} 7 | 8 | {{/if}} 9 | {{#each commits}} 10 | {{> commit root=@root}} 11 | {{/each}} 12 | {{/each}} 13 | 14 | {{> footer}} 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [4.1.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/4.0.0...4.1.0) (2024-10-20) 2 | 3 | 4 | ### Features 5 | 6 | * Add ZeptoMail support ([#98](https://github.com/parse-community/parse-server-api-mail-adapter/issues/98)) ([f211b99](https://github.com/parse-community/parse-server-api-mail-adapter/commit/f211b99a2b53fa476d48991f6dcf9e5bcd9fcc90)) 7 | 8 | # Changelog 9 | 10 | Changelogs are separated by distribution channel for better overview: 11 | 12 | | Changelog | Branch | Stability | Purpose | NPM Channel | 13 | |----------------------------------|---------------------------|---------------|--------------|-------------| 14 | | [✅ Stable Releases][log_release] | [release][branch_release] | stable | production | @latest | 15 | | [⚠️ Beta Releases][log_beta] | [beta][branch_beta] | pretty stable | development | @beta | 16 | | [🔥 Alpha Releases][log_alpha] | [alpha][branch_alpha] | unstable | contribution | @alpha | 17 | 18 | 19 | [log_release]: https://github.com/mtrezza/parse-server-api-mail-adapter/blob/release/changelogs/CHANGELOG_release.md 20 | [log_beta]: https://github.com/mtrezza/parse-server-api-mail-adapter/blob/beta/changelogs/CHANGELOG_beta.md 21 | [log_alpha]: https://github.com/mtrezza/parse-server-api-mail-adapter/blob/alpha/changelogs/CHANGELOG_alpha.md 22 | [branch_release]: https://github.com/mtrezza/parse-server-api-mail-adapter/tree/release 23 | [branch_beta]: https://github.com/mtrezza/parse-server-api-mail-adapter/tree/beta 24 | [branch_alpha]: https://github.com/mtrezza/parse-server-api-mail-adapter/tree/alpha 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Manuel Trezza 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 | # Parse Server API Mail Adapter 2 | 3 | [](https://github.com/parse-community/parse-server-api-mail-adapter/actions?query=workflow%3Aci+branch%3Arelease) 4 | [](https://snyk.io/test/github/parse-community/parse-server-api-mail-adapter) 5 | [](https://codecov.io/gh/parse-community/parse-server-api-mail-adapter) 6 | [](https://github.com/parse-community/parse-dashboard/releases) 7 | 8 | [](https://nodejs.org) 9 | 10 | [](https://www.npmjs.com/package/parse-server-api-mail-adapter) 11 | 12 | --- 13 | 14 | The Parse Server API Mail Adapter enables Parse Server to send emails using any 3rd party API with built-in dynamic templates and localization. 15 | 16 | ## Transfer 17 | 18 | ℹ️ This repository has been transferred to the Parse Platform Organization on May 15, 2022. Please update any links that you may have to this repository, for example if you cloned or forked this repository and maintain a remote link to this original repository, or if you are referencing a GitHub commit directly as your dependency. 19 | 20 | --- 21 | 22 | # Content 23 | 24 | - [Installation](#installation) 25 | - [Demo](#demo) 26 | - [Configuration](#configuration) 27 | - [Templates](#templates) 28 | - [Placeholders](#placeholders) 29 | - [Password Reset and Email Verification](#password-reset-and-email-verification) 30 | - [Localization](#localization) 31 | - [Cloud Code](#cloud-code) 32 | - [Example](#example) 33 | - [Parameters](#parameters) 34 | - [Supported APIs](#supported-apis) 35 | - [Providers](#providers) 36 | - [AWS Simple Email Service](#aws-simple-email-service) 37 | - [Mailgun](#mailgun) 38 | - [ZeptoMail](#zeptomail) 39 | - [Custom API](#custom-api) 40 | - [Need help?](#need-help) 41 | 42 | # Installation 43 | 44 | 1. Install adapter: 45 | ``` 46 | npm install --save parse-server-api-mail-adapter 47 | ``` 48 | 2. Add [template files](#templates) to a subdirectory. 49 | 2. Add [adapter configuration](#configuration) to Parse Server. 50 | 51 | # Demo 52 | 53 | The demo script makes it easy to test adapter configurations and templates by sending emails without Parse Server via the email service provider [Mailgun](https://www.mailgun.com): 54 | 55 | 1. Create a file `mailgun.json` in the `demo` directory with the following content: 56 | ```js 57 | { 58 | "key": "MAILGUN_API_KEY", // e.g. abc123 59 | "domain": "MAILGUN_DOMAIN", // e.g. sandbox-example@mailgun.org 60 | "sender": "SENDER_EMAIL", // e.g. sender@example.com 61 | "recipient": "RECIPIENT_EMAIL" // e.g. recipient@example.com 62 | } 63 | ``` 64 | 2. Run `node ./demo` to execute the script and send an email. 65 | 66 | You can modify the script to use any other API you like or debug-step through the sending process to better understand the adapter internals. 67 | 68 | # Configuration 69 | 70 | An example configuration to add the API Mail Adapter to Parse Server could look like this: 71 | 72 | ```js 73 | const Mailgun = require('mailgun.js'); 74 | const formData = require('form-data'); 75 | const { ApiPayloadConverter } = require('parse-server-api-mail-adapter'); 76 | 77 | // Configure mail client 78 | const mailgun = new Mailgun(formData); 79 | const mailgunClient = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY }); 80 | const mailgunDomain = process.env.MAILGUN_DOMAIN; 81 | 82 | // Configure Parse Server 83 | const server = new ParseServer({ 84 | ...otherServerOptions, 85 | 86 | emailAdapter: { 87 | module: 'parse-server-api-mail-adapter', 88 | options: { 89 | // The email address from which emails are sent. 90 | sender: 'sender@example.com', 91 | // The email templates. 92 | templates: { 93 | // The template used by Parse Server to send an email for password 94 | // reset; this is a reserved template name. 95 | passwordResetEmail: { 96 | subjectPath: './files/password_reset_email_subject.txt', 97 | textPath: './files/password_reset_email.txt', 98 | htmlPath: './files/password_reset_email.html' 99 | }, 100 | // The template used by Parse Server to send an email for email 101 | // address verification; this is a reserved template name. 102 | verificationEmail: { 103 | subjectPath: './files/verification_email_subject.txt', 104 | textPath: './files/verification_email.txt', 105 | htmlPath: './files/verification_email.html' 106 | }, 107 | // A custom email template that can be used when sending emails 108 | // from Cloud Code; the template name can be chosen freely; it 109 | // is possible to add various custom templates. 110 | customEmail: { 111 | subjectPath: './files/custom_email_subject.txt', 112 | textPath: './files/custom_email.txt', 113 | htmlPath: './files/custom_email.html', 114 | // Placeholders are filled into the template file contents. 115 | // For example, the placeholder `{{appName}}` in the email 116 | // will be replaced the value defined here. 117 | placeholders: { 118 | appName: "ExampleApp" 119 | }, 120 | // Extras to add to the email payload that is accessible in the 121 | // `apiCallback`. 122 | extra: { 123 | replyTo: 'no-reply@example.com' 124 | }, 125 | // A callback that makes the Parse User accessible and allows 126 | // to return user-customized placeholders that will override 127 | // the default template placeholders. It also makes the user 128 | // locale accessible, if it was returned by the `localeCallback`, 129 | // and the current placeholders that will be augmented. 130 | placeholderCallback: async ({ user, locale, placeholders }) => { 131 | return { 132 | phone: user.get('phone'); 133 | }; 134 | }, 135 | // A callback that makes the Parse User accessible and allows 136 | // to return the locale of the user for template localization. 137 | localeCallback: async (user) => { 138 | return user.get('locale'); 139 | } 140 | } 141 | }, 142 | // The asynchronous callback that contains the composed email payload to 143 | // be passed on to an 3rd party API and optional meta data. The payload 144 | // may need to be converted specifically for the API; conversion for 145 | // common APIs is conveniently available in the `ApiPayloadConverter`. 146 | // Below is an example for the Mailgun client. 147 | apiCallback: async ({ payload, locale }) => { 148 | const mailgunPayload = ApiPayloadConverter.mailgun(payload); 149 | await mailgunClient.messages.create(mailgunDomain, mailgunPayload); 150 | } 151 | } 152 | } 153 | }); 154 | ``` 155 | 156 | # Templates 157 | 158 | Emails are composed using templates. A template defines the paths to its content files, for example: 159 | 160 | ```js 161 | templates: { 162 | exampleTemplate: { 163 | subjectPath: './files/custom_email_subject.txt', 164 | textPath: './files/custom_email.txt', 165 | htmlPath: './files/custom_email.html', 166 | } 167 | }, 168 | ``` 169 | 170 | There are different files for different parts of the email: 171 | - subject (`subjectPath`) 172 | - plain-text content (`textPath`) 173 | - HTML content (`htmlPath`) 174 | 175 | See the [templates](https://github.com/parse-community/parse-server-api-mail-adapter/tree/main/spec/templates) for examples how placeholders can be used. 176 | 177 | # Placeholders 178 | Placeholders allow to dynamically insert text into the template content. The placeholder values are filled in according to the key-value definitions returned by the placeholder callback in the adapter configuration. 179 | 180 | This is using the [mustache](http://mustache.github.io/mustache.5.html) template syntax. The most commonly used tags are: 181 | - `{{double-mustache}}`: The most basic form of tag; inserts text as HTML escaped by default. 182 | - `{{{triple-mustache}}}`: Inserts text with unescaped HTML, which is required to insert a URL for example. 183 | 184 | ### Password Reset and Email Verification 185 | 186 | By default, the following placeholders are available in the password reset and email verification templates: 187 | - `{{appName}}`: The app name as set in the Parse Server configuration. 188 | - `{{username}}`: The username of the user who requested the email. 189 | - `{{link}}`: The URL to the Parse Server endpoint for password reset or email verification. 190 | 191 | # Localization 192 | 193 | Localization allows to use a specific template depending on the user locale. To turn on localization for a template, add a `localeCallback` to the template configuration. 194 | 195 | The locale returned by `localeCallback` will be used to look for locale-specific template files. If the callback returns an invalid locale or nothing at all (`undefined`), localization will be ignored and the default files will be used. 196 | 197 | The locale-specific files are placed in sub-folders with the name of either the whole locale (e.g. `de-AT`), or only the language (e.g. `de`). The locale has to be in format `[language]-[country]` as specified in [IETF BCP 47](https://tools.ietf.org/html/bcp47), e.g. `de-AT`. 198 | 199 | Localized files are placed in sub-folders of the given path, for example: 200 | ```js 201 | base/ 202 | ├── example.html // default file 203 | └── de/ // de language folder 204 | │ └── example.html // de localized file 205 | └── de-AT/ // de-AT locale folder 206 | │ └── example.html // de-AT localized file 207 | ``` 208 | 209 | Files are matched with the user locale in the following order: 210 | 1. **Locale** (locale `de-AT` matches file in folder `de-AT`) 211 | 2. **Language** (locale `de-AT` matches file in folder `de` if there is no file in folder `de-AT`) 212 | 3. **Default** (default file in base folder is returned if there is no file in folders `de-AT` and `de`) 213 | 214 | # Cloud Code 215 | 216 | Sending an email directly from Cloud Code is possible since Parse Server > 4.5.0. This adapter supports this convenience method. 217 | 218 | ## Example 219 | 220 | If the `user` provided has an email address set, it is not necessary to set a `recipient` because the mail adapter will by default use the mail address of the `user`. 221 | 222 | ```js 223 | Parse.Cloud.sendEmail({ 224 | templateName: "next_level_email", 225 | placeholders: { gameScore: 100, nextLevel: 2 }, 226 | user: parseUser // user with email address 227 | }); 228 | ``` 229 | 230 | ## Parameters 231 | 232 | | Parameter | Type | Optional | Default Value | Example Value | Description | 233 | |----------------|--------------|----------|---------------|-----------------------------|------------------------------------------------------------------------------------------------| 234 | | `sender` | `String` | | - | `from@example.com` | The email sender address; overrides the sender address specified in the adapter configuration. | 235 | | `recipient` | `String` | | - | `to@example.com` | The email recipient; if set overrides the email address of the `user`. | 236 | | `subject` | `String` | | - | `Welcome` | The email subject. | 237 | | `text` | `String` | | - | `Thank you for signing up!` | The plain-text email content. | 238 | | `html` | `String` | yes | `undefined` | `...` | The HTML email content. | 239 | | `templateName` | `String` | yes | `undefined` | `customTemplate` | The template name. | 240 | | `placeholders` | `Object` | yes | `{}` | `{ key: value }` | The template placeholders. | 241 | | `extra` | `Object` | yes | `{}` | `{ key: value }` | Any additional variables to pass to the mail provider API. | 242 | | `user` | `Parse.User` | yes | `undefined` | - | The Parse User that the is the recipient of the email. | 243 | 244 | # Supported APIs 245 | 246 | This adapter supports any REST API by adapting the API payload in the adapter configuration `apiCallback` according to the API specification. 247 | 248 | ## Providers 249 | 250 | For convenience, support for common email provider APIs is already built into this adapter and available via the `ApiPayloadConverter`. 251 | 252 | The following is a list of currently supported API providers. If the provider you are using is not already supported, please feel free to open an issue. 253 | 254 | > [!CAUTION] 255 | > The code examples below may show sensitive data such as API keys to be stored as environment variables. This is not a recommended practice and only used for simplicity to demonstrate how an adapter can be set up for development. 256 | 257 | ### AWS Simple Email Service 258 | 259 | This is an example for the AWS Simple Email Service client using the AWS JavaScript SDK v3: 260 | 261 | ```js 262 | const { SES, SendEmailCommand } = require('@aws-sdk/client-ses'); 263 | const { 264 | // Get credentials via IMDS from the AWS instance (when deployed on AWS instance) 265 | fromInstanceMetadata, 266 | // Get AWS credentials from environment variables (when testing locally) 267 | fromEnv, 268 | } = require('@aws-sdk/credential-providers'); 269 | 270 | // Get AWS credentials depending on environment 271 | const credentialProvider= process.env.NODE_ENV == 'production' ? fromInstanceMetadata() : fromEnv(); 272 | const credentials = await credentialProvider(); 273 | 274 | // Configure mail client 275 | const sesClient = new SES({ 276 | credentials, 277 | region: 'eu-west-1', 278 | apiVersion: '2010-12-01' 279 | }); 280 | 281 | // Configure Parse Server 282 | const server = new ParseServer({ 283 | // ... other server options 284 | emailAdapter: { 285 | module: 'parse-server-api-mail-adapter', 286 | options: { 287 | // ... other adapter options 288 | apiCallback: async ({ payload, locale }) => { 289 | const awsSesPayload = ApiPayloadConverter.awsSes(payload); 290 | const command = new SendEmailCommand(awsSesPayload); 291 | await sesClient.send(command); 292 | } 293 | } 294 | } 295 | }); 296 | ``` 297 | 298 | ### Mailgun 299 | 300 | This is an example for the Mailgun client: 301 | 302 | ```js 303 | // Configure mail client 304 | const mailgun = require('mailgun.js'); 305 | const mailgunClient = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY }); 306 | const mailgunDomain = process.env.MAILGUN_DOMAIN; 307 | 308 | // Configure Parse Server 309 | const server = new ParseServer({ 310 | // ... other server options 311 | emailAdapter: { 312 | module: 'parse-server-api-mail-adapter', 313 | options: { 314 | // ... other adapter options 315 | apiCallback: async ({ payload, locale }) => { 316 | const mailgunPayload = ApiPayloadConverter.mailgun(payload); 317 | await mailgunClient.messages.create(mailgunDomain, mailgunPayload); 318 | } 319 | } 320 | } 321 | }); 322 | ``` 323 | 324 | ### ZeptoMail 325 | 326 | This is an example for the ZeptoMail Service client using the ZeptoMail JavaScript SDK. 327 | Provide comma separated email adddresses in recepient parameter to send to multiple. 328 | 329 | ```js 330 | const { SendMailClient } = require('zeptomail'); 331 | 332 | // Configure mail client 333 | const url = process.env.ZEPTOMAIL_URL; 334 | const token = process.env.ZEPTOMAIL_TOKEN; 335 | const zeptoMaiClient = new SendMailClient({ url, token }); 336 | 337 | // Configure Parse Server 338 | const server = new ParseServer({ 339 | // ... other server options 340 | emailAdapter: { 341 | module: 'parse-server-api-mail-adapter', 342 | options: { 343 | // ... other adapter options 344 | apiCallback: async ({ payload, locale }) => { 345 | const zeptoMailPayload = ApiPayloadConverter.zeptomail({ api: '1.1', payload }); 346 | await zeptoMaiClient.sendMail(zeptoMailPayload); 347 | }, 348 | } 349 | } 350 | }); 351 | ``` 352 | 353 | ## Custom API 354 | 355 | This is an example of how the API payload can be adapted in the adapter configuration `apiCallback` according to a custom email provider's API specification. 356 | 357 | ```js 358 | // Configure mail client 359 | const customMail = require('customMail.js'); 360 | const customMailClient = customMail.configure({ ... }); 361 | 362 | // Configure Parse Server 363 | const server = new ParseServer({ 364 | // ... other server options 365 | emailAdapter: { 366 | module: 'parse-server-api-mail-adapter', 367 | options: { 368 | // ... other adapter options 369 | apiCallback: async ({ payload, locale }) => { 370 | const customPayload = { 371 | customFrom: payload.from, 372 | customTo: payload.to, 373 | customSubject: payload.subject, 374 | customText: payload.text 375 | }; 376 | await customMailClient.sendEmail(customPayload); 377 | } 378 | } 379 | } 380 | }); 381 | ``` 382 | 383 | # Need help? 384 | 385 | - Ask on StackOverflow using the [parse-server](https://stackoverflow.com/questions/tagged/parse-server) tag. 386 | - Search through existing [issues](https://github.com/parse-community/parse-server-api-mail-adapter/issues) or open a new issue. 387 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a vulnerability only use [this form](https://form.jotform.com/210144651871350). 4 | 5 | **Please do not report a security vulnerability directly on GitHub.** 6 | -------------------------------------------------------------------------------- /changelogs/CHANGELOG_alpha.md: -------------------------------------------------------------------------------- 1 | # [3.0.0-alpha.1](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0-alpha.9...3.0.0-alpha.1) (2023-04-29) 2 | 3 | 4 | ### Features 5 | 6 | * Remove support for Node 12 ([#79](https://github.com/parse-community/parse-server-api-mail-adapter/issues/79)) ([3a480d9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/3a480d98f58be723945cd78e3eaa18a2062af1ed)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Remove support for Node 12. ([3a480d9](3a480d9)) 12 | 13 | # [2.1.0-alpha.9](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0-alpha.8...2.1.0-alpha.9) (2022-09-15) 14 | 15 | 16 | ### Features 17 | 18 | * add AWS SES payload adapter ([#65](https://github.com/parse-community/parse-server-api-mail-adapter/issues/65)) ([4ecc4c9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4ecc4c9a119fbfcfd658dc7b73e28acaceba9c67)) 19 | 20 | # [2.1.0-alpha.8](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0-alpha.7...2.1.0-alpha.8) (2022-06-04) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * security upgrade semver-regex from 3.1.3 to 3.1.4 ([#62](https://github.com/parse-community/parse-server-api-mail-adapter/issues/62)) ([4db06d2](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4db06d218f5b4358701c8f8601a271f2da650ca1)) 26 | 27 | # [2.1.0-alpha.7](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.6...2.1.0-alpha.7) (2022-04-10) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * security upgrade node-fetch from 2.6.1 to 2.6.7 ([#60](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/60)) ([576d336](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/576d3365c296adfb0ccd5706d56fae6477f1946c)) 33 | 34 | # [2.1.0-alpha.6](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.5...2.1.0-alpha.6) (2022-04-09) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * security upgrade minimist from 1.2.5 to 1.2.6 ([#59](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/59)) ([1a5253f](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/1a5253fd81d763c7f123fa14d788edbc83e04027)) 40 | 41 | # [2.1.0-alpha.5](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.4...2.1.0-alpha.5) (2022-01-22) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * bump trim-off-newlines from 1.0.1 to 1.0.3 ([#57](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/57)) ([794cfb6](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/794cfb642678ac83b22c2c666eb9e109cc84e0df)) 47 | 48 | # [2.1.0-alpha.4](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.3...2.1.0-alpha.4) (2022-01-22) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * bump nanoid from 3.1.29 to 3.2.0 ([#56](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/56)) ([76a374b](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/76a374b52100e1ce05823300da8a2df21d64ac6c)) 54 | 55 | # [2.1.0-alpha.3](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.2...2.1.0-alpha.3) (2021-10-08) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * bump dev dependencies ([#48](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/48)) ([8e371b7](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8e371b7499605ac57cfe985b92032bddd270153d)) 61 | 62 | # [2.1.0-alpha.2](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.1.0-alpha.1...2.1.0-alpha.2) (2021-10-08) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * bump semver-regex from 3.1.2 to 3.1.3 ([#47](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/47)) ([8380a43](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8380a436cb3adc1c5519bdaa4e1dfd5f8259d879)) 68 | 69 | # [2.1.0-alpha.1](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.0.0...2.1.0-alpha.1) (2021-08-26) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * upgrade dependencies ([a3ef631](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/a3ef631894861e3bb1b29dc0b67c9c18b43b0410)) 75 | ### Features 76 | 77 | * add semantic release ([f1bc580](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/f1bc580a471d087c7b936e42af5bed9ea45172f3)) 78 | -------------------------------------------------------------------------------- /changelogs/CHANGELOG_beta.md: -------------------------------------------------------------------------------- 1 | # [3.0.0-beta.1](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0-beta.2...3.0.0-beta.1) (2023-04-29) 2 | 3 | 4 | ### Features 5 | 6 | * Remove support for Node 12 ([#79](https://github.com/parse-community/parse-server-api-mail-adapter/issues/79)) ([3a480d9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/3a480d98f58be723945cd78e3eaa18a2062af1ed)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Remove support for Node 12. ([3a480d9](3a480d9)) 12 | 13 | # [2.1.0-beta.2](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0-beta.1...2.1.0-beta.2) (2023-01-24) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * bump nanoid from 3.1.29 to 3.2.0 ([#56](https://github.com/parse-community/parse-server-api-mail-adapter/issues/56)) ([76a374b](https://github.com/parse-community/parse-server-api-mail-adapter/commit/76a374b52100e1ce05823300da8a2df21d64ac6c)) 19 | * bump trim-off-newlines from 1.0.1 to 1.0.3 ([#57](https://github.com/parse-community/parse-server-api-mail-adapter/issues/57)) ([794cfb6](https://github.com/parse-community/parse-server-api-mail-adapter/commit/794cfb642678ac83b22c2c666eb9e109cc84e0df)) 20 | * security upgrade minimist from 1.2.5 to 1.2.6 ([#59](https://github.com/parse-community/parse-server-api-mail-adapter/issues/59)) ([1a5253f](https://github.com/parse-community/parse-server-api-mail-adapter/commit/1a5253fd81d763c7f123fa14d788edbc83e04027)) 21 | * security upgrade node-fetch from 2.6.1 to 2.6.7 ([#60](https://github.com/parse-community/parse-server-api-mail-adapter/issues/60)) ([576d336](https://github.com/parse-community/parse-server-api-mail-adapter/commit/576d3365c296adfb0ccd5706d56fae6477f1946c)) 22 | * security upgrade semver-regex from 3.1.3 to 3.1.4 ([#62](https://github.com/parse-community/parse-server-api-mail-adapter/issues/62)) ([4db06d2](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4db06d218f5b4358701c8f8601a271f2da650ca1)) 23 | 24 | ### Features 25 | 26 | * add AWS SES payload adapter ([#65](https://github.com/parse-community/parse-server-api-mail-adapter/issues/65)) ([4ecc4c9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4ecc4c9a119fbfcfd658dc7b73e28acaceba9c67)) 27 | 28 | # [2.1.0-beta.1](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.0.0...2.1.0-beta.1) (2021-10-08) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * bump dev dependencies ([#48](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/48)) ([8e371b7](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8e371b7499605ac57cfe985b92032bddd270153d)) 34 | * bump semver-regex from 3.1.2 to 3.1.3 ([#47](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/47)) ([8380a43](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8380a436cb3adc1c5519bdaa4e1dfd5f8259d879)) 35 | * upgrade dependencies ([a3ef631](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/a3ef631894861e3bb1b29dc0b67c9c18b43b0410)) 36 | 37 | ### Features 38 | 39 | * add semantic release ([f1bc580](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/f1bc580a471d087c7b936e42af5bed9ea45172f3)) 40 | -------------------------------------------------------------------------------- /changelogs/CHANGELOG_release.md: -------------------------------------------------------------------------------- 1 | # [4.0.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/3.1.1...4.0.0) (2024-07-12) 2 | 3 | 4 | ### Features 5 | 6 | * Add support for Node 20, 22; remove support for Node 14, 16 ([#94](https://github.com/parse-community/parse-server-api-mail-adapter/issues/94)) ([cfec252](https://github.com/parse-community/parse-server-api-mail-adapter/commit/cfec2527aa56484251870658c621d99a8d281de5)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Removes support for Node 14, 16. ([cfec252](cfec252)) 12 | 13 | ## [3.1.1](https://github.com/parse-community/parse-server-api-mail-adapter/compare/3.1.0...3.1.1) (2023-10-18) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * Security bump @babel/traverse from 7.15.0 to 7.23.2 ([#91](https://github.com/parse-community/parse-server-api-mail-adapter/issues/91)) ([36fd9c2](https://github.com/parse-community/parse-server-api-mail-adapter/commit/36fd9c24ee897751694b375ed92aeb50868e8623)) 19 | 20 | # [3.1.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/3.0.0...3.1.0) (2023-10-12) 21 | 22 | 23 | ### Features 24 | 25 | * Add TypeScript definitions ([#90](https://github.com/parse-community/parse-server-api-mail-adapter/issues/90)) ([9162a9a](https://github.com/parse-community/parse-server-api-mail-adapter/commit/9162a9a120ca2453d8018ed558aa7f9d4f9dcdfd)) 26 | 27 | # [3.0.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.2.0...3.0.0) (2023-04-29) 28 | 29 | 30 | ### Features 31 | 32 | * Remove support for Node 12 ([#79](https://github.com/parse-community/parse-server-api-mail-adapter/issues/79)) ([3a480d9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/3a480d98f58be723945cd78e3eaa18a2062af1ed)) 33 | 34 | 35 | ### BREAKING CHANGES 36 | 37 | * Remove support for Node 12. ([3a480d9](3a480d9)) 38 | 39 | # [2.2.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0...2.2.0) (2023-01-24) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * bump nanoid from 3.1.29 to 3.2.0 ([#56](https://github.com/parse-community/parse-server-api-mail-adapter/issues/56)) ([76a374b](https://github.com/parse-community/parse-server-api-mail-adapter/commit/76a374b52100e1ce05823300da8a2df21d64ac6c)) 45 | * bump trim-off-newlines from 1.0.1 to 1.0.3 ([#57](https://github.com/parse-community/parse-server-api-mail-adapter/issues/57)) ([794cfb6](https://github.com/parse-community/parse-server-api-mail-adapter/commit/794cfb642678ac83b22c2c666eb9e109cc84e0df)) 46 | * security upgrade minimist from 1.2.5 to 1.2.6 ([#59](https://github.com/parse-community/parse-server-api-mail-adapter/issues/59)) ([1a5253f](https://github.com/parse-community/parse-server-api-mail-adapter/commit/1a5253fd81d763c7f123fa14d788edbc83e04027)) 47 | * security upgrade node-fetch from 2.6.1 to 2.6.7 ([#60](https://github.com/parse-community/parse-server-api-mail-adapter/issues/60)) ([576d336](https://github.com/parse-community/parse-server-api-mail-adapter/commit/576d3365c296adfb0ccd5706d56fae6477f1946c)) 48 | * security upgrade semver-regex from 3.1.3 to 3.1.4 ([#62](https://github.com/parse-community/parse-server-api-mail-adapter/issues/62)) ([4db06d2](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4db06d218f5b4358701c8f8601a271f2da650ca1)) 49 | 50 | ### Features 51 | 52 | * add AWS SES payload adapter ([#65](https://github.com/parse-community/parse-server-api-mail-adapter/issues/65)) ([4ecc4c9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4ecc4c9a119fbfcfd658dc7b73e28acaceba9c67)) 53 | 54 | # [2.2.0](https://github.com/parse-community/parse-server-api-mail-adapter/compare/2.1.0...2.2.0) (2023-01-24) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * bump nanoid from 3.1.29 to 3.2.0 ([#56](https://github.com/parse-community/parse-server-api-mail-adapter/issues/56)) ([76a374b](https://github.com/parse-community/parse-server-api-mail-adapter/commit/76a374b52100e1ce05823300da8a2df21d64ac6c)) 60 | * bump trim-off-newlines from 1.0.1 to 1.0.3 ([#57](https://github.com/parse-community/parse-server-api-mail-adapter/issues/57)) ([794cfb6](https://github.com/parse-community/parse-server-api-mail-adapter/commit/794cfb642678ac83b22c2c666eb9e109cc84e0df)) 61 | * security upgrade minimist from 1.2.5 to 1.2.6 ([#59](https://github.com/parse-community/parse-server-api-mail-adapter/issues/59)) ([1a5253f](https://github.com/parse-community/parse-server-api-mail-adapter/commit/1a5253fd81d763c7f123fa14d788edbc83e04027)) 62 | * security upgrade node-fetch from 2.6.1 to 2.6.7 ([#60](https://github.com/parse-community/parse-server-api-mail-adapter/issues/60)) ([576d336](https://github.com/parse-community/parse-server-api-mail-adapter/commit/576d3365c296adfb0ccd5706d56fae6477f1946c)) 63 | * security upgrade semver-regex from 3.1.3 to 3.1.4 ([#62](https://github.com/parse-community/parse-server-api-mail-adapter/issues/62)) ([4db06d2](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4db06d218f5b4358701c8f8601a271f2da650ca1)) 64 | 65 | ### Features 66 | 67 | * add AWS SES payload adapter ([#65](https://github.com/parse-community/parse-server-api-mail-adapter/issues/65)) ([4ecc4c9](https://github.com/parse-community/parse-server-api-mail-adapter/commit/4ecc4c9a119fbfcfd658dc7b73e28acaceba9c67)) 68 | 69 | # [2.1.0](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/2.0.0...2.1.0) (2021-10-09) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * bump dev dependencies ([#48](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/48)) ([8e371b7](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8e371b7499605ac57cfe985b92032bddd270153d)) 75 | * bump semver-regex from 3.1.2 to 3.1.3 ([#47](https://github.com/mtrezza/parse-server-api-mail-adapter/issues/47)) ([8380a43](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/8380a436cb3adc1c5519bdaa4e1dfd5f8259d879)) 76 | * upgrade dependencies ([a3ef631](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/a3ef631894861e3bb1b29dc0b67c9c18b43b0410)) 77 | 78 | ### Features 79 | 80 | * add semantic release ([f1bc580](https://github.com/mtrezza/parse-server-api-mail-adapter/commit/f1bc580a471d087c7b936e42af5bed9ea45172f3)) 81 | 82 | # 2.0.0 83 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.7...2.0.0) 84 | ### ⚠️ Breaking Changes 85 | - Bumped Node.js version requirement to >=12 (Manuel Trezza) [#39](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/39) 86 | ### 🧬 Other Changes 87 | - Fixed demo script and README for `mailgun.js` 3.x which requires `form-data` (Stefan Trauth, Manuel Trezza) [#32](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/32) 88 | 89 | # 1.0.7 90 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.6...1.0.7) 91 | 92 | ### 🧬 Other Changes 93 | - Added supported providers to README (Manuel Trezza) [#34](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/34) 94 | - Bump postcss from 8.2.9 to 8.2.15 (dependabot) [#37](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/37) 95 | 96 | # 1.0.6 97 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.5...1.0.6) 98 | 99 | ### 🧬 Other Changes 100 | - Fixes failing to send email in Cloud Code without template (Manuel Trezza) [#26](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/26) 101 | 102 | # 1.0.5 103 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.4...1.0.5) 104 | 105 | ### 🧬 Other Changes 106 | - Fixes undefined `user` in `localeCallback` when sending email via `Parse.Cloud.sendEmail()` (wlky, Manuel Trezza) [#18](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/18) 107 | 108 | # 1.0.4 109 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.3...1.0.4) 110 | 111 | ### 🐛 Fixes 112 | - Fixed failing Parse Server adapter controller validation. [#13](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/13). Thanks to [mtrezza](https://github.com/mtrezza). 113 | 114 | ### 🧬 Improvements 115 | - Added lint to CI workflow. [#14](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/14). Thanks to [mtrezza](https://github.com/mtrezza). 116 | 117 | # 1.0.3 118 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.2...1.0.3) 119 | 120 | ### 🐛 Fixes 121 | - Added missing release script invocation when publishing package to npm. [#11](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/11). Thanks to [mtrezza](https://github.com/mtrezza). 122 | 123 | # 1.0.2 124 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.1...1.0.2) 125 | 126 | ### 🧬 Improvements 127 | - Added locale to placeholder callback. [#6](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/6). Thanks to [mtrezza](https://github.com/mtrezza). 128 | - Added current placeholders to placeholder callback. [#7](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/7). Thanks to [mtrezza](https://github.com/mtrezza). 129 | 130 | # 1.0.1 131 | [Full Changelog](https://github.com/mtrezza/parse-server-api-mail-adapter/compare/1.0.0...1.0.1) 132 | 133 | ### 🐛 Fixes 134 | - Removed unused config parameter from docs. [#1](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/1). Thanks to [mtrezza](https://github.com/mtrezza). 135 | 136 | ### 🧬 Improvements 137 | - ⚠️ Added locale to API callback. This is a breaking change because the `apiCallback` now returns an object `{payload, locale}` instead of only the `payload`. [#2](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/2). Thanks to [mtrezza](https://github.com/mtrezza) 138 | - Added badges to readme. [#3](https://github.com/mtrezza/parse-server-api-mail-adapter/pull/3). Thanks to [mtrezza](https://github.com/mtrezza) 139 | 140 | # 1.0.0 141 | - Initial commit. 142 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * ============================================================== 5 | * Demo script to send an email using the Mailgun API. 6 | * ============================================================== 7 | * Instructions: 8 | * 9 | * 1. Create a file `mailgun.json` in the directory of this demo 10 | * script with the following keys to configure the demo script: 11 | * 12 | * ``` 13 | * { 14 | * key: "xxx", // The Mailgun API key. 15 | * domain: "xxx", // The Mailgun domain. 16 | * host: "xxx", // The Mailgun host. 17 | * sender: "xxx", // The email sender. 18 | * recipient: "xxx", // The email recipient. 19 | * } 20 | * ``` 21 | * 22 | * 2. Run this script with `node ./demo` to send an email. 🤞 23 | * ============================================================== 24 | */ 25 | 26 | const ApiMailAdapter = require('../src/ApiMailAdapter'); 27 | const ApiPayloadConverter = require('../src/ApiPayloadConverter'); 28 | const formData = require("form-data"); 29 | const Mailgun = require('mailgun.js'); 30 | const path = require('path'); 31 | 32 | const { 33 | key, 34 | domain, 35 | sender, 36 | recipient 37 | } = require('./mailgun.json'); 38 | 39 | // Declare mail client 40 | const mailgun = new Mailgun(formData); 41 | const mailgunClient = mailgun.client({ username: "api", key }); 42 | 43 | // Configure mail client 44 | const filePath = (file) => path.resolve(__dirname, '../spec/templates/', file); 45 | const config = { 46 | sender: sender, 47 | templates: { 48 | passwordResetEmail: { 49 | subjectPath: filePath('password_reset_email_subject.txt'), 50 | textPath: filePath('password_reset_email.txt'), 51 | htmlPath: filePath('password_reset_email.html') 52 | }, 53 | verificationEmail: { 54 | subjectPath: filePath('verification_email_subject.txt'), 55 | textPath: filePath('verification_email.txt'), 56 | htmlPath: filePath('verification_email.html') 57 | }, 58 | customEmail: { 59 | subjectPath: filePath('custom_email_subject.txt'), 60 | textPath: filePath('custom_email.txt'), 61 | htmlPath: filePath('custom_email.html'), 62 | placeholders: { 63 | username: "DefaultUser", 64 | appName: "DefaultApp" 65 | }, 66 | extra: { 67 | replyTo: 'no-reply@example.com' 68 | } 69 | } 70 | }, 71 | apiCallback: async ({ payload }) => { 72 | const mailgunPayload = ApiPayloadConverter.mailgun(payload); 73 | await mailgunClient.messages.create(domain, mailgunPayload); 74 | } 75 | }; 76 | 77 | const adapter = new ApiMailAdapter(config); 78 | 79 | adapter.sendMail({ 80 | templateName: 'customEmail', 81 | recipient: recipient, 82 | placeholders: { 83 | appName: "ExampleApp", 84 | username: "ExampleUser" 85 | }, 86 | direct: true 87 | }); 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-server-api-mail-adapter", 3 | "version": "4.1.0", 4 | "description": "Universal Mail Adapter for Parse Server, supports any email provider REST API, with localization and templates.", 5 | "main": "./lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/parse-community/parse-server-api-mail-adapter.git" 9 | }, 10 | "keywords": [ 11 | "parse", 12 | "parse-server", 13 | "mail-adapter", 14 | "email-adapter", 15 | "mail", 16 | "email" 17 | ], 18 | "author": "Manuel Trezza", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/parse-community/parse-server-api-mail-adapter/issues" 22 | }, 23 | "homepage": "https://github.com/parse-community/parse-server-api-mail-adapter", 24 | "files": [ 25 | "src", 26 | "lib", 27 | "demo" 28 | ], 29 | "dependencies": { 30 | "mustache": "4.2.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "7.15.7", 34 | "@babel/core": "7.15.8", 35 | "@babel/plugin-proposal-object-rest-spread": "7.15.6", 36 | "@babel/plugin-transform-flow-strip-types": "7.14.5", 37 | "@babel/preset-env": "7.15.8", 38 | "@semantic-release/changelog": "6.0.3", 39 | "@semantic-release/commit-analyzer": "13.0.0", 40 | "@semantic-release/git": "10.0.1", 41 | "@semantic-release/github": "11.0.0", 42 | "@semantic-release/npm": "12.0.1", 43 | "@semantic-release/release-notes-generator": "14.0.1", 44 | "@types/jasmine": "5.1.4", 45 | "@types/node": "22.7.6", 46 | "babel-eslint": "10.1.0", 47 | "eslint": "7.32.0", 48 | "eslint-plugin-flowtype": "5.9.0", 49 | "form-data": "4.0.0", 50 | "jasmine": "3.9.0", 51 | "jasmine-spec-reporter": "7.0.0", 52 | "madge": "5.0.1", 53 | "mailgun.js": "3.5.9", 54 | "nyc": "15.1.0", 55 | "semantic-release": "24.1.3", 56 | "typescript": "5.2.2" 57 | }, 58 | "engines": { 59 | "node": "18 || 20 || 22" 60 | }, 61 | "scripts": { 62 | "build:watch": "babel src --out-dir lib --source-maps --watch", 63 | "build": "babel src --out-dir lib --source-maps && tsc", 64 | "lint": "eslint '{src,spec,demo}/**/*.js'", 65 | "lint:fix": "eslint '{src,spec,demo}/**/*.js' --fix", 66 | "madge": "node_modules/.bin/madge ./src $npm_config_arg", 67 | "madge:circular": "npm run madge --arg=--circular", 68 | "test": "nyc jasmine", 69 | "prepare": "npm run build && npm test", 70 | "demo": "node ./demo" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /spec/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jasmine": true 4 | }, 5 | "globals": { 6 | }, 7 | "rules": { 8 | "no-console": [0], 9 | "no-var": "error" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /spec/ApiMailAdapter.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs').promises; 5 | const ApiMailAdapter = require('../src/ApiMailAdapter'); 6 | const ApiPayloadConverter = require('../src/ApiPayloadConverter'); 7 | const Errors = require('../src/Errors'); 8 | const { Parse } = require('./helper'); 9 | 10 | const user = new Parse.User(); 11 | const link = 'http://example.com'; 12 | const appName = 'ExampleApp'; 13 | const apiResponseSuccess = async () => "Success"; 14 | const config = { 15 | apiCallback: apiResponseSuccess, 16 | sender: 'from@example.com', 17 | templates: { 18 | passwordResetEmail: { 19 | subjectPath: path.join(__dirname, 'templates/password_reset_email_subject.txt'), 20 | textPath: path.join(__dirname, 'templates/password_reset_email.txt'), 21 | htmlPath: path.join(__dirname, 'templates/password_reset_email.html'), 22 | }, 23 | verificationEmail: { 24 | subjectPath: path.join(__dirname, 'templates/verification_email_subject.txt'), 25 | textPath: path.join(__dirname, 'templates/verification_email.txt'), 26 | htmlPath: path.join(__dirname, 'templates/verification_email.html'), 27 | placeholderCallback: () => {}, 28 | }, 29 | customEmail: { 30 | subjectPath: path.join(__dirname, 'templates/custom_email_subject.txt'), 31 | textPath: path.join(__dirname, 'templates/custom_email.txt'), 32 | htmlPath: path.join(__dirname, 'templates/custom_email.html'), 33 | extra: { 34 | replyTo: 'replyto@example.com', 35 | }, 36 | }, 37 | customEmailWithPlaceholderCallback: { 38 | subjectPath: path.join(__dirname, 'templates/custom_email_subject.txt'), 39 | textPath: path.join(__dirname, 'templates/custom_email.txt'), 40 | htmlPath: path.join(__dirname, 'templates/custom_email.html'), 41 | placeholders: { 42 | appName: "TemplatePlaceholder" 43 | }, 44 | placeholderCallback: () => new Promise((resolve) => { 45 | resolve({ 46 | appName: 'CallbackPlaceholder' 47 | }); 48 | }) 49 | }, 50 | customEmailWithLocaleCallback: { 51 | subjectPath: path.join(__dirname, 'templates/custom_email_subject.txt'), 52 | textPath: path.join(__dirname, 'templates/custom_email.txt'), 53 | htmlPath: path.join(__dirname, 'templates/custom_email.html'), 54 | localeCallback: async () => { return 'de-AT'; } 55 | }, 56 | customEmailWithPlaceholderCallbackAndLocaleCallback: { 57 | subjectPath: path.join(__dirname, 'templates/custom_email_subject.txt'), 58 | textPath: path.join(__dirname, 'templates/custom_email.txt'), 59 | htmlPath: path.join(__dirname, 'templates/custom_email.html'), 60 | placeholders: { 61 | appName: "TemplatePlaceholder" 62 | }, 63 | placeholderCallback: () => new Promise((resolve) => { 64 | resolve({ 65 | appName: 'CallbackPlaceholder' 66 | }); 67 | }), 68 | localeCallback: async () => { return 'de-AT'; } 69 | }, 70 | } 71 | }; 72 | const examplePayload = { 73 | from: "from@example.com", 74 | to: "to@example.com", 75 | replyTo: "replyto@example.com", 76 | subject: "ExampleSubject", 77 | text: "ExampleText", 78 | html: "ExampleHtml" 79 | } 80 | 81 | describe('ApiMailAdapter', () => { 82 | const ds = 'dummy string'; 83 | const df = () => "dummy function"; 84 | 85 | describe('initialization', function () { 86 | function adapter (config) { 87 | return (() => new ApiMailAdapter(config)).bind(null); 88 | } 89 | 90 | it('fails with invalid configuration', async () => { 91 | const configs = [ 92 | undefined, 93 | null, 94 | {}, 95 | ]; 96 | for (const config of configs) { 97 | expect(adapter(config)).toThrow(Errors.Error.configurationInvalid); 98 | } 99 | }); 100 | 101 | it('fails with invalid templates', async () => { 102 | const configs = [ 103 | { apiCallback: df, sender: ds }, 104 | { apiCallback: df, sender: ds, templates: {} }, 105 | { apiCallback: df, sender: ds, templates: [] }, 106 | ]; 107 | for (const config of configs) { 108 | expect(adapter(config)).toThrow(Errors.Error.templatesInvalid); 109 | } 110 | }); 111 | 112 | it('fails with invalid template content path', async () => { 113 | const configs = [ 114 | { apiCallback: df, sender: ds, templates: { customEmail: {} } }, 115 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds } } }, 116 | { apiCallback: df, sender: ds, templates: { customEmail: { textPath: ds } } }, 117 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: 1 } } }, 118 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: 1, textPath: ds } } }, 119 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, htmlPath: 1 } } } 120 | ]; 121 | for (const config of configs) { 122 | expect(adapter(config)).toThrow(Errors.Error.templateContentPathInvalid); 123 | } 124 | }); 125 | 126 | it('fails with invalid placeholder callback', async () => { 127 | const configs = [ 128 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, placeholderCallback: {} } } }, 129 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, placeholderCallback: ds } } } 130 | ]; 131 | for (const config of configs) { 132 | expect(adapter(config)).toThrow(Errors.Error.templateCallbackNoFunction); 133 | } 134 | }); 135 | 136 | it('fails with missing or invalid API callback', async () => { 137 | const configs = [ 138 | { sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds } } }, 139 | { apiCallback: null, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds } } }, 140 | { apiCallback: true, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds } } }, 141 | { apiCallback: ds, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds } } }, 142 | ]; 143 | for (const config of configs) { 144 | expect(adapter(config)).toThrow(Errors.Error.apiCallbackNoFunction); 145 | } 146 | }); 147 | 148 | it('fails with invalid locale callback', async () => { 149 | const configs = [ 150 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, localeCallback: ds } } }, 151 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, localeCallback: true } } }, 152 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, localeCallback: [] } } }, 153 | ]; 154 | for (const config of configs) { 155 | expect(adapter(config)).toThrow(Errors.Error.localeCallbackNoFunction); 156 | } 157 | }); 158 | 159 | it('succeeds with valid configuration', async () => { 160 | const configs = [ 161 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds } } }, 162 | { apiCallback: df, sender: ds, templates: { customEmail: { subjectPath: ds, textPath: ds, placeholderCallback: df } } } 163 | ]; 164 | for (const config of configs) { 165 | expect(adapter(config)()).toBeInstanceOf(ApiMailAdapter); 166 | } 167 | }); 168 | }); 169 | 170 | describe('send password reset email', function () { 171 | 172 | it('returns promise', async () => { 173 | const adapter = new ApiMailAdapter(config); 174 | const options = { link, appName, user }; 175 | 176 | const promise = adapter.sendPasswordResetEmail(options); 177 | expect(promise).toBeInstanceOf(Promise); 178 | }); 179 | 180 | it('invokes API callback with correct arguments', async () => { 181 | const adapter = new ApiMailAdapter(config); 182 | const _sendMail = spyOn(ApiMailAdapter.prototype, '_sendMail').and.callThrough(); 183 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 184 | const templateName = 'passwordResetEmail'; 185 | const options = { link, appName, user }; 186 | const expectedArguments = { templateName, link, appName, user }; 187 | 188 | await adapter.sendPasswordResetEmail(options); 189 | expect(_sendMail.calls.all()[0].args[0]).toEqual(expectedArguments); 190 | expect(apiCallback.calls.all()[0].args[0].payload.from).toEqual(config.sender); 191 | expect(apiCallback.calls.all()[0].args[0].payload.to).toEqual(user.get('email')); 192 | expect(apiCallback.calls.all()[0].args[0].payload.subject).toMatch("Reset"); 193 | expect(apiCallback.calls.all()[0].args[0].payload.text).toMatch("reset"); 194 | expect(apiCallback.calls.all()[0].args[0].payload.html).toMatch("reset"); 195 | }); 196 | }); 197 | 198 | describe('send verification email', function () { 199 | 200 | it('returns promise', async () => { 201 | const adapter = new ApiMailAdapter(config); 202 | const options = { link, appName, user }; 203 | 204 | const promise = adapter.sendVerificationEmail(options); 205 | expect(promise).toBeInstanceOf(Promise); 206 | }); 207 | 208 | it('invokes API callback with correct arguments', async () => { 209 | const adapter = new ApiMailAdapter(config); 210 | const _sendMail = spyOn(ApiMailAdapter.prototype, '_sendMail').and.callThrough(); 211 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 212 | const templateName = 'verificationEmail'; 213 | const options = { link, appName, user }; 214 | const expectedArguments = { templateName, link, appName, user }; 215 | 216 | await adapter.sendVerificationEmail(options); 217 | expect(_sendMail.calls.all()[0].args[0]).toEqual(expectedArguments); 218 | expect(apiCallback.calls.all()[0].args[0].payload.from).toEqual(config.sender); 219 | expect(apiCallback.calls.all()[0].args[0].payload.to).toEqual(user.get('email')); 220 | expect(apiCallback.calls.all()[0].args[0].payload.subject).toMatch("Verification"); 221 | expect(apiCallback.calls.all()[0].args[0].payload.text).toMatch("verify"); 222 | expect(apiCallback.calls.all()[0].args[0].payload.html).toMatch("verify"); 223 | }); 224 | }); 225 | 226 | describe('send generic email', function () { 227 | 228 | it('invokes _sendMail() with correct arguments', async () => { 229 | const adapter = new ApiMailAdapter(config); 230 | spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 231 | const _sendMail = spyOn(ApiMailAdapter.prototype, '_sendMail').and.callThrough(); 232 | const options = { 233 | sender: config.sender, 234 | recipient: 'to@example.com', 235 | subject: 'ExampleSubject', 236 | text: 'ExampleText', 237 | html: 'ExampleHtml', 238 | templateName: 'customEmail', 239 | placeholders: { 240 | appName: 'ExampleApp', 241 | username: 'ExampleUser' 242 | }, 243 | extra: { 244 | field: "ExampleExtra" 245 | }, 246 | user: undefined, 247 | }; 248 | const expectedArguments = Object.assign({}, options, { direct: true }); 249 | 250 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 251 | expect(_sendMail.calls.all()[0].args[0]).toEqual(expectedArguments); 252 | }); 253 | 254 | it('allows sendMail() without using a template', async () => { 255 | const adapter = new ApiMailAdapter(config); 256 | const apiCallbackSpy = spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 257 | const options = { 258 | sender: config.sender, 259 | recipient: 'to@example.com', 260 | subject: 'ExampleSubject', 261 | text: 'ExampleText', 262 | html: 'ExampleHtml', 263 | }; 264 | 265 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 266 | const apiPayload = apiCallbackSpy.calls.all()[0].args[0].payload; 267 | expect(apiPayload.from).toEqual(options.sender); 268 | expect(apiPayload.to).toEqual(options.recipient); 269 | expect(apiPayload.subject).toEqual(options.subject); 270 | expect(apiPayload.text).toEqual(options.text); 271 | expect(apiPayload.html).toEqual(options.html); 272 | }); 273 | 274 | it('passes user to callback when user is passed to sendMail()', async () => { 275 | const adapter = new ApiMailAdapter(config); 276 | const localeCallbackSpy = spyOn(config.templates.customEmailWithLocaleCallback, 'localeCallback').and.callThrough(); 277 | const options = { 278 | templateName: 'customEmailWithLocaleCallback', 279 | user: new Parse.User(), 280 | }; 281 | 282 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 283 | expect(localeCallbackSpy.calls.all()[0].args[0].get('locale')).toBe(options.user.get('locale')); 284 | }); 285 | 286 | it('uses user email if no recipient is passed to sendMail()', async () => { 287 | const adapter = new ApiMailAdapter(config); 288 | const apiCallbackSpy = spyOn(adapter, 'apiCallback').and.callThrough(); 289 | const options = { 290 | templateName: 'customEmailWithLocaleCallback', 291 | user: new Parse.User(), 292 | }; 293 | 294 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 295 | expect(apiCallbackSpy.calls.all()[0].args[0].payload.to).toBe(options.user.get('email')); 296 | }); 297 | 298 | it('overrides user email if recipient is passed to sendMail()', async () => { 299 | const adapter = new ApiMailAdapter(config); 300 | const apiCallbackSpy = spyOn(adapter, 'apiCallback').and.callThrough(); 301 | const options = { 302 | recipient: 'override@example.com', 303 | templateName: 'customEmailWithLocaleCallback', 304 | user: new Parse.User(), 305 | }; 306 | 307 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 308 | expect(apiCallbackSpy.calls.all()[0].args[0].payload.to).toBe(options.recipient); 309 | }); 310 | }); 311 | 312 | describe('generate API payload', function () { 313 | 314 | it('creates payload with correct properties', async () => { 315 | const adapter = new ApiMailAdapter(config); 316 | spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 317 | const _createApiData = spyOn(adapter, '_createApiData').and.callThrough(); 318 | const options = { 319 | sender: config.sender, 320 | recipient: 'to@example.com', 321 | subject: 'ExampleSubject', 322 | text: 'ExampleText', 323 | html: 'ExampleHtml', 324 | templateName: 'customEmail', 325 | placeholders: { 326 | appName: 'ExampleApp', 327 | username: 'ExampleUser' 328 | }, 329 | extra: { 330 | field: "ExampleExtra" 331 | } 332 | }; 333 | 334 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 335 | 336 | const { payload } = (await _createApiData.calls.all()[0].returnValue); 337 | expect(payload.from).toMatch(options.sender); 338 | expect(payload.to).toMatch(options.recipient); 339 | expect(payload.subject).toMatch(options.subject); 340 | expect(payload.text).toMatch(options.text); 341 | expect(payload.html).toMatch(options.html); 342 | expect(payload.replyTo).toMatch(config.templates[options.templateName].extra.replyTo); 343 | }); 344 | 345 | it('creates payload with correct properties when overriding extras', async () => { 346 | const adapter = new ApiMailAdapter(config); 347 | spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 348 | const _createApiData = spyOn(adapter, '_createApiData').and.callThrough(); 349 | const options = { 350 | sender: config.sender, 351 | recipient: 'to@example.com', 352 | subject: 'ExampleSubject', 353 | text: 'ExampleText', 354 | html: 'ExampleHtml', 355 | templateName: 'customEmail', 356 | placeholders: { 357 | appName: 'ExampleApp', 358 | username: 'ExampleUser' 359 | }, 360 | extra: { 361 | replyTo: "override@example.com" 362 | } 363 | }; 364 | 365 | await expectAsync(adapter.sendMail(options)).toBeResolved(); 366 | 367 | const { payload } = (await _createApiData.calls.all()[0].returnValue); 368 | expect(payload.from).toMatch(options.sender); 369 | expect(payload.to).toMatch(options.recipient); 370 | expect(payload.subject).toMatch(options.subject); 371 | expect(payload.text).toMatch(options.text); 372 | expect(payload.html).toMatch(options.html); 373 | expect(payload.replyTo).not.toMatch(config.templates[options.templateName].extra.replyTo); 374 | expect(payload.replyTo).toMatch(options.extra.replyTo); 375 | }); 376 | }); 377 | 378 | describe('convert API payload', () => { 379 | const adapter = new ApiMailAdapter(config); 380 | const converter = ApiPayloadConverter; 381 | 382 | beforeEach(() => { 383 | spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 384 | }); 385 | 386 | it('converts payload for Mailgun', () => { 387 | const payload = converter.mailgun(examplePayload); 388 | expect(payload.from).toBe(examplePayload.from); 389 | expect(payload.to).toBe(examplePayload.to); 390 | expect(payload['h:Reply-To']).toBe(examplePayload.replyTo); 391 | expect(payload.subject).toBe(examplePayload.subject); 392 | expect(payload.text).toBe(examplePayload.text); 393 | expect(payload.html).toBe(examplePayload.html); 394 | }); 395 | 396 | it('converts payload for AWS SES (SDK v3)', () => { 397 | const payload = converter.awsSes(examplePayload); 398 | expect(payload.Source).toEqual([examplePayload.from]); 399 | expect(payload.Destination.ToAddresses).toEqual([examplePayload.to]); 400 | expect(payload.ReplyToAddresses).toEqual([examplePayload.replyTo]); 401 | expect(payload.Message.Subject.Data).toBe(examplePayload.subject); 402 | expect(payload.Message.Body.Text.Data).toBe(examplePayload.text); 403 | expect(payload.Message.Body.Html.Data).toBe(examplePayload.html); 404 | }); 405 | 406 | describe('convert ZeptoMail API v1.1 payload', () => { 407 | it('converts single recipient payload', () => { 408 | const payload = converter.zeptomail({ api: '1.1', payload: examplePayload}); 409 | expect(payload.from.address).toEqual(examplePayload.from); 410 | expect(payload.to).toBeInstanceOf(Array); 411 | expect(payload.to.length).toBe(1); 412 | expect(payload.to[0].email_address.address).toEqual(examplePayload.to); 413 | expect(payload.reply_to).toBeInstanceOf(Array); 414 | expect(payload.reply_to.length).toBe(1); 415 | expect(payload.reply_to[0].address).toEqual(examplePayload.replyTo); 416 | expect(payload.subject).toBe(examplePayload.subject); 417 | expect(payload.textbody).toBe(examplePayload.text); 418 | expect(payload.htmlbody).toBe(examplePayload.html); 419 | }); 420 | 421 | it('converts multiple recipients payload', () => { 422 | const examplePayload = { 423 | from: "from@example.com", 424 | to: "to@example.com,toanother@example.com", 425 | replyTo: "replyto@example.com, replytoanother@example.com", 426 | subject: "ExampleSubject", 427 | text: "ExampleText", 428 | html: "ExampleHtml" 429 | } 430 | const payload = converter.zeptomail({ api: '1.1', payload: examplePayload}); 431 | expect(payload.from.address).toEqual(examplePayload.from); 432 | expect(payload.to).toBeInstanceOf(Array); 433 | const exmplePayloadToAddresses = examplePayload.to.split(','); 434 | const toAddresses = payload.to.map(entry => entry.email_address.address); 435 | exmplePayloadToAddresses.forEach((address, index) => { 436 | expect(address).toBe(toAddresses[index]); 437 | }); 438 | expect(payload.reply_to).toBeInstanceOf(Array); 439 | const exmpleReplyToAddresses = examplePayload.replyTo.split(',').map(addr => addr.trim()); 440 | const replyToAddresses = payload.reply_to[0].address.split(',').map(addr => addr.trim()); 441 | exmpleReplyToAddresses.forEach((exampleAddress, index) => { 442 | expect(replyToAddresses[index]).toBe(exampleAddress); 443 | }); 444 | expect(payload.subject).toBe(examplePayload.subject); 445 | expect(payload.textbody).toBe(examplePayload.text); 446 | expect(payload.htmlbody).toBe(examplePayload.html); 447 | }); 448 | 449 | it('throws if unsupported version', () => { 450 | expect(() => converter.zeptomail({ api: 'invalidVersion', payload: examplePayload})).toThrowError(/invalidVersion/); 451 | }); 452 | }); 453 | }); 454 | 455 | describe('invoke _sendMail', function () { 456 | 457 | it('throws if template name is missing', async () => { 458 | const adapter = new ApiMailAdapter(config); 459 | await expectAsync(adapter._sendMail({})).toBeRejectedWith(Errors.Error.templateConfigurationNoName); 460 | }); 461 | 462 | it('throws if template name does not exist', async () => { 463 | const adapter = new ApiMailAdapter(config); 464 | const configs = [ 465 | { templateName: 'invalid' }, 466 | ]; 467 | for (const config of configs) { 468 | await expectAsync(adapter._sendMail(config)).toBeRejectedWith(Errors.Error.noTemplateWithName('invalid')); 469 | } 470 | }); 471 | 472 | it('throws if recipient is not set', async () => { 473 | const adapter = new ApiMailAdapter(config); 474 | await expectAsync(adapter._sendMail({ templateName: 'customEmail', direct: true })).toBeRejectedWith(Errors.Error.noRecipient); 475 | }); 476 | 477 | it('catches exception during email sending', async () => { 478 | const adapter = new ApiMailAdapter(config); 479 | const error = new Error("Conversion error"); 480 | spyOn(adapter, 'apiCallback').and.callFake(() => { throw error; }); 481 | const options = { 482 | templateName: 'passwordResetEmail', 483 | link: 'http://example.com', 484 | appName: 'ExampleApp', 485 | user: user 486 | } 487 | await expectAsync(adapter._sendMail(options)).toBeRejectedWith(error); 488 | }); 489 | 490 | it('catches exception during direct mail sending', async () => { 491 | const adapter = new ApiMailAdapter(config); 492 | const error = new Error("Conversion error"); 493 | spyOn(adapter, 'apiCallback').and.callFake(() => { throw error; }); 494 | const options = { 495 | templateName: 'customEmail', 496 | recipient: 'to@example.com', 497 | direct: true 498 | } 499 | await expectAsync(adapter._sendMail(options)).toBeRejectedWith(error); 500 | }); 501 | 502 | it('loads template content files', async () => { 503 | const adapter = new ApiMailAdapter(config); 504 | const _loadFile = spyOn(adapter, '_loadFile').and.callThrough(); 505 | const options = { message: {}, template: config.templates.customEmail }; 506 | 507 | await adapter._createApiData(options); 508 | const subjectFileData = await fs.readFile(options.template.subjectPath); 509 | const textFileData = await fs.readFile(options.template.textPath); 510 | const htmlFileData = await fs.readFile(options.template.htmlPath); 511 | const subjectSpyData = await _loadFile.calls.all()[0].returnValue; 512 | const textSpyData = await _loadFile.calls.all()[1].returnValue; 513 | const htmlSpyData = await _loadFile.calls.all()[2].returnValue; 514 | 515 | expect(subjectSpyData.toString('utf8')).toEqual(subjectFileData.toString('utf8')); 516 | expect(textSpyData.toString('utf8')).toEqual(textFileData.toString('utf8')); 517 | expect(htmlSpyData.toString('utf8')).toEqual(htmlFileData.toString('utf8')); 518 | }); 519 | 520 | it('fills placeholders with callback values', async () => { 521 | const adapter = new ApiMailAdapter(config); 522 | const _fillPlaceholders = spyOn(adapter, '_fillPlaceholders').and.callThrough(); 523 | const options = { 524 | message: {}, 525 | template: config.templates.customEmail, 526 | placeholders: { appName: 'ExampleApp' } 527 | }; 528 | 529 | await adapter._createApiData(options); 530 | 531 | expect(_fillPlaceholders.calls.all()[0].args[0]).not.toContain('ExampleApp'); 532 | expect(_fillPlaceholders.calls.all()[1].args[0]).not.toContain('ExampleApp'); 533 | expect(_fillPlaceholders.calls.all()[2].args[0]).not.toContain('ExampleApp'); 534 | expect(_fillPlaceholders.calls.all()[0].returnValue).toContain('ExampleApp'); 535 | expect(_fillPlaceholders.calls.all()[1].returnValue).toContain('ExampleApp'); 536 | expect(_fillPlaceholders.calls.all()[2].returnValue).toContain('ExampleApp'); 537 | }); 538 | }); 539 | 540 | describe('invoke _loadFile', function () { 541 | 542 | it('rejects with error if file loading fails', async () => { 543 | const adapter = new ApiMailAdapter(config); 544 | const invalidPath = path.join(__dirname, 'templates/invalid.txt'); 545 | 546 | await expectAsync(adapter._loadFile(invalidPath)).toBeRejected(); 547 | }); 548 | 549 | it('resolves if file loading succeeds', async () => { 550 | const adapter = new ApiMailAdapter(config); 551 | const validPath = path.join(__dirname, 'templates/custom_email.txt'); 552 | 553 | const data = await adapter._loadFile(validPath); 554 | expect(data).toBeInstanceOf(Buffer); 555 | }); 556 | }); 557 | 558 | describe('placeholders', function () { 559 | 560 | it('returns valid placeholders', async () => { 561 | const adapter = new ApiMailAdapter(config); 562 | const placeholders = { key: 'value' }; 563 | expect(adapter._validatePlaceholders(placeholders)).toEqual({ key: 'value' }); 564 | }); 565 | 566 | it('returns empty object for invalid placeholders', async () => { 567 | const adapter = new ApiMailAdapter(config); 568 | const placeholders = 'invalid'; 569 | expect(adapter._validatePlaceholders(placeholders)).toEqual({}); 570 | }); 571 | 572 | it('fills in the template placeholder without placeholder callback', async () => { 573 | const adapter = new ApiMailAdapter(config); 574 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 575 | const email = { 576 | templateName: 'customEmailWithPlaceholderCallback', 577 | recipient: 'to@example.com', 578 | direct: true 579 | } 580 | const template = config.templates[email.templateName]; 581 | const templatePlaceholder = template.placeholders.appName; 582 | const callbackPlaceholder = (await config.templates[email.templateName].placeholderCallback()).appName; 583 | spyOn(template, 'placeholderCallback').and.callFake(() => {}); 584 | 585 | await adapter._sendMail(email); 586 | expect(apiCallback.calls.all()[0].args[0].payload.text).toContain(templatePlaceholder); 587 | expect(apiCallback.calls.all()[0].args[0].payload.text).not.toContain(callbackPlaceholder); 588 | }); 589 | 590 | it('overrides the template placeholder with the callback placeholder', async () => { 591 | const adapter = new ApiMailAdapter(config); 592 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 593 | const email = { 594 | templateName: 'customEmailWithPlaceholderCallback', 595 | recipient: 'to@example.com', 596 | direct: true 597 | } 598 | const template = config.templates[email.templateName]; 599 | const templatePlaceholder = template.placeholders.appName; 600 | const callbackPlaceholder = (await template.placeholderCallback()).appName; 601 | 602 | await adapter._sendMail(email); 603 | expect(apiCallback.calls.all()[0].args[0].payload.text).toContain(callbackPlaceholder); 604 | expect(apiCallback.calls.all()[0].args[0].payload.text).not.toContain(templatePlaceholder); 605 | }); 606 | 607 | it('overrides the template placeholder with the email placeholder', async () => { 608 | const adapter = new ApiMailAdapter(config); 609 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 610 | const email = { 611 | templateName: 'customEmailWithPlaceholderCallback', 612 | recipient: 'to@example.com', 613 | direct: true, 614 | placeholders: { 615 | appName: "EmailPlaceholder" 616 | } 617 | } 618 | const template = config.templates[email.templateName]; 619 | const templatePlaceholder = template.placeholders.appName; 620 | const emailPlaceholder = email.placeholders.appName; 621 | spyOn(template, 'placeholderCallback').and.callFake(async () => { 622 | return {}; 623 | }); 624 | 625 | await adapter._sendMail(email); 626 | expect(apiCallback.calls.all()[0].args[0].payload.text).toContain(emailPlaceholder); 627 | expect(apiCallback.calls.all()[0].args[0].payload.text).not.toContain(templatePlaceholder); 628 | }); 629 | 630 | it('makes placeholders accessible in placeholder callback', async () => { 631 | const adapter = new ApiMailAdapter(config); 632 | const templateName = 'customEmailWithPlaceholderCallback'; 633 | const template = config.templates[templateName]; 634 | const email = { templateName, link, appName, user }; 635 | const placeholderCallback = spyOn(template, 'placeholderCallback').and.callThrough(); 636 | 637 | await adapter._sendMail(email); 638 | expect(placeholderCallback.calls.all()[0].args[0].placeholders.link).toBe(link); 639 | expect(placeholderCallback.calls.all()[0].args[0].placeholders.appName).toBe(appName); 640 | }); 641 | 642 | it('makes user locale accessible in placeholder callback', async () => { 643 | const adapter = new ApiMailAdapter(config); 644 | const apiCallback = spyOn(adapter, 'apiCallback').and.callThrough(); 645 | const email = { 646 | templateName: 'customEmailWithPlaceholderCallbackAndLocaleCallback', 647 | recipient: 'to@example.com', 648 | direct: true 649 | } 650 | const template = config.templates[email.templateName]; 651 | const locale = await template.localeCallback(); 652 | 653 | await adapter._sendMail(email); 654 | expect(apiCallback.calls.all()[0].args[0].locale).toBe(locale); 655 | }); 656 | }); 657 | 658 | describe('localization', () => { 659 | let adapter; 660 | let options; 661 | let apiCallback; 662 | 663 | beforeEach(async () => { 664 | adapter = new ApiMailAdapter(config); 665 | apiCallback = spyOn(adapter, 'apiCallback').and.callFake(apiResponseSuccess); 666 | 667 | options = { 668 | message: {}, 669 | template: config.templates.customEmailWithLocaleCallback, 670 | placeholders: {}, 671 | }; 672 | }); 673 | 674 | it('uses user locale variable from user locale callback', async () => { 675 | const _loadFile = spyOn(adapter, '_loadFile').and.callThrough(); 676 | await adapter._createApiData(options); 677 | 678 | const subjectFileData = await fs.readFile(path.join(__dirname, 'templates/de-AT/custom_email_subject.txt')); 679 | const textFileData = await fs.readFile(path.join(__dirname, 'templates/de-AT/custom_email.txt')); 680 | const htmlFileData = await fs.readFile(path.join(__dirname, 'templates/de-AT/custom_email.html')); 681 | const subjectSpyData = await _loadFile.calls.all()[0].returnValue; 682 | const textSpyData = await _loadFile.calls.all()[1].returnValue; 683 | const htmlSpyData = await _loadFile.calls.all()[2].returnValue; 684 | 685 | expect(subjectSpyData.toString('utf8')).toEqual(subjectFileData.toString('utf8')); 686 | expect(textSpyData.toString('utf8')).toEqual(textFileData.toString('utf8')); 687 | expect(htmlSpyData.toString('utf8')).toEqual(htmlFileData.toString('utf8')); 688 | }); 689 | 690 | it('falls back to language localization if there is no locale match', async () => { 691 | // Pretend that there are not files in folder `de-AT` 692 | spyOn(adapter, '_fileExists').and.callFake(async (path) => { 693 | return !/\/templates\/de-AT\//.test(path); 694 | }); 695 | const _loadFile = spyOn(adapter, '_loadFile').and.callThrough(); 696 | await adapter._createApiData(options); 697 | 698 | const subjectFileData = await fs.readFile(path.join(__dirname, 'templates/de/custom_email_subject.txt')); 699 | const textFileData = await fs.readFile(path.join(__dirname, 'templates/de/custom_email.txt')); 700 | const htmlFileData = await fs.readFile(path.join(__dirname, 'templates/de/custom_email.html')); 701 | const subjectSpyData = await _loadFile.calls.all()[0].returnValue; 702 | const textSpyData = await _loadFile.calls.all()[1].returnValue; 703 | const htmlSpyData = await _loadFile.calls.all()[2].returnValue; 704 | 705 | expect(subjectSpyData.toString('utf8')).toEqual(subjectFileData.toString('utf8')); 706 | expect(textSpyData.toString('utf8')).toEqual(textFileData.toString('utf8')); 707 | expect(htmlSpyData.toString('utf8')).toEqual(htmlFileData.toString('utf8')); 708 | }); 709 | 710 | it('falls back to default file if there is no language or locale match', async () => { 711 | // Pretend that there are no files in folders `de-AT` and `de` 712 | spyOn(adapter, '_fileExists').and.callFake(async (path) => { 713 | return !/\/templates\/de(-AT)?\//.test(path); 714 | }); 715 | const _loadFile = spyOn(adapter, '_loadFile').and.callThrough(); 716 | await adapter._createApiData(options); 717 | 718 | const subjectFileData = await fs.readFile(path.join(__dirname, 'templates/custom_email_subject.txt')); 719 | const textFileData = await fs.readFile(path.join(__dirname, 'templates/custom_email.txt')); 720 | const htmlFileData = await fs.readFile(path.join(__dirname, 'templates/custom_email.html')); 721 | const subjectSpyData = await _loadFile.calls.all()[0].returnValue; 722 | const textSpyData = await _loadFile.calls.all()[1].returnValue; 723 | const htmlSpyData = await _loadFile.calls.all()[2].returnValue; 724 | 725 | expect(subjectSpyData.toString('utf8')).toEqual(subjectFileData.toString('utf8')); 726 | expect(textSpyData.toString('utf8')).toEqual(textFileData.toString('utf8')); 727 | expect(htmlSpyData.toString('utf8')).toEqual(htmlFileData.toString('utf8')); 728 | }); 729 | 730 | it('falls back to default file if file access throws', async () => { 731 | const getLocalizedFilePathSpy = spyOn(adapter, '_getLocalizedFilePath').and.callThrough(); 732 | spyOn(fs, 'access').and.callFake(async () => { 733 | throw 'Test file access error'; 734 | }); 735 | await adapter._createApiData(options); 736 | const file = await getLocalizedFilePathSpy.calls.all()[0].returnValue; 737 | expect(file).toMatch(options.template.subjectPath); 738 | }); 739 | 740 | it('makes user locale available in API callback', async () => { 741 | const locale = await options.template.localeCallback(); 742 | const email = { 743 | templateName: 'customEmailWithLocaleCallback', 744 | recipient: 'to@example.com', 745 | direct: true 746 | } 747 | await adapter._sendMail(email); 748 | expect(apiCallback.calls.all()[0].args[0].locale).toContain(locale); 749 | }); 750 | }); 751 | }); 752 | -------------------------------------------------------------------------------- /spec/Errors.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Errors = require('../src/Errors'); 4 | 5 | describe('Errors', () => { 6 | it('should create a custom error', () => { 7 | const message = "Example Error"; 8 | const error = Errors.customError(message); 9 | expect(error.message).toBe(message); 10 | expect(error instanceof Error).toBeTrue(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /spec/MailAdapter.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const MailAdapter = require('../src/MailAdapter'); 4 | const { Parse } = require('./helper'); 5 | 6 | describe('MailAdapter', () => { 7 | const adapter = new MailAdapter() 8 | const user = new Parse.User(); 9 | 10 | it('should have a method called sendMail', () => { 11 | expect(typeof adapter.sendMail).toBe('function'); 12 | expect(adapter.sendMail()).toBeUndefined(); 13 | }); 14 | 15 | it('should have a method called sendVerificationEmail', () => { 16 | expect(typeof adapter.sendVerificationEmail).toBe('function'); 17 | expect(adapter.sendVerificationEmail({ link: 'link', appName: 'appName', user: user })).toBeUndefined(); 18 | }); 19 | 20 | it('should have a method called sendPasswordResetEmail', () => { 21 | expect(typeof adapter.sendPasswordResetEmail).toBe('function'); 22 | expect(adapter.sendPasswordResetEmail({ link: 'link', appName: 'appName', user: user })).toBeUndefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /spec/helper.js: -------------------------------------------------------------------------------- 1 | const SpecReporter = require('jasmine-spec-reporter').SpecReporter; 2 | 3 | // Set up jasmine 4 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; 5 | jasmine.getEnv().addReporter(new SpecReporter()); 6 | 7 | // Simulate Parse User class 8 | const Parse = { 9 | User: class User { 10 | get(key) { 11 | switch (key) { 12 | case 'username': 13 | return 'ExampleUsername'; 14 | case 'email': 15 | return 'to@example.com'; 16 | case 'locale': 17 | return 'de-AT'; 18 | } 19 | } 20 | } 21 | }; 22 | 23 | module.exports = { 24 | Parse 25 | }; 26 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "*spec.js" 5 | ], 6 | "helpers": [ 7 | "helper.js" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": false 11 | } 12 | -------------------------------------------------------------------------------- /spec/templates/custom_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 |Hello!
9 |This is a notice regarding your account:
10 |{{username}}
11 |{{appName}}
13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/templates/custom_email.txt: -------------------------------------------------------------------------------- 1 | Hello! 2 | 3 | This is a notice regarding your account: 4 | {{username}} 5 | 6 | {{appName}} -------------------------------------------------------------------------------- /spec/templates/custom_email_subject.txt: -------------------------------------------------------------------------------- 1 | Account Notice - {{appName}} -------------------------------------------------------------------------------- /spec/templates/de-AT/custom_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |Hello!
9 |This is a notice regarding your account:
10 |{{username}}
11 |{{appName}} (de-AT)
13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/templates/de-AT/custom_email.txt: -------------------------------------------------------------------------------- 1 | Hello! 2 | 3 | This is a notice regarding your account: 4 | {{username}} 5 | 6 | {{appName}} (de-AT) -------------------------------------------------------------------------------- /spec/templates/de-AT/custom_email_subject.txt: -------------------------------------------------------------------------------- 1 | Account Notice - {{appName}} (de-AT) -------------------------------------------------------------------------------- /spec/templates/de/custom_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |Hello!
9 |This is a notice regarding your account:
10 |{{username}}
11 |{{appName}} (de)
13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/templates/de/custom_email.txt: -------------------------------------------------------------------------------- 1 | Hello! 2 | 3 | This is a notice regarding your account: 4 | {{username}} 5 | 6 | {{appName}} (de) -------------------------------------------------------------------------------- /spec/templates/de/custom_email_subject.txt: -------------------------------------------------------------------------------- 1 | Account Notice - {{appName}} (de) -------------------------------------------------------------------------------- /spec/templates/password_reset_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |Hello!
9 |You requested to reset the password for your account:
10 |{{username}}
11 |Click the button below to reset your password:
12 |ExampleApp
16 | 17 | 18 | -------------------------------------------------------------------------------- /spec/templates/password_reset_email.txt: -------------------------------------------------------------------------------- 1 | Hello! 2 | 3 | You requested to reset the password for your account: 4 | 5 | {{username}} 6 | 7 | Click the link below to reset your password: 8 | 9 | {{{link}}} 10 | 11 | ExampleApp -------------------------------------------------------------------------------- /spec/templates/password_reset_email_subject.txt: -------------------------------------------------------------------------------- 1 | Password Reset -------------------------------------------------------------------------------- /spec/templates/verification_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |Welcome!
9 |Click the button below to verify your email address:
10 |ExampleApp
14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/templates/verification_email.txt: -------------------------------------------------------------------------------- 1 | Welcome! 2 | 3 | Click the link below to verify your email address: 4 | 5 | {{{link}}} 6 | 7 | ExampleApp -------------------------------------------------------------------------------- /spec/templates/verification_email_subject.txt: -------------------------------------------------------------------------------- 1 | Email Address Verification -------------------------------------------------------------------------------- /src/ApiMailAdapter.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs').promises; 3 | const Mustache = require('mustache'); 4 | const MailAdapter = require('./MailAdapter'); 5 | const Errors = require('./Errors'); 6 | 7 | /** 8 | * @class ApiMailAdapter 9 | * @description An email adapter for Parse Server to send emails via mail provider APIs. 10 | */ 11 | class ApiMailAdapter extends MailAdapter { 12 | /** 13 | * Creates a new mail adapter. 14 | * @param {Object} options The configuration options. 15 | */ 16 | constructor(options) { 17 | 18 | // Get parameters 19 | const { sender, templates, apiCallback } = options || {}; 20 | 21 | // Ensure required parameters are set 22 | if (!sender) { 23 | throw Errors.Error.configurationInvalid; 24 | } 25 | 26 | // Ensure email templates are set 27 | if (!templates || Object.keys(templates).length === 0) { 28 | throw Errors.Error.templatesInvalid; 29 | } 30 | 31 | // Ensure API callback is set 32 | if (typeof apiCallback !== 'function') { 33 | throw Errors.Error.apiCallbackNoFunction; 34 | } 35 | 36 | // Initialize 37 | super(options); 38 | 39 | // Validate templates 40 | for (const key in templates) { 41 | this._validateTemplate(templates[key]); 42 | } 43 | 44 | // Set properties 45 | this.sender = sender; 46 | this.templates = templates; 47 | this.apiCallback = apiCallback 48 | } 49 | 50 | /** 51 | * @function sendPasswordResetEmail 52 | * @description Sends a password reset email. 53 | * @param {String} link The password reset link. 54 | * @param {String} appName The app name. 55 | * @param {String} user The Parse User. 56 | * @returns {Promise