Skip to content

Commit

Permalink
feat(core): add PUT and DELETE email-templates api
Browse files Browse the repository at this point in the history
add PUT and DELETE email-templates api
  • Loading branch information
simeng-li committed Feb 8, 2025
1 parent 32c1e3e commit 94f2845
Show file tree
Hide file tree
Showing 10 changed files with 312 additions and 8 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))
);
});
}

Check warning on line 52 in packages/core/src/queries/email-templates.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/email-templates.ts#L35-L52

Added lines #L35 - L52 were not covered by tests
}
80 changes: 80 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,80 @@
{
"tags": [
{
"name": "Email templates",
"description": "Manage custom i18n email templates for various types of emails, such as sign-in verification codes and password resets."
}
],
"paths": {
"/api/email-templates": {
"put": {
"tags": ["Dev feature"],
"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": "Thj 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": {
"tags": ["Dev feature"],
"summary": "Delete an email template",
"description": "Delete an email template by its ID.",
"response": {
"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();
}

Check warning on line 43 in packages/core/src/routes/email-template/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/email-template/index.ts#L33-L43

Added lines #L33 - L43 were not covered by tests
);

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

Check warning on line 62 in packages/core/src/routes/email-template/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/email-template/index.ts#L55-L62

Added lines #L55 - L62 were not covered by tests
);
}
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,15 +1,8 @@
{
"tags": [
{
"name": "Dev feature"
},
{
"name": "Cloud only"
}
],
"paths": {
"/api/sso-connectors/{id}/idp-initiated-auth-config": {
"get": {
"tags": ["Dev feature", "Cloud only"],
"summary": "Get IdP initiated auth config",
"description": "Get the IdP initiated authentication config of the given SAML SSO connector.",
"responses": {
Expand All @@ -22,6 +15,7 @@
}
},
"put": {
"tags": ["Dev feature", "Cloud only"],
"summary": "Set IdP initiated auth config",
"description": "Set IdP initiated authentication config for a given SAML SSO connector. Any existing IdP initiated auth config will be overwritten.",
"requestBody": {
Expand Down Expand Up @@ -62,6 +56,7 @@
}
},
"delete": {
"tags": ["Dev feature", "Cloud only"],
"summary": "Delete IdP initiated auth config",
"description": "Delete the IdP initiated authentication config of the given SAML SSO connector.",
"responses": {
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 94f2845

Please sign in to comment.