mirror of
https://github.com/logto-io/logto.git
synced 2025-02-24 22:05:56 -05:00
feat(connector): update email connectors to support i18n (#7048)
* feat(connector): update email connectors to support i18n update email connectors to support i18n custom email templates * feat(connector): update SMTP connector update SMTP connector to support i18n email template
This commit is contained in:
parent
c7b0fefb5c
commit
4bcc9c1cf4
15 changed files with 554 additions and 113 deletions
|
@ -1,6 +1,6 @@
|
|||
import { TemplateType } from '@logto/connector-kit';
|
||||
|
||||
import { mockedConfigWithAllRequiredTemplates } from './mock.js';
|
||||
import { mockedConfigWithAllRequiredTemplates, mockGenericI18nEmailTemplate } from './mock.js';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfigWithAllRequiredTemplates);
|
||||
|
||||
|
@ -9,6 +9,8 @@ const singleSendMail = vi.fn(() => ({
|
|||
statusCode: 200,
|
||||
}));
|
||||
|
||||
const getI18nEmailTemplate = vi.fn().mockResolvedValue(mockGenericI18nEmailTemplate);
|
||||
|
||||
vi.mock('./single-send-mail.js', () => ({
|
||||
singleSendMail,
|
||||
}));
|
||||
|
@ -51,4 +53,22 @@ describe('sendMessage()', () => {
|
|||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should call singleSendMail() with custom template', async () => {
|
||||
const toEmail = 'to@email.com';
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
await connector.sendMessage({
|
||||
to: toEmail,
|
||||
type: TemplateType.Generic,
|
||||
payload: { code: '1234', applicationName: 'bar' },
|
||||
});
|
||||
expect(singleSendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HtmlBody: 'Verification code is 1234',
|
||||
Subject: 'Generic email',
|
||||
FromAlias: 'Foo bar',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { assert } from '@silverhand/essentials';
|
||||
import { assert, trySafe } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import type {
|
||||
CreateConnector,
|
||||
EmailConnector,
|
||||
GetConnectorConfig,
|
||||
GetI18nEmailTemplate,
|
||||
SendMessageFunction,
|
||||
} from '@logto/connector-kit';
|
||||
import {
|
||||
|
@ -25,13 +26,20 @@ import {
|
|||
} from './types.js';
|
||||
|
||||
const sendMessage =
|
||||
(getConfig: GetConnectorConfig): SendMessageFunction =>
|
||||
(
|
||||
getConfig: GetConnectorConfig,
|
||||
getI18nEmailTemplate?: GetI18nEmailTemplate
|
||||
): SendMessageFunction =>
|
||||
async (data, inputConfig) => {
|
||||
const { to, type, payload } = data;
|
||||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, aliyunDmConfigGuard);
|
||||
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
|
||||
const template = templates.find((template) => template.usageType === type);
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nEmailTemplate?.(type, payload.locale));
|
||||
|
||||
// Fall back to the default template if the custom i18n template is not found.
|
||||
const template = customTemplate ?? templates.find((template) => template.usageType === type);
|
||||
|
||||
assert(
|
||||
template,
|
||||
|
@ -49,7 +57,9 @@ const sendMessage =
|
|||
ReplyToAddress: 'false',
|
||||
AddressType: '1',
|
||||
ToAddress: to,
|
||||
FromAlias: fromAlias,
|
||||
FromAlias: customTemplate?.sendFrom
|
||||
? replaceSendMessageHandlebars(customTemplate.sendFrom, payload)
|
||||
: fromAlias,
|
||||
Subject: replaceSendMessageHandlebars(template.subject, payload),
|
||||
HtmlBody: replaceSendMessageHandlebars(template.content, payload),
|
||||
},
|
||||
|
@ -96,12 +106,15 @@ const errorHandler = (errorResponseBody: string) => {
|
|||
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription, ...rest });
|
||||
};
|
||||
|
||||
const createAliyunDmConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
|
||||
const createAliyunDmConnector: CreateConnector<EmailConnector> = async ({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
}) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: aliyunDmConfigGuard,
|
||||
sendMessage: sendMessage(getConfig),
|
||||
sendMessage: sendMessage(getConfig, getI18nEmailTemplate),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { type EmailTemplateDetails } from '@logto/connector-kit';
|
||||
|
||||
export const mockedParameters = {
|
||||
AccessKeyId: 'testid',
|
||||
AccountName: "<a%b'>",
|
||||
|
@ -60,3 +62,10 @@ export const mockedConfigWithAllRequiredTemplates = {
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockGenericI18nEmailTemplate: EmailTemplateDetails = {
|
||||
subject: 'Generic email',
|
||||
content: 'Verification code is {{code}}',
|
||||
replyTo: 'Reply-to {{to}}',
|
||||
sendFrom: 'Foo {{applicationName}}',
|
||||
};
|
||||
|
|
|
@ -2,10 +2,12 @@ import { SESv2Client } from '@aws-sdk/client-sesv2';
|
|||
import { TemplateType } from '@logto/connector-kit';
|
||||
|
||||
import createConnector from './index.js';
|
||||
import { mockedConfig } from './mock.js';
|
||||
import { mockedConfig, mockGenericI18nEmailTemplate } from './mock.js';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
|
||||
|
||||
const getI18nEmailTemplate = vi.fn().mockResolvedValue(mockGenericI18nEmailTemplate);
|
||||
|
||||
vi.spyOn(SESv2Client.prototype, 'send').mockResolvedValue({
|
||||
MessageId: 'mocked-message-id',
|
||||
$metadata: {
|
||||
|
@ -85,4 +87,38 @@ describe('sendMessage()', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call SendMail() with custom template', async () => {
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
const toMail = 'to@email.com';
|
||||
const { emailAddress } = mockedConfig;
|
||||
await connector.sendMessage({
|
||||
to: toMail,
|
||||
type: TemplateType.Generic,
|
||||
payload: { code: '1234', link: 'https://logto.dev' },
|
||||
});
|
||||
const toExpected = [toMail];
|
||||
expect(SESv2Client.prototype.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
FromEmailAddress: emailAddress,
|
||||
Destination: { ToAddresses: toExpected },
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: 'Generic email', Charset: 'utf8' },
|
||||
Body: {
|
||||
Html: {
|
||||
Data: 'Verification code is 1234',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FeedbackForwardingEmailAddress: undefined,
|
||||
FeedbackForwardingEmailAddressIdentityArn: undefined,
|
||||
FromEmailAddressIdentityArn: undefined,
|
||||
ConfigurationSetName: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { assert } from '@silverhand/essentials';
|
||||
import { assert, trySafe } from '@silverhand/essentials';
|
||||
|
||||
import type { SESv2Client, SendEmailCommand, SendEmailCommandOutput } from '@aws-sdk/client-sesv2';
|
||||
import { SESv2ServiceException } from '@aws-sdk/client-sesv2';
|
||||
|
@ -6,6 +6,7 @@ import type {
|
|||
CreateConnector,
|
||||
EmailConnector,
|
||||
GetConnectorConfig,
|
||||
GetI18nEmailTemplate,
|
||||
SendMessageFunction,
|
||||
} from '@logto/connector-kit';
|
||||
import {
|
||||
|
@ -20,13 +21,20 @@ import { awsSesConfigGuard } from './types.js';
|
|||
import { makeClient, makeCommand, makeEmailContent } from './utils.js';
|
||||
|
||||
const sendMessage =
|
||||
(getConfig: GetConnectorConfig): SendMessageFunction =>
|
||||
(
|
||||
getConfig: GetConnectorConfig,
|
||||
getI18nEmailTemplate?: GetI18nEmailTemplate
|
||||
): SendMessageFunction =>
|
||||
async (data, inputConfig) => {
|
||||
const { to, type, payload } = data;
|
||||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, awsSesConfigGuard);
|
||||
const { accessKeyId, accessKeySecret, region, templates } = config;
|
||||
const template = templates.find((template) => template.usageType === type);
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nEmailTemplate?.(type, payload.locale));
|
||||
|
||||
// Fall back to the default template if the custom i18n template is not found.
|
||||
const template = customTemplate ?? templates.find((template) => template.usageType === type);
|
||||
|
||||
assert(
|
||||
template,
|
||||
|
@ -58,12 +66,15 @@ const sendMessage =
|
|||
}
|
||||
};
|
||||
|
||||
const createAwsSesConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
|
||||
const createAwsSesConnector: CreateConnector<EmailConnector> = async ({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
}) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: awsSesConfigGuard,
|
||||
sendMessage: sendMessage(getConfig),
|
||||
sendMessage: sendMessage(getConfig, getI18nEmailTemplate),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { type EmailTemplateDetails } from '@logto/connector-kit';
|
||||
|
||||
export const mockedConfig = {
|
||||
accessKeyId: 'accessKeyId',
|
||||
accessKeySecret: 'accessKeySecret+cltHAJ',
|
||||
|
@ -31,3 +33,9 @@ export const mockedConfig = {
|
|||
},
|
||||
],
|
||||
};
|
||||
export const mockGenericI18nEmailTemplate: EmailTemplateDetails = {
|
||||
subject: 'Generic email',
|
||||
content: 'Verification code is {{code}}',
|
||||
replyTo: 'Reply-to {{to}}',
|
||||
sendFrom: 'Foo',
|
||||
};
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import type { EmailContent } from '@aws-sdk/client-sesv2';
|
||||
import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { replaceSendMessageHandlebars, type SendMessagePayload } from '@logto/connector-kit';
|
||||
import {
|
||||
type EmailTemplateDetails,
|
||||
replaceSendMessageHandlebars,
|
||||
type SendMessagePayload,
|
||||
} from '@logto/connector-kit';
|
||||
|
||||
import type { AwsSesConfig, Template } from './types.js';
|
||||
|
||||
|
@ -18,7 +22,10 @@ export const makeClient = (
|
|||
return new SESv2Client({ credentials, region });
|
||||
};
|
||||
|
||||
export const makeEmailContent = (template: Template, payload: SendMessagePayload): EmailContent => {
|
||||
export const makeEmailContent = (
|
||||
template: Template | EmailTemplateDetails,
|
||||
payload: SendMessagePayload
|
||||
): EmailContent => {
|
||||
return {
|
||||
Simple: {
|
||||
Subject: { Data: replaceSendMessageHandlebars(template.subject, payload), Charset: 'utf8' },
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import nock from 'nock';
|
||||
|
||||
import { TemplateType } from '@logto/connector-kit';
|
||||
import { type EmailTemplateDetails, TemplateType } from '@logto/connector-kit';
|
||||
|
||||
import createMailgunConnector from './index.js';
|
||||
import { type MailgunConfig } from './types.js';
|
||||
|
||||
const getConfig = vi.fn();
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
const getI18nEmailTemplate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const domain = 'example.com';
|
||||
const apiKey = 'apiKey';
|
||||
const connector = await createMailgunConnector({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
});
|
||||
const baseConfig: Partial<MailgunConfig> = {
|
||||
domain: 'example.com',
|
||||
|
@ -248,4 +251,73 @@ describe('Maligun connector', () => {
|
|||
'[Error: ConnectorError: {"statusCode":400,"body":"{\\"message\\":\\"error\\"}"}]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should send email with custom i18n template', async () => {
|
||||
nockMessages({
|
||||
from: baseConfig.from,
|
||||
to: 'bar@example.com',
|
||||
subject: 'Passcode 123456',
|
||||
html: '<p>Your passcode is 123456</p>',
|
||||
'h:Reply-To': 'Reply to bar@example.com',
|
||||
});
|
||||
|
||||
getI18nEmailTemplate.mockResolvedValue({
|
||||
subject: 'Passcode {{code}}',
|
||||
content: '<p>Your passcode is {{code}}</p>',
|
||||
replyTo: 'Reply to {{to}}',
|
||||
} satisfies EmailTemplateDetails);
|
||||
|
||||
getConfig.mockResolvedValue({
|
||||
...baseConfig,
|
||||
deliveries: {
|
||||
[TemplateType.Generic]: {
|
||||
subject: 'Verification code is {{code}}',
|
||||
html: '<p>Your verification code is {{code}}</p>',
|
||||
replyTo: 'baz@example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await connector.sendMessage({
|
||||
to: 'bar@example.com',
|
||||
type: TemplateType.Generic,
|
||||
payload: { code: '123456' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should send text email with custom i18n template', async () => {
|
||||
nockMessages({
|
||||
from: `Foo <${baseConfig.from}>`,
|
||||
to: 'bar@example.com',
|
||||
subject: 'Passcode 123456',
|
||||
html: 'Your passcode is 123456',
|
||||
text: 'Your passcode is 123456',
|
||||
'h:Reply-To': 'Reply to bar@example.com',
|
||||
});
|
||||
|
||||
getI18nEmailTemplate.mockResolvedValue({
|
||||
subject: 'Passcode {{code}}',
|
||||
content: 'Your passcode is {{code}}',
|
||||
replyTo: 'Reply to {{to}}',
|
||||
contentType: 'text/plain',
|
||||
sendFrom: `{{applicationName}} <${baseConfig.from}>`,
|
||||
} satisfies EmailTemplateDetails);
|
||||
|
||||
getConfig.mockResolvedValue({
|
||||
...baseConfig,
|
||||
deliveries: {
|
||||
[TemplateType.Generic]: {
|
||||
subject: 'Verification code is {{code}}',
|
||||
html: '<p>Your verification code is {{code}}</p>',
|
||||
replyTo: 'baz@example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await connector.sendMessage({
|
||||
to: 'bar@example.com',
|
||||
type: TemplateType.Generic,
|
||||
payload: { code: '123456', applicationName: 'Foo' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { assert, conditional, trySafe } from '@silverhand/essentials';
|
||||
import { got, HTTPError } from 'got';
|
||||
|
||||
import type {
|
||||
|
@ -6,6 +7,8 @@ import type {
|
|||
CreateConnector,
|
||||
EmailConnector,
|
||||
SendMessagePayload,
|
||||
GetI18nEmailTemplate,
|
||||
EmailTemplateDetails,
|
||||
} from '@logto/connector-kit';
|
||||
import {
|
||||
ConnectorError,
|
||||
|
@ -46,17 +49,46 @@ const getDataFromDeliveryConfig = (
|
|||
};
|
||||
};
|
||||
|
||||
const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => {
|
||||
const getDataFromCustomTemplate = (
|
||||
{ replyTo, subject, content, contentType = 'text/html', sendFrom }: EmailTemplateDetails,
|
||||
payload: SendMessagePayload
|
||||
): Record<string, string | undefined> => {
|
||||
return {
|
||||
subject: replaceSendMessageHandlebars(subject, payload),
|
||||
'h:Reply-To': replyTo && replaceSendMessageHandlebars(replyTo, payload),
|
||||
// Since html can render plain text, we always send the content as html
|
||||
html: conditional(replaceSendMessageHandlebars(content, payload)),
|
||||
// If contentType is text/plain, we will use text instead of html
|
||||
text: conditional(
|
||||
contentType === 'text/plain' && replaceSendMessageHandlebars(content, payload)
|
||||
),
|
||||
// If provided this value will override the from value in the config
|
||||
from: sendFrom && replaceSendMessageHandlebars(sendFrom, payload),
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage = (
|
||||
getConfig: GetConnectorConfig,
|
||||
getI18nEmailTemplate?: GetI18nEmailTemplate
|
||||
): SendMessageFunction => {
|
||||
return async ({ to, type, payload }, inputConfig) => {
|
||||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, mailgunConfigGuard);
|
||||
|
||||
const { endpoint, domain, apiKey, from, deliveries } = config;
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nEmailTemplate?.(type, payload.locale));
|
||||
const template = deliveries[type] ?? deliveries[TemplateType.Generic];
|
||||
|
||||
if (!template) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.TemplateNotFound);
|
||||
}
|
||||
const data = customTemplate
|
||||
? getDataFromCustomTemplate(customTemplate, {
|
||||
...payload,
|
||||
to,
|
||||
})
|
||||
: // Fallback to the default template if the custom i18n template is not found.
|
||||
template && getDataFromDeliveryConfig(template, payload);
|
||||
|
||||
assert(data, new ConnectorError(ConnectorErrorCodes.TemplateNotFound));
|
||||
|
||||
try {
|
||||
return await got.post(
|
||||
|
@ -67,7 +99,7 @@ const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => {
|
|||
form: {
|
||||
from,
|
||||
to,
|
||||
...removeUndefinedKeys(getDataFromDeliveryConfig(template, payload)),
|
||||
...removeUndefinedKeys(data),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -84,12 +116,15 @@ const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => {
|
|||
};
|
||||
};
|
||||
|
||||
const createMailgunMailConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
|
||||
const createMailgunMailConnector: CreateConnector<EmailConnector> = async ({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
}) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: mailgunConfigGuard,
|
||||
sendMessage: sendMessage(getConfig),
|
||||
sendMessage: sendMessage(getConfig, getI18nEmailTemplate),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { assert } from '@silverhand/essentials';
|
||||
import { assert, trySafe } from '@silverhand/essentials';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import type {
|
||||
|
@ -26,8 +26,7 @@ const sendMessage =
|
|||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, mockMailConfigGuard);
|
||||
|
||||
const customTemplate = getI18nTemplate && (await getI18nTemplate(type, payload.locale));
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nTemplate?.(type, payload.locale));
|
||||
// Fall back to the default template if the custom template is not found.
|
||||
const template =
|
||||
customTemplate ?? config.templates.find((template) => template.usageType === type);
|
||||
|
|
|
@ -1,12 +1,120 @@
|
|||
import createConnector from './index.js';
|
||||
import { mockedConfig } from './mock.js';
|
||||
import nock from 'nock';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
|
||||
import { TemplateType } from '@logto/connector-kit';
|
||||
|
||||
import createConnector from './index.js';
|
||||
import { fromEmail, mockedConfig, mockedGenericEmailParameters, toEmail } from './mock.js';
|
||||
|
||||
const getConfig = vi.fn();
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
const getI18nEmailTemplate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
|
||||
const nockMessages = (
|
||||
expectation: Record<string, unknown>,
|
||||
endpoint = 'https://api.sendgrid.com'
|
||||
) =>
|
||||
nock(endpoint)
|
||||
.post('/v3/mail/send')
|
||||
.matchHeader('authorization', `Bearer ${mockedConfig.apiKey}`)
|
||||
.reply((_, body, callback) => {
|
||||
expect(body).toMatchObject(expectation);
|
||||
callback(null, [200, 'OK']);
|
||||
});
|
||||
|
||||
describe('SendGrid connector', () => {
|
||||
it('init without throwing errors', async () => {
|
||||
await expect(createConnector({ getConfig })).resolves.not.toThrow();
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
// TODO: add test cases
|
||||
it('should send generic email with default config', async () => {
|
||||
nockMessages(mockedGenericEmailParameters);
|
||||
|
||||
getConfig.mockResolvedValue(mockedConfig);
|
||||
|
||||
await connector.sendMessage({
|
||||
to: toEmail,
|
||||
type: TemplateType.Generic,
|
||||
payload: { code: '123456' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error if template not found', async () => {
|
||||
getConfig.mockResolvedValue(mockedConfig);
|
||||
|
||||
await expect(
|
||||
connector.sendMessage({
|
||||
to: toEmail,
|
||||
type: TemplateType.OrganizationInvitation,
|
||||
payload: { link: 'https://example.com' },
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot('[Error: ConnectorError: template_not_found]');
|
||||
});
|
||||
|
||||
it('should send organization invitation email with default config', async () => {
|
||||
nockMessages({
|
||||
...mockedGenericEmailParameters,
|
||||
subject: 'Organization invitation',
|
||||
content: [
|
||||
{
|
||||
type: 'text/plain',
|
||||
value: 'Your link is https://example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
getConfig.mockResolvedValue({
|
||||
...mockedConfig,
|
||||
templates: [
|
||||
...mockedConfig.templates,
|
||||
{
|
||||
usageType: 'OrganizationInvitation',
|
||||
type: 'text/plain',
|
||||
subject: 'Organization invitation',
|
||||
content: 'Your link is {{link}}',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await connector.sendMessage({
|
||||
to: toEmail,
|
||||
type: TemplateType.OrganizationInvitation,
|
||||
payload: { link: 'https://example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should send email with custom i18n template', async () => {
|
||||
getI18nEmailTemplate.mockResolvedValue({
|
||||
subject: 'Passcode {{code}}',
|
||||
content: '<p>Your passcode is {{code}}</p>',
|
||||
contentType: 'text/html',
|
||||
sendFrom: '{{applicationName}}',
|
||||
replyTo: '{{userName}}',
|
||||
});
|
||||
|
||||
nockMessages({
|
||||
personalizations: [{ to: [{ email: toEmail, name: 'John Doe' }] }],
|
||||
from: { email: fromEmail, name: 'Test app' },
|
||||
subject: 'Passcode 123456',
|
||||
content: [
|
||||
{
|
||||
type: 'text/html',
|
||||
value: '<p>Your passcode is 123456</p>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
getConfig.mockResolvedValue(mockedConfig);
|
||||
|
||||
await connector.sendMessage({
|
||||
to: toEmail,
|
||||
type: TemplateType.Generic,
|
||||
payload: {
|
||||
code: '123456',
|
||||
applicationName: 'Test app',
|
||||
userName: 'John Doe',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { assert } from '@silverhand/essentials';
|
||||
import { assert, conditional, trySafe } from '@silverhand/essentials';
|
||||
import { got, HTTPError } from 'got';
|
||||
|
||||
import type {
|
||||
|
@ -6,6 +6,9 @@ import type {
|
|||
SendMessageFunction,
|
||||
CreateConnector,
|
||||
EmailConnector,
|
||||
GetI18nEmailTemplate,
|
||||
EmailTemplateDetails,
|
||||
SendMessagePayload,
|
||||
} from '@logto/connector-kit';
|
||||
import {
|
||||
ConnectorError,
|
||||
|
@ -16,42 +19,84 @@ import {
|
|||
} from '@logto/connector-kit';
|
||||
|
||||
import { defaultMetadata, endpoint } from './constant.js';
|
||||
import { sendGridMailConfigGuard } from './types.js';
|
||||
import type { EmailData, Personalization, Content, PublicParameters } from './types.js';
|
||||
import { ContextType, sendGridMailConfigGuard } from './types.js';
|
||||
import type { PublicParameters, SendGridMailConfig } from './types.js';
|
||||
|
||||
const buildParametersFromDefaultTemplate = (
|
||||
to: string,
|
||||
config: SendGridMailConfig,
|
||||
template: SendGridMailConfig['templates'][0],
|
||||
payload: SendMessagePayload
|
||||
): PublicParameters => {
|
||||
return {
|
||||
personalizations: [{ to: [{ email: to }] }],
|
||||
from: {
|
||||
email: config.fromEmail,
|
||||
...conditional(config.fromName && { name: config.fromName }),
|
||||
},
|
||||
subject: replaceSendMessageHandlebars(template.subject, payload),
|
||||
content: [
|
||||
{
|
||||
type: template.type,
|
||||
value: replaceSendMessageHandlebars(template.content, payload),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const buildParametersFromCustomTemplate = (
|
||||
to: string,
|
||||
config: SendGridMailConfig,
|
||||
{ subject, content, replyTo, sendFrom, contentType = 'text/html' }: EmailTemplateDetails,
|
||||
payload: SendMessagePayload
|
||||
): PublicParameters => {
|
||||
return {
|
||||
personalizations: [
|
||||
{
|
||||
to: [
|
||||
{
|
||||
email: to,
|
||||
// If replyTo is provided, we will replace the handlebars with the payload
|
||||
...conditional(replyTo && { name: replaceSendMessageHandlebars(replyTo, payload) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
from: {
|
||||
email: config.fromEmail,
|
||||
// If sendFrom is provided, we will replace the handlebars with the payload
|
||||
...conditional(sendFrom && { name: replaceSendMessageHandlebars(sendFrom, payload) }),
|
||||
},
|
||||
subject: replaceSendMessageHandlebars(subject, payload),
|
||||
content: [
|
||||
{
|
||||
type: contentType === 'text/html' ? ContextType.Html : ContextType.Text,
|
||||
value: replaceSendMessageHandlebars(content, payload),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage =
|
||||
(getConfig: GetConnectorConfig): SendMessageFunction =>
|
||||
(
|
||||
getConfig: GetConnectorConfig,
|
||||
getI18nEmailTemplate?: GetI18nEmailTemplate
|
||||
): SendMessageFunction =>
|
||||
async (data, inputConfig) => {
|
||||
const { to, type, payload } = data;
|
||||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, sendGridMailConfigGuard);
|
||||
const { apiKey, fromEmail, fromName, templates } = config;
|
||||
const { apiKey, templates } = config;
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nEmailTemplate?.(type, payload.locale));
|
||||
|
||||
const template = templates.find((template) => template.usageType === type);
|
||||
|
||||
assert(
|
||||
template,
|
||||
new ConnectorError(
|
||||
ConnectorErrorCodes.TemplateNotFound,
|
||||
`Template not found for type: ${type}`
|
||||
)
|
||||
);
|
||||
const parameters = customTemplate
|
||||
? buildParametersFromCustomTemplate(to, config, customTemplate, payload)
|
||||
: template && buildParametersFromDefaultTemplate(to, config, template, payload);
|
||||
|
||||
const toEmailData: EmailData[] = [{ email: to }];
|
||||
const fromEmailData: EmailData = fromName
|
||||
? { email: fromEmail, name: fromName }
|
||||
: { email: fromEmail };
|
||||
const personalizations: Personalization = { to: toEmailData };
|
||||
const content: Content = {
|
||||
type: template.type,
|
||||
value: replaceSendMessageHandlebars(template.content, payload),
|
||||
};
|
||||
|
||||
const parameters: PublicParameters = {
|
||||
personalizations: [personalizations],
|
||||
from: fromEmailData,
|
||||
subject: replaceSendMessageHandlebars(template.subject, payload),
|
||||
content: [content],
|
||||
};
|
||||
assert(parameters, new ConnectorError(ConnectorErrorCodes.TemplateNotFound));
|
||||
|
||||
try {
|
||||
return await got.post(endpoint, {
|
||||
|
@ -82,12 +127,15 @@ const sendMessage =
|
|||
}
|
||||
};
|
||||
|
||||
const createSendGridMailConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
|
||||
const createSendGridMailConnector: CreateConnector<EmailConnector> = async ({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
}) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: sendGridMailConfigGuard,
|
||||
sendMessage: sendMessage(getConfig),
|
||||
sendMessage: sendMessage(getConfig, getI18nEmailTemplate),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -7,15 +7,24 @@ import type {
|
|||
} from './types.js';
|
||||
import { ContextType } from './types.js';
|
||||
|
||||
const receivers: EmailData[] = [{ email: 'foo@logto.io' }];
|
||||
const sender: EmailData = { email: 'noreply@logto.test.io', name: 'Logto Test' };
|
||||
const personalizations: Personalization[] = [{ to: receivers }];
|
||||
const content: Content[] = [{ type: ContextType.Text, value: 'This is a test template.' }];
|
||||
export const toEmail = 'foo@logto.io';
|
||||
export const fromEmail = 'noreply@logto.test.io';
|
||||
export const fromName = 'Logto Test';
|
||||
|
||||
export const mockedParameters: PublicParameters = {
|
||||
const receivers: EmailData[] = [{ email: toEmail }];
|
||||
const sender: EmailData = { email: fromEmail, name: fromName };
|
||||
const personalizations: Personalization[] = [{ to: receivers }];
|
||||
const content: Content[] = [
|
||||
{
|
||||
type: ContextType.Text,
|
||||
value: 'Your Logto verification code is 123456. The code will remain active for 10 minutes.',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockedGenericEmailParameters: PublicParameters = {
|
||||
personalizations,
|
||||
from: sender,
|
||||
subject: 'Test SendGrid Mail',
|
||||
subject: 'Logto Generic Template',
|
||||
content,
|
||||
};
|
||||
|
||||
|
@ -23,13 +32,36 @@ export const mockedApiKey = 'apikey';
|
|||
|
||||
export const mockedConfig: SendGridMailConfig = {
|
||||
apiKey: mockedApiKey,
|
||||
fromEmail: 'noreply@logto.test.io',
|
||||
fromEmail,
|
||||
fromName,
|
||||
templates: [
|
||||
{
|
||||
usageType: 'SignIn',
|
||||
type: ContextType.Text,
|
||||
subject: 'Logto SignIn Template',
|
||||
content:
|
||||
'Your Logto sign-in verification code is {{code}}. The code will remain active for 10 minutes.',
|
||||
},
|
||||
{
|
||||
usageType: 'Register',
|
||||
type: ContextType.Text,
|
||||
subject: 'Logto Register Template',
|
||||
content:
|
||||
'Your Logto sign-up verification code is {{code}}. The code will remain active for 10 minutes.',
|
||||
},
|
||||
{
|
||||
usageType: 'ForgotPassword',
|
||||
type: ContextType.Text,
|
||||
subject: 'Logto ForgotPassword Template',
|
||||
content:
|
||||
'Your Logto password change verification code is {{code}}. The code will remain active for 10 minutes.',
|
||||
},
|
||||
{
|
||||
usageType: 'Generic',
|
||||
type: ContextType.Text,
|
||||
subject: 'Logto Test Template',
|
||||
content: 'This is for testing purposes only. Your verification code is {{code}}.',
|
||||
subject: 'Logto Generic Template',
|
||||
content:
|
||||
'Your Logto verification code is {{code}}. The code will remain active for 10 minutes.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { TemplateType } from '@logto/connector-kit';
|
||||
import { type EmailTemplateDetails, TemplateType } from '@logto/connector-kit';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
|
@ -17,6 +17,8 @@ import {
|
|||
import { smtpConfigGuard } from './types.js';
|
||||
|
||||
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
const getI18nEmailTemplate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const sendMail = vi.fn();
|
||||
|
||||
|
@ -29,11 +31,11 @@ describe('SMTP connector', () => {
|
|||
});
|
||||
|
||||
it('init without throwing errors', async () => {
|
||||
await expect(createConnector({ getConfig })).resolves.not.toThrow();
|
||||
await expect(createConnector({ getConfig, getI18nEmailTemplate })).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should send mail with proper options', async () => {
|
||||
const connector = await createConnector({ getConfig });
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
await connector.sendMessage({
|
||||
to: 'foo',
|
||||
type: TemplateType.Register,
|
||||
|
@ -49,7 +51,7 @@ describe('SMTP connector', () => {
|
|||
});
|
||||
|
||||
it('should send mail with proper data', async () => {
|
||||
const connector = await createConnector({ getConfig });
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
await connector.sendMessage({
|
||||
to: 'bar',
|
||||
type: TemplateType.SignIn,
|
||||
|
@ -65,7 +67,7 @@ describe('SMTP connector', () => {
|
|||
});
|
||||
|
||||
it('should send mail with proper data (2)', async () => {
|
||||
const connector = await createConnector({ getConfig });
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
await connector.sendMessage({
|
||||
to: 'baz',
|
||||
type: TemplateType.OrganizationInvitation,
|
||||
|
@ -86,6 +88,7 @@ describe('SMTP connector', () => {
|
|||
...mockedConfig,
|
||||
customHeaders: { 'X-Test': 'test', 'X-Test-Another': ['test1', 'test2', 'test3'] },
|
||||
}),
|
||||
getI18nEmailTemplate,
|
||||
});
|
||||
await connector.sendMessage({
|
||||
to: 'baz',
|
||||
|
@ -157,3 +160,31 @@ describe('Test config guard', () => {
|
|||
expect(result.success && result.data).toMatchObject(expect.objectContaining(testConfig));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test SMTP connector with custom i18n templates', () => {
|
||||
it('should send mail with custom i18n template', async () => {
|
||||
getI18nEmailTemplate.mockResolvedValue({
|
||||
subject: 'Custom subject {{code}}',
|
||||
content: 'Your verification code is {{code}}',
|
||||
contentType: 'text/plain',
|
||||
replyTo: `{{userName}}`,
|
||||
sendFrom: `{{applicationName}} <notice@test.smtp>`,
|
||||
} satisfies EmailTemplateDetails);
|
||||
|
||||
const connector = await createConnector({ getConfig, getI18nEmailTemplate });
|
||||
|
||||
await connector.sendMessage({
|
||||
to: 'bar',
|
||||
type: TemplateType.SignIn,
|
||||
payload: { code: '234567', userName: 'John Doe', applicationName: 'Test app' },
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
from: 'Test app <notice@test.smtp>',
|
||||
subject: 'Custom subject 234567',
|
||||
text: 'Your verification code is 234567',
|
||||
to: 'bar',
|
||||
replyTo: 'John Doe',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { assert, conditional } from '@silverhand/essentials';
|
||||
import { assert, conditional, trySafe } from '@silverhand/essentials';
|
||||
|
||||
import type {
|
||||
GetConnectorConfig,
|
||||
CreateConnector,
|
||||
EmailConnector,
|
||||
SendMessageFunction,
|
||||
SendMessagePayload,
|
||||
GetI18nEmailTemplate,
|
||||
EmailTemplateDetails,
|
||||
} from '@logto/connector-kit';
|
||||
import {
|
||||
ConnectorError,
|
||||
|
@ -18,15 +21,51 @@ import type Mail from 'nodemailer/lib/mailer';
|
|||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { defaultMetadata } from './constant.js';
|
||||
import { ContextType, smtpConfigGuard } from './types.js';
|
||||
import { ContextType, type SmtpConfig, smtpConfigGuard } from './types.js';
|
||||
|
||||
const buildMailOptions = (
|
||||
config: SmtpConfig,
|
||||
template: SmtpConfig['templates'][number] | EmailTemplateDetails,
|
||||
payload: SendMessagePayload,
|
||||
to: string
|
||||
): Mail.Options => {
|
||||
return {
|
||||
to,
|
||||
replyTo:
|
||||
'replyTo' in template && template.replyTo
|
||||
? replaceSendMessageHandlebars(template.replyTo, payload)
|
||||
: config.replyTo,
|
||||
from:
|
||||
'sendFrom' in template && template.sendFrom
|
||||
? replaceSendMessageHandlebars(template.sendFrom, payload)
|
||||
: config.fromEmail,
|
||||
subject: replaceSendMessageHandlebars(template.subject, payload),
|
||||
[template.contentType === ContextType.Text ? 'text' : 'html']: replaceSendMessageHandlebars(
|
||||
template.content,
|
||||
payload
|
||||
),
|
||||
...conditional(
|
||||
config.customHeaders &&
|
||||
Object.entries(config.customHeaders).length > 0 && {
|
||||
headers: config.customHeaders,
|
||||
}
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage =
|
||||
(getConfig: GetConnectorConfig): SendMessageFunction =>
|
||||
(
|
||||
getConfig: GetConnectorConfig,
|
||||
getI18nEmailTemplate?: GetI18nEmailTemplate
|
||||
): SendMessageFunction =>
|
||||
async (data, inputConfig) => {
|
||||
const { to, type, payload } = data;
|
||||
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
|
||||
validateConfig(config, smtpConfigGuard);
|
||||
const template = config.templates.find((template) => template.usageType === type);
|
||||
|
||||
const customTemplate = await trySafe(async () => getI18nEmailTemplate?.(type, payload.locale));
|
||||
const template =
|
||||
customTemplate ?? config.templates.find((template) => template.usageType === type);
|
||||
|
||||
assert(
|
||||
template,
|
||||
|
@ -37,27 +76,8 @@ const sendMessage =
|
|||
);
|
||||
|
||||
const configOptions: SMTPTransport.Options = config;
|
||||
|
||||
const transporter = nodemailer.createTransport(configOptions);
|
||||
|
||||
const contentsObject = parseContents(
|
||||
replaceSendMessageHandlebars(template.content, payload),
|
||||
template.contentType
|
||||
);
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
to,
|
||||
from: config.fromEmail,
|
||||
replyTo: config.replyTo,
|
||||
subject: replaceSendMessageHandlebars(template.subject, payload),
|
||||
...conditional(
|
||||
config.customHeaders &&
|
||||
Object.entries(config.customHeaders).length > 0 && {
|
||||
headers: config.customHeaders,
|
||||
}
|
||||
),
|
||||
...contentsObject,
|
||||
};
|
||||
const mailOptions = buildMailOptions(config, template, payload, to);
|
||||
|
||||
try {
|
||||
return await transporter.sendMail(mailOptions);
|
||||
|
@ -69,23 +89,15 @@ const sendMessage =
|
|||
}
|
||||
};
|
||||
|
||||
const parseContents = (contents: string, contentType: ContextType) => {
|
||||
switch (contentType) {
|
||||
case ContextType.Text: {
|
||||
return { text: contents };
|
||||
}
|
||||
case ContextType.Html: {
|
||||
return { html: contents };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createSmtpConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
|
||||
const createSmtpConnector: CreateConnector<EmailConnector> = async ({
|
||||
getConfig,
|
||||
getI18nEmailTemplate,
|
||||
}) => {
|
||||
return {
|
||||
metadata: defaultMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: smtpConfigGuard,
|
||||
sendMessage: sendMessage(getConfig),
|
||||
sendMessage: sendMessage(getConfig, getI18nEmailTemplate),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue