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:
parent
17b10ce2f7
commit
b0135bcd3c
10 changed files with 275 additions and 45 deletions
55
.changeset/small-hairs-pretend.md
Normal file
55
.changeset/small-hairs-pretend.md
Normal 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}}'
|
||||
```
|
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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" />`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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#%&()+./:=?@~-]*/;
|
||||
|
|
Loading…
Add table
Reference in a new issue