1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00
unleash.unleash/src/lib/services/email-service.ts
2024-11-04 12:10:32 +01:00

680 lines
22 KiB
TypeScript

import { createTransport, type Transporter } from 'nodemailer';
import Mustache from 'mustache';
import path from 'path';
import { existsSync, readFileSync } from 'fs';
import type { Logger } from '../logger';
import NotFoundError from '../error/notfound-error';
import type { 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;
bcc?: string;
subject: string;
html: string;
text: string;
attachments?: {
filename: string;
path: string;
cid: string;
}[];
headers?: Record<string, string>;
}
const RESET_MAIL_SUBJECT = 'Unleash - Reset your password';
const GETTING_STARTED_SUBJECT = 'Welcome to Unleash';
const ORDER_ENVIRONMENTS_SUBJECT =
'Unleash - ordered environments successfully';
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';
export const MAIL_ACCEPTED = '250 Accepted';
export type ChangeRequestScheduleConflictData =
| { reason: 'flag archived'; flagName: string }
| {
reason: 'strategy deleted';
flagName: string;
strategyId: string;
}
| {
reason: 'strategy updated';
flagName: string;
strategyId: string;
}
| {
reason: 'segment updated';
segment: { id: number; name: string };
}
| {
reason: 'environment variants updated';
flagName: string;
environment: string;
};
export type OrderEnvironmentData = {
name: string;
type: string;
};
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',
conflictingChangeRequestId: number | undefined,
changeRequests: {
id: number;
scheduledAt: string;
link: string;
title?: string;
}[],
flagName: string,
project: string,
strategyId?: string,
) {
const conflictData =
conflictScope === 'flag'
? { reason: 'flag archived' as const, flagName }
: {
reason: 'strategy deleted' as const,
flagName,
strategyId: strategyId ?? '',
};
return this.sendScheduledChangeSuspendedEmail(
recipient,
conflictData,
conflictingChangeRequestId,
changeRequests,
project,
);
}
async sendScheduledChangeSuspendedEmail(
recipient: string,
conflictData: ChangeRequestScheduleConflictData,
conflictingChangeRequestId: number | undefined,
changeRequests: {
id: number;
scheduledAt: string;
link: string;
title?: string;
}[],
project: string,
) {
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: 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: '',
});
});
}
async sendOrderEnvironmentEmail(
userEmail: string,
customerId: string,
environments: OrderEnvironmentData[],
): Promise<IEmailEnvelope> {
if (this.configured()) {
const context = {
userEmail,
customerId,
environments: environments.map((data) => ({
name: this.stripSpecialCharacters(data.name),
type: this.stripSpecialCharacters(data.type),
})),
};
const bodyHtml = await this.compileTemplate(
'order-environments',
TemplateFormat.HTML,
context,
);
const bodyText = await this.compileTemplate(
'order-environments',
TemplateFormat.PLAIN,
context,
);
const email = {
from: this.sender,
to: userEmail,
bcc:
process.env.ORDER_ENVIRONMENTS_BCC ||
'pro-sales@getunleash.io',
subject: ORDER_ENVIRONMENTS_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer!.sendMail(email).then(
() =>
this.logger.info(
'Successfully sent order environments email',
),
(e) =>
this.logger.warn(
'Failed to send order environments 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: ORDER_ENVIRONMENTS_SUBJECT,
html: '',
text: '',
});
});
}
async sendProductivityReportEmail(
userEmail: string,
userName: string,
metrics: {
health: number;
flagsCreated: number;
productionUpdates: number;
},
): Promise<IEmailEnvelope> {
if (this.configured()) {
const context = {
userName,
userEmail,
...metrics,
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: Record<string, string> = {};
Object.entries(this.config.email.optionalHeaders || {}).forEach(
([key, value]) => {
if (typeof value === 'string') {
headers[key] = value;
}
},
);
const email: IEmailEnvelope = {
from: this.sender,
to: userEmail,
bcc: '',
subject: PRODUCTIVITY_REPORT,
html: bodyHtml,
text: bodyText,
attachments: [
this.resolveTemplateAttachment(
template,
'unleash-logo.png',
'unleashLogo',
),
],
headers,
} satisfies IEmailEnvelope;
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(): 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');
}
private resolveTemplateAttachment(
templateName: string,
filename: string,
cid: string,
): {
filename: string;
path: string;
cid: string;
} {
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(): boolean {
return this.sender !== 'not-configured' && this.mailer !== undefined;
}
stripSpecialCharacters(str: string): string {
return str?.replace(/[`~!@#$%^&*()_|+=?;:'",.<>{}[\]\\/]/gi, '');
}
}