mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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`,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
106
api/src/services/mail/index.ts
Normal file
106
api/src/services/mail/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
api/src/services/mailer.ts
Normal file
39
api/src/services/mailer.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user