0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

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
This commit is contained in:
simeng-li 2025-02-21 15:19:30 +08:00 committed by GitHub
parent 17b10ce2f7
commit b0135bcd3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 275 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T extends ManagementApiRout
...[
originalRouter,
{
queries: {
organizations: { invitations },
},
queries: { organizations },
libraries: { organizationInvitations },
},
]: RouterInitArgs<T>
) {
const { invitations } = organizations;
const router = new SchemaRouter(OrganizationInvitations, invitations, {
errorHandler,
disabled: {
@ -99,9 +100,13 @@ export default function organizationInvitationRoutes<T extends ManagementApiRout
params: { id },
body,
} = ctx.guard;
const { invitee } = await invitations.findById(id);
const { invitee, organizationId } = await invitations.findById(id);
const organization = await organizations.findById(organizationId);
await organizationInvitations.sendEmail(invitee, body);
await organizationInvitations.sendEmail(invitee, {
organization: buildOrganizationExtraInfo(organization),
...body,
});
ctx.status = 204;
return next();
}

View file

@ -1,5 +1,6 @@
import { type ConnectorMetadata, ServiceConnector } from '@logto/connector-kit';
import { conditional } from '@silverhand/essentials';
import { type Organization } from '@logto/schemas';
import { conditional, pick } from '@silverhand/essentials';
import cleanDeep from 'clean-deep';
import { string, object } from 'zod';
@ -16,3 +17,8 @@ export const buildExtraInfo = (metadata: ConnectorMetadata) => {
};
return cleanDeep(extraInfo, { emptyObjects: false });
};
export type OrganizationExtraInfo = Pick<Organization, 'id' | 'name' | 'branding'>;
export const buildOrganizationExtraInfo = (organization: Organization): OrganizationExtraInfo =>
pick(organization, 'id', 'name', 'branding');

View file

@ -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:
'<p>Click {{link}} to join the organization {{organization.name}}.<p><img src="{{organization.branding.logoUrl}}" />{{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: `<p>Click https://example.com to join the organization test.<p><img src="https://example.com/logo.png" />`,
});
});
});

View file

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

View file

@ -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<string, unknown>, path: string): unknown | undefined => {
return path.split('.').reduce<unknown | undefined>((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<string, unknown>)[part];
}, object);
};

View file

@ -62,7 +62,7 @@ export type SendMessagePayload = {
* @example 'en-US'
*/
locale?: string;
} & Record<string, string>;
} & Record<string, unknown>;
/** 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<SendMessagePayload>;
.catchall(z.unknown()) satisfies z.ZodType<SendMessagePayload>;
export const urlRegEx =
/(https?:\/\/)?(?:www\.)?[\w#%+.:=@~-]{1,256}\.[\d()A-Za-z]{1,6}\b[\w#%&()+./:=?@~-]*/;