0
Fork 0
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:
simeng-li 2025-02-21 13:45:23 +08:00 committed by GitHub
parent c7b0fefb5c
commit 4bcc9c1cf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 554 additions and 113 deletions

View file

@ -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()
);
});
});

View file

@ -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),
};
};

View file

@ -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}}',
};

View file

@ -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,
},
})
);
});
});

View file

@ -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),
};
};

View file

@ -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',
};

View file

@ -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' },

View file

@ -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' },
});
});
});

View file

@ -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),
};
};

View file

@ -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);

View file

@ -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',
},
});
});
});

View file

@ -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),
};
};

View file

@ -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.',
},
],
};

View file

@ -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',
});
});
});

View file

@ -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),
};
};