From b0135bcd3c7385245ec3edc000ede8470b82b006 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 21 Feb 2025 15:19:30 +0800 Subject: [PATCH] feat(toolkit,core): support nested properties in email template variables (#7053) * refactor(core): add email templates redis cache add email templates redis cache * fix(core): remove unnessesary ternary remove unnessesary ternary * refactor(toolkit): email template support nested properties refactor email template handlebar to support nested properties access * feat(core): add organization extra info add organization extra info to the organization invitation email payload * chore: add changeset add changeset * refactor(core): remove custom data from orgnization context remove custom data from organization context --- .changeset/small-hairs-pretend.md | 55 ++++++++++++ .../connector-mock-email/src/index.ts | 11 ++- .../connector-tencent-sms/src/index.ts | 5 +- .../src/libraries/organization-invitation.ts | 15 +++- .../routes/organization-invitation/index.ts | 15 ++-- .../src/utils/connectors/extra-information.ts | 8 +- .../organization-invitation.test.ts | 89 +++++++++++++------ .../toolkit/connector-kit/src/index.test.ts | 70 +++++++++++++++ packages/toolkit/connector-kit/src/index.ts | 48 ++++++++-- .../connector-kit/src/types/passwordless.ts | 4 +- 10 files changed, 275 insertions(+), 45 deletions(-) create mode 100644 .changeset/small-hairs-pretend.md diff --git a/.changeset/small-hairs-pretend.md b/.changeset/small-hairs-pretend.md new file mode 100644 index 000000000..52689725e --- /dev/null +++ b/.changeset/small-hairs-pretend.md @@ -0,0 +1,55 @@ +--- +"@logto/connector-kit": minor +--- + +enhanced handlebars template processing in the connector to support nested property access in email template variables. + +## Updates + +- Updated `replaceSendMessageHandlebars` logic to handle nested property paths in template variables +- Latest template processing logic now supports: + - Direct replacement of primitive values (string/number/null/undefined) + - Deep property access using dot-notation (e.g., `organization.branding.logoUrl`) + - Graceful handling of missing properties (replaces with empty string) + - Preservation of original handlebars when variables aren't provided in payload + +## Examples + +1. Direct replacement + +```ts +replaceSendMessageKeysWithPayload("Your verification code is {{code}}", { + code: "123456", +}); +// 'Your verification code is 123456' +``` + +2. Deep property access + +```ts +replaceSendMessageKeysWithPayload( + "Your logo is {{organization.branding.logoUrl}}", + { organization: { branding: { logoUrl: "https://example.com/logo.png" } } } +); +// 'Your logo is https://example.com/logo.png' +``` + +3. Missing properties + +```ts +replaceSendMessageKeysWithPayload( + "Your logo is {{organization.branding.logoUrl}}", + { organization: { name: "foo" } } +); +// 'Your logo is ' +``` + +4. Preservation of missing variables + +```ts +replaceSendMessageKeysWithPayload( + "Your application is {{application.name}}", + {} +); +// 'Your application is {{application.name}}' +``` diff --git a/packages/connectors/connector-mock-email/src/index.ts b/packages/connectors/connector-mock-email/src/index.ts index e663fe832..3bbaf16c8 100644 --- a/packages/connectors/connector-mock-email/src/index.ts +++ b/packages/connectors/connector-mock-email/src/index.ts @@ -14,6 +14,7 @@ import { validateConfig, ConnectorType, mockConnectorFilePaths, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import { defaultMetadata } from './constant.js'; @@ -41,7 +42,15 @@ const sendMessage = await fs.writeFile( mockConnectorFilePaths.Email, - JSON.stringify({ address: to, code: payload.code, type, payload, template }) + '\n' + JSON.stringify({ + address: to, + code: payload.code, + type, + payload, + template, + subject: replaceSendMessageHandlebars(template.subject, payload), + content: replaceSendMessageHandlebars(template.content, payload), + }) + '\n' ); return { address: to, data: payload }; diff --git a/packages/connectors/connector-tencent-sms/src/index.ts b/packages/connectors/connector-tencent-sms/src/index.ts index dc072ffcf..ef1f58379 100644 --- a/packages/connectors/connector-tencent-sms/src/index.ts +++ b/packages/connectors/connector-tencent-sms/src/index.ts @@ -45,8 +45,11 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction { ) ); + // Tencent SMS API requires all parameters to be strings. Force parse all payload values to string. + const parametersSet = Object.values(payload).map(String); + try { - const httpResponse = await sendSmsRequest(template.templateCode, Object.values(payload), to, { + const httpResponse = await sendSmsRequest(template.templateCode, parametersSet, to, { secretId: accessKeyId, secretKey: accessKeySecret, sdkAppId, diff --git a/packages/core/src/libraries/organization-invitation.ts b/packages/core/src/libraries/organization-invitation.ts index 1c867a6c7..4e81a09f3 100644 --- a/packages/core/src/libraries/organization-invitation.ts +++ b/packages/core/src/libraries/organization-invitation.ts @@ -11,6 +11,10 @@ import RequestError from '#src/errors/RequestError/index.js'; import OrganizationQueries from '#src/queries/organization/index.js'; import { createUserQueries } from '#src/queries/user.js'; import type Queries from '#src/tenants/Queries.js'; +import { + buildOrganizationExtraInfo, + type OrganizationExtraInfo, +} from '#src/utils/connectors/extra-information.js'; import { type ConnectorLibrary } from './connector.js'; @@ -90,7 +94,11 @@ export class OrganizationInvitationLibrary { } if (messagePayload) { - await this.sendEmail(invitee, messagePayload); + const organization = await organizationQueries.findById(organizationId); + await this.sendEmail(invitee, { + organization: buildOrganizationExtraInfo(organization), + ...messagePayload, + }); } // Additional query to get the full invitation data @@ -204,7 +212,10 @@ export class OrganizationInvitationLibrary { } /** Send an organization invitation email. */ - async sendEmail(to: string, payload: SendMessagePayload) { + async sendEmail( + to: string, + payload: SendMessagePayload & { organization: OrganizationExtraInfo } + ) { const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email); return emailConnector.sendMessage({ to, diff --git a/packages/core/src/routes/organization-invitation/index.ts b/packages/core/src/routes/organization-invitation/index.ts index 083f69402..64bb5ccad 100644 --- a/packages/core/src/routes/organization-invitation/index.ts +++ b/packages/core/src/routes/organization-invitation/index.ts @@ -10,6 +10,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; import assertThat from '#src/utils/assert-that.js'; +import { buildOrganizationExtraInfo } from '#src/utils/connectors/extra-information.js'; import { errorHandler } from '../organization/utils.js'; import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; @@ -18,13 +19,13 @@ export default function organizationInvitationRoutes ) { + const { invitations } = organizations; + const router = new SchemaRouter(OrganizationInvitations, invitations, { errorHandler, disabled: { @@ -99,9 +100,13 @@ export default function organizationInvitationRoutes { }; return cleanDeep(extraInfo, { emptyObjects: false }); }; + +export type OrganizationExtraInfo = Pick; + +export const buildOrganizationExtraInfo = (organization: Organization): OrganizationExtraInfo => + pick(organization, 'id', 'name', 'branding'); diff --git a/packages/integration-tests/src/tests/api/email-templates/organization-invitation.test.ts b/packages/integration-tests/src/tests/api/email-templates/organization-invitation.test.ts index ae231fd23..89fc58806 100644 --- a/packages/integration-tests/src/tests/api/email-templates/organization-invitation.test.ts +++ b/packages/integration-tests/src/tests/api/email-templates/organization-invitation.test.ts @@ -1,4 +1,4 @@ -import { TemplateType } from '@logto/connector-kit'; +import { type EmailTemplateDetails, TemplateType } from '@logto/connector-kit'; import { mockEmailConnectorConfig } from '#src/__mocks__/connectors-mock.js'; import { type MockEmailTemplatePayload } from '#src/__mocks__/email-templates.js'; @@ -8,30 +8,31 @@ import { readConnectorMessage } from '#src/helpers/index.js'; import { OrganizationApiTest, OrganizationInvitationApiTest } from '#src/helpers/organization.js'; import { devFeatureTest, generateEmail } from '#src/utils.js'; +const mockEnTemplate: MockEmailTemplatePayload = { + languageTag: 'en', + templateType: TemplateType.OrganizationInvitation, + details: { + subject: 'Organization invitation', + content: 'Click {{link}} to join the organization.', + contentType: 'text/html', + }, +}; +const mockDeTemplate: MockEmailTemplatePayload = { + ...mockEnTemplate, + languageTag: 'de', +}; +const mockFrSignInTemplate: MockEmailTemplatePayload = { + ...mockEnTemplate, + languageTag: 'fr', + templateType: TemplateType.SignIn, +}; + devFeatureTest.describe('organization invitation API with i18n email templates', () => { const emailTemplatesApi = new EmailTemplatesApiTest(); const invitationApi = new OrganizationInvitationApiTest(); const organizationApi = new OrganizationApiTest(); const mockEmail = generateEmail(); - const mockEnTemplate: MockEmailTemplatePayload = { - languageTag: 'en', - templateType: TemplateType.OrganizationInvitation, - details: { - subject: 'Test template', - content: 'Test value: {{code}}', - contentType: 'text/html', - }, - }; - const mockDeTemplate: MockEmailTemplatePayload = { - ...mockEnTemplate, - languageTag: 'de', - }; - const mockFrSignInTemplate: MockEmailTemplatePayload = { - ...mockEnTemplate, - languageTag: 'fr', - templateType: TemplateType.SignIn, - }; beforeAll(async () => { await Promise.all([ @@ -49,8 +50,6 @@ devFeatureTest.describe('organization invitation API with i18n email templates', }); it('should read and use the i18n email template for organization invitation', async () => { - await setEmailConnector(); - const organization = await organizationApi.create({ name: 'test' }); await invitationApi.create({ @@ -74,8 +73,6 @@ devFeatureTest.describe('organization invitation API with i18n email templates', }); it('should fallback to the default language template if the i18n template is not found for the given language', async () => { - await setEmailConnector(); - const organization = await organizationApi.create({ name: 'test' }); await invitationApi.create({ @@ -99,7 +96,6 @@ devFeatureTest.describe('organization invitation API with i18n email templates', }); it('should be able to resend the email after creating an invitation with a different language', async () => { - await setEmailConnector(); const organization = await organizationApi.create({ name: 'test' }); const invitation = await invitationApi.create({ @@ -143,7 +139,7 @@ devFeatureTest.describe('organization invitation API with i18n email templates', const organization = await organizationApi.create({ name: 'test' }); - const invitation = await invitationApi.create({ + await invitationApi.create({ organizationId: organization.id, invitee: mockEmail, expiresAt: Date.now() + 1_000_000, @@ -162,4 +158,47 @@ devFeatureTest.describe('organization invitation API with i18n email templates', ), }); }); + + it('should render the template with the extra organization information', async () => { + const organizationInvitationTemplate: EmailTemplateDetails = { + subject: 'You are invited to join {{organization.name}}', + content: + '

Click {{link}} to join the organization {{organization.name}}.

{{organization.invalid_field.foo}}', + contentType: 'text/html', + }; + + await emailTemplatesApi.create([ + { + languageTag: 'en', + templateType: TemplateType.OrganizationInvitation, + details: organizationInvitationTemplate, + }, + ]); + + const organization = await organizationApi.create({ + name: 'test', + branding: { + logoUrl: 'https://example.com/logo.png', + }, + }); + + await invitationApi.create({ + organizationId: organization.id, + invitee: mockEmail, + expiresAt: Date.now() + 1_000_000, + messagePayload: { + link: 'https://example.com', + }, + }); + + await expect(readConnectorMessage('Email')).resolves.toMatchObject({ + type: 'OrganizationInvitation', + payload: { + link: 'https://example.com', + }, + template: organizationInvitationTemplate, + subject: `You are invited to join test`, + content: `

Click https://example.com to join the organization test.

`, + }); + }); }); diff --git a/packages/toolkit/connector-kit/src/index.test.ts b/packages/toolkit/connector-kit/src/index.test.ts index 2e6e164ff..63bc84d53 100644 --- a/packages/toolkit/connector-kit/src/index.test.ts +++ b/packages/toolkit/connector-kit/src/index.test.ts @@ -6,6 +6,7 @@ import { parseJsonObject, replaceSendMessageHandlebars, validateConfig, + getValue, } from './index.js'; describe('validateConfig', () => { @@ -91,4 +92,73 @@ describe('replaceSendMessageHandlebars', () => { 'Your verification code is 123456' ); }); + + it('should replace handlebars that have nested properties with payload', () => { + const template = + 'Your application name is {{application.name}}, {{ application.customData.foo }}, {{ application.customData.bar }}, {{ application.customData.baz.1 }}'; + const payload = { + application: { + name: 'Logto', + customData: { + foo: 'foo', + baz: [1, '2', null], + }, + }, + }; + expect(replaceSendMessageHandlebars(template, payload)).toEqual( + 'Your application name is Logto, foo, , 2' + ); + }); + + it('should not replace handlebars if root property does not exist in payload', () => { + const template = 'Your {{ application.name }} sign in verification code is {{ code }}'; + const payload = { + code: '123456', + }; + + expect(replaceSendMessageHandlebars(template, payload)).toEqual( + 'Your {{ application.name }} sign in verification code is 123456' + ); + }); +}); + +describe('getValue', () => { + it('should return value from object', () => { + const object = { foo: { bar: { baz: 'qux' } } }; + expect(getValue(object, 'foo')).toEqual(object.foo); + expect(getValue(object, 'foo.bar')).toEqual(object.foo.bar); + expect(getValue(object, 'foo.bar.baz')).toEqual('qux'); + }); + + it('should return value from array', () => { + const object = { + list: [ + { name: 'name1', age: 1 }, + { name: 'name2', age: 2 }, + ], + }; + expect(getValue(object, 'list')).toEqual(object.list); + expect(getValue(object, 'list.0')).toEqual(object.list[0]); + expect(getValue(object, 'list.0.name')).toEqual('name1'); + }); + + it('should return undefined if path is not found', () => { + const object = { foo: { bar: { baz: 'qux' } } }; + expect(getValue(object, 'foo.baz')).toEqual(undefined); + expect(getValue(object, 'foo.bar.baz.qux')).toEqual(undefined); + }); + + it('should return undefined if path is not an object', () => { + const object = { + foo: 'foo', + bar: 1, + baz: true, + qux: [1, '2', null], + quux: null, + }; + + for (const key of Object.keys(object)) { + expect(getValue(object, `${key}.foo`)).toEqual(undefined); + } + }); }); diff --git a/packages/toolkit/connector-kit/src/index.ts b/packages/toolkit/connector-kit/src/index.ts index b9f560fe1..d52c9e152 100644 --- a/packages/toolkit/connector-kit/src/index.ts +++ b/packages/toolkit/connector-kit/src/index.ts @@ -61,8 +61,11 @@ export const mockConnectorFilePaths = Object.freeze({ /** * Replace all handlebars that match the keys in {@link SendMessagePayload} with the payload - * values. If the payload does not contain the key, the handlebar will be replaced with an empty - * string. + * values. + * + * - If the payload does not contain the root property, the handlebars will not be replaced. + * - If the payload contains the root property but does not contain the nested property, + * the handlebars will be replaced with an empty string. * * @param template The template to replace the handlebars with. * @param payload The payload to replace the handlebars with. @@ -72,21 +75,50 @@ export const mockConnectorFilePaths = Object.freeze({ * ```ts * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', { code: '123456' }); * // 'Your verification code is 123456' + * + * replaceSendMessageKeysWithPayload('Your application name is {{application.name}}', { application: { name: 'Logto' } }); + * // 'Your application name is Logto' + * + * replaceSendMessageKeysWithPayload('Your application name is {{application.name}}', { application: {}}); + * // 'Your application name is ' * ``` * * @example * ```ts * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', {}); - * // 'Your verification code is ' + * // 'Your verification code is {{code}}' + * + * replaceSendMessageKeysWithPayload('Your application name is {{application.name}}', {}); + * // 'Your application name is {{application.name}}' * ``` */ export const replaceSendMessageHandlebars = ( template: string, payload: SendMessagePayload ): string => { - return Object.keys(payload).reduce( - (accumulator, key) => - accumulator.replaceAll(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), payload[key] ?? ''), - template - ); + const regex = /{{\s*([\w.]+)\s*}}/g; + + return template.replaceAll(regex, (handleBar, key: string) => { + const baseKey = key.split('.')[0]; + // If the root variable does not exist in the payload, return the original key + if (!(baseKey && baseKey in payload)) { + return handleBar; + } + + const value = getValue(payload, key); + + return String(value ?? ''); + }); +}; + +export const getValue = (object: Record, path: string): unknown | undefined => { + return path.split('.').reduce((current, part) => { + // Return undefined if the current value is not an object + if (!current || typeof current !== 'object') { + return; + } + + // eslint-disable-next-line no-restricted-syntax + return (current as Record)[part]; + }, object); }; diff --git a/packages/toolkit/connector-kit/src/types/passwordless.ts b/packages/toolkit/connector-kit/src/types/passwordless.ts index a76533974..5cb975557 100644 --- a/packages/toolkit/connector-kit/src/types/passwordless.ts +++ b/packages/toolkit/connector-kit/src/types/passwordless.ts @@ -62,7 +62,7 @@ export type SendMessagePayload = { * @example 'en-US' */ locale?: string; -} & Record; +} & Record; /** The guard for {@link SendMessagePayload}. */ export const sendMessagePayloadGuard = z @@ -71,7 +71,7 @@ export const sendMessagePayloadGuard = z link: z.string().optional(), locale: z.string().optional(), }) - .catchall(z.string()) satisfies z.ZodType; + .catchall(z.unknown()) satisfies z.ZodType; export const urlRegEx = /(https?:\/\/)?(?:www\.)?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b[\w#%&()+./:=?@~-]*/;