diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index a9c2430c4f395..91c71a78a8ee0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -208,6 +208,10 @@ describe('params validation', () => { Object { "bcc": Array [], "cc": Array [], + "kibanaFooterLink": Object { + "path": "/", + "text": "Go to Kibana", + }, "message": "this is the message", "subject": "this is a test", "to": Array [ @@ -228,35 +232,40 @@ describe('params validation', () => { }); describe('execute()', () => { - test('ensure parameters are as expected', async () => { - const config: ActionTypeConfigType = { - service: '__json', - host: 'a host', - port: 42, - secure: true, - from: 'bob@example.com', - hasAuth: true, - }; - const secrets: ActionTypeSecretsType = { - user: 'bob', - password: 'supersecret', - }; - const params: ActionParamsType = { - to: ['jim@example.com'], - cc: ['james@example.com'], - bcc: ['jimmy@example.com'], - subject: 'the subject', - message: 'a message to you', - }; + const config: ActionTypeConfigType = { + service: '__json', + host: 'a host', + port: 42, + secure: true, + from: 'bob@example.com', + hasAuth: true, + }; + const secrets: ActionTypeSecretsType = { + user: 'bob', + password: 'supersecret', + }; + const params: ActionParamsType = { + to: ['jim@example.com'], + cc: ['james@example.com'], + bcc: ['jimmy@example.com'], + subject: 'the subject', + message: 'a message to you', + kibanaFooterLink: { + path: '/', + text: 'Go to Kibana', + }, + }; + + const actionId = 'some-id'; + const executorOptions: EmailActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; - const actionId = 'some-id'; - const executorOptions: EmailActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; + test('ensure parameters are as expected', async () => { sendEmailMock.mockReset(); const result = await actionType.executor(executorOptions); expect(result).toMatchInlineSnapshot(` @@ -267,69 +276,63 @@ describe('execute()', () => { } `); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` - Object { - "content": Object { - "message": "a message to you", - "subject": "the subject", - }, - "hasAuth": true, - "proxySettings": undefined, - "routing": Object { - "bcc": Array [ - "jimmy@example.com", - ], - "cc": Array [ - "james@example.com", - ], - "from": "bob@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "password": "supersecret", - "service": "__json", - "user": "bob", - }, - } + Object { + "content": Object { + "message": "a message to you + + -- + + This message was sent by Kibana.", + "subject": "the subject", + }, + "hasAuth": true, + "proxySettings": undefined, + "routing": Object { + "bcc": Array [ + "jimmy@example.com", + ], + "cc": Array [ + "james@example.com", + ], + "from": "bob@example.com", + "to": Array [ + "jim@example.com", + ], + }, + "transport": Object { + "password": "supersecret", + "service": "__json", + "user": "bob", + }, + } `); }); test('parameters are as expected with no auth', async () => { - const config: ActionTypeConfigType = { - service: null, - host: 'a host', - port: 42, - secure: true, - from: 'bob@example.com', - hasAuth: false, - }; - const secrets: ActionTypeSecretsType = { - user: null, - password: null, - }; - const params: ActionParamsType = { - to: ['jim@example.com'], - cc: ['james@example.com'], - bcc: ['jimmy@example.com'], - subject: 'the subject', - message: 'a message to you', + const customExecutorOptions: EmailActionTypeExecutorOptions = { + ...executorOptions, + config: { + ...config, + service: null, + hasAuth: false, + }, + secrets: { + ...secrets, + user: null, + password: null, + }, }; - const actionId = 'some-id'; - const executorOptions: EmailActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; sendEmailMock.mockReset(); - await actionType.executor(executorOptions); + await actionType.executor(customExecutorOptions); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "content": Object { - "message": "a message to you", + "message": "a message to you + + -- + + This message was sent by Kibana.", "subject": "the subject", }, "hasAuth": false, @@ -356,37 +359,23 @@ describe('execute()', () => { }); test('returns expected result when an error is thrown', async () => { - const config: ActionTypeConfigType = { - service: null, - host: 'a host', - port: 42, - secure: true, - from: 'bob@example.com', - hasAuth: false, - }; - const secrets: ActionTypeSecretsType = { - user: null, - password: null, - }; - const params: ActionParamsType = { - to: ['jim@example.com'], - cc: ['james@example.com'], - bcc: ['jimmy@example.com'], - subject: 'the subject', - message: 'a message to you', + const customExecutorOptions: EmailActionTypeExecutorOptions = { + ...executorOptions, + config: { + ...config, + service: null, + hasAuth: false, + }, + secrets: { + ...secrets, + user: null, + password: null, + }, }; - const actionId = 'some-id'; - const executorOptions: EmailActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; sendEmailMock.mockReset(); sendEmailMock.mockRejectedValue(new Error('wops')); - const result = await actionType.executor(executorOptions); + const result = await actionType.executor(customExecutorOptions); expect(result).toMatchInlineSnapshot(` Object { "actionId": "some-id", @@ -405,15 +394,19 @@ describe('execute()', () => { bcc: ['jim', '{{rogue}}', 'bob'], subject: '{{rogue}}', message: '{{rogue}}', + kibanaFooterLink: { + path: '/', + text: 'Go to Kibana', + }, }; const variables = { rogue: '*bold*', }; - const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + const renderedParams = actionType.renderParameterTemplates!(paramsWithTemplates, variables); // Yes, this is tested in the snapshot below, but it's double-escaped there, // so easier to see here that the escaping is correct. - expect(params.message).toBe('\\*bold\\*'); - expect(params).toMatchInlineSnapshot(` + expect(renderedParams.message).toBe('\\*bold\\*'); + expect(renderedParams).toMatchInlineSnapshot(` Object { "bcc": Array [ "jim", @@ -423,10 +416,65 @@ describe('execute()', () => { "cc": Array [ "*bold*", ], + "kibanaFooterLink": Object { + "path": "/", + "text": "Go to Kibana", + }, "message": "\\\\*bold\\\\*", "subject": "*bold*", "to": Array [], } `); }); + + test('provides a footer link to Kibana when publicBaseUrl is defined', async () => { + const actionTypeWithPublicUrl = getActionType({ + logger: mockedLogger, + configurationUtilities: actionsConfigMock.create(), + publicBaseUrl: 'https://localhost:1234/foo/bar', + }); + + await actionTypeWithPublicUrl.executor(executorOptions); + + expect(sendEmailMock).toHaveBeenCalledTimes(1); + const sendMailCall = sendEmailMock.mock.calls[0][1]; + expect(sendMailCall.content.message).toMatchInlineSnapshot(` + "a message to you + + -- + + This message was sent by Kibana. [Go to Kibana](https://localhost:1234/foo/bar)." + `); + }); + + test('allows to generate a deep link into Kibana when publicBaseUrl is defined', async () => { + const actionTypeWithPublicUrl = getActionType({ + logger: mockedLogger, + configurationUtilities: actionsConfigMock.create(), + publicBaseUrl: 'https://localhost:1234/foo/bar', + }); + + const customExecutorOptions: EmailActionTypeExecutorOptions = { + ...executorOptions, + params: { + ...params, + kibanaFooterLink: { + path: '/my/app', + text: 'View this in Kibana', + }, + }, + }; + + await actionTypeWithPublicUrl.executor(customExecutorOptions); + + expect(sendEmailMock).toHaveBeenCalledTimes(1); + const sendMailCall = sendEmailMock.mock.calls[0][1]; + expect(sendMailCall.content.message).toMatchInlineSnapshot(` + "a message to you + + -- + + This message was sent by Kibana. [View this in Kibana](https://localhost:1234/foo/bar/my/app)." + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 06f18916d7ee5..cf4ace99ed5dc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -31,6 +31,8 @@ export type EmailActionTypeExecutorOptions = ActionTypeExecutorOptions< // config definition export type ActionTypeConfigType = TypeOf; +const EMAIL_FOOTER_DIVIDER = '\n\n--\n\n'; + const ConfigSchemaProps = { service: schema.nullable(schema.string()), host: schema.nullable(schema.string()), @@ -102,6 +104,16 @@ const ParamsSchema = schema.object( bcc: schema.arrayOf(schema.string(), { defaultValue: [] }), subject: schema.string(), message: schema.string(), + // kibanaFooterLink isn't inteded for users to set, this is here to be able to programatically + // provide a more contextual URL in the footer (ex: URL to the alert details page) + kibanaFooterLink: schema.object({ + path: schema.string({ defaultValue: '/' }), + text: schema.string({ + defaultValue: i18n.translate('xpack.actions.builtin.email.kibanaFooterLinkText', { + defaultMessage: 'Go to Kibana', + }), + }), + }), }, { validate: validateParams, @@ -122,12 +134,13 @@ function validateParams(paramsObject: unknown): string | void { interface GetActionTypeParams { logger: Logger; + publicBaseUrl?: string; configurationUtilities: ActionsConfigurationUtilities; } // action type definition export function getActionType(params: GetActionTypeParams): EmailActionType { - const { logger, configurationUtilities } = params; + const { logger, publicBaseUrl, configurationUtilities } = params; return { id: '.email', minimumLicenseRequired: 'gold', @@ -142,7 +155,7 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { params: ParamsSchema, }, renderParameterTemplates, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, publicBaseUrl }), }; } @@ -161,7 +174,10 @@ function renderParameterTemplates( // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + publicBaseUrl, + }: { logger: GetActionTypeParams['logger']; publicBaseUrl: GetActionTypeParams['publicBaseUrl'] }, execOptions: EmailActionTypeExecutorOptions ): Promise> { const actionId = execOptions.actionId; @@ -187,6 +203,11 @@ async function executor( transport.secure = getSecureValue(config.secure, config.port); } + const footerMessage = getFooterMessage({ + publicBaseUrl, + kibanaFooterLink: params.kibanaFooterLink, + }); + const sendEmailOptions: SendEmailOptions = { transport, routing: { @@ -197,7 +218,7 @@ async function executor( }, content: { subject: params.subject, - message: params.message, + message: `${params.message}${EMAIL_FOOTER_DIVIDER}${footerMessage}`, }, proxySettings: execOptions.proxySettings, hasAuth: config.hasAuth, @@ -244,3 +265,25 @@ function getSecureValue(secure: boolean | null | undefined, port: number | null) if (port === 465) return true; return false; } + +function getFooterMessage({ + publicBaseUrl, + kibanaFooterLink, +}: { + publicBaseUrl: GetActionTypeParams['publicBaseUrl']; + kibanaFooterLink: ActionParamsType['kibanaFooterLink']; +}) { + if (!publicBaseUrl) { + return i18n.translate('xpack.actions.builtin.email.sentByKibanaMessage', { + defaultMessage: 'This message was sent by Kibana.', + }); + } + + return i18n.translate('xpack.actions.builtin.email.customViewInKibanaMessage', { + defaultMessage: 'This message was sent by Kibana. [{kibanaFooterLinkText}]({link}).', + values: { + kibanaFooterLinkText: kibanaFooterLink.text, + link: `${publicBaseUrl}${kibanaFooterLink.path === '/' ? '' : kibanaFooterLink.path}`, + }, + }); +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index edbf13d9e5ed1..c2058d63683bf 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -23,12 +23,16 @@ export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, actionTypeRegistry, logger, + publicBaseUrl, }: { actionsConfigUtils: ActionsConfigurationUtilities; actionTypeRegistry: ActionTypeRegistry; logger: Logger; + publicBaseUrl?: string; }) { - actionTypeRegistry.register(getEmailActionType({ logger, configurationUtilities })); + actionTypeRegistry.register( + getEmailActionType({ logger, configurationUtilities, publicBaseUrl }) + ); actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServerLogActionType({ logger })); diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 4d52b1c8b3492..7c41bf99af472 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -215,6 +215,7 @@ export class ActionsPlugin implements Plugin, Plugi logger: this.logger, actionTypeRegistry, actionsConfigUtils, + publicBaseUrl: core.http.basePath.publicBaseUrl, }); const usageCollection = plugins.usageCollection; diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 4cb82e9cc86a1..b414e726f0101 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -15,6 +15,11 @@ import { import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { KibanaRequest } from 'kibana/server'; import { asSavedObjectExecutionSource } from '../../../actions/server'; +import { InjectActionParamsOpts } from './inject_action_params'; + +jest.mock('./inject_action_params', () => ({ + injectActionParams: jest.fn(), +})); const alertType: AlertType = { id: 'test', @@ -69,6 +74,11 @@ const createExecutionHandlerParams = { beforeEach(() => { jest.resetAllMocks(); + jest + .requireMock('./inject_action_params') + .injectActionParams.mockImplementation( + ({ actionParams }: InjectActionParamsOpts) => actionParams + ); createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( @@ -146,6 +156,17 @@ test('enqueues execution per selected action', async () => { ], ] `); + + expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ + alertId: '1', + actionTypeId: 'test', + actionParams: { + alertVal: 'My 1 name-of-alert default tag-A,tag-B 2 goes here', + contextVal: 'My goes here', + foo: true, + stateVal: 'My goes here', + }, + }); }); test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index e02a4a1c823c0..8c7ad79483194 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -12,6 +12,7 @@ import { } from '../../../actions/server'; import { IEventLogger, IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS } from '../plugin'; +import { injectActionParams } from './inject_action_params'; import { AlertAction, AlertInstanceState, @@ -94,7 +95,15 @@ export function createExecutionHandler({ alertParams, }), }; - }); + }) + .map((action) => ({ + ...action, + params: injectActionParams({ + alertId, + actionParams: action.params, + actionTypeId: action.actionTypeId, + }), + })); const alertLabel = `${alertType.id}:${alertId}: '${alertName}'`; diff --git a/x-pack/plugins/alerts/server/task_runner/inject_action_params.test.ts b/x-pack/plugins/alerts/server/task_runner/inject_action_params.test.ts new file mode 100644 index 0000000000000..c14f862c7cc22 --- /dev/null +++ b/x-pack/plugins/alerts/server/task_runner/inject_action_params.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { injectActionParams } from './inject_action_params'; + +describe('injectActionParams', () => { + test(`passes through when actionTypeId isn't .email`, () => { + const actionParams = { + message: 'State: "{{state.value}}", Context: "{{context.value}}"', + }; + const result = injectActionParams({ + actionParams, + alertId: '1', + actionTypeId: '.server-log', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "message": "State: \\"{{state.value}}\\", Context: \\"{{context.value}}\\"", + } + `); + }); + + test('injects viewInKibanaPath and viewInKibanaText when actionTypeId is .email', () => { + const actionParams = { + body: { + message: 'State: "{{state.value}}", Context: "{{context.value}}"', + }, + }; + const result = injectActionParams({ + actionParams, + alertId: '1', + actionTypeId: '.email', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "body": Object { + "message": "State: \\"{{state.value}}\\", Context: \\"{{context.value}}\\"", + }, + "kibanaFooterLink": Object { + "path": "/app/management/insightsAndAlerting/triggersActions/alert/1", + "text": "View alert in Kibana", + }, + } + `); + }); +}); diff --git a/x-pack/plugins/alerts/server/task_runner/inject_action_params.ts b/x-pack/plugins/alerts/server/task_runner/inject_action_params.ts new file mode 100644 index 0000000000000..9a7fab558a77a --- /dev/null +++ b/x-pack/plugins/alerts/server/task_runner/inject_action_params.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { AlertActionParams } from '../types'; + +export interface InjectActionParamsOpts { + alertId: string; + actionTypeId: string; + actionParams: AlertActionParams; +} + +export function injectActionParams({ + alertId, + actionTypeId, + actionParams, +}: InjectActionParamsOpts) { + // Inject kibanaFooterLink if action type is email. This is used by the email action type + // to inject a "View alert in Kibana" with a URL in the email's footer. + if (actionTypeId === '.email') { + return { + ...actionParams, + kibanaFooterLink: { + path: `/app/management/insightsAndAlerting/triggersActions/alert/${alertId}`, + text: i18n.translate('xpack.alerts.injectActionParams.email.kibanaFooterLinkText', { + defaultMessage: 'View alert in Kibana', + }), + }, + }; + } + + // Fallback, return action params unchanged + return actionParams; +} diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 866dd0581b548..cf944008c08d6 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -90,6 +90,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ...xPackApiIntegrationTestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + '--server.publicBaseUrl=https://localhost:5601', `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerts.invalidateApiKeysTask.interval="15s"', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts index f6b0f06a6722e..571c9bae24f29 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts @@ -107,8 +107,9 @@ export default function emailTest({ getService }: FtrProviderContext) { cc: null, bcc: null, subject: 'email-subject', - html: '

email-message

\n', - text: 'email-message', + html: `

email-message

\n

--

\n

This message was sent by Kibana. Go to Kibana.

\n`, + text: + 'email-message\n\n--\n\nThis message was sent by Kibana. [Go to Kibana](https://localhost:5601).', headers: {}, }, }); @@ -129,9 +130,38 @@ export default function emailTest({ getService }: FtrProviderContext) { .expect(200) .then((resp: any) => { const { text, html } = resp.body.data.message; - expect(text).to.eql('_italic_ **bold** https://elastic.co link'); + expect(text).to.eql( + '_italic_ **bold** https://elastic.co link\n\n--\n\nThis message was sent by Kibana. [Go to Kibana](https://localhost:5601).' + ); + expect(html).to.eql( + `

italic bold https://elastic.co link

\n

--

\n

This message was sent by Kibana. Go to Kibana.

\n` + ); + }); + }); + + it('should allow customizing the kibana footer link', async () => { + await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['kibana-action-test@elastic.co'], + subject: 'message with markdown', + message: 'message', + kibanaFooterLink: { + path: '/my/path', + text: 'View my path in Kibana', + }, + }, + }) + .expect(200) + .then((resp: any) => { + const { text, html } = resp.body.data.message; + expect(text).to.eql( + 'message\n\n--\n\nThis message was sent by Kibana. [View my path in Kibana](https://localhost:5601/my/path).' + ); expect(html).to.eql( - '

italic bold https://elastic.co link

\n' + `

message

\n

--

\n

This message was sent by Kibana. View my path in Kibana.

\n` ); }); }); @@ -278,8 +308,9 @@ export default function emailTest({ getService }: FtrProviderContext) { cc: null, bcc: null, subject: 'email-subject', - html: '

email-message

\n', - text: 'email-message', + html: `

email-message

\n

--

\n

This message was sent by Kibana. Go to Kibana.

\n`, + text: + 'email-message\n\n--\n\nThis message was sent by Kibana. [Go to Kibana](https://localhost:5601).', headers: {}, }, });