Skip to content

Commit

Permalink
feat(core): add PUT and DELETE email-templates api (#7010)
Browse files Browse the repository at this point in the history
* feat(core): add PUT and DELETE email-templates api

add PUT and DELETE email-templates api

* fix(core): fix openapi docs errors

fix openapi docs errors
  • Loading branch information
simeng-li authored Feb 11, 2025
1 parent e6a9ed5 commit 99bd856
Show file tree
Hide file tree
Showing 13 changed files with 316 additions and 6 deletions.
53 changes: 53 additions & 0 deletions packages/core/src/queries/email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
type EmailTemplate,
type EmailTemplateKeys,
EmailTemplates,
type CreateEmailTemplate,
} from '@logto/schemas';
import { type CommonQueryMethods } from '@silverhand/slonik';

import SchemaQueries from '#src/utils/SchemaQueries.js';

import { type WellKnownCache } from '../caches/well-known.js';
import { buildInsertIntoWithPool } from '../database/insert-into.js';
import { convertToIdentifiers, type OmitAutoSetFields } from '../utils/sql.js';

export default class EmailTemplatesQueries extends SchemaQueries<
EmailTemplateKeys,
CreateEmailTemplate,
EmailTemplate
> {
constructor(
pool: CommonQueryMethods,
// TODO: Implement redis cache for email templates
private readonly wellKnownCache: WellKnownCache
) {
super(pool, EmailTemplates);
}

/**
* Upsert multiple email templates
*
* If the email template already exists with the same language tag, tenant ID, and template type,
* template details will be updated.
*/
async upsertMany(
emailTemplates: ReadonlyArray<OmitAutoSetFields<CreateEmailTemplate>>
): Promise<readonly EmailTemplate[]> {
const { fields } = convertToIdentifiers(EmailTemplates);

return this.pool.transaction(async (transaction) => {
const insertIntoTransaction = buildInsertIntoWithPool(transaction)(EmailTemplates, {
returning: true,
onConflict: {
fields: [fields.tenantId, fields.languageTag, fields.templateType],
setExcludedFields: [fields.details],
},
});

return Promise.all(
emailTemplates.map(async (emailTemplate) => insertIntoTransaction(emailTemplate))
);
});
}
}
81 changes: 81 additions & 0 deletions packages/core/src/routes/email-template/index.openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"tags": [
{
"name": "Email templates",
"description": "Manage custom i18n email templates for various types of emails, such as sign-in verification codes and password resets."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/email-templates": {
"put": {
"summary": "Replace email templates",
"description": "Create or replace a list of email templates. If an email template with the same language tag and template type already exists, its details will be updated.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"templates": {
"type": "array",
"items": {
"properties": {
"languageTag": {
"description": "The language tag of the email template, e.g., `en` or `zh-CN`."
},
"templateType": {
"description": "The type of the email template, e.g. `SignIn` or `ForgotPassword`"
},
"details": {
"description": "The details of the email template.",
"properties": {
"subject": {
"description": "The template of the email subject."
},
"content": {
"description": "The template of the email body."
},
"contentType": {
"description": "The content type of the email body. (Only required by some specific email providers.)"
},
"replyTo": {
"description": "The reply name template of the email. If not provided, the target email address will be used. (The render logic may differ based on the email provider.)"
},
"sendFrom": {
"description": "The send from name template of the email. If not provided, the default Logto email address will be used. (The render logic may differ based on the email provider.)"
}
}
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "The list of newly created or replaced email templates."
}
}
}
},
"/api/email-templates/{id}": {
"delete": {
"summary": "Delete an email template",
"description": "Delete an email template by its ID.",
"responses": {
"204": {
"description": "The email template was deleted successfully."
},
"404": {
"description": "The email template was not found."
}
}
}
}
}
}
64 changes: 64 additions & 0 deletions packages/core/src/routes/email-template/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { EmailTemplates } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';

import koaGuard from '#src/middleware/koa-guard.js';

import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';

const pathPrefix = '/email-templates';

export default function emailTemplateRoutes<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const { emailTemplates: emailTemplatesQueries } = queries;

router.put(
pathPrefix,
koaGuard({
body: z.object({
templates: EmailTemplates.createGuard
.omit({
id: true,
tenantId: true,
createdAt: true,
})
.array()
.min(1),
}),
response: EmailTemplates.guard.array(),
status: [200, 422],
}),
async (ctx, next) => {
const { body } = ctx.guard;

ctx.body = await emailTemplatesQueries.upsertMany(
body.templates.map((template) => ({
id: generateStandardId(),
...template,
}))
);

return next();
}
);

