From 35544a30c5fdda2f8943d42433c5b9015ec6d4f3 Mon Sep 17 00:00:00 2001 From: sahachide Date: Sun, 7 Feb 2021 14:23:26 +0100 Subject: [PATCH] feat: add email support with MJML and nunjucks engine (or just plain text) --- docker-compose.yml | 8 +++ docker/services/mailcatcher/Dockerfile | 9 ++++ package-lock.json | 71 ++++++++++++++++++++++++-- package.json | 6 ++- src/config/default.ts | 4 ++ src/dependencies/Injector.ts | 2 + src/email/EmailFactory.ts | 63 ++++++++++++----------- src/index.ts | 2 + src/types/interfaces.ts | 23 ++++++--- src/types/types.ts | 15 ++++++ 10 files changed, 161 insertions(+), 42 deletions(-) create mode 100644 docker/services/mailcatcher/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index bb87b0a..ab8c036 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,11 @@ services: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test + + mailcatcher: + container_name: mailcatcher + build: + context: docker/services/mailcatcher + dockerfile: Dockerfile + ports: + - 1080:1080 diff --git a/docker/services/mailcatcher/Dockerfile b/docker/services/mailcatcher/Dockerfile new file mode 100644 index 0000000..0e97ca5 --- /dev/null +++ b/docker/services/mailcatcher/Dockerfile @@ -0,0 +1,9 @@ +FROM ruby:2.7.2-alpine3.12 + +RUN set -xe \ + && apk add --no-cache libstdc++ sqlite-libs \ + && apk add --no-cache --virtual .build-deps build-base sqlite-dev \ + && gem install mailcatcher -v 0.7.1 -N \ + && apk del .build-deps + +ENTRYPOINT ["mailcatcher", "--no-quit", "--smtp-ip=0.0.0.0", "--http-ip=0.0.0.0", "--foreground"] diff --git a/package-lock.json b/package-lock.json index c663068..4472643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2555,6 +2555,11 @@ "@types/node": "*" } }, + "@types/html-to-text": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-6.0.0.tgz", + "integrity": "sha512-MkE9pBbP8kXm6ReXDf2VbmWA0bC1svBzM0c76OdawqlUf2peGGsoHoLn/gv/Ldq/Sl9QJhbEQAj0qtQCkHWtEA==" + }, "@types/ioredis": { "version": "4.17.5", "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.17.5.tgz", @@ -2688,7 +2693,6 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.0.tgz", "integrity": "sha512-KY7bFWB0MahRZvVW4CuW83qcCDny59pJJ0MQ5ifvfcjNwPlIT0vW4uARO4u1gtkYnWdhSvURegecY/tzcukJcA==", - "dev": true, "requires": { "@types/node": "*" } @@ -7606,8 +7610,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "default-gateway": { "version": "4.2.0", @@ -11055,6 +11058,68 @@ "integrity": "sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==", "dev": true }, + "html-to-text": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-7.0.0.tgz", + "integrity": "sha512-UR/WMSHRN8m+L7qQUhbSoxylwBovNPS+xURn/pHeJvbnemhyMiuPYBTBGqB6s8ajAARN5jzKfF0d3CY86VANpA==", + "requires": { + "deepmerge": "^4.2.2", + "he": "^1.2.0", + "htmlparser2": "^6.0.0", + "minimist": "^1.2.5" + }, + "dependencies": { + "dom-serializer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz", + "integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", + "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==" + }, + "domhandler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", + "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "requires": { + "domelementtype": "^2.1.0" + } + }, + "domutils": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.4.tgz", + "integrity": "sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0" + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + }, + "htmlparser2": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.0.0.tgz", + "integrity": "sha512-numTQtDZMoh78zJpaNdJ9MXb2cv5G3jwUoe3dMQODubZvLoGvTE/Ofp6sHvH8OGKcN/8A47pGLi/k58xHP/Tfw==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.4.4", + "entities": "^2.0.0" + } + } + } + }, "htmlparser2": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", diff --git a/package.json b/package.json index bec33a7..7425577 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "dev:clean": "yalc installations clean zents", "dev:dependency-update": "npm-check -u", "test": "jest --config jest.config.json", - "test:docker": "docker-compose up -d --build" + "test:start-docker": "docker-compose up -d --build" }, "files": [ "lib/**/*", @@ -56,7 +56,9 @@ ], "dependencies": { "@types/formidable": "1.0.31", + "@types/html-to-text": "^6.0.0", "@types/ioredis": "^4.17.5", + "@types/nodemailer": "6.4.0", "@types/nunjucks": "3.1.3", "app-root-path": "3.0.0", "bcrypt": "^5.0.0", @@ -66,6 +68,7 @@ "dayjs": "^1.9.3", "find-my-way": "^3.0.4", "formidable": "2.0.0-canary.20200504.1", + "html-to-text": "^7.0.0", "ioredis": "~4.17.3", "jsonwebtoken": "^8.5.1", "lodash.merge": "~4.6.2", @@ -98,7 +101,6 @@ "@types/mjml": "^4.7.0", "@types/ms": "^0.7.31", "@types/node": "^14.11.10", - "@types/nodemailer": "^6.4.0", "@types/parseurl": "^1.3.1", "@types/qs": "^6.9.5", "@types/secure-password": "^3.1.0", diff --git a/src/config/default.ts b/src/config/default.ts index dd8393a..c6dab7f 100644 --- a/src/config/default.ts +++ b/src/config/default.ts @@ -52,7 +52,11 @@ export const defaultConfig: ZenConfig = { lstripBlocks: false, }, email: { + enable: false, engine: 'mjml', + htmlToText: { + enable: true, + }, }, log: { level: 'info', diff --git a/src/dependencies/Injector.ts b/src/dependencies/Injector.ts index d5498b7..ec627a3 100644 --- a/src/dependencies/Injector.ts +++ b/src/dependencies/Injector.ts @@ -19,6 +19,7 @@ import { BodyContextAction } from './InjectorAction/BodyContextAction' import type { Class } from 'type-fest' import type { Context } from '../http/Context' import { CookieContextAction } from './InjectorAction/CookieContextAction' +import { EmailAction } from './InjectorAction/EmailAction' import { ErrorContextAction } from './InjectorAction/ErrorContextAction' import type { ModuleContext } from './ModuleContext' import { ParamsContextAction } from './InjectorAction/ParamsContextAction' @@ -63,6 +64,7 @@ export class Injector { new CookieContextAction(this, context), new RequestContextAction(this, context), new ResponseContextAction(this, context), + new EmailAction(this), new ErrorContextAction(this, context), new AllContextAction(this, context), ] diff --git a/src/email/EmailFactory.ts b/src/email/EmailFactory.ts index 186838a..ef9ba17 100644 --- a/src/email/EmailFactory.ts +++ b/src/email/EmailFactory.ts @@ -1,50 +1,36 @@ -import type { EmailTemplates } from '../types/types' -import type { Transport } from 'nodemailer' +import type { EmailTemplates, MailOptions } from '../types/types' + +import type { MailResponse } from '../types/interfaces' +import type { Transporter } from 'nodemailer' import { config } from '../config/config' import { createTransport } from 'nodemailer' +import { htmlToText } from 'html-to-text' import { log } from '../log/logger' import mjml2html from 'mjml' import { renderString } from 'nunjucks' export class EmailFactory { - protected transporter: Transport | null + protected transporter: Transporter constructor(protected emailTemplates: EmailTemplates) { - if (typeof config.email?.host === 'string') { - this.transporter = (createTransport(config.email) as unknown) as Transport + if (config.email?.enable) { + this.transporter = createTransport(config.email) } else { this.transporter = null } } - public send({ - to = config.email.defaults.to, - cc = config.email.defaults.cc, - bcc = config.email.defaults.bcc, - from = config.email.defaults.from, - topic = config.email.defaults.topic, - template, - payload = {}, - engine = config.email.engine, - }: { - to: string - from?: string - cc?: string - bcc?: string - topic: string - template: string - payload?: Record - engine?: string - }): void { + + public async send(options: MailOptions): Promise { if (this.transporter === null) { - log.warn( - 'Trying to send an E-Mail without proper email configuration. Please configure at least a email host', + throw new Error( + 'Trying to send an E-Mail without proper email configuration. Please enable email in your ZenTS configuration before sending emails', ) - - return - } else if (!this.emailTemplates.has(template)) { - throw new Error(`Email template "${template}" not found!`) + } else if (!this.emailTemplates.has(options.template)) { + throw new Error(`Email template "${options.template}" not found!`) } + const engine = options.engine ?? config.email.engine + const { template, payload } = options let content = this.emailTemplates.get(template) if (engine !== 'plain') { @@ -62,5 +48,22 @@ export class EmailFactory { content = result.html } + + const data: MailOptions = + typeof config.email.mailOptions === 'undefined' + ? options + : Object.assign({}, config.email.mailOptions, options) + + if (engine !== 'plain') { + data.html = content + + if (config.email?.htmlToText?.enable) { + data.text = htmlToText(content, config.email?.htmlToText) + } + } else if (!data.keepText) { + data.text = content + } + + return (await this.transporter.sendMail(data)) as MailResponse } } diff --git a/src/index.ts b/src/index.ts index 13a8cc0..a4c5ceb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,10 @@ export { log } from './log/' export { SecurityProviderOptions, SecurityProvider } from './security/' export { Context, + Email, TemplateFilter, QueryString, + MailOptions, InjectedConnection, InjectedEntityManager, InjectedRepository, diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index 95d3fd5..2790f79 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -5,6 +5,7 @@ import type { HTTPMethod, HeaderValue, LogLevel, + MailOptions, TemplateFileExtension, TemplateFiltersMap, } from './types' @@ -14,6 +15,7 @@ import type { ConnectionOptions } from 'typeorm' import type { Context as ContextClass } from '../http/Context' import type { ControllerFactory } from '../controller/ControllerFactory' import type { EmailFactory } from '../email/EmailFactory' +import type { HtmlToTextOptions } from 'html-to-text' import type { RedisOptions } from 'ioredis' import type { Request } from '../http/Request' import type { RequestFactory } from '../http/RequestFactory' @@ -169,6 +171,15 @@ export interface LoadModuleResult { // ---- M +export interface MailResponse { + messageId?: string + envelope: Record + accepted: string[] + rejected: string[] + pending: string[] + response: string +} + export interface ModuleDependency { propertyKey: string dependency: Class @@ -470,12 +481,11 @@ export interface ZenConfig { | 'hex' } email?: { - defaults?: { - to?: string - cc?: string - bcc?: string - from?: string - topic?: string + enable?: boolean + engine?: 'mjml' | 'nunjucks' | 'plain' + mailOptions?: Partial + htmlToText?: HtmlToTextOptions & { + enable?: boolean } host?: string port?: number @@ -500,7 +510,6 @@ export interface ZenConfig { maxMessages?: number rateDelta?: number rateLimit?: number - engine?: 'mjml' | 'nunjucks' | 'plain' mjml?: { fonts?: { [key: string]: string diff --git a/src/types/types.ts b/src/types/types.ts index d5dc3ab..24af6e0 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -14,8 +14,11 @@ import type { import type { IncomingMessage, ServerResponse } from 'http' import type { DB_TYPE } from './enums' +import type { EmailFactory } from '../email/EmailFactory' +import type { HtmlToTextOptions } from 'html-to-text' import type { Redis } from 'ioredis' import type { SecurityProvider } from '../security/SecurityProvider' +import type { SendMailOptions } from 'nodemailer' import type { Stream } from 'stream' import type { TemplateResponse } from '../template/TemplateResponse' import type findMyWay from 'find-my-way' @@ -40,6 +43,8 @@ export type DatabaseObjectType = T extends DB_TYPE.ORM ? Connection : Redis export type Entities = Map +export type Email = EmailFactory + export type EmailTemplates = Map export type ErrorResponseData = JsonObject | JsonArray @@ -103,6 +108,16 @@ export type LoaderTemplates = Map export type LogLevel = 'fatal' | 'error' | 'warn' | 'log' | 'info' | 'success' | 'debug' | 'trace' // ---- M + +export type MailOptions = Partial & + Partial & { + template: string + payload?: Record + engine?: string + keepHtml?: boolean + keepText?: boolean + } + // ---- N export type NunjucksFilterCallback = (err: any, result: any) => void