[ResponseOps] provide config to turn off email action footer (#154919)

resolves https://github.com/elastic/kibana/issues/135402

Allows deployments to not have the default footer added to alerting
emails via the new `xpack.actions.enableFooterInEmail` config setting.
The default value is `true`, which renders the footer.  Setting the
value to `false` will cause no footer to be rendered.

Also changes the footer separator from `--` to `---`, which renders
nicer in HTML, as a `<hr>` element.
This commit is contained in:
Patrick Mueller 2023-04-17 11:19:16 -04:00 committed by GitHub
parent 70d5dad847
commit bcfe4b0005
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 96 additions and 20 deletions

View file

@ -130,6 +130,9 @@ A list of allowed email domains which can be used with the email connector. When
WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly. WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly.
`xpack.actions.enableFooterInEmail` {ess-icon}::
A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions. Default: true.
`xpack.actions.enabledActionTypes` {ess-icon}:: `xpack.actions.enabledActionTypes` {ess-icon}::
A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types.
+ +

View file

@ -580,6 +580,7 @@ describe('create()', () => {
verificationMode: 'full', verificationMode: 'full',
proxyVerificationMode: 'full', proxyVerificationMode: 'full',
}, },
enableFooterInEmail: true,
}); });
const localActionTypeRegistryParams = { const localActionTypeRegistryParams = {

View file

@ -27,6 +27,7 @@ const createActionsConfigMock = () => {
getMicrosoftGraphApiUrl: jest.fn().mockReturnValue(undefined), getMicrosoftGraphApiUrl: jest.fn().mockReturnValue(undefined),
validateEmailAddresses: jest.fn().mockReturnValue(undefined), validateEmailAddresses: jest.fn().mockReturnValue(undefined),
getMaxAttempts: jest.fn().mockReturnValue(3), getMaxAttempts: jest.fn().mockReturnValue(3),
enableFooterInEmail: jest.fn().mockReturnValue(true),
}; };
return mocked; return mocked;
}; };

View file

@ -33,6 +33,7 @@ const defaultActionsConfig: ActionsConfig = {
proxyVerificationMode: 'full', proxyVerificationMode: 'full',
verificationMode: 'full', verificationMode: 'full',
}, },
enableFooterInEmail: true,
}; };
describe('ensureUriAllowed', () => { describe('ensureUriAllowed', () => {

View file

@ -53,6 +53,7 @@ export interface ActionsConfigurationUtilities {
addresses: string[], addresses: string[],
options?: ValidateEmailAddressesOptions options?: ValidateEmailAddressesOptions
): string | undefined; ): string | undefined;
enableFooterInEmail: () => boolean;
} }
function allowListErrorMessage(field: AllowListingField, value: string) { function allowListErrorMessage(field: AllowListingField, value: string) {
@ -215,5 +216,6 @@ export function getActionsConfigurationUtilities(
DEFAULT_MAX_ATTEMPTS DEFAULT_MAX_ATTEMPTS
); );
}, },
enableFooterInEmail: () => config.enableFooterInEmail,
}; };
} }

View file

@ -23,6 +23,7 @@ describe('config validation', () => {
"allowedHosts": Array [ "allowedHosts": Array [
"*", "*",
], ],
"enableFooterInEmail": true,
"enabledActionTypes": Array [ "enabledActionTypes": Array [
"*", "*",
], ],
@ -57,6 +58,7 @@ describe('config validation', () => {
"allowedHosts": Array [ "allowedHosts": Array [
"*", "*",
], ],
"enableFooterInEmail": true,
"enabledActionTypes": Array [ "enabledActionTypes": Array [
"*", "*",
], ],
@ -198,6 +200,7 @@ describe('config validation', () => {
"allowedHosts": Array [ "allowedHosts": Array [
"*", "*",
], ],
"enableFooterInEmail": true,
"enabledActionTypes": Array [ "enabledActionTypes": Array [
"*", "*",
], ],

View file

@ -125,6 +125,7 @@ export const configSchema = schema.object({
connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)), connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)),
}) })
), ),
enableFooterInEmail: schema.boolean({ defaultValue: true }),
}); });
export type ActionsConfig = TypeOf<typeof configSchema>; export type ActionsConfig = TypeOf<typeof configSchema>;

View file

