diff --git a/.changeset-staged/real-carpets-wait.md b/.changeset-staged/real-carpets-wait.md new file mode 100644 index 000000000..bb318af17 --- /dev/null +++ b/.changeset-staged/real-carpets-wait.md @@ -0,0 +1,5 @@ +--- +"@logto/cloud": minor +--- + +Add Cloud API: send email diff --git a/packages/cli/package.json b/packages/cli/package.json index 84bbd1180..87e6a98a6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,7 +43,7 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "@logto/connector-kit": "workspace:1.0.0-rc.2", + "@logto/connector-kit": "workspace:*", "@logto/core-kit": "workspace:*", "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 487844e19..1596af3e0 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@logto/cli": "workspace:*", + "@logto/connector-kit": "workspace:*", "@logto/core-kit": "workspace:*", "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index f6e11b56b..369ee5413 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,6 +1,6 @@ import { cloudApiIndicator } from '@logto/schemas'; import type { RequestContext } from '@withtyped/server'; -import createServer, { compose, withRequest } from '@withtyped/server'; +import createServer, { withBody, compose, withRequest } from '@withtyped/server'; import dotenv from 'dotenv'; import { findUp } from 'find-up'; @@ -25,6 +25,7 @@ const { listen } = createServer({ withPathname( '/api', compose() + .and(withBody()) .and(withAuth({ endpoint: EnvSet.global.logtoEndpoint, audience: cloudApiIndicator })) .and(router.routes()) ) diff --git a/packages/cloud/src/libraries/services.ts b/packages/cloud/src/libraries/services.ts index f4d5bbd33..fe543a0aa 100644 --- a/packages/cloud/src/libraries/services.ts +++ b/packages/cloud/src/libraries/services.ts @@ -1,6 +1,13 @@ +import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; +import type { AllConnector, EmailConnector, SendMessagePayload } from '@logto/connector-kit'; +import { ConnectorType, validateConfig } from '@logto/connector-kit'; import { adminTenantId } from '@logto/schemas'; +import { trySafe } from '@logto/shared'; +import { RequestError } from '@withtyped/server'; import type { Queries } from '#src/queries/index.js'; +import type { LogtoConnector } from '#src/utils/connector/index.js'; +import { loadConnectorFactories } from '#src/utils/connector/index.js'; export class ServicesLibrary { constructor(public readonly queries: Queries) {} @@ -13,4 +20,80 @@ export class ServicesLibrary { return application.customClientMetadata.tenantId; } + + async getAdminTenantLogtoConnectors(): Promise { + const databaseConnectors = await this.queries.connectors.findAllConnectors(adminTenantId); + + const logtoConnectors = await Promise.all( + databaseConnectors.map(async (databaseConnector) => { + const { id, metadata, connectorId } = databaseConnector; + + const connectorFactories = await loadConnectorFactories(); + + const connectorFactory = connectorFactories.find( + ({ metadata }) => metadata.id === connectorId + ); + + if (!connectorFactory) { + return; + } + + return trySafe(async () => { + const { rawConnector, rawMetadata } = await buildRawConnector( + connectorFactory, + async () => { + const databaseConnectors = await this.queries.connectors.findAllConnectors( + adminTenantId + ); + const connector = databaseConnectors.find((connector) => connector.id === id); + + if (!connector) { + throw new RequestError(`Unable to find connector ${id}`, 500); + } + + return connector.config; + } + ); + + const connector: AllConnector = { + ...defaultConnectorMethods, + ...rawConnector, + metadata: { + ...rawMetadata, + ...metadata, + }, + }; + + return { + ...connector, + validateConfig: (config: unknown) => { + validateConfig(config, rawConnector.configGuard); + }, + dbEntry: databaseConnector, + }; + }); + }) + ); + + return logtoConnectors.filter( + (logtoConnector): logtoConnector is LogtoConnector => logtoConnector !== undefined + ); + } + + async sendEmail(data: SendMessagePayload) { + const connectors = await this.getAdminTenantLogtoConnectors(); + + const connector = connectors.find( + (connector): connector is LogtoConnector => + connector.type === ConnectorType.Email + ); + + if (!connector) { + throw new RequestError('Unable to find email connector', 500); + } + + const { sendMessage } = connector; + + return sendMessage(data); + } } diff --git a/packages/cloud/src/queries/connector.ts b/packages/cloud/src/queries/connector.ts new file mode 100644 index 000000000..6b9ef3ec4 --- /dev/null +++ b/packages/cloud/src/queries/connector.ts @@ -0,0 +1,22 @@ +import type { Connector } from '@logto/schemas'; +import type { PostgreSql } from '@withtyped/postgres'; +import { sql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; + +export type ConnectorsQuery = ReturnType; + +export const createConnectorsQuery = (client: Queryable) => { + const findAllConnectors = async (tenantId: string) => { + const { rows } = await client.query(sql` + select id, sync_profile as "syncProfile", + config, metadata, storage, connector_id as "connectorId", + created_at as "createdAt" + from connectors + where tenant_id=${tenantId} + `); + + return rows; + }; + + return { findAllConnectors }; +}; diff --git a/packages/cloud/src/queries/index.ts b/packages/cloud/src/queries/index.ts index 5765089d1..9c271ff44 100644 --- a/packages/cloud/src/queries/index.ts +++ b/packages/cloud/src/queries/index.ts @@ -4,6 +4,7 @@ import { EnvSet } from '#src/env-set/index.js'; import { parseDsn } from '#src/utils/postgres.js'; import { createApplicationsQueries } from './application.js'; +import { createConnectorsQuery } from './connector.js'; import { createTenantsQueries } from './tenants.js'; import { createUsersQueries } from './users.js'; @@ -14,4 +15,5 @@ export class Queries { public readonly tenants = createTenantsQueries(this.client); public readonly users = createUsersQueries(this.client); public readonly applications = createApplicationsQueries(this.client); + public readonly connectors = createConnectorsQuery(this.client); } diff --git a/packages/cloud/src/routes/service.test.ts b/packages/cloud/src/routes/service.test.ts index 495df7762..1d258277a 100644 --- a/packages/cloud/src/routes/service.test.ts +++ b/packages/cloud/src/routes/service.test.ts @@ -6,6 +6,12 @@ import { MockServicesLibrary } from '#src/test-utils/libraries.js'; import { servicesRoutes } from './services.js'; +const mockSendMessagePayload = { + to: 'logto@gmail.com', + type: 'SignIn', + payload: { code: '1234' }, +}; + describe('POST /api/services/send-email', () => { const library = new MockServicesLibrary(); const router = servicesRoutes(library); @@ -13,7 +19,9 @@ describe('POST /api/services/send-email', () => { it('should throw 403 when lack of permission', async () => { await expect( router.routes()( - buildRequestAuthContext('POST /services/send-email')(), + buildRequestAuthContext('POST /services/send-email', { + body: { data: mockSendMessagePayload }, + })(), noop, createHttpContext() ) @@ -24,9 +32,12 @@ describe('POST /api/services/send-email', () => { library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId'); await router.routes()( - buildRequestAuthContext('POST /services/send-email')([CloudScope.SendEmail]), + buildRequestAuthContext('POST /services/send-email', { + body: { data: mockSendMessagePayload }, + })([CloudScope.SendEmail]), async ({ status }) => { expect(status).toBe(201); + expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload); }, createHttpContext() ); diff --git a/packages/cloud/src/routes/services.ts b/packages/cloud/src/routes/services.ts index 018a0b22b..7a4766b72 100644 --- a/packages/cloud/src/routes/services.ts +++ b/packages/cloud/src/routes/services.ts @@ -1,5 +1,7 @@ +import { sendMessagePayloadGuard } from '@logto/connector-kit'; import { CloudScope } from '@logto/schemas'; import { createRouter, RequestError } from '@withtyped/server'; +import { z } from 'zod'; import type { ServicesLibrary } from '#src/libraries/services.js'; import type { WithAuthContext } from '#src/middleware/with-auth.js'; @@ -7,7 +9,7 @@ import type { WithAuthContext } from '#src/middleware/with-auth.js'; export const servicesRoutes = (library: ServicesLibrary) => createRouter('/services').post( '/send-email', - {}, + { body: z.object({ data: sendMessagePayloadGuard }) }, async (context, next) => { if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) { throw new RequestError('Forbidden due to lack of permission.', 403); @@ -15,9 +17,11 @@ export const servicesRoutes = (library: ServicesLibrary) => const tenantId = await library.getTenantIdFromApplicationId(context.auth.id); - // TODO send email + // TODO limitation control console.log(tenantId); + await library.sendEmail(context.guarded.body.data); + return next({ ...context, status: 201 }); } ); diff --git a/packages/cloud/src/test-utils/libraries.ts b/packages/cloud/src/test-utils/libraries.ts index 05355906b..0607c76a3 100644 --- a/packages/cloud/src/test-utils/libraries.ts +++ b/packages/cloud/src/test-utils/libraries.ts @@ -21,4 +21,8 @@ export class MockServicesLibrary implements ServicesLibrary { } public getTenantIdFromApplicationId = jest.fn, [string]>(); + + public sendEmail = jest.fn(); + + public getAdminTenantLogtoConnectors = jest.fn(); } diff --git a/packages/cloud/src/utils/connector/index.ts b/packages/cloud/src/utils/connector/index.ts new file mode 100644 index 000000000..eee70ec2c --- /dev/null +++ b/packages/cloud/src/utils/connector/index.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { loadConnectorFactories as _loadConnectorFactories } from '@logto/cli/lib/connector/index.js'; +import { connectorDirectory } from '@logto/cli/lib/constants.js'; +import { getConnectorPackagesFromDirectory } from '@logto/cli/lib/utils.js'; +import { findPackage } from '@logto/shared'; + +export * from './types.js'; + +export const loadConnectorFactories = async () => { + const currentDirname = path.dirname(fileURLToPath(import.meta.url)); + const cloudDirectory = await findPackage(currentDirname); + const coreDirectory = cloudDirectory && path.join(cloudDirectory, '..', 'core'); + const directory = coreDirectory && path.join(coreDirectory, connectorDirectory); + + if (!directory || !existsSync(directory)) { + return []; + } + + const connectorPackages = await getConnectorPackagesFromDirectory(directory); + + const connectorFactories = await _loadConnectorFactories(connectorPackages, false); + + return connectorFactories; +}; diff --git a/packages/cloud/src/utils/connector/types.ts b/packages/cloud/src/utils/connector/types.ts new file mode 100644 index 000000000..a16c14430 --- /dev/null +++ b/packages/cloud/src/utils/connector/types.ts @@ -0,0 +1,11 @@ +import type { AllConnector } from '@logto/connector-kit'; +import type { Connector } from '@logto/schemas'; + +export { ConnectorType } from '@logto/schemas'; + +/** + * The connector type with full context. + */ +export type LogtoConnector = T & { + validateConfig: (config: unknown) => void; +} & { dbEntry: Connector }; diff --git a/packages/toolkit/connector-kit/src/types.ts b/packages/toolkit/connector-kit/src/types.ts index c1e10d8cd..ae758b216 100644 --- a/packages/toolkit/connector-kit/src/types.ts +++ b/packages/toolkit/connector-kit/src/types.ts @@ -214,10 +214,17 @@ export type EmailConnector = BaseConnector & { sendMessage: SendMessageFunction; }; -export type SendMessageFunction = ( - data: { to: string; type: VerificationCodeType; payload: { code: string } }, - config?: unknown -) => Promise; +export const sendMessagePayloadGuard = z.object({ + to: z.string(), + type: verificationCodeTypeGuard, + payload: z.object({ + code: z.string(), + }), +}); + +export type SendMessagePayload = z.infer; + +export type SendMessageFunction = (data: SendMessagePayload, config?: unknown) => Promise; // MARK: Social connector export type SocialConnector = BaseConnector & { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1d3175b2..2aa09da4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: packages/cli: specifiers: - '@logto/connector-kit': workspace:1.0.0-rc.2 + '@logto/connector-kit': workspace:* '@logto/core-kit': workspace:* '@logto/schemas': workspace:* '@logto/shared': workspace:* @@ -111,6 +111,7 @@ importers: packages/cloud: specifiers: '@logto/cli': workspace:* + '@logto/connector-kit': workspace:* '@logto/core-kit': workspace:* '@logto/schemas': workspace:* '@logto/shared': workspace:* @@ -143,6 +144,7 @@ importers: zod: ^3.20.2 dependencies: '@logto/cli': link:../cli + '@logto/connector-kit': link:../toolkit/connector-kit '@logto/core-kit': link:../toolkit/core-kit '@logto/schemas': link:../schemas '@logto/shared': link:../shared