mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
[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:
committed by
GitHub
parent
6e707b7d1b
commit
67c4df0e78
143
api/src/operations/mail/index.test.ts
Normal file
143
api/src/operations/mail/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,3 +27,4 @@
|
||||
- akshay-sood
|
||||
- nickrum
|
||||
- danielduckworth
|
||||
- JonathanSchndr
|
||||
|
||||
Reference in New Issue
Block a user