[Actions] Allowing service specification in email connector config (#110458) (#111146)

* 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:
ymao1 2021-09-03 12:20:31 -04:00 committed by GitHub
parent 54d1f9d9b4
commit ef2e1306f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1181 additions and 22 deletions

View file

@ -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*.

View file

@ -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';

View file

@ -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: {

View file

@ -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;

View file

@ -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));
});
});

View file

@ -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,
});
})
)
);
};

View file

@ -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);
}

View file

@ -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', () => {

View file

@ -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> => {

View file

@ -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"],

View file

@ -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;

View file

@ -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}`);
}

View file

@ -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', () => {

View file

@ -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);
}

View file

@ -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',

View file

@ -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);

View file

@ -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',
{

View file

@ -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);
});
});

View file

@ -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,
};
}

View file

@ -78,6 +78,7 @@ export interface EmailConfig {
port: number;
secure?: boolean;
hasAuth: boolean;
service: string;
}
export interface EmailSecrets {

View file

@ -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';

View file

@ -40,6 +40,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
list: jest.fn(),
} as ActionTypeRegistryContract,
charts: chartPluginMock.createStartContract(),
isCloud: false,
kibanaFeatures: [],
element: ({
style: { cursor: 'pointer' },

View file

@ -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,

View file

@ -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"',

View file

@ -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,
},
});
});
});
}

View file

@ -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');
});
});
}

View file

@ -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"
}
}
}

View file

@ -572,6 +572,9 @@
}
}
},
"coreMigrationVersion": {
"type": "keyword"
},
"dashboard": {
"properties": {
"description": {