From 4bcc9c1cf4ab2be3a72fa43bef7c28535c7c0388 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 21 Feb 2025 13:45:23 +0800 Subject: [PATCH] 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 --- .../connector-aliyun-dm/src/index.test.ts | 22 +++- .../connector-aliyun-dm/src/index.ts | 25 +++- .../connector-aliyun-dm/src/mock.ts | 9 ++ .../connector-aws-ses/src/index.test.ts | 38 +++++- .../connectors/connector-aws-ses/src/index.ts | 21 ++- .../connectors/connector-aws-ses/src/mock.ts | 8 ++ .../connectors/connector-aws-ses/src/utils.ts | 11 +- .../connector-mailgun/src/index.test.ts | 74 ++++++++++- .../connectors/connector-mailgun/src/index.ts | 49 ++++++- .../connector-mock-email/src/index.ts | 5 +- .../src/index.test.ts | 120 +++++++++++++++++- .../connector-sendgrid-email/src/index.ts | 108 +++++++++++----- .../connector-sendgrid-email/src/mock.ts | 50 ++++++-- .../connector-smtp/src/index.test.ts | 41 +++++- .../connectors/connector-smtp/src/index.ts | 86 +++++++------ 15 files changed, 554 insertions(+), 113 deletions(-) diff --git a/packages/connectors/connector-aliyun-dm/src/index.test.ts b/packages/connectors/connector-aliyun-dm/src/index.test.ts index 89c6495f5..25a94d08c 100644 --- a/packages/connectors/connector-aliyun-dm/src/index.test.ts +++ b/packages/connectors/connector-aliyun-dm/src/index.test.ts @@ -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() + ); + }); }); diff --git a/packages/connectors/connector-aliyun-dm/src/index.ts b/packages/connectors/connector-aliyun-dm/src/index.ts index 4fbd8865c..993cf8d4c 100644 --- a/packages/connectors/connector-aliyun-dm/src/index.ts +++ b/packages/connectors/connector-aliyun-dm/src/index.ts @@ -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 = async ({ getConfig }) => { +const createAliyunDmConnector: CreateConnector = async ({ + getConfig, + getI18nEmailTemplate, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: aliyunDmConfigGuard, - sendMessage: sendMessage(getConfig), + sendMessage: sendMessage(getConfig, getI18nEmailTemplate), }; }; diff --git a/packages/connectors/connector-aliyun-dm/src/mock.ts b/packages/connectors/connector-aliyun-dm/src/mock.ts index 891f3f689..fa74ec93e 100644 --- a/packages/connectors/connector-aliyun-dm/src/mock.ts +++ b/packages/connectors/connector-aliyun-dm/src/mock.ts @@ -1,3 +1,5 @@ +import { type EmailTemplateDetails } from '@logto/connector-kit'; + export const mockedParameters = { AccessKeyId: 'testid', AccountName: "", @@ -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}}', +}; diff --git a/packages/connectors/connector-aws-ses/src/index.test.ts b/packages/connectors/connector-aws-ses/src/index.test.ts index 408b6de16..a3f5e46d6 100644 --- a/packages/connectors/connector-aws-ses/src/index.test.ts +++ b/packages/connectors/connector-aws-ses/src/index.test.ts @@ -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, + }, + }) + ); + }); }); diff --git a/packages/connectors/connector-aws-ses/src/index.ts b/packages/connectors/connector-aws-ses/src/index.ts index 3451e3719..23873357b 100644 --- a/packages/connectors/connector-aws-ses/src/index.ts +++ b/packages/connectors/connector-aws-ses/src/index.ts @@ -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 = async ({ getConfig }) => { +const createAwsSesConnector: CreateConnector = async ({ + getConfig, + getI18nEmailTemplate, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: awsSesConfigGuard, - sendMessage: sendMessage(getConfig), + sendMessage: sendMessage(getConfig, getI18nEmailTemplate), }; }; diff --git a/packages/connectors/connector-aws-ses/src/mock.ts b/packages/connectors/connector-aws-ses/src/mock.ts index c71398b03..93b4a0943 100644 --- a/packages/connectors/connector-aws-ses/src/mock.ts +++ b/packages/connectors/connector-aws-ses/src/mock.ts @@ -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', +}; diff --git a/packages/connectors/connector-aws-ses/src/utils.ts b/packages/connectors/connector-aws-ses/src/utils.ts index a4c24c8bf..8feb7f49c 100644 --- a/packages/connectors/connector-aws-ses/src/utils.ts +++ b/packages/connectors/connector-aws-ses/src/utils.ts @@ -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' }, diff --git a/packages/connectors/connector-mailgun/src/index.test.ts b/packages/connectors/connector-mailgun/src/index.test.ts index ac1b4d034..19773289d 100644 --- a/packages/connectors/connector-mailgun/src/index.test.ts +++ b/packages/connectors/connector-mailgun/src/index.test.ts @@ -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 = { 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: '

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}}', + } satisfies EmailTemplateDetails); + + getConfig.mockResolvedValue({ + ...baseConfig, + deliveries: { + [TemplateType.Generic]: { + subject: 'Verification code is {{code}}', + html: '

