[Feat] Flow: Add Mail Templates (#15829)

* add mail templates to flow (proposal)

* add template placeholder to base

* add translations

* add test

* update lint

* accept cla

* fix mail operation unit test

* eslint fix

* Fix linting

* Add test for custom template and data

* Fix import for ts 5

* Another ts type fix

---------

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: ian <licitdev@gmail.com>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
This commit is contained in:
Jonathan Schneider
2023-04-21 21:41:35 +02:00
committed by GitHub
parent 6e707b7d1b
commit 67c4df0e78
5 changed files with 210 additions and 16 deletions

View File

@@ -0,0 +1,143 @@
import knex from 'knex';
import { MockClient } from 'knex-mock-client';
import type { SpyInstance } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { MailService } from '../../services/mail/index.js';
import * as mdUtil from '../../utils/md.js';
import type { Options } from './index.js';
import config from './index.js';
describe('Operations / Mail', () => {
let mockOperationContext: any;
let mailServiceSendSpy: SpyInstance;
let mdSpy: SpyInstance;
beforeEach(async () => {
mockOperationContext = {
accountability: null,
database: vi.mocked(knex.default({ client: MockClient })),
getSchema: vi.fn().mockResolvedValue({}),
};
mailServiceSendSpy = vi.spyOn(MailService.prototype, 'send').mockResolvedValue(true);
mdSpy = vi.spyOn(mdUtil, 'md');
});
test('use base template when type is template but no template was selected', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'template',
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({ to: options.to, subject: options.subject, template: { name: 'base', data: {} } })
);
expect(mailServiceSendSpy).toHaveBeenCalledWith(expect.not.objectContaining({ html: expect.any(String) }));
});
test('use custom template when type is template and custom template was selected', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'template',
template: 'custom',
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({ to: options.to, subject: options.subject, template: { name: 'custom', data: {} } })
);
expect(mailServiceSendSpy).toHaveBeenCalledWith(expect.not.objectContaining({ html: expect.any(String) }));
});
test('pass custom data with template when type is template and data is included', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'template',
data: { key: 'value' },
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: options.to,
subject: options.subject,
template: { name: 'base', data: { key: 'value' } },
})
);
expect(mailServiceSendSpy).toHaveBeenCalledWith(expect.not.objectContaining({ html: expect.any(String) }));
});
test('pass custom data with template when type is template, custom template was selected and data is included', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'template',
template: 'custom',
data: { key: 'value' },
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: options.to,
subject: options.subject,
template: { name: 'custom', data: { key: 'value' } },
})
);
expect(mailServiceSendSpy).toHaveBeenCalledWith(expect.not.objectContaining({ html: expect.any(String) }));
});
test('use body as is when type is wysiwyg', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'wysiwyg',
body: 'test body',
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: options.to,
subject: options.subject,
html: options.body,
})
);
expect(mdSpy).not.toHaveBeenCalled();
});
test('use md() on body when type is markdown', async () => {
const options: Options = {
to: 'test@example.com',
subject: 'Test',
type: 'markdown',
body: 'test body',
};
await config.handler(options, mockOperationContext);
expect(mailServiceSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: options.to,
subject: options.subject,
html: '<p>test body</p>\n',
})
);
expect(mdSpy).toHaveBeenCalled();
});
});

View File

@@ -1,27 +1,34 @@
import { defineOperationApi } from '@directus/utils';
import type { EmailOptions } from '../../services/mail/index.js';
import { MailService } from '../../services/mail/index.js';
import { md } from '../../utils/md.js';
type Options = {
body: string;
export type Options = {
body?: string;
template?: string;
data?: Record<string, any>;
to: string;
type: 'wysiwyg' | 'markdown';
type: 'wysiwyg' | 'markdown' | 'template';
subject: string;
};
export default defineOperationApi<Options>({
id: 'mail',
handler: async ({ body, to, type, subject }, { accountability, database, getSchema }) => {
handler: async ({ body, template, data, to, type, subject }, { accountability, database, getSchema }) => {
const mailService = new MailService({ schema: await getSchema({ database }), accountability, knex: database });
// If 'body' is of type object/undefined (happens when body consists solely of a placeholder)
// convert it to JSON string
const mailObject: EmailOptions = { to, subject };
const safeBody = typeof body !== 'string' ? JSON.stringify(body) : body;
await mailService.send({
html: type === 'wysiwyg' ? safeBody : md(safeBody),
to,
subject,
});
if (type === 'template') {
mailObject.template = {
name: template || 'base',
data: data || {},
};
} else {
mailObject.html = type === 'wysiwyg' ? safeBody : md(safeBody);
}
await mailService.send(mailObject);
},
});

View File

@@ -2133,6 +2133,8 @@ operations:
to: To
to_placeholder: Add e-mail addresses and press enter...
body: Body
template: Template
data: Data
notification:
name: Send Notification
description: Send an in-app notification to one or more users

View File

@@ -5,7 +5,7 @@ export default defineOperationApp({
icon: 'mail',
name: '$t:operations.mail.name',
description: '$t:operations.mail.description',
overview: ({ subject, to, type, body }) => [
overview: ({ subject, to, type }) => [
{
label: '$t:subject',
text: subject,
@@ -18,10 +18,6 @@ export default defineOperationApp({
label: '$t:type',
text: type || 'markdown',
},
{
label: '$t:operations.mail.body',
text: body,
},
],
options: (panel) => {
return [
@@ -70,10 +66,27 @@ export default defineOperationApp({
text: '$t:interfaces.input-rich-text-html.wysiwyg',
value: 'wysiwyg',
},
{
text: '$t:operations.mail.template',
value: 'template',
},
],
},
},
},
{
field: 'template',
name: '$t:operations.mail.template',
type: 'string',
meta: {
interface: 'input',
hidden: panel.type !== 'template',
width: 'half',
options: {
placeholder: 'base',
},
},
},
{
field: 'body',
name: '$t:operations.mail.body',
@@ -81,6 +94,34 @@ export default defineOperationApp({
meta: {
width: 'full',
interface: panel.type === 'wysiwyg' ? 'input-rich-text-html' : 'input-rich-text-md',
hidden: panel.type === 'template',
},
},
{
field: 'data',
name: '$t:operations.mail.data',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',
hidden: panel.type !== 'template',
options: {
language: 'json',
placeholder: JSON.stringify(
{
url: 'example.com',
},
null,
2
),
template: JSON.stringify(
{
url: 'example.com',
},
null,
2
),
},
},
},
];

View File

@@ -27,3 +27,4 @@
- akshay-sood
- nickrum
- danielduckworth
- JonathanSchndr