unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
396 lines • 17.7 kB
JavaScript
import { createTransport } from 'nodemailer';
import Mustache from 'mustache';
import path from 'path';
import { existsSync, readFileSync } from 'fs';
import NotFoundError from '../error/notfound-error.js';
import { productivityReportViewModel, } from '../features/productivity-report/productivity-report-view-model.js';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export var TemplateFormat;
(function (TemplateFormat) {
TemplateFormat["HTML"] = "html";
TemplateFormat["PLAIN"] = "plain";
})(TemplateFormat || (TemplateFormat = {}));
export var TransporterType;
(function (TransporterType) {
TransporterType["SMTP"] = "smtp";
TransporterType["JSON"] = "json";
})(TransporterType || (TransporterType = {}));
const RESET_MAIL_SUBJECT = 'Unleash - Reset your password';
const GETTING_STARTED_SUBJECT = 'Welcome to Unleash';
const PRODUCTIVITY_REPORT = 'Unleash - productivity report';
const SCHEDULED_CHANGE_CONFLICT_SUBJECT = 'Unleash - Scheduled changes can no longer be applied';
const SCHEDULED_EXECUTION_FAILED_SUBJECT = 'Unleash - Scheduled change request could not be applied';
const REQUESTED_CR_APPROVAL_SUBJECT = 'Unleash - new change request waiting to be reviewed';
export const MAIL_ACCEPTED = '250 Accepted';
export class EmailService {
constructor(config, transportProvider) {
this.config = config;
this.logger = config.getLogger('services/email-service.ts');
this.flagResolver = config.flagResolver;
const { email } = config;
if (email?.host) {
this.sender = email.sender;
const provider = transportProvider
? transportProvider
: createTransport;
if (email.host === 'test') {
this.mailer = provider({ jsonTransport: true });
}
else {
this.mailer = provider({
host: email.host,
port: email.port,
secure: email.secure,
auth: {
user: email.smtpuser ?? '',
pass: email.smtppass ?? '',
},
...email.transportOptions,
});
}
this.logger.info(`Initialized transport to ${email.host} on port ${email.port} with user: ${email.smtpuser}`);
}
else {
this.sender = 'not-configured';
this.mailer = undefined;
}
}
async sendRequestedCRApprovalEmail(recipient, crApprovalParams) {
if (this.configured()) {
const year = new Date().getFullYear();
const bodyHtml = await this.compileTemplate('requested-cr-approval', TemplateFormat.HTML, {
...crApprovalParams,
year,
});
const bodyText = await this.compileTemplate('requested-cr-approval', TemplateFormat.PLAIN, {
...crApprovalParams,
year,
});
const email = {
from: this.sender,
to: recipient,
subject: REQUESTED_CR_APPROVAL_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent requested-cr-approval email'), (e) => this.logger.warn('Failed to send requested-cr-approval email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an email service');
res({
from: this.sender,
to: recipient,
subject: REQUESTED_CR_APPROVAL_SUBJECT,
html: '',
text: '',
});
});
}
async sendScheduledExecutionFailedEmail(recipient, changeRequestLink, changeRequestTitle, scheduledAt, errorMessage) {
if (this.configured()) {
const year = new Date().getFullYear();
const bodyHtml = await this.compileTemplate('scheduled-execution-failed', TemplateFormat.HTML, {
changeRequestLink,
changeRequestTitle,
scheduledAt,
errorMessage,
year,
});
const bodyText = await this.compileTemplate('scheduled-execution-failed', TemplateFormat.PLAIN, {
changeRequestLink,
changeRequestTitle,
scheduledAt,
errorMessage,
year,
});
const email = {
from: this.sender,
to: recipient,
subject: SCHEDULED_EXECUTION_FAILED_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent scheduled-execution-failed email'), (e) => this.logger.warn('Failed to send scheduled-execution-failed email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an email service');
this.logger.debug('Change request link: ', changeRequestLink);
res({
from: this.sender,
to: recipient,
subject: SCHEDULED_EXECUTION_FAILED_SUBJECT,
html: '',
text: '',
});
});
}
async sendScheduledChangeConflictEmail(recipient, conflictScope, conflictingChangeRequestId, changeRequests, flagName, project, strategyId) {
const conflictData = conflictScope === 'flag'
? { reason: 'flag archived', flagName }
: {
reason: 'strategy deleted',
flagName,
strategyId: strategyId ?? '',
};
return this.sendScheduledChangeSuspendedEmail(recipient, conflictData, conflictingChangeRequestId, changeRequests, project);
}
async sendScheduledChangeSuspendedEmail(recipient, conflictData, conflictingChangeRequestId, changeRequests, project) {
if (this.configured()) {
const year = new Date().getFullYear();
const getConflictDetails = () => {
switch (conflictData.reason) {
case 'flag archived':
return {
conflictScope: 'flag',
conflict: `The feature flag ${conflictData.flagName} in ${project} has been archived`,
flagArchived: true,
flagLink: `${this.config.server.unleashUrl}/projects/${project}/archive?sort=archivedAt&search=${conflictData.flagName}`,
canBeRescheduled: false,
};
case 'strategy deleted':
return {
conflictScope: 'strategy',
conflict: `The strategy with id ${conflictData.strategyId} for flag ${conflictData.flagName} in ${project} has been deleted`,
canBeRescheduled: false,
};
case 'strategy updated':
return {
conflictScope: 'strategy',
conflict: `A strategy belonging to ${conflictData.flagName} (ID: ${conflictData.strategyId}) in the project ${project} has been updated, and your changes would overwrite some of the recent changes`,
canBeRescheduled: true,
};
case 'environment variants updated':
return {
conflictScope: 'environment variant configuration',
conflict: `The ${conflictData.environment} environment variant configuration for ${conflictData.flagName} in the project ${project} has been updated, and your changes would overwrite some of the recent changes`,
canBeRescheduled: true,
};
case 'segment updated':
return {
conflictScope: 'segment',
conflict: `Segment ${conflictData.segment.id} ("${conflictData.segment.name}") in ${project} has been updated, and your changes would overwrite some of the recent changes`,
canBeRescheduled: true,
};
}
};
const { canBeRescheduled, conflict, conflictScope, flagArchived = false, flagLink = false, } = getConflictDetails();
const conflictingChangeRequestLink = conflictingChangeRequestId
? `${this.config.server.unleashUrl}/projects/${project}/change-requests/${conflictingChangeRequestId}`
: false;
const bodyHtml = await this.compileTemplate('scheduled-change-conflict', TemplateFormat.HTML, {
conflict,
conflictScope,
canBeRescheduled,
flagArchived,
flagLink,
conflictingChangeRequestLink,
changeRequests,
year,
});
const bodyText = await this.compileTemplate('scheduled-change-conflict', TemplateFormat.PLAIN, {
conflict,
conflictScope,
canBeRescheduled,
flagArchived,
flagLink,
conflictingChangeRequestLink,
changeRequests,
year,
});
const email = {
from: this.sender,
to: recipient,
subject: SCHEDULED_CHANGE_CONFLICT_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent scheduled-change-conflict email'), (e) => this.logger.warn('Failed to send scheduled-change-conflict email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an email service');
res({
from: this.sender,
to: recipient,
subject: SCHEDULED_CHANGE_CONFLICT_SUBJECT,
html: '',
text: '',
});
});
}
async sendResetMail(name, recipient, resetLink) {
if (this.configured()) {
const year = new Date().getFullYear();
const bodyHtml = await this.compileTemplate('reset-password', TemplateFormat.HTML, {
resetLink,
name,
year,
});
const bodyText = await this.compileTemplate('reset-password', TemplateFormat.PLAIN, {
resetLink,
name,
year,
});
const email = {
from: this.sender,
to: recipient,
subject: RESET_MAIL_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent reset-password email'), (e) => this.logger.warn('Failed to send reset-password email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an emailservice');
this.logger.debug('Reset link: ', resetLink);
res({
from: this.sender,
to: recipient,
subject: RESET_MAIL_SUBJECT,
html: '',
text: '',
});
});
}
async sendGettingStartedMail(name, recipient, unleashUrl, passwordLink) {
if (this.configured()) {
const year = new Date().getFullYear();
const context = {
passwordLink,
name: this.stripSpecialCharacters(name),
year,
unleashUrl,
recipient,
};
const gettingStartedTemplate = 'getting-started';
// If the password link is the base Unleash URL, we remove it from the context
// This can happen if the instance is using SSO instead of password-based authentication
// In that case, our template should show the alternative path: You don't set a password, you log in with SSO
if (passwordLink === unleashUrl) {
delete context.passwordLink;
}
const bodyHtml = await this.compileTemplate(gettingStartedTemplate, TemplateFormat.HTML, context);
const bodyText = await this.compileTemplate(gettingStartedTemplate, TemplateFormat.PLAIN, context);
const email = {
from: this.sender,
to: recipient,
subject: GETTING_STARTED_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent getting started email'), (e) => this.logger.warn('Failed to send getting started email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an EmailService');
res({
from: this.sender,
to: recipient,
subject: GETTING_STARTED_SUBJECT,
html: '',
text: '',
});
});
}
async sendProductivityReportEmail(userEmail, userName, metrics) {
if (this.configured()) {
const context = productivityReportViewModel({
metrics,
userEmail,
userName,
unleashUrl: this.config.server.unleashUrl,
});
const template = 'productivity-report';
const bodyHtml = await this.compileTemplate(template, TemplateFormat.HTML, context);
const bodyText = await this.compileTemplate(template, TemplateFormat.PLAIN, context);
const headers = {};
Object.entries(this.config.email.optionalHeaders || {}).forEach(([key, value]) => {
if (typeof value === 'string') {
headers[key] = value;
}
});
const email = {
from: this.sender,
to: userEmail,
bcc: '',
subject: PRODUCTIVITY_REPORT,
html: bodyHtml,
text: bodyText,
attachments: [
this.resolveTemplateAttachment(template, 'unleash-logo.png', 'unleashLogo'),
],
headers,
};
process.nextTick(() => {
this.mailer.sendMail(email).then(() => this.logger.info('Successfully sent productivity report email'), (e) => this.logger.warn('Failed to send productivity report email', e));
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn('No mailer is configured. Please read the docs on how to configure an email service');
res({
from: this.sender,
to: userEmail,
bcc: '',
subject: PRODUCTIVITY_REPORT,
html: '',
text: '',
});
});
}
isEnabled() {
return this.mailer !== undefined;
}
async compileTemplate(templateName, format, context) {
try {
const template = this.resolveTemplate(templateName, format);
return await Promise.resolve(Mustache.render(template, context));
}
catch (e) {
this.logger.info(`Could not find template ${templateName}`);
return Promise.reject(e);
}
}
resolveTemplate(templateName, format) {
const topPath = path.resolve(__dirname, '../../mailtemplates');
const template = path.join(topPath, templateName, `${templateName}.${format}.mustache`);
if (existsSync(template)) {
return readFileSync(template, 'utf-8');
}
throw new NotFoundError('Could not find template');
}
resolveTemplateAttachment(templateName, filename, cid) {
const topPath = path.resolve(__dirname, '../../mailtemplates');
const attachment = path.join(topPath, templateName, filename);
if (existsSync(attachment)) {
return {
filename,
path: attachment,
cid,
};
}
throw new NotFoundError('Could not find email attachment');
}
configured() {
return this.sender !== 'not-configured' && this.mailer !== undefined;
}
stripSpecialCharacters(str) {
return str?.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[\]\\/]/gi, '');
}
}
//# sourceMappingURL=email-service.js.map