* Initial commit of serverType in email connector config * Fleshing in route to get well known email service configs from nodemailer * Adding elastic cloud to well known server type * Cleaning up email constants and allowing for empty selection * Showing error if user doesn't select server type * Adding hook for setting email config based on server type * Adding tests and making sure settings are not overwritten on edit * Fixing functional test * Adding migration * Adding functional test for migration * Repurposing service instead of adding serverType * Cleanup * Disabling host/port/secure form fields when settings retrieved from API * Updating docs for service * Filtering options based on whether cloud is enabled * Initialize as disabled * Fixing types * Update docs/management/connectors/action-types/email.asciidoc Co-authored-by: David Kilfoyle <41695641+kilfoyle@users.noreply.github.com> Co-authored-by: David Kilfoyle <41695641+kilfoyle@users.noreply.github.com> # Conflicts: # x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts # x-pack/plugins/actions/server/saved_objects/actions_migrations.ts
This commit is contained in:
parent
54d1f9d9b4
commit
ef2e1306f4
|
@ -51,7 +51,7 @@ Use the <<action-settings, Action configuration settings>> to customize connecto
|
|||
|
||||
Config defines information for the connector type.
|
||||
|
||||
`service`:: The name of a https://nodemailer.com/smtp/well-known/[well-known email service provider]. If `service` is provided, `host`, `port`, and `secure` properties are ignored. For more information on the `gmail` service value, see the https://nodemailer.com/usage/using-gmail/[Nodemailer Gmail documentation].
|
||||
`service`:: The name of the email service. If `service` is `elastic_cloud` (for Elastic Cloud notifications) or one of Nodemailer's https://nodemailer.com/smtp/well-known/[well-known email service providers], `host`, `port`, and `secure` properties are ignored. If `service` is `other`, `host` and `port` properties must be defined. For more information on the `gmail` service value, see the https://nodemailer.com/usage/using-gmail/[Nodemailer Gmail documentation].
|
||||
`from`:: An email address that corresponds to *Sender*.
|
||||
`host`:: A string that corresponds to *Host*.
|
||||
`port`:: A number that corresponds to *Port*.
|
||||
|
|
|
@ -13,3 +13,4 @@ export * from './alert_history_schema';
|
|||
export * from './rewrite_request_case';
|
||||
|
||||
export const BASE_ACTION_API_PATH = '/api/actions';
|
||||
export const INTERNAL_BASE_ACTION_API_PATH = '/internal/actions';
|
||||
|
|
|
@ -52,7 +52,7 @@ describe('actionTypeRegistry.get() works', () => {
|
|||
});
|
||||
|
||||
describe('config validation', () => {
|
||||
test('config validation succeeds when config is valid', () => {
|
||||
test('config validation succeeds when config is valid for nodemailer well known service', () => {
|
||||
const config: Record<string, unknown> = {
|
||||
service: 'gmail',
|
||||
from: 'bob@example.com',
|
||||
|
@ -64,14 +64,46 @@ describe('config validation', () => {
|
|||
port: null,
|
||||
secure: null,
|
||||
});
|
||||
});
|
||||
|
||||
delete config.service;
|
||||
config.host = 'elastic.co';
|
||||
config.port = 8080;
|
||||
config.hasAuth = true;
|
||||
test(`config validation succeeds when config is valid and defaults to 'other' when service is undefined`, () => {
|
||||
const config: Record<string, unknown> = {
|
||||
from: 'bob@example.com',
|
||||
host: 'elastic.co',
|
||||
port: 8080,
|
||||
hasAuth: true,
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...config,
|
||||
service: null,
|
||||
service: 'other',
|
||||
secure: null,
|
||||
});
|
||||
});
|
||||
|
||||
test(`config validation succeeds when config is valid and service requires custom host/port value`, () => {
|
||||
const config: Record<string, unknown> = {
|
||||
service: 'exchange_server',
|
||||
from: 'bob@example.com',
|
||||
host: 'elastic.co',
|
||||
port: 8080,
|
||||
hasAuth: true,
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...config,
|
||||
secure: null,
|
||||
});
|
||||
});
|
||||
|
||||
test(`config validation succeeds when config is valid and service is elastic_cloud`, () => {
|
||||
const config: Record<string, unknown> = {
|
||||
service: 'elastic_cloud',
|
||||
from: 'bob@example.com',
|
||||
hasAuth: true,
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...config,
|
||||
host: null,
|
||||
port: null,
|
||||
secure: null,
|
||||
});
|
||||
});
|
||||
|
@ -325,7 +357,7 @@ describe('execute()', () => {
|
|||
...executorOptions,
|
||||
config: {
|
||||
...config,
|
||||
service: null,
|
||||
service: 'other',
|
||||
hasAuth: false,
|
||||
},
|
||||
secrets: {
|
||||
|
@ -381,12 +413,73 @@ describe('execute()', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
test('parameters are as expected when using elastic_cloud service', async () => {
|
||||
const customExecutorOptions: EmailActionTypeExecutorOptions = {
|
||||
...executorOptions,
|
||||
config: {
|
||||
...config,
|
||||
service: 'elastic_cloud',
|
||||
hasAuth: false,
|
||||
},
|
||||
secrets: {
|
||||
...secrets,
|
||||
user: null,
|
||||
password: null,
|
||||
},
|
||||
};
|
||||
|
||||
sendEmailMock.mockReset();
|
||||
await actionType.executor(customExecutorOptions);
|
||||
expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"configurationUtilities": Object {
|
||||
"ensureActionTypeEnabled": [MockFunction],
|
||||
"ensureHostnameAllowed": [MockFunction],
|
||||
"ensureUriAllowed": [MockFunction],
|
||||
"getCustomHostSettings": [MockFunction],
|
||||
"getProxySettings": [MockFunction],
|
||||
"getResponseSettings": [MockFunction],
|
||||
"getSSLSettings": [MockFunction],
|
||||
"isActionTypeEnabled": [MockFunction],
|
||||
"isHostnameAllowed": [MockFunction],
|
||||
"isUriAllowed": [MockFunction],
|
||||
},
|
||||
"content": Object {
|
||||
"message": "a message to you
|
||||
|
||||
--
|
||||
|
||||
This message was sent by Kibana.",
|
||||
"subject": "the subject",
|
||||
},
|
||||
"hasAuth": false,
|
||||
"routing": Object {
|
||||
"bcc": Array [
|
||||
"jimmy@example.com",
|
||||
],
|
||||
"cc": Array [
|
||||
"james@example.com",
|
||||
],
|
||||
"from": "bob@example.com",
|
||||
"to": Array [
|
||||
"jim@example.com",
|
||||
],
|
||||
},
|
||||
"transport": Object {
|
||||
"host": "dockerhost",
|
||||
"port": 10025,
|
||||
"secure": false,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('returns expected result when an error is thrown', async () => {
|
||||
const customExecutorOptions: EmailActionTypeExecutorOptions = {
|
||||
...executorOptions,
|
||||
config: {
|
||||
...config,
|
||||
service: null,
|
||||
service: 'other',
|
||||
hasAuth: false,
|
||||
},
|
||||
secrets: {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { curry } from 'lodash';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import nodemailerGetService from 'nodemailer/lib/well-known';
|
||||
import SMTPConnection from 'nodemailer/lib/smtp-connection';
|
||||
|
||||
import { sendEmail, JSON_TRANSPORT_SERVICE, SendEmailOptions, Transport } from './lib/send_email';
|
||||
import { portSchema } from './lib/schemas';
|
||||
|
@ -32,10 +33,29 @@ export type EmailActionTypeExecutorOptions = ActionTypeExecutorOptions<
|
|||
// config definition
|
||||
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
|
||||
|
||||
// supported values for `service` in addition to nodemailer's list of well-known services
|
||||
export enum AdditionalEmailServices {
|
||||
ELASTIC_CLOUD = 'elastic_cloud',
|
||||
EXCHANGE = 'exchange_server',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
// these values for `service` require users to fill in host/port/secure
|
||||
export const CUSTOM_CONFIG_SERVICES: string[] = [
|
||||
AdditionalEmailServices.EXCHANGE,
|
||||
AdditionalEmailServices.OTHER,
|
||||
];
|
||||
|
||||
export const ELASTIC_CLOUD_SERVICE: SMTPConnection.Options = {
|
||||
host: 'dockerhost',
|
||||
port: 10025,
|
||||
secure: false,
|
||||
};
|
||||
|
||||
const EMAIL_FOOTER_DIVIDER = '\n\n--\n\n';
|
||||
|
||||
const ConfigSchemaProps = {
|
||||
service: schema.nullable(schema.string()),
|
||||
service: schema.string({ defaultValue: 'other' }),
|
||||
host: schema.nullable(schema.string()),
|
||||
port: schema.nullable(portSchema()),
|
||||
secure: schema.nullable(schema.boolean()),
|
||||
|
@ -58,7 +78,8 @@ function validateConfig(
|
|||
// translate messages.
|
||||
if (config.service === JSON_TRANSPORT_SERVICE) {
|
||||
return;
|
||||
} else if (config.service == null) {
|
||||
} else if (CUSTOM_CONFIG_SERVICES.indexOf(config.service) >= 0) {
|
||||
// If configured `service` requires custom host/port/secure settings, validate that they are set
|
||||
if (config.host == null && config.port == null) {
|
||||
return 'either [service] or [host]/[port] is required';
|
||||
}
|
||||
|
@ -75,6 +96,7 @@ function validateConfig(
|
|||
return `[host] value '${config.host}' is not in the allowedHosts configuration`;
|
||||
}
|
||||
} else {
|
||||
// Check configured `service` against nodemailer list of well known services + any custom ones allowed by Kibana
|
||||
const host = getServiceNameHost(config.service);
|
||||
if (host == null) {
|
||||
return `[service] value '${config.service}' is not valid`;
|
||||
|
@ -201,13 +223,20 @@ async function executor(
|
|||
transport.password = secrets.password;
|
||||
}
|
||||
|
||||
if (config.service !== null) {
|
||||
transport.service = config.service;
|
||||
} else {
|
||||
if (CUSTOM_CONFIG_SERVICES.indexOf(config.service) >= 0) {
|
||||
// use configured host/port/secure values
|
||||
// already validated service or host/port is not null ...
|
||||
transport.host = config.host!;
|
||||
transport.port = config.port!;
|
||||
transport.secure = getSecureValue(config.secure, config.port);
|
||||
} else if (config.service === AdditionalEmailServices.ELASTIC_CLOUD) {
|
||||
// use custom elastic cloud settings
|
||||
transport.host = ELASTIC_CLOUD_SERVICE.host!;
|
||||
transport.port = ELASTIC_CLOUD_SERVICE.port!;
|
||||
transport.secure = ELASTIC_CLOUD_SERVICE.secure!;
|
||||
} else {
|
||||
// use nodemailer's well known service config
|
||||
transport.service = config.service;
|
||||
}
|
||||
|
||||
const footerMessage = getFooterMessage({
|
||||
|
@ -253,6 +282,10 @@ async function executor(
|
|||
// utilities
|
||||
|
||||
function getServiceNameHost(service: string): string | null {
|
||||
if (service === AdditionalEmailServices.ELASTIC_CLOUD) {
|
||||
return ELASTIC_CLOUD_SERVICE.host!;
|
||||
}
|
||||
|
||||
const serviceEntry = nodemailerGetService(service);
|
||||
if (serviceEntry === false) return null;
|
||||
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* 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 { getWellKnownEmailServiceRoute } from './get_well_known_email_service';
|
||||
import { httpServiceMock } from 'src/core/server/mocks';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { mockHandlerArguments } from './legacy/_mock_handler_arguments';
|
||||
import { verifyAccessAndContext } from './verify_access_and_context';
|
||||
|
||||
jest.mock('./verify_access_and_context.ts', () => ({
|
||||
verifyAccessAndContext: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
(verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler);
|
||||
});
|
||||
|
||||
describe('getWellKnownEmailServiceRoute', () => {
|
||||
it('returns config for well known email service', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
|
||||
const [config, handler] = router.get.mock.calls[0];
|
||||
expect(config.path).toMatchInlineSnapshot(
|
||||
`"/internal/actions/connector/_email_config/{service}"`
|
||||
);
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
params: { service: 'gmail' },
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.ok).toHaveBeenCalledWith({
|
||||
body: {
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns config for elastic cloud email service', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
|
||||
const [config, handler] = router.get.mock.calls[0];
|
||||
expect(config.path).toMatchInlineSnapshot(
|
||||
`"/internal/actions/connector/_email_config/{service}"`
|
||||
);
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
params: { service: 'elastic_cloud' },
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {
|
||||
"host": "dockerhost",
|
||||
"port": 10025,
|
||||
"secure": false,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.ok).toHaveBeenCalledWith({
|
||||
body: {
|
||||
host: 'dockerhost',
|
||||
port: 10025,
|
||||
secure: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty for unknown service', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
|
||||
const [config, handler] = router.get.mock.calls[0];
|
||||
expect(config.path).toMatchInlineSnapshot(
|
||||
`"/internal/actions/connector/_email_config/{service}"`
|
||||
);
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
params: { service: 'foo' },
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(res.ok).toHaveBeenCalledWith({
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('ensures the license allows getting well known email service config', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
|
||||
const [, handler] = router.get.mock.calls[0];
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
params: { service: 'gmail' },
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
|
||||
});
|
||||
|
||||
it('ensures the license check prevents getting well known email service config', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
(verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => {
|
||||
throw new Error('OMG');
|
||||
});
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
|
||||
const [, handler] = router.get.mock.calls[0];
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
params: { service: 'gmail' },
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
|
||||
|
||||
expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 } from '@kbn/config-schema';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import nodemailerGetService from 'nodemailer/lib/well-known';
|
||||
import SMTPConnection from 'nodemailer/lib/smtp-connection';
|
||||
import { ILicenseState } from '../lib';
|
||||
import { INTERNAL_BASE_ACTION_API_PATH } from '../../common';
|
||||
import { ActionsRequestHandlerContext } from '../types';
|
||||
import { verifyAccessAndContext } from './verify_access_and_context';
|
||||
import { AdditionalEmailServices, ELASTIC_CLOUD_SERVICE } from '../builtin_action_types/email';
|
||||
|
||||
const paramSchema = schema.object({
|
||||
service: schema.string(),
|
||||
});
|
||||
|
||||
export const getWellKnownEmailServiceRoute = (
|
||||
router: IRouter<ActionsRequestHandlerContext>,
|
||||
licenseState: ILicenseState
|
||||
) => {
|
||||
router.get(
|
||||
{
|
||||
path: `${INTERNAL_BASE_ACTION_API_PATH}/connector/_email_config/{service}`,
|
||||
validate: {
|
||||
params: paramSchema,
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(
|
||||
verifyAccessAndContext(licenseState, async function (context, req, res) {
|
||||
const { service } = req.params;
|
||||
|
||||
let response: SMTPConnection.Options = {};
|
||||
if (service === AdditionalEmailServices.ELASTIC_CLOUD) {
|
||||
response = ELASTIC_CLOUD_SERVICE;
|
||||
} else {
|
||||
const serviceEntry = nodemailerGetService(service);
|
||||
if (serviceEntry) {
|
||||
response = {
|
||||
host: serviceEntry.host,
|
||||
port: serviceEntry.port,
|
||||
secure: serviceEntry.secure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return res.ok({
|
||||
body: response,
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
|
@ -15,6 +15,7 @@ import { getActionRoute } from './get';
|
|||
import { getAllActionRoute } from './get_all';
|
||||
import { connectorTypesRoute } from './connector_types';
|
||||
import { updateActionRoute } from './update';
|
||||
import { getWellKnownEmailServiceRoute } from './get_well_known_email_service';
|
||||
import { defineLegacyRoutes } from './legacy';
|
||||
|
||||
export function defineRoutes(
|
||||
|
@ -30,4 +31,6 @@ export function defineRoutes(
|
|||
updateActionRoute(router, licenseState);
|
||||
connectorTypesRoute(router, licenseState);
|
||||
executeActionRoute(router, licenseState);
|
||||
|
||||
getWellKnownEmailServiceRoute(router, licenseState);
|
||||
}
|
||||
|
|
|
@ -118,6 +118,54 @@ describe('successful migrations', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('7.16.0', () => {
|
||||
test('set service config property for .email connectors if service is undefined', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockDataForEmail({ config: { service: undefined } });
|
||||
const migratedAction = migration716(action, context);
|
||||
expect(migratedAction.attributes.config).toEqual({
|
||||
service: 'other',
|
||||
});
|
||||
expect(migratedAction).toEqual({
|
||||
...action,
|
||||
attributes: {
|
||||
...action.attributes,
|
||||
config: {
|
||||
service: 'other',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('set service config property for .email connectors if service is null', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockDataForEmail({ config: { service: null } });
|
||||
const migratedAction = migration716(action, context);
|
||||
expect(migratedAction.attributes.config).toEqual({
|
||||
service: 'other',
|
||||
});
|
||||
expect(migratedAction).toEqual({
|
||||
...action,
|
||||
attributes: {
|
||||
...action.attributes,
|
||||
config: {
|
||||
service: 'other',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('skips migrating .email connectors if service is defined, even if value is nonsense', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockDataForEmail({ config: { service: 'gobbledygook' } });
|
||||
const migratedAction = migration716(action, context);
|
||||
expect(migratedAction.attributes.config).toEqual({
|
||||
service: 'gobbledygook',
|
||||
});
|
||||
expect(migratedAction).toEqual(action);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles errors during migrations', () => {
|
||||
|
|
|
@ -62,10 +62,17 @@ export function getActionsMigrations(
|
|||
pipeMigrations(addisMissingSecretsField)
|
||||
);
|
||||
|
||||
const migrationEmailActionsSixteen = createEsoMigration(
|
||||
encryptedSavedObjects,
|
||||
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> => doc.attributes.actionTypeId === '.email',
|
||||
pipeMigrations(setServiceConfigIfNotSet)
|
||||
);
|
||||
|
||||
return {
|
||||
'7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'),
|
||||
'7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'),
|
||||
'7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'),
|
||||
'7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -149,6 +156,24 @@ const addHasAuthConfigurationObject = (
|
|||
};
|
||||
};
|
||||
|
||||
const setServiceConfigIfNotSet = (
|
||||
doc: SavedObjectUnsanitizedDoc<RawAction>
|
||||
): SavedObjectUnsanitizedDoc<RawAction> => {
|
||||
if (doc.attributes.actionTypeId !== '.email' || null != doc.attributes.config.service) {
|
||||
return doc;
|
||||
}
|
||||
return {
|
||||
...doc,
|
||||
attributes: {
|
||||
...doc.attributes,
|
||||
config: {
|
||||
...doc.attributes.config,
|
||||
service: 'other',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const addisMissingSecretsField = (
|
||||
doc: SavedObjectUnsanitizedDoc<RawAction>
|
||||
): SavedObjectUnsanitizedDoc<RawAction> => {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"version": "kibana",
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"optionalPlugins": ["alerting", "features", "home", "spaces"],
|
||||
"optionalPlugins": ["alerting", "cloud", "features", "home", "spaces"],
|
||||
"requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"],
|
||||
"configPath": ["xpack", "trigger_actions_ui"],
|
||||
"extraPublicDirs": ["public/common", "public/common/constants"],
|
||||
|
|
|
@ -37,6 +37,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
|
|||
alerting?: AlertingStart;
|
||||
spaces?: SpacesPluginStart;
|
||||
storage?: Storage;
|
||||
isCloud: boolean;
|
||||
setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
ruleTypeRegistry: RuleTypeRegistryContract;
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { HttpSetup } from 'kibana/public';
|
||||
import { INTERNAL_BASE_ACTION_API_PATH } from '../../../constants';
|
||||
import { EmailConfig } from '../types';
|
||||
|
||||
export async function getServiceConfig({
|
||||
http,
|
||||
service,
|
||||
}: {
|
||||
http: HttpSetup;
|
||||
service: string;
|
||||
}): Promise<Partial<Pick<EmailConfig, 'host' | 'port' | 'secure'>>> {
|
||||
return await http.get(`${INTERNAL_BASE_ACTION_API_PATH}/connector/_email_config/${service}`);
|
||||
}
|
|
@ -9,6 +9,7 @@ import { TypeRegistry } from '../../../type_registry';
|
|||
import { registerBuiltInActionTypes } from '../index';
|
||||
import { ActionTypeModel } from '../../../../types';
|
||||
import { EmailActionConnector } from '../types';
|
||||
import { getEmailServices } from './email';
|
||||
|
||||
const ACTION_TYPE_ID = '.email';
|
||||
let actionTypeModel: ActionTypeModel;
|
||||
|
@ -29,6 +30,18 @@ describe('actionTypeRegistry.get() works', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getEmailServices', () => {
|
||||
test('should return elastic cloud service if isCloudEnabled is true', () => {
|
||||
const services = getEmailServices(true);
|
||||
expect(services.find((service) => service.value === 'elastic_cloud')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not return elastic cloud service if isCloudEnabled is false', () => {
|
||||
const services = getEmailServices(false);
|
||||
expect(services.find((service) => service.value === 'elastic_cloud')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector validation', () => {
|
||||
test('connector validation succeeds when connector config is valid', async () => {
|
||||
const actionConnector = {
|
||||
|
@ -46,6 +59,7 @@ describe('connector validation', () => {
|
|||
host: 'localhost',
|
||||
test: 'test',
|
||||
hasAuth: true,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
|
||||
|
@ -55,6 +69,7 @@ describe('connector validation', () => {
|
|||
from: [],
|
||||
port: [],
|
||||
host: [],
|
||||
service: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -82,6 +97,7 @@ describe('connector validation', () => {
|
|||
host: 'localhost',
|
||||
test: 'test',
|
||||
hasAuth: false,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
|
||||
|
@ -91,6 +107,7 @@ describe('connector validation', () => {
|
|||
from: [],
|
||||
port: [],
|
||||
host: [],
|
||||
service: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -113,6 +130,7 @@ describe('connector validation', () => {
|
|||
config: {
|
||||
from: 'test@test.com',
|
||||
hasAuth: true,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
|
||||
|
@ -122,6 +140,7 @@ describe('connector validation', () => {
|
|||
from: [],
|
||||
port: ['Port is required.'],
|
||||
host: ['Host is required.'],
|
||||
service: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -148,6 +167,7 @@ describe('connector validation', () => {
|
|||
host: 'localhost',
|
||||
test: 'test',
|
||||
hasAuth: true,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
|
||||
|
@ -157,6 +177,7 @@ describe('connector validation', () => {
|
|||
from: [],
|
||||
port: [],
|
||||
host: [],
|
||||
service: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -183,6 +204,7 @@ describe('connector validation', () => {
|
|||
host: 'localhost',
|
||||
test: 'test',
|
||||
hasAuth: true,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
|
||||
|
@ -192,6 +214,7 @@ describe('connector validation', () => {
|
|||
from: [],
|
||||
port: [],
|
||||
host: [],
|
||||
service: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -202,6 +225,44 @@ describe('connector validation', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
test('connector validation fails when server type is not selected', async () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
user: 'user',
|
||||
password: 'password',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.email',
|
||||
isPreconfigured: false,
|
||||
name: 'email',
|
||||
config: {
|
||||
from: 'test@test.com',
|
||||
port: 2323,
|
||||
host: 'localhost',
|
||||
test: 'test',
|
||||
hasAuth: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await actionTypeModel.validateConnector((actionConnector as unknown) as EmailActionConnector)
|
||||
).toEqual({
|
||||
config: {
|
||||
errors: {
|
||||
from: [],
|
||||
port: [],
|
||||
host: [],
|
||||
service: ['Service is required.'],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
errors: {
|
||||
user: [],
|
||||
password: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('action params validation', () => {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { lazy } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiSelectOption } from '@elastic/eui';
|
||||
import {
|
||||
ActionTypeModel,
|
||||
ConnectorValidationResult,
|
||||
|
@ -14,6 +15,69 @@ import {
|
|||
} from '../../../../types';
|
||||
import { EmailActionParams, EmailConfig, EmailSecrets, EmailActionConnector } from '../types';
|
||||
|
||||
const emailServices: EuiSelectOption[] = [
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.gmailServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Gmail',
|
||||
}
|
||||
),
|
||||
value: 'gmail',
|
||||
},
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.outlookServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Outlook',
|
||||
}
|
||||
),
|
||||
value: 'outlook365',
|
||||
},
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.amazonSesServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Amazon SES',
|
||||
}
|
||||
),
|
||||
value: 'ses',
|
||||
},
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.elasticCloudServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Elastic Cloud',
|
||||
}
|
||||
),
|
||||
value: 'elastic_cloud',
|
||||
},
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.exchangeServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'MS Exchange Server',
|
||||
}
|
||||
),
|
||||
value: 'exchange_server',
|
||||
},
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.otherServerTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Other',
|
||||
}
|
||||
),
|
||||
value: 'other',
|
||||
},
|
||||
];
|
||||
|
||||
export function getEmailServices(isCloudEnabled: boolean) {
|
||||
return isCloudEnabled
|
||||
? emailServices
|
||||
: emailServices.filter((service) => service.value !== 'elastic_cloud');
|
||||
}
|
||||
|
||||
export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, EmailActionParams> {
|
||||
const mailformat = /^[^@\s]+@[^@\s]+$/;
|
||||
return {
|
||||
|
@ -41,6 +105,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
|
|||
from: new Array<string>(),
|
||||
port: new Array<string>(),
|
||||
host: new Array<string>(),
|
||||
service: new Array<string>(),
|
||||
};
|
||||
const secretsErrors = {
|
||||
user: new Array<string>(),
|
||||
|
@ -69,6 +134,9 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
|
|||
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
|
||||
secretsErrors.password.push(translations.PASSWORD_REQUIRED);
|
||||
}
|
||||
if (!action.config.service) {
|
||||
configErrors.service.push(translations.SERVICE_REQUIRED);
|
||||
}
|
||||
if (action.secrets.user && !action.secrets.password) {
|
||||
secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER_USED);
|
||||
}
|
||||
|
|
|
@ -9,8 +9,10 @@ import React from 'react';
|
|||
import { mountWithIntl } from '@kbn/test/jest';
|
||||
import { EmailActionConnector } from '../types';
|
||||
import EmailActionConnectorFields from './email_connector';
|
||||
import * as hooks from './use_email_config';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('EmailActionConnectorFields renders', () => {
|
||||
test('all connector fields is rendered', () => {
|
||||
const actionConnector = {
|
||||
|
@ -29,7 +31,7 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [] }}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
|
@ -39,6 +41,7 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe(
|
||||
'test@test.com'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy();
|
||||
|
@ -59,7 +62,7 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [] }}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
|
@ -75,6 +78,136 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeFalsy();
|
||||
});
|
||||
|
||||
test('service field defaults to empty when not defined', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
user: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.email',
|
||||
name: 'email',
|
||||
config: {
|
||||
from: 'test@test.com',
|
||||
hasAuth: true,
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe(
|
||||
'test@test.com'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy();
|
||||
expect(wrapper.find('select[data-test-subj="emailServiceSelectInput"]').prop('value')).toEqual(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
test('service field is correctly selected when defined', () => {
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
user: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.email',
|
||||
name: 'email',
|
||||
config: {
|
||||
from: 'test@test.com',
|
||||
hasAuth: true,
|
||||
service: 'gmail',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy();
|
||||
expect(wrapper.find('select[data-test-subj="emailServiceSelectInput"]').prop('value')).toEqual(
|
||||
'gmail'
|
||||
);
|
||||
});
|
||||
|
||||
test('host, port and secure fields should be disabled when service field is set to well known service', () => {
|
||||
jest
|
||||
.spyOn(hooks, 'useEmailConfig')
|
||||
.mockImplementation(() => ({ emailServiceConfigurable: false, setEmailService: jest.fn() }));
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
user: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.email',
|
||||
name: 'email',
|
||||
config: {
|
||||
from: 'test@test.com',
|
||||
hasAuth: true,
|
||||
service: 'gmail',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-test-subj="emailPortInput"]').first().prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-test-subj="emailSecureSwitch"]').first().prop('disabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('host, port and secure fields should not be disabled when service field is set to other', () => {
|
||||
jest
|
||||
.spyOn(hooks, 'useEmailConfig')
|
||||
.mockImplementation(() => ({ emailServiceConfigurable: true, setEmailService: jest.fn() }));
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
user: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.email',
|
||||
name: 'email',
|
||||
config: {
|
||||
from: 'test@test.com',
|
||||
hasAuth: true,
|
||||
service: 'other',
|
||||
},
|
||||
} as EmailActionConnector;
|
||||
const wrapper = mountWithIntl(
|
||||
<EmailActionConnectorFields
|
||||
action={actionConnector}
|
||||
errors={{ from: [], port: [], host: [], user: [], password: [], service: [] }}
|
||||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('[data-test-subj="emailPortInput"]').first().prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('[data-test-subj="emailSecureSwitch"]').first().prop('disabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('should display a message to remember username and password when creating a connector with authentication', () => {
|
||||
const actionConnector = {
|
||||
actionTypeId: '.email',
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFieldNumber,
|
||||
EuiFieldPassword,
|
||||
EuiSelect,
|
||||
EuiSwitch,
|
||||
EuiFormRow,
|
||||
EuiTitle,
|
||||
|
@ -24,13 +25,22 @@ import { ActionConnectorFieldsProps } from '../../../../types';
|
|||
import { EmailActionConnector } from '../types';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label';
|
||||
import { getEmailServices } from './email';
|
||||
import { useEmailConfig } from './use_email_config';
|
||||
|
||||
export const EmailActionConnectorFields: React.FunctionComponent<
|
||||
ActionConnectorFieldsProps<EmailActionConnector>
|
||||
> = ({ action, editActionConfig, editActionSecrets, errors, readOnly }) => {
|
||||
const { docLinks } = useKibana().services;
|
||||
const { from, host, port, secure, hasAuth } = action.config;
|
||||
const { docLinks, http, isCloud } = useKibana().services;
|
||||
const { from, host, port, secure, hasAuth, service } = action.config;
|
||||
const { user, password } = action.secrets;
|
||||
|
||||
const { emailServiceConfigurable, setEmailService } = useEmailConfig(
|
||||
http,
|
||||
service,
|
||||
editActionConfig
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!action.id) {
|
||||
editActionConfig('hasAuth', true);
|
||||
|
@ -42,6 +52,8 @@ export const EmailActionConnectorFields: React.FunctionComponent<
|
|||
from !== undefined && errors.from !== undefined && errors.from.length > 0;
|
||||
const isHostInvalid: boolean =
|
||||
host !== undefined && errors.host !== undefined && errors.host.length > 0;
|
||||
const isServiceInvalid: boolean =
|
||||
service !== undefined && errors.service !== undefined && errors.service.length > 0;
|
||||
const isPortInvalid: boolean =
|
||||
port !== undefined && errors.port !== undefined && errors.port.length > 0;
|
||||
|
||||
|
@ -93,6 +105,31 @@ export const EmailActionConnectorFields: React.FunctionComponent<
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.serviceTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Service',
|
||||
}
|
||||
)}
|
||||
error={errors.serverType}
|
||||
isInvalid={isServiceInvalid}
|
||||
>
|
||||
<EuiSelect
|
||||
name="service"
|
||||
hasNoInitialSelection={true}
|
||||
value={service}
|
||||
disabled={readOnly}
|
||||
isInvalid={isServiceInvalid}
|
||||
data-test-subj="emailServiceSelectInput"
|
||||
options={getEmailServices(isCloud)}
|
||||
onChange={(e) => {
|
||||
setEmailService(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="emailHost"
|
||||
|
@ -108,6 +145,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
|
|||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
disabled={!emailServiceConfigurable}
|
||||
readOnly={readOnly}
|
||||
isInvalid={isHostInvalid}
|
||||
name="host"
|
||||
|
@ -144,6 +182,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
|
|||
prepend=":"
|
||||
isInvalid={isPortInvalid}
|
||||
fullWidth
|
||||
disabled={!emailServiceConfigurable}
|
||||
readOnly={readOnly}
|
||||
name="port"
|
||||
value={port || ''}
|
||||
|
@ -169,7 +208,8 @@ export const EmailActionConnectorFields: React.FunctionComponent<
|
|||
defaultMessage: 'Secure',
|
||||
}
|
||||
)}
|
||||
disabled={readOnly}
|
||||
data-test-subj="emailSecureSwitch"
|
||||
disabled={readOnly || !emailServiceConfigurable}
|
||||
checked={secure || false}
|
||||
onChange={(e) => {
|
||||
editActionConfig('secure', e.target.checked);
|
||||
|
|
|
@ -28,6 +28,13 @@ export const PORT_REQUIRED = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const SERVICE_REQUIRED = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServiceText',
|
||||
{
|
||||
defaultMessage: 'Service is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const HOST_REQUIRED = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText',
|
||||
{
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
import { useEmailConfig } from './use_email_config';
|
||||
|
||||
const http = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const editActionConfig = jest.fn();
|
||||
|
||||
const renderUseEmailConfigHook = (currentService?: string) =>
|
||||
renderHook(() =>
|
||||
useEmailConfig((http as unknown) as HttpSetup, currentService, editActionConfig)
|
||||
);
|
||||
|
||||
describe('useEmailConfig', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('should call get email config API when service changes and handle result', async () => {
|
||||
http.get.mockResolvedValueOnce({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
});
|
||||
const { result, waitForNextUpdate } = renderUseEmailConfigHook();
|
||||
await act(async () => {
|
||||
result.current.setEmailService('gmail');
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/internal/actions/connector/_email_config/gmail');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('service', 'gmail');
|
||||
|
||||
expect(editActionConfig).toHaveBeenCalledWith('host', 'smtp.gmail.com');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('port', 465);
|
||||
expect(editActionConfig).toHaveBeenCalledWith('secure', true);
|
||||
|
||||
expect(result.current.emailServiceConfigurable).toEqual(false);
|
||||
});
|
||||
|
||||
it('should call get email config API when service changes and handle partial result', async () => {
|
||||
http.get.mockResolvedValueOnce({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
});
|
||||
const { result, waitForNextUpdate } = renderUseEmailConfigHook();
|
||||
await act(async () => {
|
||||
result.current.setEmailService('gmail');
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/internal/actions/connector/_email_config/gmail');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('service', 'gmail');
|
||||
|
||||
expect(editActionConfig).toHaveBeenCalledWith('host', 'smtp.gmail.com');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('port', 465);
|
||||
expect(editActionConfig).toHaveBeenCalledWith('secure', false);
|
||||
|
||||
expect(result.current.emailServiceConfigurable).toEqual(false);
|
||||
});
|
||||
|
||||
it('should call get email config API when service changes and handle empty result', async () => {
|
||||
http.get.mockResolvedValueOnce({});
|
||||
const { result, waitForNextUpdate } = renderUseEmailConfigHook();
|
||||
await act(async () => {
|
||||
result.current.setEmailService('foo');
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/internal/actions/connector/_email_config/foo');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('service', 'foo');
|
||||
|
||||
expect(editActionConfig).toHaveBeenCalledWith('host', '');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('port', 0);
|
||||
expect(editActionConfig).toHaveBeenCalledWith('secure', false);
|
||||
|
||||
expect(result.current.emailServiceConfigurable).toEqual(true);
|
||||
});
|
||||
|
||||
it('should call get email config API when service changes and handle errors', async () => {
|
||||
http.get.mockImplementationOnce(() => {
|
||||
throw new Error('no!');
|
||||
});
|
||||
const { result, waitForNextUpdate } = renderUseEmailConfigHook();
|
||||
await act(async () => {
|
||||
result.current.setEmailService('foo');
|
||||
await waitForNextUpdate();
|
||||
});
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/internal/actions/connector/_email_config/foo');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('service', 'foo');
|
||||
|
||||
expect(editActionConfig).toHaveBeenCalledWith('host', '');
|
||||
expect(editActionConfig).toHaveBeenCalledWith('port', 0);
|
||||
expect(editActionConfig).toHaveBeenCalledWith('secure', false);
|
||||
|
||||
expect(result.current.emailServiceConfigurable).toEqual(true);
|
||||
});
|
||||
|
||||
it('should call get email config API when initial service value is passed and determine if config is editable without overwriting config', async () => {
|
||||
http.get.mockResolvedValueOnce({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
});
|
||||
const { result } = renderUseEmailConfigHook('gmail');
|
||||
expect(http.get).toHaveBeenCalledWith('/internal/actions/connector/_email_config/gmail');
|
||||
expect(editActionConfig).not.toHaveBeenCalled();
|
||||
expect(result.current.emailServiceConfigurable).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { EmailConfig } from '../types';
|
||||
import { getServiceConfig } from './api';
|
||||
|
||||
export function useEmailConfig(
|
||||
http: HttpSetup,
|
||||
currentService: string | undefined,
|
||||
editActionConfig: (property: string, value: unknown) => void
|
||||
) {
|
||||
const [emailServiceConfigurable, setEmailServiceConfigurable] = useState<boolean>(false);
|
||||
const [emailService, setEmailService] = useState<string | undefined>(undefined);
|
||||
|
||||
const getEmailServiceConfig = useCallback(
|
||||
async (service: string) => {
|
||||
let serviceConfig: Partial<Pick<EmailConfig, 'host' | 'port' | 'secure'>>;
|
||||
try {
|
||||
serviceConfig = await getServiceConfig({ http, service });
|
||||
setEmailServiceConfigurable(isEmpty(serviceConfig));
|
||||
} catch (err) {
|
||||
serviceConfig = {};
|
||||
setEmailServiceConfigurable(true);
|
||||
}
|
||||
|
||||
return serviceConfig;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[editActionConfig]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (emailService) {
|
||||
const serviceConfig = await getEmailServiceConfig(emailService);
|
||||
|
||||
editActionConfig('service', emailService);
|
||||
editActionConfig('host', serviceConfig?.host ? serviceConfig.host : '');
|
||||
editActionConfig('port', serviceConfig?.port ? serviceConfig.port : 0);
|
||||
editActionConfig('secure', null != serviceConfig?.secure ? serviceConfig.secure : false);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [emailService]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (currentService) {
|
||||
await getEmailServiceConfig(currentService);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentService]);
|
||||
|
||||
return {
|
||||
emailServiceConfigurable,
|
||||
setEmailService,
|
||||
};
|
||||
}
|
|
@ -78,6 +78,7 @@ export interface EmailConfig {
|
|||
port: number;
|
||||
secure?: boolean;
|
||||
hasAuth: boolean;
|
||||
service: string;
|
||||
}
|
||||
|
||||
export interface EmailSecrets {
|
||||
|
|
|
@ -11,7 +11,7 @@ export {
|
|||
BASE_ALERTING_API_PATH,
|
||||
INTERNAL_BASE_ALERTING_API_PATH,
|
||||
} from '../../../../alerting/common';
|
||||
export { BASE_ACTION_API_PATH } from '../../../../actions/common';
|
||||
export { BASE_ACTION_API_PATH, INTERNAL_BASE_ACTION_API_PATH } from '../../../../actions/common';
|
||||
|
||||
export type Section = 'connectors' | 'rules';
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
|
|||
list: jest.fn(),
|
||||
} as ActionTypeRegistryContract,
|
||||
charts: chartPluginMock.createStartContract(),
|
||||
isCloud: false,
|
||||
kibanaFeatures: [],
|
||||
element: ({
|
||||
style: { cursor: 'pointer' },
|
||||
|
|
|
@ -66,6 +66,7 @@ export interface TriggersAndActionsUIPublicPluginStart {
|
|||
interface PluginsSetup {
|
||||
management: ManagementSetup;
|
||||
home?: HomePublicPluginSetup;
|
||||
cloud?: { isCloudEnabled: boolean };
|
||||
}
|
||||
|
||||
interface PluginsStart {
|
||||
|
@ -148,6 +149,7 @@ export class Plugin
|
|||
charts: pluginsStart.charts,
|
||||
alerting: pluginsStart.alerting,
|
||||
spaces: pluginsStart.spaces,
|
||||
isCloud: Boolean(plugins.cloud?.isCloudEnabled),
|
||||
element: params.element,
|
||||
storage: new Storage(window.localStorage),
|
||||
setBreadcrumbs: params.setBreadcrumbs,
|
||||
|
|
|
@ -149,7 +149,11 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
|
|||
serverArgs: [
|
||||
...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'),
|
||||
...(options.publicBaseUrl ? ['--server.publicBaseUrl=https://localhost:5601'] : []),
|
||||
`--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`,
|
||||
`--xpack.actions.allowedHosts=${JSON.stringify([
|
||||
'localhost',
|
||||
'some.non.existent.com',
|
||||
'smtp.live.com',
|
||||
])}`,
|
||||
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
|
||||
'--xpack.alerting.invalidateApiKeysTask.interval="15s"',
|
||||
'--xpack.alerting.healthCheck.interval="1s"',
|
||||
|
|
|
@ -319,5 +319,122 @@ export default function emailTest({ getService }: FtrProviderContext) {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 when creating an email action without defining service', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
config: {
|
||||
from: 'bob@example.com',
|
||||
host: 'some.non.existent.com',
|
||||
port: 25,
|
||||
hasAuth: true,
|
||||
},
|
||||
secrets: {
|
||||
user: 'bob',
|
||||
password: 'supersecret',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
service: 'other',
|
||||
hasAuth: true,
|
||||
host: 'some.non.existent.com',
|
||||
port: 25,
|
||||
secure: null,
|
||||
from: 'bob@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(typeof createdAction.id).to.be('string');
|
||||
|
||||
const { body: fetchedAction } = await supertest
|
||||
.get(`/api/actions/connector/${createdAction.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(fetchedAction).to.eql({
|
||||
id: fetchedAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
from: 'bob@example.com',
|
||||
service: 'other',
|
||||
hasAuth: true,
|
||||
host: 'some.non.existent.com',
|
||||
port: 25,
|
||||
secure: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 when creating an email action with nodemailer well-defined service', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
config: {
|
||||
from: 'bob@example.com',
|
||||
service: 'hotmail',
|
||||
hasAuth: true,
|
||||
},
|
||||
secrets: {
|
||||
user: 'bob',
|
||||
password: 'supersecret',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
service: 'hotmail',
|
||||
hasAuth: true,
|
||||
host: null,
|
||||
port: null,
|
||||
secure: null,
|
||||
from: 'bob@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(typeof createdAction.id).to.be('string');
|
||||
|
||||
const { body: fetchedAction } = await supertest
|
||||
.get(`/api/actions/connector/${createdAction.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(fetchedAction).to.eql({
|
||||
id: fetchedAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'An email action',
|
||||
connector_type_id: '.email',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
from: 'bob@example.com',
|
||||
service: 'hotmail',
|
||||
hasAuth: true,
|
||||
host: null,
|
||||
port: null,
|
||||
secure: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -64,5 +64,23 @@ export default function createGetTests({ getService }: FtrProviderContext) {
|
|||
expect(responseWithisMissingSecrets.status).to.eql(200);
|
||||
expect(responseWithisMissingSecrets.body.isMissingSecrets).to.eql(false);
|
||||
});
|
||||
|
||||
it('7.16.0 migrates email connector configurations to set `service` property if not set', async () => {
|
||||
const connectorWithService = await supertest.get(
|
||||
`${getUrlPrefix(``)}/api/actions/action/0f8f2810-0a59-11ec-9a7c-fd0c2b83ff7c`
|
||||
);
|
||||
|
||||
expect(connectorWithService.status).to.eql(200);
|
||||
expect(connectorWithService.body.config).key('service');
|
||||
expect(connectorWithService.body.config.service).to.eql('someservice');
|
||||
|
||||
const connectorWithoutService = await supertest.get(
|
||||
`${getUrlPrefix(``)}/api/actions/action/1e0824a0-0a59-11ec-9a7c-fd0c2b83ff7c`
|
||||
);
|
||||
|
||||
expect(connectorWithoutService.status).to.eql(200);
|
||||
expect(connectorWithoutService.body.config).key('service');
|
||||
expect(connectorWithoutService.body.config.service).to.eql('other');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -110,3 +110,67 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "doc",
|
||||
"value": {
|
||||
"id": "action:0f8f2810-0a59-11ec-9a7c-fd0c2b83ff7c",
|
||||
"index": ".kibana_1",
|
||||
"source": {
|
||||
"action": {
|
||||
"actionTypeId" : ".email",
|
||||
"name" : "test email connector with auth",
|
||||
"isMissingSecrets" : false,
|
||||
"config" : {
|
||||
"hasAuth" : true,
|
||||
"from" : "me@me.com",
|
||||
"host" : "smtp.myhost.com",
|
||||
"port" : 25,
|
||||
"service" : "someservice",
|
||||
"secure" : null
|
||||
},
|
||||
"secrets" : "V2EJEtTv3yTFi1kdglhNahnKYWCS+J7aWCJQU+eEqGPZEz6n7G1NsBWoh7IY0FteLTilTteQXyY/Eg3k/7bb0G8Mz+WBZ1mRvUggGTFqgoOptyUsvHoBhv0R/1bCTCabN3Pe88AfnC+VDXqwuMifpmgKEEsKF3H8VONv7TYO02FW"
|
||||
},
|
||||
"migrationVersion": {
|
||||
"action": "7.14.0"
|
||||
},
|
||||
"coreMigrationVersion" : "7.15.0",
|
||||
"references": [
|
||||
],
|
||||
"type": "action",
|
||||
"updated_at": "2021-08-31T12:43:37.117Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "doc",
|
||||
"value": {
|
||||
"id": "action:1e0824a0-0a59-11ec-9a7c-fd0c2b83ff7c",
|
||||
"index": ".kibana_1",
|
||||
"source": {
|
||||
"action": {
|
||||
"actionTypeId" : ".email",
|
||||
"name" : "test email connector no auth",
|
||||
"isMissingSecrets" : false,
|
||||
"config" : {
|
||||
"hasAuth" : false,
|
||||
"from" : "you@you.com",
|
||||
"host" : "smtp.you.com",
|
||||
"port" : 485,
|
||||
"secure" : true,
|
||||
"service" : null
|
||||
},
|
||||
"secrets" : "iw/bRTXZQXOV0ODocb6FQnHR6AyeVyD91We03llNStyTNFwuHVWdFl6ZdiEEeDOadBMeJomvp/dAfQevGpbwWdclcu9F87x3CfeGqV9DtBy0dXRbx9PzKBwgJdK3ucHQDFAs8ZXQbefvCOFjCHGAsJDPhTKj5rTUyg=="
|
||||
},
|
||||
"migrationVersion": {
|
||||
"action": "7.14.0"
|
||||
},
|
||||
"coreMigrationVersion" : "7.15.0",
|
||||
"references": [
|
||||
],
|
||||
"type": "action",
|
||||
"updated_at": "2021-08-31T12:44:01.396Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -572,6 +572,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"coreMigrationVersion": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"dashboard": {
|
||||
"properties": {
|
||||
"description": {
|
||||
|
|
Loading…
Reference in a new issue