Add MailService (#5265)

* Create MailService

Fixes #5229, ref #3372, #4664, #4858, #5090, #3104, #3465, #2774, #3741

* Fix path to templates extensions

* Add mailservice example to hooks docs
This commit is contained in:
Rijk van Zanten
2021-04-26 17:55:34 -04:00
committed by GitHub
parent 29797dfb97
commit d25c35fee7
10 changed files with 197 additions and 150 deletions

View File

@@ -1,143 +0,0 @@
import database from '../database';
import logger from '../logger';
import nodemailer, { Transporter } from 'nodemailer';
import { Liquid } from 'liquidjs';
import path from 'path';
import fs from 'fs';
import { promisify } from 'util';
import env from '../env';
const readFile = promisify(fs.readFile);
const liquidEngine = new Liquid({
root: path.resolve(__dirname, 'templates'),
extname: '.liquid',
});
export let transporter: Transporter | null = null;
if (env.EMAIL_TRANSPORT === 'sendmail') {
transporter = nodemailer.createTransport({
sendmail: true,
newline: env.EMAIL_SENDMAIL_NEW_LINE || 'unix',
path: env.EMAIL_SENDMAIL_PATH || '/usr/sbin/sendmail',
});
} else if (env.EMAIL_TRANSPORT.toLowerCase() === 'smtp') {
transporter = nodemailer.createTransport({
pool: env.EMAIL_SMTP_POOL,
host: env.EMAIL_SMTP_HOST,
port: env.EMAIL_SMTP_PORT,
secure: env.EMAIL_SMTP_SECURE,
auth: {
user: env.EMAIL_SMTP_USER,
pass: env.EMAIL_SMTP_PASSWORD,
},
} as any);
} else {
logger.warn('Illegal transport given for email. Check the EMAIL_TRANSPORT env var.');
}
if (transporter) {
transporter.verify((error) => {
if (error) {
logger.warn(`Couldn't connect to email server.`);
logger.warn(`Email verification error: ${error}`);
} else {
logger.info(`Email connection established`);
}
});
}
export type EmailOptions = {
to: string; // email address of the recipient
from: string;
subject: string;
text: string;
html: string;
};
/**
* Get an object with default template options to pass to the email templates.
*/
async function getDefaultTemplateOptions() {
const projectInfo = await database
.select(['project_name', 'project_logo', 'project_color'])
.from('directus_settings')
.first();
return {
projectName: projectInfo?.project_name || 'Directus',
projectColor: projectInfo?.project_color || '#546e7a',
projectLogo: getProjectLogoURL(projectInfo?.project_logo),
};
function getProjectLogoURL(logoID?: string) {
let projectLogoURL = env.PUBLIC_URL;
if (projectLogoURL.endsWith('/') === false) {
projectLogoURL += '/';
}
if (logoID) {
projectLogoURL += `assets/${logoID}`;
} else {
projectLogoURL += `admin/img/directus-white.png`;
}
return projectLogoURL;
}
}
export default async function sendMail(options: EmailOptions) {
if (!transporter) return;
const templateString = await readFile(path.join(__dirname, 'templates/base.liquid'), 'utf8');
const html = await liquidEngine.parseAndRender(templateString, { html: options.html });
options.from = options.from || (env.EMAIL_FROM as string);
try {
await transporter.sendMail({ ...options, html: html });
} catch (error) {
logger.warn('[Email] Unexpected error while sending an email:');
logger.warn(error);
}
}
export async function sendInviteMail(email: string, url: string) {
if (!transporter) return;
const defaultOptions = await getDefaultTemplateOptions();
const html = await liquidEngine.renderFile('user-invitation', {
...defaultOptions,
email,
url,
});
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${defaultOptions.projectName}] You've been invited`,
});
}
export async function sendPasswordResetMail(email: string, url: string) {
if (!transporter) return;
const defaultOptions = await getDefaultTemplateOptions();
const html = await liquidEngine.renderFile('password-reset', {
...defaultOptions,
email,
url,
});
await transporter.sendMail({
from: env.EMAIL_FROM,
to: email,
html: html,
subject: `[${defaultOptions.projectName}] Password Reset Request`,
});
}

View File

@@ -7,6 +7,7 @@ export * from './files';
export * from './folders';
export * from './graphql';
export * from './items';
export * from './mail';
export * from './meta';
export * from './payload';
export * from './permissions';
@@ -16,7 +17,7 @@ export * from './revisions';
export * from './roles';
export * from './server';
export * from './settings';
export * from './specifications';
export * from './users';
export * from './utils';
export * from './webhooks';
export * from './specifications';

View File

