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|* + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + + +
+ Scheduled changes can no longer be applied + + Email not displaying correctly?
View it in your browser. +
+ +
+ + + + + +
+ +
+ +
+ + + + + +
+

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}} +
+ +
+ + + + + + + + + +
+ Follow us on Github +
+ Copyright © {{ year }} | Bricks Software | All rights reserved. +
+ +
+ Our mailing address is: team@getunleash.io +
+ +
+ +
+ +
+
+ + 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);