Skip to content

Commit

Permalink
feat: add email support with MJML and nunjucks engine (or just plain …
Browse files Browse the repository at this point in the history
…text)
  • Loading branch information
sahachide committed Feb 2, 2021
1 parent 03ca4c2 commit cb59d67
Show file tree
Hide file tree
Showing 19 changed files with 1,130 additions and 88 deletions.
949 changes: 881 additions & 68 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@
"jsonwebtoken": "^8.5.1",
"lodash.merge": "~4.6.2",
"mime-types": "~2.1.27",
"mjml": "^4.8.1",
"ms": "^2.1.2",
"nodemailer": "^6.4.17",
"nunjucks": "~3.2.2",
"parseurl": "~1.3.3",
"qs": "~6.9.4",
Expand All @@ -93,8 +95,10 @@
"@types/lodash": "^4.14.162",
"@types/lodash.merge": "^4.6.6",
"@types/mime-types": "^2.1.0",
"@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",
Expand Down
4 changes: 4 additions & 0 deletions src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const defaultConfig: ZenConfig = {
template: './template/',
service: './service/',
entity: './entity/',
email: './email/',
public: join(appDir, 'public'),
},
web: {
Expand Down Expand Up @@ -50,6 +51,9 @@ export const defaultConfig: ZenConfig = {
trimBlocks: false,
lstripBlocks: false,
},
email: {
engine: 'mjml',
},
log: {
level: 'info',
wrapConsole: false,
Expand Down
10 changes: 0 additions & 10 deletions src/controller/Controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import type { Environment } from '../template/Environment'
import { TemplateResponse } from '../template/TemplateResponse'

/**
* The basic ZenTS Controller class. Your custom Controller should extend this controller
* in order to use the template engine.
*/
export abstract class Controller {
constructor(protected templateEnvironment: Environment) {}

/**
* Renders a given template. The supplied key argument is either the filename or the path/to/file without the file extension.
*
* @param key The key of the template to render (path/to/file without extension)
* @param context Optional context of the template. This object will be available inside the rendered template.
*/
protected async render(
key: string,
context?: Record<string, unknown>,
Expand Down
3 changes: 3 additions & 0 deletions src/controller/ControllerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { Environment, Loader } from '../template/'

import { AbstractFactory } from '../core/AbstractFactory'
import type { DatabaseContainer } from '../database/DatabaseContainer'
import type { EmailFactory } from '../email/EmailFactory'
import type { SessionFactory } from '../security/SessionFactory'

export class ControllerFactory extends AbstractFactory {
protected templateEnvironment: Environment

constructor(
protected readonly controllers: Controllers,
emailFactory: EmailFactory,
sessionFactory: SessionFactory,
securityProviders: SecurityProviders,
databaseContainer: DatabaseContainer,
Expand All @@ -21,6 +23,7 @@ export class ControllerFactory extends AbstractFactory {
this.templateEnvironment = new Environment(templateLoader, templateData)
this.injector = this.buildInjector({
databaseContainer,
emailFactory,
securityProviders,
sessionFactory,
})
Expand Down
10 changes: 9 additions & 1 deletion src/core/AbstractFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DatabaseContainer } from '../database/DatabaseContainer'
import type { EmailFactory } from '../email/EmailFactory'
import { Injector } from '../dependencies/Injector'
import { ModuleContext } from '../dependencies/ModuleContext'
import type { SecurityProviders } from '../types/types'
Expand All @@ -13,14 +14,21 @@ export abstract class AbstractFactory {

protected buildInjector({
databaseContainer,
emailFactory,
securityProviders,
sessionFactory,
}: {
databaseContainer: DatabaseContainer
emailFactory: EmailFactory
securityProviders: SecurityProviders
sessionFactory: SessionFactory
}): Injector {
const context = new ModuleContext(databaseContainer, sessionFactory, securityProviders)
const context = new ModuleContext(
databaseContainer,
emailFactory,
sessionFactory,
securityProviders,
)
const injector = new Injector(context)

return injector
Expand Down
23 changes: 17 additions & 6 deletions src/core/AutoLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Controllers,
EmailTemplates,
Entities,
SecurityProviders,
Services,
Expand All @@ -8,6 +9,7 @@ import type {

import { ControllerLoader } from '../controller/ControllerLoader'
import { DatabaseContainer } from '../database/DatabaseContainer'
import { EmailTemplateLoader } from '../email/EmailTemplateLoader'
import { EntityLoader } from '../database/EntityLoader'
import { Registry } from './Registry'
import { SecurityProviderLoader } from '../security/SecurityProviderLoader'
Expand All @@ -22,13 +24,15 @@ export class AutoLoader {
controllers,
services,
templateData,
emailTemplates,
entities,
connection,
redisClient,
] = await Promise.all([
this.loadControllers(),
this.loadServices(),
this.loadTemplateData(),
this.loadEmailTemplates(),
this.loadEntities(),
createConnection(),
createRedisClient(),
Expand All @@ -41,6 +45,7 @@ export class AutoLoader {
controllers,
services,
templateData,
emailTemplates,
databaseContainer,
entities,
securityProviders,
Expand All @@ -49,6 +54,15 @@ export class AutoLoader {
return registry
}

protected loadSecurityProviders(
entities: Entities,
databaseContainer: DatabaseContainer,
): SecurityProviders {
const securityProviderLoader = new SecurityProviderLoader()

return securityProviderLoader.load(entities, databaseContainer)
}

protected async loadControllers(): Promise<Controllers> {
const controllerLoader = new ControllerLoader()

Expand All @@ -73,12 +87,9 @@ export class AutoLoader {
return await entityLoader.load()
}

protected loadSecurityProviders(
entities: Entities,
databaseContainer: DatabaseContainer,
): SecurityProviders {
const securityProviderLoader = new SecurityProviderLoader()
protected async loadEmailTemplates(): Promise<EmailTemplates> {
const emailTemplateLoader = new EmailTemplateLoader()

return securityProviderLoader.load(entities, databaseContainer)
return await emailTemplateLoader.load()
}
}
18 changes: 15 additions & 3 deletions src/core/Registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
import type {
Controllers,
DB_TYPE,
EmailTemplates,
Entities,
RegistryFactories,
SecurityProviders,
Expand All @@ -10,7 +10,9 @@ import {

import type { Connection } from 'typeorm'
import { ControllerFactory } from '../controller/ControllerFactory'
import { DB_TYPE } from '../types'
import { DatabaseContainer } from '../database/DatabaseContainer'
import { EmailFactory } from '../email'
import type { Redis } from 'ioredis'
import { RequestFactory } from '../http/RequestFactory'
import { RouterFactory } from '../router/RouterFactory'
Expand All @@ -24,24 +26,34 @@ export class Registry {
protected readonly controllers: Controllers,
protected readonly services: Services,
templateData: TemplateEngineLoaderResult,
emailTemplates: EmailTemplates,
protected readonly databaseContainer: DatabaseContainer,
protected readonly entities: Entities,
protected readonly securityProviders: SecurityProviders,
) {
const sessionFactory = new SessionFactory(securityProviders, databaseContainer)
const emailFactory = new EmailFactory(emailTemplates)

this.factories = {
router: new RouterFactory(),
controller: new ControllerFactory(
controllers,
emailFactory,
sessionFactory,
securityProviders,
databaseContainer,
templateData,
),
request: new RequestFactory(this),
service: new ServiceFactory(services, sessionFactory, securityProviders, databaseContainer),
service: new ServiceFactory(
services,
emailFactory,
sessionFactory,
securityProviders,
databaseContainer,
),
session: sessionFactory,
email: emailFactory,
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/decorators/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Class } from 'type-fest'
import { REFLECT_METADATA } from '../types/enums'

export function email(target: Class, propertyKey: string, parameterIndex: number): void {
Reflect.defineMetadata(REFLECT_METADATA.EMAIL, parameterIndex, target, propertyKey)
}
1 change: 1 addition & 0 deletions src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './controller'
export * from './redis'
export * from './security'
export * from './context'
export * from './email'
19 changes: 19 additions & 0 deletions src/dependencies/InjectorAction/EmailAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces'

import { AbstractAction } from './AbstractAction'
import { REFLECT_METADATA } from '../../types/enums'

export class EmailAction extends AbstractAction {
public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter {
if (!Reflect.hasMetadata(REFLECT_METADATA.EMAIL, instance, method)) {
return
}

const metadata = Reflect.getMetadata(REFLECT_METADATA.EMAIL, instance, method) as number

return {
index: metadata,
value: this.injector.context.getEmailFactory(),
}
}
}
5 changes: 5 additions & 0 deletions src/dependencies/ModuleContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Connection } from 'typeorm'
import { DB_TYPE } from '../types'
import type { DatabaseContainer } from '../database/DatabaseContainer'
import type { EmailFactory } from '../email/EmailFactory'
import type { Redis } from 'ioredis'
import type { SecurityProvider } from '../security/SecurityProvider'
import type { SecurityProviders } from '../types/types'
Expand All @@ -9,6 +10,7 @@ import type { SessionFactory } from '../security/SessionFactory'
export class ModuleContext {
constructor(
protected readonly databaseContainer: DatabaseContainer,
protected readonly emailFactory: EmailFactory,
protected readonly sessionFactory: SessionFactory,
protected readonly securityProviders: SecurityProviders,
) {}
Expand All @@ -27,4 +29,7 @@ export class ModuleContext {
public getSessionFactory(): SessionFactory {
return this.sessionFactory
}
public getEmailFactory(): EmailFactory {
return this.emailFactory
}
}
66 changes: 66 additions & 0 deletions src/email/EmailFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { EmailTemplates } from '../types/types'
import type { Transport } from 'nodemailer'
import { config } from '../config/config'
import { createTransport } from 'nodemailer'
import { log } from '../log/logger'
import mjml2html from 'mjml'
import { renderString } from 'nunjucks'

export class EmailFactory {
protected transporter: Transport | null

constructor(protected emailTemplates: EmailTemplates) {
if (typeof config.email?.host === 'string') {
this.transporter = (createTransport(config.email) as unknown) as Transport
} 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<string, unknown>
engine?: string
}): void {
if (this.transporter === null) {
log.warn(
'Trying to send an E-Mail without proper email configuration. Please configure at least a email host',
)

return
} else if (!this.emailTemplates.has(template)) {
throw new Error(`Email template "${template}" not found!`)
}

let content = this.emailTemplates.get(template)

if (engine !== 'plain') {
content = renderString(content, payload)
}

if (engine === 'mjml') {
const result = mjml2html(content, config.email?.mjml)

if (result.errors.length) {
log.error(result.errors)

throw new Error('Failed to render MJML! See error(s) above for more information.')
}

content = result.html
}
}
}
36 changes: 36 additions & 0 deletions src/email/EmailTemplateLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { EmailTemplates } from '../types/types'
import { config } from '../config/config'
import { fs } from '../filesystem/FS'
import { parse } from 'path'
import { promises } from 'fs'

export class EmailTemplateLoader {
public async load(): Promise<EmailTemplates> {
const emailTemplates = new Map() as EmailTemplates
const emailFilesPath = fs.resolveZenPath('email')

if (!((await fs.exists(emailFilesPath)) || config.email.engine === 'plain')) {
return emailTemplates
}

const filePaths = await this.loadFiles(emailFilesPath)

for (const filePath of filePaths) {
const { name } = parse(filePath)
const content = await promises.readFile(filePath, {
encoding: 'utf-8',
})

emailTemplates.set(name, content)
}

return emailTemplates
}
protected async loadFiles(emailFilesPath: string): Promise<string[]> {
const fileExtension = config.email.engine === 'mjml' ? '.mjml' : `.${config.template.extension}`

return (await fs.readDir(emailFilesPath)).filter((filePath: string) =>
filePath.endsWith(fileExtension),
)
}
}
Loading

0 comments on commit cb59d67

Please sign in to comment.