Add notifications plugin, offering basic email service (#143303)

* Misc enhancements following PR comments

* Adding functional tests

* Fixing types

* Fixing tests

* Removing unnecessary Promise.all

* Cleanup

* Misc fixes and simplifications

* Add missing tsconfig.json

* [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs'

* Add dependency to Actions plugin in tsconfig.json

* Separate setup logic from start logic

* Fix bulkEnqueueExecution params structure

* Update README

* Add UTs

* Check license type >platinum for email notifications

* Fix incorrect UTs

* Import types when possible

* Misc enhancements and code cleanup

* Transform factory => provider, update start contract

* Code cleanup, update README

* Fix TS error

* Fix CI types error

* Address PR remarks

* Address PR remarks #2

Co-authored-by: Ying Mao <ying.mao@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Gerard Soldevila 2022-11-03 15:33:52 +01:00 committed by GitHub
parent e5271bd250
commit 88815398e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1108 additions and 5 deletions

1
.github/CODEOWNERS vendored
View file

@ -89,6 +89,7 @@
/x-pack/test/search_sessions_integration/ @elastic/kibana-app-services
/test/plugin_functional/test_suites/panel_actions @elastic/kibana-app-services
/test/plugin_functional/test_suites/data_plugin @elastic/kibana-app-services
/x-pack/plugins/notifications/ @elastic/kibana-app-services
### Observability Plugins

View file

@ -589,6 +589,10 @@ Elastic.
|This plugin allows for other plugins to add data to Kibana stack monitoring documents.
|{kib-repo}blob/{branch}/x-pack/plugins/notifications/README.md[notifications]
|The Notifications plugin provides a set of services to help Solutions and plugins send notifications to users.
|{kib-repo}blob/{branch}/x-pack/plugins/observability/README.md[observability]
|This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI.

View file

@ -51,14 +51,14 @@ To use the exposed plugin start and setup contracts:
import { TelemetryPluginsStart } from '../telemetry/server`;
interface MyPlyginStartDeps {
interface MyPluginStartDeps {
telemetry?: TelemetryPluginsStart;
}
class MyPlugin {
public async start(
core: CoreStart,
{ telemetry }: MyPlyginStartDeps
{ telemetry }: MyPluginStartDeps
) {
const isOptedIn = await telemetry?.getIsOptedIn();
...

View file

@ -1104,6 +1104,8 @@
"@kbn/monitoring-collection-plugin/*": ["x-pack/plugins/monitoring_collection/*"],
"@kbn/monitoring-plugin": ["x-pack/plugins/monitoring"],
"@kbn/monitoring-plugin/*": ["x-pack/plugins/monitoring/*"],
"@kbn/notifications-plugin": ["x-pack/plugins/notifications"],
"@kbn/notifications-plugin/*": ["x-pack/plugins/notifications/*"],
"@kbn/observability-plugin": ["x-pack/plugins/observability"],
"@kbn/observability-plugin/*": ["x-pack/plugins/observability/*"],
"@kbn/osquery-plugin": ["x-pack/plugins/osquery"],

View file

@ -39,7 +39,7 @@ const createStartMock = () => {
isActionTypeEnabled: jest.fn(),
isActionExecutable: jest.fn(),
getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()),
getUnsecuredActionsClient: jest.fn().mockResolvedValue(unsecuredActionsClientMock.create()),
getUnsecuredActionsClient: jest.fn().mockReturnValue(unsecuredActionsClientMock.create()),
getActionsAuthorizationWithRequest: jest
.fn()
.mockReturnValue(actionsAuthorizationMock.create()),

View file

@ -102,7 +102,7 @@ import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework
import { SubActionConnector } from './sub_action_framework/sub_action_connector';
import { CaseConnector } from './sub_action_framework/case';
import {
IUnsecuredActionsClient,
type IUnsecuredActionsClient,
UnsecuredActionsClient,
} from './unsecured_actions_client/unsecured_actions_client';
import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured_execute_function';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { IUnsecuredActionsClient } from './unsecured_actions_client';
import type { IUnsecuredActionsClient } from './unsecured_actions_client';
export type UnsecuredActionsClientMock = jest.Mocked<IUnsecuredActionsClient>;

View file

@ -0,0 +1,70 @@
# Kibana Notifications Plugin
The Notifications plugin provides a set of services to help Solutions and plugins send notifications to users.
## Notifications Plugin public API
### Start
The `start` function exposes the following interface:
- `isEmailServiceAvailable(): boolean`:
A function to check whether the deployment is properly configured and the EmailService can be correctly retrieved.
- `getEmailService(): EmailService`:
- A function to get the basic EmailService, which can be used to send plain text emails. If the EmailService is not available, trying to retrieve it will result in an Exception.
### Usage
To use the exposed plugin start contract:
1. Make sure `notifications` is in your `optionalPlugins` in the `kibana.json` file:
```json5
// <plugin>/kibana.json
{
"id": "...",
"requiredPlugins": ["notifications"]
}
```
2. Use the exposed contract:
```ts
// <plugin>/server/plugin.ts
import { NotificationsPluginStart } from '../notifications/server`;
interface MyPluginStartDeps {
notifications?: NotificationsPluginStart;
}
class MyPlugin {
public start(
core: CoreStart,
{ notifications }: MyPluginStartDeps
) {
if (notifications.isEmailServiceAvailable()) {
const emailService = notifications.getEmailService();
emailService.sendPlainTextEmail({
to: 'foo@bar.com',
subject: 'Some subject',
message: 'Hello world!',
});
}
...
}
}
```
### Requirements
- This plugin currently depends on the `'actions'` plugin, as it uses `Connectors` under the hood.
- Note also that for each notification channel the corresponding connector must be preconfigured. E.g. to enable email notifications, an `Email` connector must exist in the system.
- Once the appropriate connectors are preconfigured in `kibana.yaml`, you can configure the `'notifications'` plugin by adding:
```yaml
notifications:
connectors:
default:
email: elastic-cloud-email # The identifier of the configured connector
```

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const PLUGIN_ID = 'notifications';

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../..',
roots: ['<rootDir>/x-pack/plugins/notifications'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/notifications',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/notifications/{common,server}/**/*.{js,ts,tsx}'],
};

View file

@ -0,0 +1,12 @@
{
"id": "notifications",
"owner": {
"name": "App Services",
"githubTeam": "kibana-app-services"
},
"version": "kibana",
"server": true,
"ui": false,
"requiredPlugins": ["actions", "licensing"],
"optionalPlugins": []
}

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, type TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor } from '@kbn/core/server';
export const configSchema = schema.object(
{
connectors: schema.maybe(
schema.object({
default: schema.maybe(
schema.object({
email: schema.maybe(schema.string()),
})
),
})
),
},
{ defaultValue: {} }
);
export type NotificationsConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<NotificationsConfigType> = {
schema: configSchema,
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { type NotificationsConfigType, config } from './config';

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
import { NotificationsPlugin } from './plugin';
export { config } from './config';
// This exports static code and TypeScript types,
// as well as, Kibana Platform `plugin()` initializer.
export type { NotificationsPluginStart } from './types';
export function plugin(initializerContext: PluginInitializerContext) {
return new NotificationsPlugin(initializerContext);
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { EmailService } from './services';
import type { NotificationsPluginStart } from './types';
import type { NotificationsPlugin } from './plugin';
const emailServiceMock: jest.Mocked<EmailService> = {
sendPlainTextEmail: jest.fn(),
};
const createEmailServiceMock = () => {
return emailServiceMock;
};
const startMock: jest.Mocked<NotificationsPluginStart> = {
isEmailServiceAvailable: jest.fn(),
getEmailService: jest.fn(createEmailServiceMock),
};
const createStartMock = () => {
return startMock;
};
const notificationsPluginMock: jest.Mocked<PublicMethodsOf<NotificationsPlugin>> = {
setup: jest.fn(),
start: jest.fn(createStartMock) as jest.Mock<NotificationsPluginStart>,
stop: jest.fn(),
};
const createNotificationsPluginMock = () => {
return notificationsPluginMock;
};
export const notificationsMock = {
createNotificationsPlugin: createNotificationsPluginMock,
createEmailService: createEmailServiceMock,
createStart: createStartMock,
clear: () => {
emailServiceMock.sendPlainTextEmail.mockClear();
startMock.getEmailService.mockClear();
startMock.isEmailServiceAvailable.mockClear();
notificationsPluginMock.setup.mockClear();
notificationsPluginMock.start.mockClear();
notificationsPluginMock.stop.mockClear();
},
};

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/server/mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import type { NotificationsConfigType } from './config';
import { NotificationsPlugin } from './plugin';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { EmailServiceProvider } from './services/connectors_email_service_provider';
import { EmailServiceStart } from './services';
jest.mock('./services/connectors_email_service_provider');
const emailServiceProviderMock = EmailServiceProvider as jest.MockedClass<
typeof EmailServiceProvider
>;
const validConnectorConfig = {
connectors: {
default: {
email: 'validConnectorId',
},
},
};
const createNotificationsPlugin = (config: NotificationsConfigType) => {
const context = coreMock.createPluginInitializerContext<NotificationsConfigType>(config);
const plugin = new NotificationsPlugin(context);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
const actionsSetup = actionsMock.createSetup();
actionsSetup.isPreconfiguredConnector.mockImplementationOnce(
(connectorId) => connectorId === 'validConnectorId'
);
const pluginSetup = {
actions: actionsSetup,
licensing: licensingMock.createSetup(),
};
const actionsStart = actionsMock.createStart();
const pluginStart = {
actions: actionsStart,
licensing: licensingMock.createStart(),
};
return {
context,
logger: context.logger.get(),
plugin,
coreSetup,
coreStart,
actionsSetup,
pluginSetup,
actionsStart,
pluginStart,
};
};
describe('Notifications Plugin', () => {
beforeEach(() => emailServiceProviderMock.mockClear());
it('should create an EmailServiceProvider passing in the configuration and logger from the initializer context', () => {
const { logger } = createNotificationsPlugin(validConnectorConfig);
expect(emailServiceProviderMock).toHaveBeenCalledTimes(1);
expect(emailServiceProviderMock).toHaveBeenCalledWith(validConnectorConfig, logger);
});
describe('setup()', () => {
it('should call setup() on the created EmailServiceProvider, passing in the setup plugin dependencies', () => {
const { plugin, coreSetup, pluginSetup } = createNotificationsPlugin(validConnectorConfig);
plugin.setup(coreSetup, pluginSetup);
expect(emailServiceProviderMock.mock.instances[0].setup).toHaveBeenCalledTimes(1);
expect(emailServiceProviderMock.mock.instances[0].setup).toBeCalledWith(pluginSetup);
});
});
describe('start()', () => {
it('should call start() on the created EmailServiceProvider, passing in the setup plugin dependencies', () => {
const { plugin, coreStart, pluginStart } = createNotificationsPlugin(validConnectorConfig);
plugin.start(coreStart, pluginStart);
expect(emailServiceProviderMock.mock.instances[0].start).toHaveBeenCalledTimes(1);
expect(emailServiceProviderMock.mock.instances[0].start).toBeCalledWith(pluginStart);
});
it('should return EmailServiceProvider.start() contract as part of its contract', () => {
const { plugin, coreStart, pluginStart } = createNotificationsPlugin(validConnectorConfig);
const emailStart: EmailServiceStart = {
getEmailService: jest.fn(),
isEmailServiceAvailable: jest.fn(),
};
const providerMock = emailServiceProviderMock.mock
.instances[0] as jest.Mocked<EmailServiceProvider>;
providerMock.start.mockReturnValue(emailStart);
const start = plugin.start(coreStart, pluginStart);
expect(emailServiceProviderMock.mock.instances[0].start).toHaveBeenCalledTimes(1);
expect(emailServiceProviderMock.mock.instances[0].start).toBeCalledWith(pluginStart);
expect(start).toEqual(expect.objectContaining(emailStart));
});
});
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
import type {
NotificationsPluginSetupDeps,
NotificationsPluginStartDeps,
NotificationsPluginStart,
} from './types';
import type { NotificationsConfigType } from './config';
import { EmailServiceProvider } from './services/connectors_email_service_provider';
export class NotificationsPlugin implements Plugin<void, NotificationsPluginStart> {
private emailServiceProvider: EmailServiceProvider;
constructor(initializerContext: PluginInitializerContext<NotificationsConfigType>) {
this.emailServiceProvider = new EmailServiceProvider(
initializerContext.config.get(),
initializerContext.logger.get()
);
}
public setup(_core: CoreSetup, plugins: NotificationsPluginSetupDeps) {
this.emailServiceProvider.setup(plugins);
}
public start(_core: CoreStart, plugins: NotificationsPluginStartDeps) {
const emailStartContract = this.emailServiceProvider.start(plugins);
return {
...emailStartContract,
};
}
public stop() {}
}

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock';
import { ConnectorsEmailService } from './connectors_email_service';
import type { PlainTextEmail } from './types';
const REQUESTER_ID = 'requesterId';
const CONNECTOR_ID = 'connectorId';
describe('sendPlainTextEmail()', () => {
describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => {
it(`omits the 'relatedSavedObjects' field if no context is provided`, () => {
const actionsClient = unsecuredActionsClientMock.create();
const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient);
const payload: PlainTextEmail = {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
};
email.sendPlainTextEmail(payload);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [
{
id: CONNECTOR_ID,
params: {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
},
},
]);
});
it(`populates the 'relatedSavedObjects' field if context is provided`, () => {
const actionsClient = unsecuredActionsClientMock.create();
const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient);
const payload: PlainTextEmail = {
to: ['user1@email.com', 'user2@email.com', 'user3@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
context: {
relatedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
};
email.sendPlainTextEmail(payload);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [
{
id: CONNECTOR_ID,
params: {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
{
id: CONNECTOR_ID,
params: {
to: ['user2@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
{
id: CONNECTOR_ID,
params: {
to: ['user3@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
]);
});
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server';
import type { EmailService, PlainTextEmail } from './types';
export class ConnectorsEmailService implements EmailService {
constructor(
private requesterId: string,
private connectorId: string,
private actionsClient: IUnsecuredActionsClient
) {}
async sendPlainTextEmail(params: PlainTextEmail): Promise<void> {
const actions = params.to.map((to) => ({
id: this.connectorId,
params: {
to: [to],
subject: params.subject,
message: params.message,
},
relatedSavedObjects: params.context?.relatedObjects,
}));
return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions);
}
}

View file

@ -0,0 +1,243 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { loggerMock } from '@kbn/logging-mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { LicensedEmailService } from './licensed_email_service';
import { EmailServiceProvider } from './connectors_email_service_provider';
import { ConnectorsEmailService } from './connectors_email_service';
import { PLUGIN_ID } from '../../common';
jest.mock('./licensed_email_service');
jest.mock('./connectors_email_service');
const licensedEmailServiceMock = LicensedEmailService as jest.MockedClass<
typeof LicensedEmailService
>;
const connectorsEmailServiceMock = ConnectorsEmailService as jest.MockedClass<
typeof ConnectorsEmailService
>;
const missingConnectorConfig = {
connectors: {
default: {},
},
};
const invalidConnectorConfig = {
connectors: {
default: {
email: 'someUnexistingConnectorId',
},
},
};
const validConnectorConfig = {
connectors: {
default: {
email: 'validConnectorId',
},
},
};
describe('ConnectorsEmailServiceProvider', () => {
const logger = loggerMock.create();
const actionsSetup = actionsMock.createSetup();
actionsSetup.isPreconfiguredConnector.mockImplementation(
(connectorId) => connectorId === 'validConnectorId'
);
beforeEach(() => {
loggerMock.clear(logger);
licensedEmailServiceMock.mockClear();
connectorsEmailServiceMock.mockClear();
});
it('implements the IEmailServiceProvider interface', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, loggerMock.create());
expect(serviceProvider.setup).toBeInstanceOf(Function);
expect(serviceProvider.start).toBeInstanceOf(Function);
});
describe('setup()', () => {
it('should log a warning if Actions or Licensing plugins are not available', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger);
serviceProvider.setup({
actions: actionsSetup,
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
`Email Service Error: 'actions' and 'licensing' plugins are required.`
);
// eslint-disable-next-line dot-notation
expect(serviceProvider['setupSuccessful']).toEqual(false);
});
it('should log a warning if no default email connector has been defined', () => {
const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger);
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
`Email Service Error: Email connector not specified.`
);
// eslint-disable-next-line dot-notation
expect(serviceProvider['setupSuccessful']).toEqual(false);
});
it('should log a warning if the specified email connector is not a preconfigured connector', () => {
const serviceProvider = new EmailServiceProvider(invalidConnectorConfig, logger);
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
`Email Service Error: Unexisting email connector 'someUnexistingConnectorId' specified.`
);
// eslint-disable-next-line dot-notation
expect(serviceProvider['setupSuccessful']).toEqual(false);
});
it('should not log a warning if required plugins are present and the specified email connector is valid', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger);
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
expect(logger.warn).not.toHaveBeenCalled();
// eslint-disable-next-line dot-notation
expect(serviceProvider['setupSuccessful']).toEqual(true);
});
});
describe('start()', () => {
it('returns an object that implements the EmailServiceStart contract', () => {
const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger);
const start = serviceProvider.start({});
expect(start.getEmailService).toBeInstanceOf(Function);
expect(start.isEmailServiceAvailable).toBeInstanceOf(Function);
});
describe('if setup has not been run', () => {
it('the start contract methods fail accordingly', () => {
const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger);
const start = serviceProvider.start({});
expect(start.isEmailServiceAvailable()).toEqual(false);
expect(() => {
start.getEmailService();
}).toThrowErrorMatchingInlineSnapshot(`"Email Service Error: setup() has not been run"`);
});
});
describe('if setup() did not complete successfully', () => {
it('the start contract methods fail accordingly', () => {
const serviceProvider = new EmailServiceProvider(invalidConnectorConfig, logger);
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
const start = serviceProvider.start({
actions: actionsMock.createStart(),
licensing: licensingMock.createStart(),
});
expect(start.isEmailServiceAvailable()).toEqual(false);
expect(() => {
start.getEmailService();
}).toThrowErrorMatchingInlineSnapshot(
`"Email Service Error: Unexisting email connector 'someUnexistingConnectorId' specified."`
);
});
});
describe('if setup() did complete successfully and Action and Licensing plugin start contracts are available', () => {
it('attempts to build an UnsecuredActionsClient', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger);
const actionsStart = actionsMock.createStart();
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
serviceProvider.start({
actions: actionsStart,
licensing: licensingMock.createStart(),
});
expect(actionsStart.getUnsecuredActionsClient).toHaveBeenCalledTimes(1);
});
describe('if getUnsecuredActionsClient() throws an Exception', () => {
it('catches the exception, and the start contract methods fail accordingly', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger);
const actionsStart = actionsMock.createStart();
actionsStart.getUnsecuredActionsClient.mockImplementation(() => {
throw new Error('Something went terribly wrong.');
});
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
const start = serviceProvider.start({
actions: actionsStart,
licensing: licensingMock.createStart(),
});
expect(start.isEmailServiceAvailable()).toEqual(false);
expect(() => {
start.getEmailService();
}).toThrowErrorMatchingInlineSnapshot(
`"Email Service Error: Something went terribly wrong."`
);
});
});
describe('if getUnsecuredActionsClient() returns an UnsecuredActionsClient', () => {
it('returns a start contract that provides valid EmailService', () => {
const serviceProvider = new EmailServiceProvider(validConnectorConfig, logger);
const licensingStart = licensingMock.createStart();
const actionsStart = actionsMock.createStart();
serviceProvider.setup({
actions: actionsSetup,
licensing: licensingMock.createSetup(),
});
const start = serviceProvider.start({
actions: actionsStart,
licensing: licensingStart,
});
expect(start.isEmailServiceAvailable()).toEqual(true);
const email = start.getEmailService();
expect(email).toBeInstanceOf(LicensedEmailService);
expect(licensedEmailServiceMock).toHaveBeenCalledTimes(1);
expect(licensedEmailServiceMock).toHaveBeenCalledWith(
connectorsEmailServiceMock.mock.instances[0],
licensingStart.license$,
'platinum',
expect.objectContaining({ debug: expect.any(Function), warn: expect.any(Function) })
);
expect(connectorsEmailServiceMock).toHaveBeenCalledTimes(1);
expect(connectorsEmailServiceMock).toHaveBeenCalledWith(
PLUGIN_ID,
validConnectorConfig.connectors.default.email,
actionsStart.getUnsecuredActionsClient()
);
});
});
});
});
});

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/core/server';
import { PluginSetupContract, PluginStartContract } from '@kbn/actions-plugin/server';
import { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { EmailService, EmailServiceStart, IEmailServiceProvider } from './types';
import type { NotificationsConfigType } from '../config';
import { LicensedEmailService } from './licensed_email_service';
import { ConnectorsEmailService } from './connectors_email_service';
import { PLUGIN_ID } from '../../common';
const MINIMUM_LICENSE = 'platinum';
export interface EmailServiceSetupDeps {
actions?: PluginSetupContract;
licensing?: LicensingPluginSetup;
}
export interface EmailServiceStartDeps {
actions?: PluginStartContract;
licensing?: LicensingPluginStart;
}
export class EmailServiceProvider
implements IEmailServiceProvider<EmailServiceSetupDeps, EmailServiceStartDeps>
{
private setupSuccessful: boolean;
private setupError: string;
constructor(private config: NotificationsConfigType, private logger: Logger) {
this.setupSuccessful = false;
this.setupError = 'Email Service Error: setup() has not been run';
}
public setup(plugins: EmailServiceSetupDeps) {
const { actions, licensing } = plugins;
if (!actions || !licensing) {
return this._registerServiceError(`Error: 'actions' and 'licensing' plugins are required.`);
}
const emailConnector = this.config.connectors?.default?.email;
if (!emailConnector) {
return this._registerServiceError('Error: Email connector not specified.');
}
if (!actions.isPreconfiguredConnector(emailConnector)) {
return this._registerServiceError(
`Error: Unexisting email connector '${emailConnector}' specified.`
);
}
this.setupSuccessful = true;
this.setupError = '';
}
public start(plugins: EmailServiceStartDeps): EmailServiceStart {
const { actions, licensing } = plugins;
let email: EmailService;
if (this.setupSuccessful && actions && licensing) {
const emailConnector = this.config.connectors!.default!.email!;
try {
const unsecuredActionsClient = actions.getUnsecuredActionsClient();
email = new LicensedEmailService(
new ConnectorsEmailService(PLUGIN_ID, emailConnector, unsecuredActionsClient),
licensing.license$,
MINIMUM_LICENSE,
this.logger
);
} catch (err) {
this._registerServiceError(err);
}
}
return {
isEmailServiceAvailable: () => !!email,
getEmailService: () => {
if (!email) {
throw new Error(this.setupError);
}
return email;
},
};
}
private _registerServiceError(error: string) {
const message = `Email Service ${error}`;
this.setupError = message;
this.logger.warn(message);
}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type { EmailService, EmailServiceStart, PlainTextEmail } from './types';
export type {
EmailServiceSetupDeps,
EmailServiceStartDeps,
} from './connectors_email_service_provider';

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Subject } from 'rxjs';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { LicensedEmailService } from './licensed_email_service';
import type { ILicense } from '@kbn/licensing-plugin/server';
import type { EmailService, PlainTextEmail } from './types';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const emailServiceMock: EmailService = {
sendPlainTextEmail: jest.fn(),
};
const validLicense = licensingMock.createLicenseMock();
const invalidLicense = licensingMock.createLicenseMock();
invalidLicense.type = 'basic';
invalidLicense.check = jest.fn(() => ({
state: 'invalid',
message: 'This is an invalid testing license',
})) as unknown as any;
const someEmail: PlainTextEmail = {
to: ['user1@email.com'],
subject: 'Some subject',
message: 'Some message',
};
describe('LicensedEmailService', () => {
const logger = loggerMock.create();
beforeEach(() => loggerMock.clear(logger));
it('observes license$ changes and logs info or warning messages accordingly', () => {
const license$ = new Subject<ILicense>();
new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
license$.next(invalidLicense);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith('This is an invalid testing license');
license$.next(validLicense);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith(
'Your current license allows sending email notifications'
);
});
describe('sendPlainTextEmail()', () => {
it('does not call the underlying email service until the license is determined and valid', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
email.sendPlainTextEmail(someEmail);
expect(emailServiceMock.sendPlainTextEmail).not.toHaveBeenCalled();
license$.next(validLicense);
await delay(1);
expect(emailServiceMock.sendPlainTextEmail).toHaveBeenCalledTimes(1);
expect(emailServiceMock.sendPlainTextEmail).toHaveBeenCalledWith(someEmail);
});
it('does not call the underlying email service if the license is invalid', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
license$.next(invalidLicense);
try {
await email.sendPlainTextEmail(someEmail);
} catch (err) {
expect(err.message).toEqual(
'The current license does not allow sending email notifications'
);
return;
}
expect('it should have thrown').toEqual('but it did not');
});
it('does not log a warning for every email attempt, but rather for every license change', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
license$.next(invalidLicense);
license$.next(validLicense);
license$.next(invalidLicense);
expect(logger.debug).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(2);
let emailsOk = 0;
let emailsKo = 0;
const silentSend = async () => {
try {
await email.sendPlainTextEmail(someEmail);
emailsOk++;
} catch (err) {
emailsKo++;
}
};
await silentSend();
await silentSend();
await silentSend();
await silentSend();
license$.next(validLicense);
await silentSend();
await silentSend();
await silentSend();
await silentSend();
expect(logger.debug).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(emailsKo).toEqual(4);
expect(emailsOk).toEqual(4);
});
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/server';
import { firstValueFrom, map, type Observable, ReplaySubject, type Subject } from 'rxjs';
import type { EmailService, PlainTextEmail } from './types';
import { PLUGIN_ID } from '../../common';
export class LicensedEmailService implements EmailService {
private validLicense$: Subject<boolean> = new ReplaySubject(1);
constructor(
private emailService: EmailService,
license$: Observable<ILicense>,
private minimumLicense: LicenseType,
private logger: Logger
) {
// no need to explicitly unsubscribe as the license$ observable already completes on stop()
license$.pipe(map((license) => this.checkValidLicense(license))).subscribe(this.validLicense$);
}
async sendPlainTextEmail(payload: PlainTextEmail): Promise<void> {
if (await firstValueFrom(this.validLicense$, { defaultValue: false })) {
await this.emailService.sendPlainTextEmail(payload);
} else {
throw new Error('The current license does not allow sending email notifications');
}
}
private checkValidLicense(license: ILicense): boolean {
const licenseCheck = license.check(PLUGIN_ID, this.minimumLicense);
if (licenseCheck.state === 'valid') {
this.logger.debug('Your current license allows sending email notifications');
return true;
}
this.logger.warn(
licenseCheck.message || 'The current license does not allow sending email notifications'
);
return false;
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface EmailService {
sendPlainTextEmail(payload: PlainTextEmail): Promise<void>;
}
export interface EmailServiceStart {
isEmailServiceAvailable(): boolean;
getEmailService(): EmailService;
}
export interface IEmailServiceProvider<T, U> {
setup(setupDeps: T): void;
start(startDeps: U): EmailServiceStart;
}
export interface RelatedSavedObject {
id: string;
type: string;
namespace?: string; // namespace is undefined for the spaceId 'default'
}
export interface PlainTextEmail {
to: string[];
subject: string;
message: string;
context?: {
relatedObjects?: RelatedSavedObject[];
};
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EmailServiceStart, EmailServiceSetupDeps, EmailServiceStartDeps } from './services';
// The 'notifications' plugin is currently only exposing an email service.
// If we want to expose other services in the future, we should update these types accordingly
export type NotificationsPluginSetupDeps = EmailServiceSetupDeps;
export type NotificationsPluginStartDeps = EmailServiceStartDeps;
export type NotificationsPluginStart = EmailServiceStart;

View file

@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true
},
"include": [
"server/**/*",
// have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636
"server/**/*.json",
"public/**/*",
"common/**/*"
],
"kbn_references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../actions/tsconfig.json" },
{ "path": "../licensing/tsconfig.json" }
]
}