diff --git a/src/lib/services/email-service.test.ts b/src/lib/services/email-service.test.ts
index dbb9a36c5c..3333c9ccd9 100644
--- a/src/lib/services/email-service.test.ts
+++ b/src/lib/services/email-service.test.ts
@@ -1,10 +1,11 @@
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,
@@ -12,8 +13,8 @@ test('Can send reset email', async () => {
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';
@@ -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',
@@ -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,
@@ -64,8 +65,8 @@ test('Can supply additional SMTP transport options', async () => {
},
},
},
- noLoggerProvider,
- );
+ getLogger: noLoggerProvider,
+ } as unknown as IUnleashConfig);
expect(spy).toHaveBeenCalledWith({
auth: {
@@ -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',
);
diff --git a/src/lib/services/email-service.ts b/src/lib/services/email-service.ts
index 77dcb016b6..3dc8d77c9b 100644
--- a/src/lib/services/email-service.ts
+++ b/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;
@@ -31,6 +31,8 @@ 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';
@@ -38,13 +40,16 @@ 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') {
@@ -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,
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index 03b6596476..20b8cd7813 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -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,
diff --git a/src/lib/services/user-service.test.ts b/src/lib/services/user-service.test.ts
index d7275f8126..9eeb514aa3 100644
--- a/src/lib/services/user-service.test.ts
+++ b/src/lib/services/user-service.test.ts
@@ -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,
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
diff --git a/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.html.mustache b/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.html.mustache
new file mode 100644
index 0000000000..dbc58b2807
--- /dev/null
+++ b/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.html.mustache
@@ -0,0 +1,527 @@
+
+
+
+
+ *|MC:SUBJECT|*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ Conflict detected in a scheduled change
+
+
+ {{ conflict }}. Scheduled change requests that use this {{ conflictScope }} can no longer be applied and their scheduled applications will fail{{#conflictResolution}}{{.}}{{/conflictResolution}}{{^conflictResolution}}.{{/conflictResolution}}
+
+ For you, this concerns change requests:
+
+ {{#changeRequests}}
+
+ {{/changeRequests}}
+ |
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
diff --git a/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.plain.mustache b/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.plain.mustache
new file mode 100644
index 0000000000..425540681c
--- /dev/null
+++ b/src/mailtemplates/schedule-change-conflict/scheduled-change-conflict.plain.mustache
@@ -0,0 +1,9 @@
+Scheduled changes can no longer be applied
+
+{{ conflict }}. Scheduled change requests that use this {{ conflictScope }} can no longer be applied and their scheduled applications will fail{{#conflictResolution}}{{.}} ({{conflictResolutionLink}}){{/conflictResolution}}{{^conflictResolution}}.{{/conflictResolution}}
+
+For you, this concerns change requests:
+
+{{#changeRequests}}
+ - # {{id}} - {{#title}}- {{.}}{{/title}} (scheduled for {{scheduledAt}}) ({{link}})
+{{/changeRequests}}
diff --git a/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
index 8233ff0bae..25d176da6f 100644
--- a/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
+++ b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
@@ -57,7 +57,7 @@ beforeAll(async () => {
groupService,
eventService,
);
- const emailService = new EmailService(config.email, config.getLogger);
+ const emailService = new EmailService(config);
const sessionStore = new SessionStore(
db,
new EventEmitter(),
diff --git a/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts b/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
index a7a2bd241a..c87f3c5481 100644
--- a/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
+++ b/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
@@ -44,7 +44,7 @@ beforeAll(async () => {
eventService,
);
const resetTokenService = new ResetTokenService(stores, config);
- const emailService = new EmailService(undefined, config.getLogger);
+ const emailService = new EmailService(config);
const sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config, eventService);
diff --git a/src/test/e2e/services/reset-token-service.e2e.test.ts b/src/test/e2e/services/reset-token-service.e2e.test.ts
index 3938f2d7c1..b93e228e60 100644
--- a/src/test/e2e/services/reset-token-service.e2e.test.ts
+++ b/src/test/e2e/services/reset-token-service.e2e.test.ts
@@ -38,7 +38,7 @@ beforeAll(async () => {
);
resetTokenService = new ResetTokenService(stores, config);
sessionService = new SessionService(stores, config);
- const emailService = new EmailService(undefined, config.getLogger);
+ const emailService = new EmailService(config);
const settingService = new SettingService(
{
settingStore: new FakeSettingStore(),
diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts
index f96db6b8af..b6e702be4b 100644
--- a/src/test/e2e/services/user-service.e2e.test.ts
+++ b/src/test/e2e/services/user-service.e2e.test.ts
@@ -43,7 +43,7 @@ beforeAll(async () => {
eventService,
);
const resetTokenService = new ResetTokenService(stores, config);
- const emailService = new EmailService(undefined, config.getLogger);
+ const emailService = new EmailService(config);
sessionService = new SessionService(stores, config);
settingService = new SettingService(stores, config, eventService);