@ -580,6 +580,7 @@ const BaseActionsConfig: ActionsConfig = {
maxResponseContentLength: ByteSizeValue.parse('1mb'), maxResponseContentLength: ByteSizeValue.parse('1mb'),
responseTimeout: momentDuration(1000 * 30), responseTimeout: momentDuration(1000 * 30),
customHostSettings: undefined, customHostSettings: undefined,
enableFooterInEmail: true,
}; };
function getACUfromConfig(config: Partial<ActionsConfig> = {}): ActionsConfigurationUtilities { function getACUfromConfig(config: Partial<ActionsConfig> = {}): ActionsConfigurationUtilities {

View file

@ -592,6 +592,7 @@ const BaseActionsConfig: ActionsConfig = {
maxResponseContentLength: ByteSizeValue.parse('1mb'), maxResponseContentLength: ByteSizeValue.parse('1mb'),
responseTimeout: momentDuration(1000 * 30), responseTimeout: momentDuration(1000 * 30),
customHostSettings: undefined, customHostSettings: undefined,
enableFooterInEmail: true,
}; };
function getACUfromConfig(config: Partial<ActionsConfig> = {}): ActionsConfigurationUtilities { function getACUfromConfig(config: Partial<ActionsConfig> = {}): ActionsConfigurationUtilities {

View file

@ -73,6 +73,7 @@ describe('custom_host_settings', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
maxResponseContentLength: new ByteSizeValue(1000000), maxResponseContentLength: new ByteSizeValue(1000000),
responseTimeout: moment.duration(60000), responseTimeout: moment.duration(60000),
enableFooterInEmail: true,
}; };
test('ensure it copies over the config parts that it does not touch', () => { test('ensure it copies over the config parts that it does not touch', () => {

View file

@ -46,6 +46,7 @@ describe('Actions Plugin', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
maxResponseContentLength: new ByteSizeValue(1000000), maxResponseContentLength: new ByteSizeValue(1000000),
responseTimeout: moment.duration(60000), responseTimeout: moment.duration(60000),
enableFooterInEmail: true,
}); });
plugin = new ActionsPlugin(context); plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup(); coreSetup = coreMock.createSetup();
@ -218,6 +219,7 @@ describe('Actions Plugin', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
maxResponseContentLength: new ByteSizeValue(1000000), maxResponseContentLength: new ByteSizeValue(1000000),
responseTimeout: moment.duration('60s'), responseTimeout: moment.duration('60s'),
enableFooterInEmail: true,
...overrides, ...overrides,
}; };
} }
@ -273,6 +275,7 @@ describe('Actions Plugin', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
maxResponseContentLength: new ByteSizeValue(1000000), maxResponseContentLength: new ByteSizeValue(1000000),
responseTimeout: moment.duration(60000), responseTimeout: moment.duration(60000),
enableFooterInEmail: true,
}); });
plugin = new ActionsPlugin(context); plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup(); coreSetup = coreMock.createSetup();
@ -341,6 +344,7 @@ describe('Actions Plugin', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
maxResponseContentLength: new ByteSizeValue(1000000), maxResponseContentLength: new ByteSizeValue(1000000),
responseTimeout: moment.duration('60s'), responseTimeout: moment.duration('60s'),
enableFooterInEmail: true,
...overrides, ...overrides,
}; };
} }

View file

