mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(core): add PUT and DELETE email-templates api (#7010)
* 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
This commit is contained in:
parent
e6a9ed5670
commit
99bd856acc
13 changed files with 316 additions and 6 deletions
53
packages/core/src/queries/email-templates.ts
Normal file
53
packages/core/src/queries/email-templates.ts
Normal file
|
@ -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
packages/core/src/routes/email-template/index.openapi.json
Normal file
81
packages/core/src/routes/email-template/index.openapi.json
Normal file
|
@ -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
packages/core/src/routes/email-template/index.ts
Normal file
64
packages/core/src/routes/email-template/index.ts
Normal file
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
@ -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();
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Dev feature"
|
||||
"name": "Cloud only"
|
||||
},
|
||||
{
|
||||
"name": "Cloud only"
|
||||
"name": "Dev feature"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ const managementApiIdentifiableEntityNames = Object.freeze([
|
|||
'organization-invitation',
|
||||
'saml-application',
|
||||
'secret',
|
||||
'email-template',
|
||||
]);
|
||||
|
||||
/** Additional tags that cannot be inferred from the path. */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
@ -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,
|
||||
|
|
31
packages/integration-tests/src/__mocks__/email-templates.ts
Normal file
31
packages/integration-tests/src/__mocks__/email-templates.ts
Normal file
|
@ -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
packages/integration-tests/src/api/email-templates.ts
Normal file
15
packages/integration-tests/src/api/email-templates.ts
Normal file
|
@ -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
packages/integration-tests/src/helpers/email-templates.ts
Normal file
20
packages/integration-tests/src/helpers/email-templates.ts
Normal file
|
@ -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 = [];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue