mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-01 01:18:10 +02:00
Creates a new email template for scheduled change conflicts and a function to send it. Relates to: #[1-1686](https://linear.app/unleash/issue/1-1686/send-an-email-when-the-conflicts-are-detected)  --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai> Co-authored-by: Thomas Heartman <thomas@getunleash.io>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import { createTransport, Transporter } from 'nodemailer';
|
|
import Mustache from 'mustache';
|
|
import path from 'path';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { Logger } from '../logger';
|
|
import NotFoundError from '../error/notfound-error';
|
|
import { IUnleashConfig } from '../types/option';
|
|
|
|
export interface IAuthOptions {
|
|
user: string;
|
|
pass: string;
|
|
}
|
|
|
|
export enum TemplateFormat {
|
|
HTML = 'html',
|
|
PLAIN = 'plain',
|
|
}
|
|
|
|
export enum TransporterType {
|
|
SMTP = 'smtp',
|
|
JSON = 'json',
|
|
}
|
|
|
|
export interface IEmailEnvelope {
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
text: string;
|
|
}
|
|
|
|
const RESET_MAIL_SUBJECT = 'Unleash - Reset your password';
|
|
const GETTING_STARTED_SUBJECT = 'Welcome to Unleash';
|
|
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';
|
|
|
|
export const MAIL_ACCEPTED = '250 Accepted';
|
|
|
|
export class EmailService {
|
|
private logger: Logger;
|
|
private config: IUnleashConfig;
|
|
|
|
private readonly mailer?: Transporter;
|
|
|
|
private readonly sender: string;
|
|
|
|
constructor(config: IUnleashConfig) {
|
|
this.config = config;
|
|
this.logger = config.getLogger('services/email-service.ts');
|
|
const { email } = config;
|
|
if (email?.host) {
|
|
this.sender = email.sender;
|
|
if (email.host === 'test') {
|
|
this.mailer = createTransport({ jsonTransport: true });
|
|
} else {
|
|
this.mailer = createTransport({
|
|
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 sendScheduledExecutionFailedEmail(
|
|
recipient: string,
|
|
changeRequestLink: string,
|
|
changeRequestTitle: string,
|
|
scheduledAt: string,
|
|
errorMessage: string,
|
|
): Promise<IEmailEnvelope> {
|
|
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: string,
|
|
conflictScope: 'flag' | 'strategy',
|
|
changeRequests: {
|
|
id: number;
|
|
scheduledAt: string;
|
|
link: string;
|
|
title?: string;
|
|
}[],
|
|
flagName: string,
|
|
project: string,
|
|
strategyId?: string,
|
|
) {
|
|
if (this.configured()) {
|
|
const year = new Date().getFullYear();
|
|
const conflict =
|
|
conflictScope === 'flag'
|
|
? `The feature flag ${flagName} in ${project} has been archived`
|
|
: `The strategy with id ${strategyId} for flag ${flagName} in ${project} has been deleted`;
|
|
|
|
const conflictResolution =
|
|
conflictScope === 'flag'
|
|
? ' unless the flag is revived'
|
|
: false;
|
|
|
|
const conflictResolutionLink = conflictResolution
|
|
? `${this.config.server.baseUriPath}/projects/${project}/archive?sort=archivedAt&search=${flagName}`
|
|
: false;
|
|
|
|
const bodyHtml = await this.compileTemplate(
|
|
'scheduled-change-conflict',
|
|
TemplateFormat.HTML,
|
|
{
|
|
conflict,
|
|
conflictScope,
|
|
conflictResolution,
|
|
conflictResolutionLink,
|
|
changeRequests,
|
|
year,
|
|
},
|
|
);
|
|
const bodyText = await this.compileTemplate(
|
|
'scheduled-change-conflict',
|
|
TemplateFormat.PLAIN,
|
|
{
|
|
conflict,
|
|
conflictScope,
|
|
conflictResolution,
|
|
conflictResolutionLink,
|
|
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: string,
|
|
recipient: string,
|
|
resetLink: string,
|
|
): Promise<IEmailEnvelope> {
|
|
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: string,
|
|
recipient: string,
|
|
unleashUrl: string,
|
|
passwordLink?: string,
|
|
): Promise<IEmailEnvelope> {
|
|
if (this.configured()) {
|
|
const year = new Date().getFullYear();
|
|
const context = {
|
|
passwordLink,
|
|
name: this.stripSpecialCharacters(name),
|
|
year,
|
|
unleashUrl,
|
|
};
|
|
const bodyHtml = await this.compileTemplate(
|
|
'getting-started',
|
|
TemplateFormat.HTML,
|
|
context,
|
|
);
|
|
const bodyText = await this.compileTemplate(
|
|
'getting-started',
|
|
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: '',
|
|
});
|
|
});
|
|
}
|
|
|
|
isEnabled(): boolean {
|
|
return this.mailer !== undefined;
|
|
}
|
|
|
|
async compileTemplate(
|
|
templateName: string,
|
|
format: TemplateFormat,
|
|
context: unknown,
|
|
): Promise<string> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
private resolveTemplate(
|
|
templateName: string,
|
|
format: TemplateFormat,
|
|
): string {
|
|
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');
|
|
}
|
|
|
|
configured(): boolean {
|
|
return this.sender !== 'not-configured' && this.mailer !== undefined;
|
|
}
|
|
|
|
stripSpecialCharacters(str: string): string {
|
|
return str?.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[\]\\/]/gi, '');
|
|
}
|
|
}
|