@ -523,6 +523,10 @@ describe('execute()', () => {
logger: mockedLogger, logger: mockedLogger,
}; };
beforeEach(() => {
executorOptions.configurationUtilities = actionsConfigMock.create();
});
test('ensure parameters are as expected', async () => { test('ensure parameters are as expected', async () => {
sendEmailMock.mockReset(); sendEmailMock.mockReset();
const result = await connectorType.executor(executorOptions); const result = await connectorType.executor(executorOptions);
@ -540,7 +544,7 @@ describe('execute()', () => {
"content": Object { "content": Object {
"message": "a message to you "message": "a message to you
-- ---
This message was sent by Elastic.", This message was sent by Elastic.",
"subject": "the subject", "subject": "the subject",
@ -591,7 +595,7 @@ describe('execute()', () => {
"content": Object { "content": Object {
"message": "a message to you "message": "a message to you
-- ---
This message was sent by Elastic.", This message was sent by Elastic.",
"subject": "the subject", "subject": "the subject",
@ -642,7 +646,7 @@ describe('execute()', () => {
"content": Object { "content": Object {
"message": "a message to you "message": "a message to you
-- ---
This message was sent by Elastic.", This message was sent by Elastic.",
"subject": "the subject", "subject": "the subject",
@ -738,6 +742,28 @@ describe('execute()', () => {
`); `);
}); });
test('provides no footer link when enableFooterInEmail is false', async () => {
const customExecutorOptions: EmailConnectorTypeExecutorOptions = {
...executorOptions,
configurationUtilities: {
...configurationUtilities,
enableFooterInEmail: jest.fn().mockReturnValue(false),
},
};
const connectorTypeWithPublicUrl = getConnectorType({
publicBaseUrl: 'https://localhost:1234/foo/bar',
});
await connectorTypeWithPublicUrl.executor(customExecutorOptions);
expect(customExecutorOptions.configurationUtilities.enableFooterInEmail).toHaveBeenCalledTimes(
1
);
const sendMailCall = sendEmailMock.mock.calls[0][1];
expect(sendMailCall.content.message).toMatchInlineSnapshot(`"a message to you"`);
});
test('provides a footer link to Elastic when publicBaseUrl is defined', async () => { test('provides a footer link to Elastic when publicBaseUrl is defined', async () => {
const connectorTypeWithPublicUrl = getConnectorType({ const connectorTypeWithPublicUrl = getConnectorType({
publicBaseUrl: 'https://localhost:1234/foo/bar', publicBaseUrl: 'https://localhost:1234/foo/bar',
@ -750,7 +776,7 @@ describe('execute()', () => {
expect(sendMailCall.content.message).toMatchInlineSnapshot(` expect(sendMailCall.content.message).toMatchInlineSnapshot(`
"a message to you "a message to you
-- ---
This message was sent by Elastic. [Go to Elastic](https://localhost:1234/foo/bar)." This message was sent by Elastic. [Go to Elastic](https://localhost:1234/foo/bar)."
`); `);
@ -779,7 +805,7 @@ describe('execute()', () => {
expect(sendMailCall.content.message).toMatchInlineSnapshot(` expect(sendMailCall.content.message).toMatchInlineSnapshot(`
"a message to you "a message to you
-- ---
This message was sent by Elastic. [View this in Elastic](https://localhost:1234/foo/bar/my/app)." This message was sent by Elastic. [View this in Elastic](https://localhost:1234/foo/bar/my/app)."
`); `);

View file

@ -54,7 +54,7 @@ export const ELASTIC_CLOUD_SERVICE: SMTPConnection.Options = {
secure: false, secure: false,
}; };
const EMAIL_FOOTER_DIVIDER = '\n\n--\n\n'; const EMAIL_FOOTER_DIVIDER = '\n\n---\n\n';
const ConfigSchemaProps = { const ConfigSchemaProps = {
service: schema.string({ defaultValue: 'other' }), service: schema.string({ defaultValue: 'other' }),
@ -319,10 +319,14 @@ async function executor(
transport.service = config.service; transport.service = config.service;
} }
const footerMessage = getFooterMessage({ let actualMessage = params.message;
publicBaseUrl, if (configurationUtilities.enableFooterInEmail()) {
kibanaFooterLink: params.kibanaFooterLink, const footerMessage = getFooterMessage({
}); publicBaseUrl,
kibanaFooterLink: params.kibanaFooterLink,
});
actualMessage = `${params.message}${EMAIL_FOOTER_DIVIDER}${footerMessage}`;
}
const sendEmailOptions: SendEmailOptions = { const sendEmailOptions: SendEmailOptions = {
connectorId: actionId, connectorId: actionId,
@ -335,7 +339,7 @@ async function executor(
}, },
content: { content: {
subject: params.subject, subject: params.subject,
message: `${params.message}${EMAIL_FOOTER_DIVIDER}${footerMessage}`, message: actualMessage,
}, },
hasAuth: config.hasAuth, hasAuth: config.hasAuth,
configurationUtilities, configurationUtilities,

View file

@ -27,6 +27,7 @@ interface CreateTestConfigOptions {
testFiles?: string[]; testFiles?: string[];
reportName?: string; reportName?: string;
useDedicatedTaskRunner: boolean; useDedicatedTaskRunner: boolean;
enableFooterInEmail?: boolean;
} }
// test.not-enabled is specifically not enabled // test.not-enabled is specifically not enabled
@ -75,6 +76,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
testFiles = undefined, testFiles = undefined,
reportName = undefined, reportName = undefined,
useDedicatedTaskRunner, useDedicatedTaskRunner,
enableFooterInEmail = true,
} = options; } = options;
return async ({ readConfigFile }: FtrConfigProviderContext) => { return async ({ readConfigFile }: FtrConfigProviderContext) => {
@ -173,6 +175,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
'some.non.existent.com', 'some.non.existent.com',
'smtp.live.com', 'smtp.live.com',
])}`, ])}`,
`--xpack.actions.enableFooterInEmail=${enableFooterInEmail}`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.alerting.invalidateApiKeysTask.interval="15s"', '--xpack.alerting.invalidateApiKeysTask.interval="15s"',
'--xpack.alerting.healthCheck.interval="1s"', '--xpack.alerting.healthCheck.interval="1s"',

View file

@ -43,7 +43,7 @@ export function initPlugin(router: IRouter, path: string) {
cc: null, cc: null,
bcc: null, bcc: null,
subject: 'email-subject', subject: 'email-subject',
html: `<p>email-message</p>\n<p>--</p>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`, html: `<p>email-message</p>\n<hr>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`,
text: 'email-message\n\n--\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).', text: 'email-message\n\n--\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).',
headers: {}, headers: {},
}, },

View file

@ -124,8 +124,8 @@ export default function emailTest({ getService }: FtrProviderContext) {
cc: null, cc: null,
bcc: null, bcc: null,
subject: 'email-subject', subject: 'email-subject',
html: `<p>email-message</p>\n<p>--</p>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`, html: `<p>email-message</p>\n<hr>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`,
text: 'email-message\n\n--\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).', text: 'email-message\n\n---\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).',
headers: {}, headers: {},
}, },
}); });
@ -147,10 +147,10 @@ export default function emailTest({ getService }: FtrProviderContext) {
.then((resp: any) => { .then((resp: any) => {
const { text, html } = resp.body.data.message; const { text, html } = resp.body.data.message;
expect(text).to.eql( expect(text).to.eql(
'_italic_ **bold** https://elastic.co link\n\n--\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).' '_italic_ **bold** https://elastic.co link\n\n---\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).'
); );
expect(html).to.eql( expect(html).to.eql(
`<p><em>italic</em> <strong>bold</strong> <a href="https://elastic.co">https://elastic.co</a> link</p>\n<p>--</p>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n` `<p><em>italic</em> <strong>bold</strong> <a href="https://elastic.co">https://elastic.co</a> link</p>\n<hr>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`
); );
}); });
}); });
@ -174,10 +174,10 @@ export default function emailTest({ getService }: FtrProviderContext) {
.then((resp: any) => { .then((resp: any) => {
const { text, html } = resp.body.data.message; const { text, html } = resp.body.data.message;
expect(text).to.eql( expect(text).to.eql(
'message\n\n--\n\nThis message was sent by Elastic. [View my path in Elastic](https://localhost:5601/my/path).' 'message\n\n---\n\nThis message was sent by Elastic. [View my path in Elastic](https://localhost:5601/my/path).'
); );
expect(html).to.eql( expect(html).to.eql(
`<p>message</p>\n<p>--</p>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601/my/path\">View my path in Elastic</a>.</p>\n` `<p>message</p>\n<hr>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601/my/path\">View my path in Elastic</a>.</p>\n`
); );
}); });
}); });
@ -325,8 +325,8 @@ export default function emailTest({ getService }: FtrProviderContext) {
cc: null, cc: null,
bcc: null, bcc: null,
subject: 'email-subject', subject: 'email-subject',
html: `<p>email-message</p>\n<p>--</p>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`, html: `<p>email-message</p>\n<hr>\n<p>This message was sent by Elastic. <a href=\"https://localhost:5601\">Go to Elastic</a>.</p>\n`,
text: 'email-message\n\n--\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).', text: 'email-message\n\n---\n\nThis message was sent by Elastic. [Go to Elastic](https://localhost:5601).',
headers: {}, headers: {},
}, },
}); });

View file

@ -21,4 +21,5 @@ export default createTestConfig('spaces_only', {
useDedicatedTaskRunner: true, useDedicatedTaskRunner: true,
testFiles: [require.resolve('.')], testFiles: [require.resolve('.')],
reportName: 'X-Pack Alerting API Integration Tests - Actions', reportName: 'X-Pack Alerting API Integration Tests - Actions',
enableFooterInEmail: false,
}); });

View file

@ -54,6 +54,29 @@ export default function emailTest({ getService }: FtrProviderContext) {
} }
}); });
it('does not have a footer', async () => {
const from = `bob@${EmailDomainAllowed}`;
const conn = await createConnector(from);
expect(conn.status).to.be(200);
const { id } = conn.body;
expect(id).to.be.a('string');
const to = EmailDomainsAllowed.map((domain) => `jeb@${domain}`).sort();
const cc = EmailDomainsAllowed.map((domain) => `jim@${domain}`).sort();
const bcc = EmailDomainsAllowed.map((domain) => `joe@${domain}`).sort();
const ccNames = cc.map((email) => `Jimmy Jack <${email}>`);
const run = await runConnector(id, to, ccNames, bcc);
expect(run.status).to.be(200);
const { status } = run.body || {};
expect(status).to.be('ok');
expect(run.body.data.message.text).to.be('email-message');
});
describe('fails for invalid email domains', () => { describe('fails for invalid email domains', () => {
it('in create when invalid "from" used', async () => { it('in create when invalid "from" used', async () => {
const from = `bob@not.allowed`; const from = `bob@not.allowed`;