router.delete(
`${pathPrefix}/:id`,
koaGuard({
params: z.object({
id: z.string(),
}),
status: [204, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;

await emailTemplatesQueries.deleteById(id);
ctx.status = 204;
return next();
}
);
}
6 changes: 6 additions & 0 deletions packages/core/src/routes/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import connectorRoutes from './connector/index.js';
import customPhraseRoutes from './custom-phrase.js';
import dashboardRoutes from './dashboard.js';
import domainRoutes from './domain.js';
import emailTemplateRoutes from './email-template/index.js';
import experienceApiRoutes from './experience/index.js';
import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
Expand Down Expand Up @@ -103,6 +104,11 @@ const createRouters = (tenant: TenantContext) => {
accountCentersRoutes(managementRouter, tenant);
samlApplicationRoutes(managementRouter, tenant);

// TODO: @simeng remove this condition after the feature is enabled in production
if (EnvSet.values.isDevFeaturesEnabled) {
emailTemplateRoutes(managementRouter, tenant);
}

const anonymousRouter: AnonymousRouter = new Router();

const userRouter: UserRouter = new Router();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"tags": [
{
"name": "Dev feature"
"name": "Cloud only"
},
{
"name": "Cloud only"
"name": "Dev feature"
}
],
"paths": {
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/routes/sso-connector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,8 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
}
);

if (
EnvSet.values.isDevFeaturesEnabled &&
(EnvSet.values.isCloud || EnvSet.values.isIntegrationTest)
) {
// TODO: @simeng Remove this when IdP initiated SAML SSO is ready for production
if (EnvSet.values.isDevFeaturesEnabled) {
ssoConnectorIdpInitiatedAuthConfigRoutes(...args);
}
}
1 change: 1 addition & 0 deletions packages/core/src/routes/swagger/utils/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const managementApiIdentifiableEntityNames = Object.freeze([
'organization-invitation',
'saml-application',
'secret',
'email-template',
]);

/** Additional tags that cannot be inferred from the path. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/routes/swagger/utils/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export const buildPathIdParameters = (
rootComponent: string
): Record<string, OpenAPIV3.ParameterObject> => {
const entityId = `${camelcase(rootComponent)}Id`;

const shared = {
in: 'path',
description: `The unique identifier of the ${rootComponent
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tenants/Queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';

import { AccountCenterQueries } from '../queries/account-center.js';
import EmailTemplatesQueries from '../queries/email-templates.js';
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
import { VerificationRecordQueries } from '../queries/verification-records.js';

Expand Down Expand Up @@ -72,6 +73,7 @@ export default class Queries {
verificationRecords = new VerificationRecordQueries(this.pool);
accountCenters = new AccountCenterQueries(this.pool);
tenants = createTenantQueries(this.pool);
emailTemplates = new EmailTemplatesQueries(this.pool, this.wellKnownCache);

constructor(
public readonly pool: CommonQueryMethods,
Expand Down
31 changes: 31 additions & 0 deletions packages/integration-tests/src/__mocks__/email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type CreateEmailTemplate, TemplateType } from '@logto/schemas';

export const mockEmailTemplates: Array<Omit<CreateEmailTemplate, 'id'>> = [
{
languageTag: 'en',
templateType: TemplateType.SignIn,
details: {
subject: 'Sign In',
content: 'Sign in to your account',
contentType: 'text/html',
},
},
{
languageTag: 'en',
templateType: TemplateType.Register,
details: {
subject: 'Register',
content: 'Register for an account',
contentType: 'text/html',
},
},
{
languageTag: 'de',
templateType: TemplateType.SignIn,
details: {
subject: 'Sign In',
content: 'Sign in to your account',
contentType: 'text/plain',
},
},
];
15 changes: 15 additions & 0 deletions packages/integration-tests/src/api/email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type CreateEmailTemplate, type EmailTemplate } from '@logto/schemas';

import { authedAdminApi } from './index.js';

const path = 'email-templates';

export class EmailTemplatesApi {
async create(templates: Array<Omit<CreateEmailTemplate, 'id'>>): Promise<EmailTemplate[]> {
return authedAdminApi.put(path, { json: { templates } }).json<EmailTemplate[]>();
}

async delete(id: string): Promise<void> {
await authedAdminApi.delete(`${path}/${id}`);
}
}
20 changes: 20 additions & 0 deletions packages/integration-tests/src/helpers/email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type CreateEmailTemplate, type EmailTemplate } from '@logto/schemas';

import { EmailTemplatesApi } from '#src/api/email-templates.js';

export class EmailTemplatesApiTest extends EmailTemplatesApi {
#emailTemplates: EmailTemplate[] = [];

override async create(
templates: Array<Omit<CreateEmailTemplate, 'id'>>
): Promise<EmailTemplate[]> {
const created = await super.create(templates);
this.#emailTemplates.concat(created);
return created;
}

async cleanUp(): Promise<void> {
await Promise.all(this.#emailTemplates.map(async (template) => this.delete(template.id)));
this.#emailTemplates = [];
}
}
38 changes: 38 additions & 0 deletions packages/integration-tests/src/tests/api/email-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mockEmailTemplates } from '#src/__mocks__/email-templates.js';
import { EmailTemplatesApiTest } from '#src/helpers/email-templates.js';
import { devFeatureTest } from '#src/utils.js';

devFeatureTest.describe('email templates', () => {
const emailTemplatesApi = new EmailTemplatesApiTest();

afterEach(async () => {
await emailTemplatesApi.cleanUp();
});

it('should create email templates successfully', async () => {
const created = await emailTemplatesApi.create(mockEmailTemplates);
expect(created).toHaveLength(mockEmailTemplates.length);
});

it('should update existing email template details for specified language and type', async () => {
const updatedTemplates: typeof mockEmailTemplates = mockEmailTemplates.map(
({ details, ...rest }) => ({
...rest,
details: {
subject: `${details.subject} updated`,
content: `${details.content} updated`,
},
})
);

await emailTemplatesApi.create(mockEmailTemplates);
const created = await emailTemplatesApi.create(updatedTemplates);

expect(created).toHaveLength(3);

for (const [index, template] of created.entries()) {
expect(template.details.subject).toBe(updatedTemplates[index]!.details.subject);
expect(template.details.content).toBe(updatedTemplates[index]!.details.content);
}
});
});

0 comments on commit 99bd856

Please sign in to comment.