1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02:00
unleash.unleash/src/lib/services/email-service.ts
andreas-unleash 12f79f90bb
feat: Scheduled change conflict email templates and function (#5547)
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)


![Screenshot 2023-12-05 at 16 55
51](https://github.com/Unleash/unleash/assets/104830839/4d37527e-bb83-4ac5-9437-09b6ab08c979)

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Thomas Heartman <thomas@getunleash.io>
2023-12-06 11:57:19 +02:00

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, '');
}
}