[Cases]: Add HTML email template to notify user when case is assigned to them (#159335)

## Summary

Issue: #156170

This PR adds HTML email template for a notification service when user is
assigned to a case.

It also updates stack_connectors sendEmail method to add messageHTML
param which prevents generating html from markdown when message is
already HTML.

**Cases HTML Email:** 

<img width="826" alt="image"
src="69642a0b-fd1d-48c5-b0a3-c7f2600aac9d">

### Checklist

Delete any items that are not applicable to this PR.
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes
Send an HTML email instead of a plaintext email to users when assigned
to a case

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-06-15 12:30:18 +02:00 committed by GitHub
parent afc9613a68
commit dcccab9648
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 943 additions and 49 deletions

View file

@ -40,7 +40,7 @@ describe('EmailNotificationService', () => {
theCase: caseSO,
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -51,7 +51,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -63,7 +64,7 @@ describe('EmailNotificationService', () => {
theCase: caseSO,
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -74,7 +75,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -91,7 +93,7 @@ describe('EmailNotificationService', () => {
theCase: caseSO,
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -102,7 +104,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['physical_dinosaur@elastic.co'],
});
@ -114,7 +117,7 @@ describe('EmailNotificationService', () => {
theCase: { ...caseSO, namespaces: ['space1'] },
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -125,7 +128,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -145,7 +149,7 @@ describe('EmailNotificationService', () => {
theCase: caseSO,
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -156,7 +160,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\n[View the case details](https://example.com/s/test-space/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n\r\n\r\nView the case details: https://example.com/s/test-space/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -175,7 +180,7 @@ describe('EmailNotificationService', () => {
theCase: caseSO,
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -187,6 +192,7 @@ describe('EmailNotificationService', () => {
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: defacement\r\n\r\n',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -195,10 +201,13 @@ describe('EmailNotificationService', () => {
it('shows multiple tags correctly', async () => {
await emailNotificationService.notifyAssignees({
assignees,
theCase: { ...caseSO, attributes: { ...caseSO.attributes, tags: ['one', 'two'] } },
theCase: {
...caseSO,
attributes: { ...caseSO.attributes, tags: ['one', 'two', 'three', 'four'] },
},
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -209,7 +218,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: one, two\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\nTags: one, two, three, four\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -221,7 +231,7 @@ describe('EmailNotificationService', () => {
theCase: { ...caseSO, attributes: { ...caseSO.attributes, tags: [] } },
});
expect(notifications.getEmailService().sendPlainTextEmail).toHaveBeenCalledWith({
expect(notifications.getEmailService().sendHTMLEmail).toHaveBeenCalledWith({
context: {
relatedObjects: [
{
@ -232,7 +242,8 @@ describe('EmailNotificationService', () => {
],
},
message:
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\n\r\n\r\n[View the case details](https://example.com/app/security/cases/mock-id-1)',
'You are assigned to an Elastic Case.\r\n\r\nTitle: Super Bad Security Issue\r\n\r\nStatus: open\r\n\r\nSeverity: low\r\n\r\n\r\n\r\nView the case details: https://example.com/app/security/cases/mock-id-1',
messageHTML: expect.any(String),
subject: '[Elastic][Cases] Super Bad Security Issue',
to: ['damaged_raccoon@elastic.co', 'physical_dinosaur@elastic.co', 'wet_dingo@elastic.co'],
});
@ -249,7 +260,7 @@ describe('EmailNotificationService', () => {
expect(clientArgs.logger.warn).toHaveBeenCalledWith(
'Could not notifying assignees. Email service is not available.'
);
expect(notifications.getEmailService().sendPlainTextEmail).not.toHaveBeenCalled();
expect(notifications.getEmailService().sendHTMLEmail).not.toHaveBeenCalled();
});
it('logs a warning and not notify assignees on error', async () => {
@ -265,6 +276,6 @@ describe('EmailNotificationService', () => {
expect(clientArgs.logger.warn).toHaveBeenCalledWith(
'Error notifying assignees: Cannot get user profiles'
);
expect(notifications.getEmailService().sendPlainTextEmail).not.toHaveBeenCalled();
expect(notifications.getEmailService().sendHTMLEmail).not.toHaveBeenCalled();
});
});

View file

@ -14,6 +14,7 @@ import { CASE_SAVED_OBJECT, MAX_CONCURRENT_SEARCHES } from '../../../common/cons
import type { CaseSavedObjectTransformed } from '../../common/types/case';
import { getCaseViewPath } from '../../common/utils';
import type { NotificationService, NotifyArgs } from './types';
import { assigneesTemplateRenderer } from './templates/assignees/renderer';
type WithRequiredProperty<T, K extends keyof T> = T & Required<Pick<T, K>>;
@ -46,15 +47,26 @@ export class EmailNotificationService implements NotificationService {
this.publicBaseUrl = publicBaseUrl;
}
private static getTitle(theCase: CaseSavedObjectTransformed) {
return `[Elastic][Cases] ${theCase.attributes.title}`;
}
private static getMessage(
private static getCaseUrl(
theCase: CaseSavedObjectTransformed,
spaceId: string,
publicBaseUrl?: IBasePath['publicBaseUrl']
) {
return publicBaseUrl
? getCaseViewPath({
publicBaseUrl,
caseId: theCase.id,
owner: theCase.attributes.owner,
spaceId,
})
: null;
}
private static getTitle(theCase: CaseSavedObjectTransformed) {
return `[Elastic][Cases] ${theCase.attributes.title}`;
}
private static getPlainTextMessage(theCase: CaseSavedObjectTransformed, caseUrl: string | null) {
const lineBreak = '\r\n\r\n';
let message = `You are assigned to an Elastic Case.${lineBreak}`;
message = `${message}Title: ${theCase.attributes.title}${lineBreak}`;
@ -65,20 +77,15 @@ export class EmailNotificationService implements NotificationService {
message = `${message}Tags: ${theCase.attributes.tags.join(', ')}${lineBreak}`;
}
if (publicBaseUrl) {
const caseUrl = getCaseViewPath({
publicBaseUrl,
caseId: theCase.id,
owner: theCase.attributes.owner,
spaceId,
});
message = `${message}${lineBreak}[View the case details](${caseUrl})`;
}
message = caseUrl ? `${message}${lineBreak}View the case details: ${caseUrl}` : message;
return message;
}
private static async getHTMLMessage(theCase: CaseSavedObjectTransformed, caseUrl: string | null) {
return assigneesTemplateRenderer(theCase, caseUrl);
}
public async notifyAssignees({ assignees, theCase }: NotifyArgs) {
try {
if (!this.notifications.isEmailServiceAvailable()) {
@ -86,6 +93,12 @@ export class EmailNotificationService implements NotificationService {
return;
}
const caseUrl = EmailNotificationService.getCaseUrl(
theCase,
this.spaceId,
this.publicBaseUrl
);
const uids = new Set(assignees.map((assignee) => assignee.uid));
const userProfiles = await this.security.userProfiles.bulkGet({ uids });
const users = userProfiles.map((profile) => profile.user);
@ -95,16 +108,15 @@ export class EmailNotificationService implements NotificationService {
.map((user) => user.email);
const subject = EmailNotificationService.getTitle(theCase);
const message = EmailNotificationService.getMessage(
theCase,
this.spaceId,
this.publicBaseUrl
);
const message = EmailNotificationService.getPlainTextMessage(theCase, caseUrl);
await this.notifications.getEmailService().sendPlainTextEmail({
const messageHTML = await EmailNotificationService.getHTMLMessage(theCase, caseUrl);
await this.notifications.getEmailService().sendHTMLEmail({
to,
subject,
message,
messageHTML,
context: {
relatedObjects: [
{

View file

@ -0,0 +1,39 @@
/*
* 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 { mockCases } from '../../../../mocks';
import { getByText } from '@testing-library/dom';
import { assigneesTemplateRenderer } from './renderer';
import type { CaseSavedObjectTransformed } from '../../../../common/types/case';
async function getHTMLNode(caseSO: CaseSavedObjectTransformed, mockCaseUrl: string | null) {
const div = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
div.innerHTML = await assigneesTemplateRenderer(caseSO, mockCaseUrl);
return div;
}
describe('Assignees renderer', () => {
const caseSO = mockCases[0];
const mockCaseUrl = 'https://example.com/app/security/cases/mock-id-1';
beforeEach(() => {
jest.clearAllMocks();
});
it('renders case data correctly', async () => {
const container = await getHTMLNode(caseSO, mockCaseUrl);
expect(container.querySelector('h1')).toHaveTextContent(caseSO.attributes.title);
expect(getByText(container, caseSO.attributes.description)).toBeTruthy();
expect(getByText(container, caseSO.attributes.status)).toBeTruthy();
expect(getByText(container, caseSO.attributes.severity)).toBeTruthy();
expect(container.querySelectorAll('.tags')).toHaveLength(caseSO.attributes.tags.length);
expect(container.querySelector('.btn')).toHaveTextContent('View Elastic Case');
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 fs from 'fs';
import mustache from 'mustache';
import { join } from 'path';
import { assertNever } from '@elastic/eui';
import type { CaseSavedObjectTransformed } from '../../../../common/types/case';
import { CaseStatuses, CaseSeverity } from '../../../../../common/api';
import { getTemplateFilePath } from '../utils';
const TAG_LIMIT = 3;
const DESCRIPTION_LIMIT = 300;
export const getStatusColor = (status: CaseStatuses | null | undefined): string => {
if (!status) {
return '#FFF';
}
switch (status) {
case CaseStatuses.open:
return '#0077CC';
case CaseStatuses['in-progress']:
return '#FEC514';
case CaseStatuses.closed:
return '#D3DAE6';
default:
return assertNever(status);
}
};
export const getSeverityColor = (severity: CaseSeverity | null | undefined): string => {
if (!severity) {
return '#FFF';
}
switch (severity) {
case CaseSeverity.LOW:
return '#54B399';
case CaseSeverity.MEDIUM:
return '#D6BF57';
case CaseSeverity.HIGH:
return '#DA8B45';
case CaseSeverity.CRITICAL:
return '#E7664C';
default:
return assertNever(severity);
}
};
export const assigneesTemplateRenderer = async (
caseData: CaseSavedObjectTransformed,
caseUrl: string | null
): Promise<string> => {
const fileDir = join('.', 'assignees');
const fileName = 'template.html';
const dataPath = getTemplateFilePath(fileDir, fileName);
const content = await fs.promises.readFile(dataPath, 'utf8');
const hasMoreTags = caseData.attributes.tags.length > TAG_LIMIT;
const numOfExtraTags = Math.max(caseData.attributes.tags.length - TAG_LIMIT, 0);
const template = mustache.render(content, {
title: caseData.attributes.title,
status: caseData.attributes.status,
statusColor: getStatusColor(caseData.attributes.status),
severity: caseData.attributes.severity,
severityColor: getSeverityColor(caseData.attributes.severity),
hasMoreTags: hasMoreTags ? numOfExtraTags : null,
tags: caseData.attributes.tags.slice(0, TAG_LIMIT),
description:
caseData.attributes.description.length > DESCRIPTION_LIMIT
? `${caseData.attributes.description.slice(0, DESCRIPTION_LIMIT)}...`
: caseData.attributes.description,
url: caseUrl,
});
return template;
};

View file

@ -0,0 +1,211 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="initial-scale=1.0" />
<title>Cases</title>
<style>
body {
width: 100%;
max-width: 900px;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
margin: 0;
padding: 0;
background-color: #FAFAFA;
justify-content: center;
}
img {
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
a:hover {
text-decoration: none;
}
.ExternalClass {
width: 100%;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 14px;
color: #1A1C21;
}
</style>
</head>
<body id="notify_user_template" align="center"
style="width: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; max-width: 900px; justify-content: center; font-family: Inter, Arial, sans-serif; font-size: 14px; color: #1A1C21; padding: 0; margin-left: auto; margin-right: auto;"
bgcolor="#FAFAFA">
<table cellpadding="0" cellspacing="0" align="center" border="0" width="100%"
style="font-family: Inter, Arial, sans-serif; font-size: 14px; width: 100%; border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
<tr>
<td align="center" border="0" bgcolor="#FAFAFA"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
<table cellpadding="0" cellspacing="0" width="100%"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" align="center"
border="0">
<tr>
<td
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding: 30px 40px;">
<a href="https://www.elastic.co"
style="text-decoration: none; color: #00788a; border-width: 0;">
<img src="https://info.elastic.co/rs/813-MAM-392/images/elastic-welcome-2020-logo.png"
width="103" height="48"
style="text-decoration: none; -ms-interpolation-mode: bicubic; border-width: 0;" />
</a>
</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" align="center" border="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0px 40px;"
bgcolor="#ffffff">
<tr>
<td width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding: 30px;">
<h1 style="font-weight: bold; color: #1A1C21; font-size: 34px; margin: 0;">Cases:
{{title}}</h1>
</td>
</tr>
<tr>
<td width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding: 40px 30px;">
<h3 style="color: #1A1C21; font-weight: bold; font-size: 22px; margin: 0;">You are assigned
to an Elastic Case</h3>
</td>
</tr>
<tr>
<td style="line-height: 30px; mso-line-height-rule: exactly; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0; padding: 60px 30px 30px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt;"
align="center">
<tr>
<td
style="width: 120px; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<h5 style="color: #1A1C21; font-weight: bold; font-size: 16px; margin: 0;">
Status
</h5>
</td>
<td width="630px"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<span
style="width: 112px; font-size: 12px; line-height: 24px; padding: 4px 24px; border-radius: 12px; text-transform: uppercase; text-align: center; align-items: center; letter-spacing: 0.6px; color: #1A1C21; background-color: {{statusColor}}">
{{status}}</span>
</td>
</tr>
<tr>
<td
style="width: 120px; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<h5 style="color: #1A1C21; font-weight: bold; font-size: 16px; margin: 0;">
Severity
</h5>
</td>
<td width="630px"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<span
style="width: 112px; font-size: 12px; line-height: 24px; padding: 4px 24px; border-radius: 12px; text-transform: uppercase; text-align: center; align-items: center; letter-spacing: 0.6px; color: #1A1C21; background-color: {{severityColor}}">
{{severity}}</span>
</td>
</tr>
<tr>
<td
style="width: 120px; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<h5 style="color: #1A1C21; font-weight: bold; font-size: 16px; margin: 0;">Tags
</h5>
</td>
<td width="630px"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
{{#tags}}
<span class="tags"
style="background-color: #E0E5EE; margin-right: 15px; align-items: center; justify-content: center; width: 70px; height: 18px; font-size: 12px; padding: 1px 8px; border-radius: 3px;">{{.}}</span>
{{/tags}}
{{#hasMoreTags}}
<span style="color: #1A1C21; font-weight: normal; font-size: 12px;">+{{hasMoreTags}} more</span>
{{/hasMoreTags}}
</td>
</tr>
<tr>
<td
style="width: 120px; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<h5 style="color: #1A1C21; font-weight: bold; font-size: 16px; margin: 0;">
Description</h5>
</td>
<td width="630px"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-bottom: 1px solid #E0E5EE; padding: 16px 0;">
<span>{{description}}</span>
</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" border="0" width="100%"
style="width: 100%; margin-top: 60px; border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt;"
align="center">
<tr>
<td align="center"
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
{{#url}}
<a href="{{& url}}"
style="text-decoration: none; color: #00788a; border-width: 0;">
<span class="btn"
style="width: 245px; height: 40px; align-items: center; font-weight: normal; font-size: 14px; text-decoration: none; color: #ffffff; background-color: #BD271E; padding: 16px; border: 1px solid #BD271E; border-radius: 6px;">
View Elastic Case
</span>
</a>
{{/url}}
</td>
</tr>
</table>
</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0" border="0" class="container"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0; padding: 0;"
align="center">
<tr>
<td
style="font-size: 9px; color: #6d6d6d; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding: 40px;">
<table cellpadding="0" cellspacing="0" width="100%"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td
style="padding-bottom: 10px; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
<a href="https://www.elastic.co"
style="text-decoration: none; color: #00788a; border-width: 0;"><img
src="https://info.elastic.co/rs/813-MAM-392/images/elastic-welcome-2020-logo-footer.png"
alt="Elastic" width="74" height="25" border="0"
style="text-decoration: none; -ms-interpolation-mode: bicubic; border-width: 0;" /></a>
</td>
</tr>
<tr>
<td
style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; line-height: 24px;">
© 2022 Elasticsearch B.V. All Rights Reserved.<br />
Elasticsearch is a trademark of Elasticsearch BV, registered in the
U.S. and in other countries / <a href="https://www.elastic.co/legal/trademarks"
style="text-decoration: none; color: #0071C2; border-width: 0;">Trademarks</a>
/ <a href="https://www.elastic.co/legal/elastic-cloud-account-terms"
style="text-decoration: none; color: #0071C2; border-width: 0;">Terms</a> /
<a href="https://www.elastic.co/legal/privacy-policy"
style="text-decoration: none; color: #0071C2; border-width: 0;">Privacy</a><br />
Apache, Apache Lucene, Apache Hadoop, Hadoop, HDFS and the yellow
elephant logo are trademarks of the Apache Software Foundation in
the United States and/or other countries.
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,76 @@
/*
* 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 { mockCases } from '../../../../mocks';
import { getByText } from '@testing-library/dom';
import { assigneesTemplateRenderer } from './renderer';
import type { CaseSavedObjectTransformed } from '../../../../common/types/case';
async function getHTMLNode(caseSO: CaseSavedObjectTransformed, mockCaseUrl: string | null) {
const div = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
div.innerHTML = await assigneesTemplateRenderer(caseSO, mockCaseUrl);
return div;
}
describe('Assignees template', () => {
const caseSO = mockCases[0];
const mockCaseUrl = 'https://example.com/app/security/cases/mock-id-1';
beforeEach(() => {
jest.clearAllMocks();
});
it('renders case data correctly', async () => {
const container = await getHTMLNode(caseSO, mockCaseUrl);
expect(container.querySelector('h1')).toHaveTextContent(caseSO.attributes.title);
expect(getByText(container, caseSO.attributes.description)).toBeTruthy();
expect(getByText(container, caseSO.attributes.status)).toBeTruthy();
expect(getByText(container, caseSO.attributes.severity)).toBeTruthy();
expect(container.querySelectorAll('.tags')).toHaveLength(caseSO.attributes.tags.length);
expect(container.querySelector('.btn')).toHaveTextContent('View Elastic Case');
});
it('renders long description, status, severity correctly', async () => {
const longDesc =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
const container = await getHTMLNode(
{
...caseSO,
attributes: {
...caseSO.attributes,
description: longDesc,
status: caseSO.attributes.status,
severity: caseSO.attributes.severity,
},
},
mockCaseUrl
);
expect(getByText(container, `${longDesc.slice(0, 300)}...`)).toBeTruthy();
expect(getByText(container, caseSO.attributes.status)).toBeTruthy();
expect(getByText(container, caseSO.attributes.severity)).toBeTruthy();
});
it('renders different multiple tags correctly', async () => {
const container = await getHTMLNode(
{ ...caseSO, attributes: { ...caseSO.attributes, tags: ['one', 'two', 'three', 'four'] } },
mockCaseUrl
);
expect(container.querySelectorAll('.tags')).toHaveLength(3);
});
it('renders correctly when case url is null', async () => {
const container = await getHTMLNode(caseSO, null);
expect(container.querySelector('.btn')).not.toBeTruthy();
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 path, { join, resolve } from 'path';
import { getTemplateFilePath } from './utils';
describe('getTemplateFilePath', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('resolves path correctly', async () => {
const resolveSpy = jest.spyOn(path, 'resolve').mockReturnValueOnce('../fake_path');
const dataPath = getTemplateFilePath('', 'foo.js');
expect(dataPath).toEqual('../fake_path');
resolveSpy.mockRestore();
});
it('resolves path correctly with different directory name', async () => {
const dataPath = getTemplateFilePath('../sample', 'foo.js');
const expectedPath = resolve(join(__dirname, '..', 'templates', '../sample', 'foo.js'));
expect(dataPath).toEqual(expectedPath);
});
it('throws error correctly', async () => {
const getTemplateFilePathMock = jest.fn().mockImplementation(() => {
throw new Error('Error finding the file!');
});
expect(() => getTemplateFilePathMock('../sample', 'foo.js')).toThrowErrorMatchingInlineSnapshot(
'"Error finding the file!"'
);
});
});

View file

@ -0,0 +1,19 @@
/*
* 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 { join, resolve } from 'path';
export const getTemplateFilePath = (filePath: string, fileName: string): string => {
const path = join(__dirname, '..', 'templates', filePath, fileName);
const absolutePath = resolve(path);
if (!absolutePath) {
throw new Error('Error finding the file!');
}
return absolutePath;
};

View file

@ -12,6 +12,7 @@ import type { NotificationsPlugin } from './plugin';
const emailServiceMock: jest.Mocked<EmailService> = {
sendPlainTextEmail: jest.fn(),
sendHTMLEmail: jest.fn(),
};
const createEmailServiceMock = () => {
@ -43,6 +44,7 @@ export const notificationsMock = {
createStart: createStartMock,
clear: () => {
emailServiceMock.sendPlainTextEmail.mockClear();
emailServiceMock.sendHTMLEmail.mockClear();
startMock.getEmailService.mockClear();
startMock.isEmailServiceAvailable.mockClear();
notificationsPluginMock.setup.mockClear();

View file

@ -7,7 +7,7 @@
import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock';
import { ConnectorsEmailService } from './connectors_email_service';
import type { PlainTextEmail } from './types';
import type { PlainTextEmail, HTMLEmail } from './types';
const REQUESTER_ID = 'requesterId';
const CONNECTOR_ID = 'connectorId';
@ -109,3 +109,107 @@ describe('sendPlainTextEmail()', () => {
});
});
});
describe('sendHTMLEmail()', () => {
describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => {
it(`omits the 'relatedSavedObjects' field if no context is provided`, () => {
const actionsClient = unsecuredActionsClientMock.create();
const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient);
const payload: HTMLEmail = {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
};
email.sendHTMLEmail(payload);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [
{
id: CONNECTOR_ID,
params: {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
},
},
]);
});
it(`populates the 'relatedSavedObjects' field if context is provided`, () => {
const actionsClient = unsecuredActionsClientMock.create();
const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient);
const payload: HTMLEmail = {
to: ['user1@email.com', 'user2@email.com', 'user3@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
context: {
relatedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
};
email.sendHTMLEmail(payload);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith(REQUESTER_ID, [
{
id: CONNECTOR_ID,
params: {
to: ['user1@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
{
id: CONNECTOR_ID,
params: {
to: ['user2@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
{
id: CONNECTOR_ID,
params: {
to: ['user3@email.com'],
subject: 'This is a notification email',
message: 'With some contents inside.',
messageHTML: '<html><body><span>With some contents inside.</span></body></html>',
},
relatedSavedObjects: [
{
id: '9c9456a4-c160-46f5-96f7-e9ac734d0d9b',
type: 'cases',
namespace: 'space1',
},
],
},
]);
});
});
});

View file

@ -6,7 +6,7 @@
*/
import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server';
import type { EmailService, PlainTextEmail } from './types';
import type { EmailService, PlainTextEmail, HTMLEmail } from './types';
export class ConnectorsEmailService implements EmailService {
constructor(
@ -27,4 +27,19 @@ export class ConnectorsEmailService implements EmailService {
}));
return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions);
}
async sendHTMLEmail(params: HTMLEmail): Promise<void> {
const actions = params.to.map((to) => ({
id: this.connectorId,
params: {
to: [to],
subject: params.subject,
message: params.message,
messageHTML: params.messageHTML,
},
relatedSavedObjects: params.context?.relatedObjects,
}));
return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions);
}
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export type { EmailService, EmailServiceStart, PlainTextEmail } from './types';
export type { EmailService, EmailServiceStart, PlainTextEmail, HTMLEmail } from './types';
export type {
EmailServiceSetupDeps,
EmailServiceStartDeps,

View file

@ -10,12 +10,13 @@ import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { LicensedEmailService } from './licensed_email_service';
import type { ILicense } from '@kbn/licensing-plugin/server';
import type { EmailService, PlainTextEmail } from './types';
import type { EmailService, HTMLEmail, PlainTextEmail } from './types';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const emailServiceMock: EmailService = {
sendPlainTextEmail: jest.fn(),
sendHTMLEmail: jest.fn(),
};
const validLicense = licensingMock.createLicenseMock();
@ -32,6 +33,13 @@ const someEmail: PlainTextEmail = {
message: 'Some message',
};
const someHTMLEmail: HTMLEmail = {
to: ['user1@email.com'],
subject: 'Some subject',
message: 'Some message',
messageHTML: '<html><body><span>Some message</span></body></html>',
};
describe('LicensedEmailService', () => {
const logger = loggerMock.create();
@ -122,4 +130,74 @@ describe('LicensedEmailService', () => {
expect(emailsOk).toEqual(4);
});
});
describe('sendHTMLEmail()', () => {
it('does not call the underlying email service until the license is determined and valid', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
email.sendHTMLEmail(someHTMLEmail);
expect(emailServiceMock.sendHTMLEmail).not.toHaveBeenCalled();
license$.next(validLicense);
await delay(1);
expect(emailServiceMock.sendHTMLEmail).toHaveBeenCalledTimes(1);
expect(emailServiceMock.sendHTMLEmail).toHaveBeenCalledWith(someHTMLEmail);
});
it('does not call the underlying email service if the license is invalid', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
license$.next(invalidLicense);
try {
await email.sendHTMLEmail(someHTMLEmail);
} catch (err) {
expect(err.message).toEqual(
'The current license does not allow sending email notifications'
);
return;
}
expect('it should have thrown').toEqual('but it did not');
});
it('does not log a warning for every email attempt, but rather for every license change', async () => {
const license$ = new Subject<ILicense>();
const email = new LicensedEmailService(emailServiceMock, license$, 'platinum', logger);
license$.next(invalidLicense);
license$.next(validLicense);
license$.next(invalidLicense);
expect(logger.debug).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(2);
let emailsOk = 0;
let emailsKo = 0;
const silentSend = async () => {
try {
await email.sendHTMLEmail(someHTMLEmail);
emailsOk++;
} catch (err) {
emailsKo++;
}
};
await silentSend();
await silentSend();
await silentSend();
await silentSend();
license$.next(validLicense);
await silentSend();
await silentSend();
await silentSend();
await silentSend();
expect(logger.debug).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(emailsKo).toEqual(4);
expect(emailsOk).toEqual(4);
});
});
});

View file

@ -8,7 +8,7 @@
import type { Logger } from '@kbn/logging';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/server';
import { firstValueFrom, map, type Observable, ReplaySubject, type Subject } from 'rxjs';
import type { EmailService, PlainTextEmail } from './types';
import type { EmailService, HTMLEmail, PlainTextEmail } from './types';
import { PLUGIN_ID } from '../../common';
export class LicensedEmailService implements EmailService {
@ -32,6 +32,14 @@ export class LicensedEmailService implements EmailService {
}
}
async sendHTMLEmail(payload: HTMLEmail): Promise<void> {
if (await firstValueFrom(this.validLicense$, { defaultValue: false })) {
await this.emailService.sendHTMLEmail(payload);
} else {
throw new Error('The current license does not allow sending email notifications');
}
}
private checkValidLicense(license: ILicense): boolean {
const licenseCheck = license.check(PLUGIN_ID, this.minimumLicense);

View file

@ -7,6 +7,7 @@
export interface EmailService {
sendPlainTextEmail(payload: PlainTextEmail): Promise<void>;
sendHTMLEmail(payload: HTMLEmail): Promise<void>;
}
export interface EmailServiceStart {
@ -33,3 +34,7 @@ export interface PlainTextEmail {
relatedObjects?: RelatedSavedObject[];
};
}
export interface HTMLEmail extends PlainTextEmail {
messageHTML: string;
}

View file

@ -439,6 +439,7 @@ describe('params validation', () => {
"text": "Go to Elastic",
},
"message": "this is the message",
"messageHTML": null,
"subject": "this is a test",
"to": Array [
"bob@example.com",
@ -506,6 +507,7 @@ describe('execute()', () => {
bcc: ['jimmy@example.com'],
subject: 'the subject',
message: 'a message to you',
messageHTML: null,
kibanaFooterLink: {
path: '/',
text: 'Go to Elastic',
@ -547,6 +549,62 @@ describe('execute()', () => {
---
This message was sent by Elastic.",
"messageHTML": null,
"subject": "the subject",
},
"hasAuth": true,
"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('ensure parameters are as expected with HTML', async () => {
sendEmailMock.mockReset();
const executorOptionsWithHTML = {
...executorOptions,
params: {
...executorOptions.params,
messageHTML: '<html><body><span>My HTML message</span></body></html>',
},
};
const result = await connectorType.executor(executorOptionsWithHTML);
expect(result).toMatchInlineSnapshot(`
Object {
"actionId": "some-id",
"data": undefined,
"status": "ok",
}
`);
delete sendEmailMock.mock.calls[0][1].configurationUtilities;
expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(`
Object {
"connectorId": "some-id",
"content": Object {
"message": "a message to you
---
This message was sent by Elastic.",
"messageHTML": "<html><body><span>My HTML message</span></body></html>",
"subject": "the subject",
},
"hasAuth": true,
@ -598,6 +656,7 @@ describe('execute()', () => {
---
This message was sent by Elastic.",
"messageHTML": null,
"subject": "the subject",
},
"hasAuth": false,
@ -649,6 +708,7 @@ describe('execute()', () => {
---
This message was sent by Elastic.",
"messageHTML": null,
"subject": "the subject",
},
"hasAuth": false,
@ -709,6 +769,49 @@ describe('execute()', () => {
bcc: ['jim', '{{rogue}}', 'bob'],
subject: '{{rogue}}',
message: '{{rogue}}',
messageHTML: null,
kibanaFooterLink: {
path: '/',
text: 'Go to Elastic',
},
};
const variables = {
rogue: '*bold*',
};
const renderedParams = connectorType.renderParameterTemplates!(paramsWithTemplates, variables);
expect(renderedParams.message).toBe('\\*bold\\*');
expect(renderedParams).toMatchInlineSnapshot(`
Object {
"bcc": Array [
"jim",
"*bold*",
"bob",
],
"cc": Array [
"*bold*",
],
"kibanaFooterLink": Object {
"path": "/",
"text": "Go to Elastic",
},
"message": "\\\\*bold\\\\*",
"messageHTML": null,
"subject": "*bold*",
"to": Array [],
}
`);
});
test('renders parameter templates with HTML as expected', async () => {
expect(connectorType.renderParameterTemplates).toBeTruthy();
const paramsWithTemplates = {
to: [],
cc: ['{{rogue}}'],
bcc: ['jim', '{{rogue}}', 'bob'],
subject: '{{rogue}}',
message: '{{rogue}}',
messageHTML: `<html><body><span>{{rogue}}</span></body></html>`,
kibanaFooterLink: {
path: '/',
text: 'Go to Elastic',
@ -736,6 +839,7 @@ describe('execute()', () => {
"text": "Go to Elastic",
},
"message": "\\\\*bold\\\\*",
"messageHTML": "<html><body><span>*bold*</span></body></html>",
"subject": "*bold*",
"to": Array [],
}

View file

@ -154,6 +154,7 @@ const ParamsSchemaProps = {
bcc: schema.arrayOf(schema.string(), { defaultValue: [] }),
subject: schema.string(),
message: schema.string(),
messageHTML: schema.nullable(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({
@ -320,6 +321,8 @@ async function executor(
}
let actualMessage = params.message;
const actualHTMLMessage = params.messageHTML;
if (configurationUtilities.enableFooterInEmail()) {
const footerMessage = getFooterMessage({
publicBaseUrl,
@ -340,6 +343,7 @@ async function executor(
content: {
subject: params.subject,
message: actualMessage,
messageHTML: actualHTMLMessage,
},
hasAuth: config.hasAuth,
configurationUtilities,

View file

@ -96,6 +96,49 @@ describe('send_email module', () => {
`);
});
test('handles authenticated HTML email when available using service', async () => {
const sendEmailOptions = getSendEmailOptions({
content: { hasHTMLMessage: true },
transport: { service: 'other' },
});
const result = await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient);
expect(result).toBe(sendMailMockResult);
expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"auth": Object {
"pass": "changeme",
"user": "elastic",
},
"host": undefined,
"port": undefined,
"secure": false,
"tls": Object {
"rejectUnauthorized": true,
},
},
]
`);
expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"bcc": Array [],
"cc": Array [
"bob@example.com",
"robert@example.com",
],
"from": "fred@example.com",
"html": "<html><body><span>a message</span></body></html>",
"subject": "a subject",
"text": "a message",
"to": Array [
"jim@example.com",
],
},
]
`);
});
test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => {
const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock;
const getOAuthClientCredentialsAccessTokenMock =
@ -146,7 +189,9 @@ describe('send_email module', () => {
"options": Object {
"connectorId": "1",
"content": Object {
"hasHTMLMessage": false,
"message": "a message",
"messageHTML": null,
"subject": "a subject",
},
"hasAuth": true,
@ -736,7 +781,7 @@ describe('send_email module', () => {
});
function getSendEmailOptions(
{ content = {}, routing = {}, transport = {} } = {},
{ content = { hasHTMLMessage: false }, routing = {}, transport = {} } = {},
proxySettings?: ProxySettings,
customHostSettings?: CustomHostSettings
) {
@ -747,9 +792,12 @@ function getSendEmailOptions(
if (customHostSettings) {
configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings);
}
const HTMLmock = '<html><body><span>a message</span></body></html>';
return {
content: {
message: 'a message',
messageHTML: content.hasHTMLMessage ? HTMLmock : null,
subject: 'a subject',
...content,
},

View file

@ -62,6 +62,7 @@ export interface Routing {
export interface Content {
subject: string;
message: string;
messageHTML?: string | null;
}
export async function sendEmail(
@ -70,13 +71,14 @@ export async function sendEmail(
connectorTokenClient: ConnectorTokenClientContract
): Promise<unknown> {
const { transport, content } = options;
const { message } = content;
const messageHTML = htmlFromMarkdown(logger, message);
const { message, messageHTML } = content;
const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message);
if (transport.service === AdditionalEmailServices.EXCHANGE) {
return await sendEmailWithExchange(logger, options, messageHTML, connectorTokenClient);
return await sendEmailWithExchange(logger, options, renderedMessage, connectorTokenClient);
} else {
return await sendEmailWithNodemailer(logger, options, messageHTML);
return await sendEmailWithNodemailer(logger, options, renderedMessage);
}
}

View file

@ -101,6 +101,7 @@ describe('Legacy Alert Actions factory', () => {
},
message:
'Alert for monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} has recovered',
messageHTML: null,
subject:
'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} has recovered',
to: ['test@email.com'],
@ -118,6 +119,7 @@ describe('Legacy Alert Actions factory', () => {
},
message:
'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}, checked at {{context.checkedAt}}',
messageHTML: null,
subject: 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} is down',
to: ['test@email.com'],
},
@ -435,6 +437,7 @@ describe('Alert Actions factory', () => {
},
message:
'The alert for "{{context.monitorName}}" from {{context.locationName}} is no longer active: {{context.recoveryReason}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- From: {{context.locationName}} \n- Last error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
messageHTML: null,
subject:
'"{{context.monitorName}}" ({{context.locationName}}) {{context.recoveryStatus}} - Elastic Synthetics',
to: ['test@email.com'],
@ -452,6 +455,7 @@ describe('Alert Actions factory', () => {
},
message:
'"{{context.monitorName}}" is {{{context.status}}} from {{context.locationName}}. - Elastic Synthetics\n\nDetails:\n\n- Monitor name: {{context.monitorName}} \n- {{context.monitorUrlLabel}}: {{{context.monitorUrl}}} \n- Monitor type: {{context.monitorType}} \n- Checked at: {{context.checkedAt}} \n- From: {{context.locationName}} \n- Error received: {{{context.lastErrorMessage}}} \n{{{context.linkMessage}}}',
messageHTML: null,
subject:
'"{{context.monitorName}}" ({{context.locationName}}) is down - Elastic Synthetics',
to: ['test@email.com'],

View file

@ -286,6 +286,7 @@ function getEmailActionParams(
to: defaultEmail.to,
subject: isRecovery ? defaultRecoverySubjectMessage : defaultSubjectMessage,
message: isRecovery ? defaultRecoveryMessage : defaultActionMessage,
messageHTML: null,
cc: defaultEmail.cc ?? [],
bcc: defaultEmail.bcc ?? [],
kibanaFooterLink: {

View file

@ -155,6 +155,31 @@ export default function emailTest({ getService }: FtrProviderContext) {
});
});
it('should return existing html when available', async () => {
await supertest
.post(`/api/actions/connector/${createdActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
to: ['kibana-action-test@elastic.co'],
subject: 'HTML message check',
message: '_italic_ **bold** https://elastic.co link',
messageHTML:
'<html><body><a href="https://elastic.co" style="font-weight: bold; font-style: italic">View at Elastic</a></body></html>',
},
})
.expect(200)
.then((resp: any) => {
const { text, html } = resp.body.data.message;
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).'
);
expect(html).to.eql(
`<html><body><a href="https://elastic.co" style="font-weight: bold; font-style: italic">View at Elastic</a></body></html>`
);
});
});
it('should allow customizing the kibana footer link', async () => {
await supertest
.post(`/api/actions/connector/${createdActionId}/_execute`)