Your verification code is {{code}}

', + 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: '

Your verification code is {{code}}

', + replyTo: 'baz@example.com', + }, + }, + }); + + await connector.sendMessage({ + to: 'bar@example.com', + type: TemplateType.Generic, + payload: { code: '123456', applicationName: 'Foo' }, + }); + }); }); diff --git a/packages/connectors/connector-mailgun/src/index.ts b/packages/connectors/connector-mailgun/src/index.ts index e54082817..0b7d4e0bf 100644 --- a/packages/connectors/connector-mailgun/src/index.ts +++ b/packages/connectors/connector-mailgun/src/index.ts @@ -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 => { + 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 = async ({ getConfig }) => { +const createMailgunMailConnector: CreateConnector = async ({ + getConfig, + getI18nEmailTemplate, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: mailgunConfigGuard, - sendMessage: sendMessage(getConfig), + sendMessage: sendMessage(getConfig, getI18nEmailTemplate), }; }; diff --git a/packages/connectors/connector-mock-email/src/index.ts b/packages/connectors/connector-mock-email/src/index.ts index 840b287ff..e663fe832 100644 --- a/packages/connectors/connector-mock-email/src/index.ts +++ b/packages/connectors/connector-mock-email/src/index.ts @@ -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); diff --git a/packages/connectors/connector-sendgrid-email/src/index.test.ts b/packages/connectors/connector-sendgrid-email/src/index.test.ts index 1062b2159..7aae05b8e 100644 --- a/packages/connectors/connector-sendgrid-email/src/index.test.ts +++ b/packages/connectors/connector-sendgrid-email/src/index.test.ts @@ -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, + 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: '

Your passcode is {{code}}

', + 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: '

Your passcode is 123456

', + }, + ], + }); + + getConfig.mockResolvedValue(mockedConfig); + + await connector.sendMessage({ + to: toEmail, + type: TemplateType.Generic, + payload: { + code: '123456', + applicationName: 'Test app', + userName: 'John Doe', + }, + }); + }); }); diff --git a/packages/connectors/connector-sendgrid-email/src/index.ts b/packages/connectors/connector-sendgrid-email/src/index.ts index 4ba0c6837..dcfed9ba7 100644 --- a/packages/connectors/connector-sendgrid-email/src/index.ts +++ b/packages/connectors/connector-sendgrid-email/src/index.ts @@ -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 = async ({ getConfig }) => { +const createSendGridMailConnector: CreateConnector = async ({ + getConfig, + getI18nEmailTemplate, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: sendGridMailConfigGuard, - sendMessage: sendMessage(getConfig), + sendMessage: sendMessage(getConfig, getI18nEmailTemplate), }; }; diff --git a/packages/connectors/connector-sendgrid-email/src/mock.ts b/packages/connectors/connector-sendgrid-email/src/mock.ts index cf668cafd..5e9ce3ac1 100644 --- a/packages/connectors/connector-sendgrid-email/src/mock.ts +++ b/packages/connectors/connector-sendgrid-email/src/mock.ts @@ -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.', }, ], }; diff --git a/packages/connectors/connector-smtp/src/index.test.ts b/packages/connectors/connector-smtp/src/index.test.ts index b57ad02e1..b490c1b47 100644 --- a/packages/connectors/connector-smtp/src/index.test.ts +++ b/packages/connectors/connector-smtp/src/index.test.ts @@ -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}} `, + } 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 ', + subject: 'Custom subject 234567', + text: 'Your verification code is 234567', + to: 'bar', + replyTo: 'John Doe', + }); + }); +}); diff --git a/packages/connectors/connector-smtp/src/index.ts b/packages/connectors/connector-smtp/src/index.ts index f447f420e..bbd385368 100644 --- a/packages/connectors/connector-smtp/src/index.ts +++ b/packages/connectors/connector-smtp/src/index.ts @@ -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 = async ({ getConfig }) => { +const createSmtpConnector: CreateConnector = async ({ + getConfig, + getI18nEmailTemplate, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: smtpConfigGuard, - sendMessage: sendMessage(getConfig), + sendMessage: sendMessage(getConfig, getI18nEmailTemplate), }; };