@@ -0,0 +1,106 @@
import mailer from '../mailer';
import { AbstractServiceOptions, Accountability, SchemaOverview } from '../../types';
import { Knex } from 'knex';
import database from '../../database';
import env from '../../env';
import logger from '../../logger';
import fse from 'fs-extra';
import { Liquid } from 'liquidjs';
import path from 'path';
const liquidEngine = new Liquid({
root: path.resolve(__dirname, 'templates'),
extname: '.liquid',
});
export type EmailOptions = {
to: string;
template?: {
name: string;
data: Record<string, any>;
system?: boolean;
};
from?: string;
subject?: string;
text?: string;
html?: string;
};
export class MailService {
schema: SchemaOverview;
accountability: Accountability | null;
knex: Knex;
constructor(opts: AbstractServiceOptions) {
this.schema = opts.schema;
this.accountability = opts.accountability || null;
this.knex = opts?.knex || database;
}
async send(options: EmailOptions) {
if (!mailer) return;
let { to, from, subject, html, text } = options;
from = from || (env.EMAIL_FROM as string);
if (options.template) {
let templateData = options.template.data;
if (options.template.system === true) {
const defaultTemplateData = await this.getDefaultTemplateData();
templateData = {
...defaultTemplateData,
...templateData,
};
}
html = await this.renderTemplate(options.template.name, templateData, options.template.system);
}
try {
await mailer.sendMail({ to, from, subject, html, text });
} catch (error) {
logger.warn('[Email] Unexpected error while sending an email:');
logger.warn(error);
}
}
private async renderTemplate(template: string, variables: Record<string, any>, system: boolean = false) {
const resolvedPath = system
? path.join(__dirname, 'templates', template + '.liquid')
: path.resolve(env.EXTENSIONS_PATH, 'templates', template + '.liquid');
const templateString = await fse.readFile(resolvedPath, 'utf8');
const html = await liquidEngine.parseAndRender(templateString, variables);
return html;
}
private async getDefaultTemplateData() {
const projectInfo = await this.knex
.select(['project_name', 'project_logo', 'project_color'])
.from('directus_settings')
.first();
return {
projectName: projectInfo?.project_name || 'Directus',
projectColor: projectInfo?.project_color || '#546e7a',
projectLogo: getProjectLogoURL(projectInfo?.project_logo),
};
function getProjectLogoURL(logoID?: string) {
let projectLogoURL = env.PUBLIC_URL;
if (projectLogoURL.endsWith('/') === false) {
projectLogoURL += '/';
}
if (logoID) {
projectLogoURL += `assets/${logoID}`;
} else {
projectLogoURL += `admin/img/directus-white.png`;
}
return projectLogoURL;
}
}
}

View File

@@ -0,0 +1,39 @@
import logger from '../logger';
import nodemailer, { Transporter } from 'nodemailer';
import env from '../env';
let transporter: Transporter | null = null;
if (env.EMAIL_TRANSPORT === 'sendmail') {
transporter = nodemailer.createTransport({
sendmail: true,
newline: env.EMAIL_SENDMAIL_NEW_LINE || 'unix',
path: env.EMAIL_SENDMAIL_PATH || '/usr/sbin/sendmail',
});
} else if (env.EMAIL_TRANSPORT.toLowerCase() === 'smtp') {
transporter = nodemailer.createTransport({
pool: env.EMAIL_SMTP_POOL,
host: env.EMAIL_SMTP_HOST,
port: env.EMAIL_SMTP_PORT,
secure: env.EMAIL_SMTP_SECURE,
auth: {
user: env.EMAIL_SMTP_USER,
pass: env.EMAIL_SMTP_PASSWORD,
},
} as any);
} else {
logger.warn('Illegal transport given for email. Check the EMAIL_TRANSPORT env var.');
}
if (transporter) {
transporter.verify((error) => {
if (error) {
logger.warn(`Couldn't connect to email server.`);
logger.warn(`Email verification error: ${error}`);
} else {
logger.info(`Email connection established`);
}
});
}
export default transporter;

View File

@@ -7,7 +7,7 @@ import logger from '../logger';
import { version } from '../../package.json';
import macosRelease from 'macos-release';
import { SettingsService } from './settings';
import { transporter } from '../mail';
import mailer from './mailer';
import env from '../env';
import { performance } from 'perf_hooks';
import cache from '../cache';
@@ -317,7 +317,7 @@ export class ServerService {
};
try {
await transporter?.verify();
await mailer?.verify();
} catch (err) {
checks['email:connection'][0].status = 'error';
checks['email:connection'][0].output = err;

View File

@@ -1,7 +1,6 @@
import { AuthenticationService } from './authentication';
import { ItemsService, MutationOptions } from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail, sendPasswordResetMail } from '../mail';
import database from '../database';
import argon2 from 'argon2';
import {
@@ -19,6 +18,7 @@ import { RecordNotUniqueException } from '../exceptions/database/record-not-uniq
import logger from '../logger';
import { clone } from 'lodash';
import { SettingsService } from './settings';
import { MailService } from './mail';
export class UsersService extends ItemsService {
knex: Knex;
@@ -231,6 +231,12 @@ export class UsersService extends ItemsService {
knex: trx,
});
const mailService = new MailService({
schema: this.schema,
accountability: this.accountability,
knex: trx,
});
for (const email of emails) {
await service.createOne({ email, role, status: 'invited' });
@@ -239,7 +245,17 @@ export class UsersService extends ItemsService {
const inviteURL = url ?? env.PUBLIC_URL + '/admin/accept-invite';
const acceptURL = inviteURL + '?token=' + token;
await sendInviteMail(email, acceptURL);
await mailService.send({
to: email,
template: {
name: 'user-invitation',
data: {
url: acceptURL,
email,
},
system: true,
},
});
}
});
}
@@ -271,6 +287,12 @@ export class UsersService extends ItemsService {
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
if (!user) throw new ForbiddenException();
const mailService = new MailService({
schema: this.schema,
knex: this.knex,
accountability: this.accountability,
});
const payload = { email, scope: 'password-reset' };
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '1d' });
@@ -282,7 +304,17 @@ export class UsersService extends ItemsService {
const acceptURL = url ? `${url}?token=${token}` : `${env.PUBLIC_URL}/admin/reset-password?token=${token}`;
await sendPasswordResetMail(email, acceptURL);
await mailService.send({
to: email,
template: {
name: 'password-reset',
data: {
url: acceptURL,
email,
},
system: true,
},
});
}
async resetPassword(token: string, password: string) {