Skip to content

Commit

Permalink
feat: Scheduled change conflict email templates and function (#5547)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
andreas-unleash and thomasheartman committed Dec 6, 2023
1 parent da1a9d4 commit 12f79f9
Show file tree
Hide file tree
Showing 10 changed files with 673 additions and 42 deletions.
41 changes: 21 additions & 20 deletions src/lib/services/email-service.test.ts
@@ -1,19 +1,20 @@
import nodemailer from 'nodemailer';
import { EmailService } from './email-service';
import noLoggerProvider from '../../test/fixtures/no-logger';
import { IUnleashConfig } from '../types';

test('Can send reset email', async () => {
const emailService = new EmailService(
{
const emailService = new EmailService({
email: {
host: 'test',
port: 587,
secure: false,
smtpuser: '',
smtppass: '',
sender: 'noreply@getunleash.ai',
},
noLoggerProvider,
);
getLogger: noLoggerProvider,
} as unknown as IUnleashConfig);
const resetLinkUrl =
'https://unleash-hosted.com/reset-password?token=$2b$10$M06Ysso6KL4ueH/xR6rdSuY5GSymdIwmIkEUJMRkB.Qn26r5Gi5vW';

Expand All @@ -29,17 +30,17 @@ test('Can send reset email', async () => {
});

test('Can send welcome mail', async () => {
const emailService = new EmailService(
{
const emailService = new EmailService({
email: {
host: 'test',
port: 9999,
port: 587,
secure: false,
sender: 'noreply@getunleash.ai',
smtpuser: '',
smtppass: '',
sender: 'noreply@getunleash.ai',
},
noLoggerProvider,
);
getLogger: noLoggerProvider,
} as unknown as IUnleashConfig);
const content = await emailService.sendGettingStartedMail(
'Some username',
'test@test.com',
Expand All @@ -52,8 +53,8 @@ test('Can send welcome mail', async () => {
test('Can supply additional SMTP transport options', async () => {
const spy = jest.spyOn(nodemailer, 'createTransport');

new EmailService(
{
new EmailService({
email: {
host: 'smtp.unleash.test',
port: 9999,
secure: false,
Expand All @@ -64,8 +65,8 @@ test('Can supply additional SMTP transport options', async () => {
},
},
},
noLoggerProvider,
);
getLogger: noLoggerProvider,
} as unknown as IUnleashConfig);

expect(spy).toHaveBeenCalledWith({
auth: {
Expand All @@ -82,17 +83,17 @@ test('Can supply additional SMTP transport options', async () => {
});

test('should strip special characters from email subject', async () => {
const emailService = new EmailService(
{
const emailService = new EmailService({
email: {
host: 'test',
port: 9999,
port: 587,
secure: false,
sender: 'noreply@getunleash.ai',
smtpuser: '',
smtppass: '',
sender: 'noreply@getunleash.ai',
},
noLoggerProvider,
);
getLogger: noLoggerProvider,
} as unknown as IUnleashConfig);
expect(emailService.stripSpecialCharacters('http://evil.com')).toBe(
'httpevilcom',
);
Expand Down
104 changes: 99 additions & 5 deletions src/lib/services/email-service.ts
@@ -1,10 +1,10 @@
import { createTransport, Transporter } from 'nodemailer';
import Mustache from 'mustache';
import path from 'path';
import { readFileSync, existsSync } from 'fs';
import { Logger, LogProvider } from '../logger';
import { existsSync, readFileSync } from 'fs';
import { Logger } from '../logger';
import NotFoundError from '../error/notfound-error';
import { IEmailOption } from '../types/option';
import { IUnleashConfig } from '../types/option';

export interface IAuthOptions {
user: string;
Expand All @@ -31,20 +31,25 @@ export interface IEmailEnvelope {

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(email: IEmailOption | undefined, getLogger: LogProvider) {
this.logger = getLogger('services/email-service.ts');
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') {
Expand Down Expand Up @@ -138,6 +143,95 @@ export class EmailService {
});
}

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,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/services/index.ts
Expand Up @@ -140,7 +140,7 @@ export const createServices = (
eventService,
privateProjectChecker,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const featureTypeService = new FeatureTypeService(
stores,
config,
Expand Down
24 changes: 12 additions & 12 deletions src/lib/services/user-service.test.ts
Expand Up @@ -32,7 +32,7 @@ test('Should create new user', async () => {
);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const eventService = new EventService(
{ eventStore, featureTagStore: new FakeFeatureTagStore() },
config,
Expand Down Expand Up @@ -78,7 +78,7 @@ test('Should create default user - with defaults', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -117,7 +117,7 @@ test('Should create default user - with provided username and password', async (
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -161,7 +161,7 @@ test('Should not create default user - with `createAdminUser` === false', async
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -210,7 +210,7 @@ test('Should be a valid password', async () => {
config,
);

const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -248,7 +248,7 @@ test('Password must be at least 10 chars', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -288,7 +288,7 @@ test('The password must contain at least one uppercase letter.', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -330,7 +330,7 @@ test('The password must contain at least one number', async () => {
config,
);

const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -371,7 +371,7 @@ test('The password must contain at least one special character', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -412,7 +412,7 @@ test('Should be a valid password with special chars', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -450,7 +450,7 @@ test('Should send password reset email if user exists', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down Expand Up @@ -504,7 +504,7 @@ test('Should throttle password reset email', async () => {
{ resetTokenStore },
config,
);
const emailService = new EmailService(config.email, config.getLogger);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
Expand Down

0 comments on commit 12f79f9

Please sign in to comment.