mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(cloud): send email (#3405)
This commit is contained in:
parent
55cd785aaa
commit
d8d1cfeeb5
14 changed files with 191 additions and 11 deletions
5
.changeset-staged/real-carpets-wait.md
Normal file
5
.changeset-staged/real-carpets-wait.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@logto/cloud": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Cloud API: send email
|
|
@ -43,7 +43,7 @@
|
||||||
"url": "https://github.com/logto-io/logto/issues"
|
"url": "https://github.com/logto-io/logto/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/connector-kit": "workspace:1.0.0-rc.2",
|
"@logto/connector-kit": "workspace:*",
|
||||||
"@logto/core-kit": "workspace:*",
|
"@logto/core-kit": "workspace:*",
|
||||||
"@logto/schemas": "workspace:*",
|
"@logto/schemas": "workspace:*",
|
||||||
"@logto/shared": "workspace:*",
|
"@logto/shared": "workspace:*",
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/cli": "workspace:*",
|
"@logto/cli": "workspace:*",
|
||||||
|
"@logto/connector-kit": "workspace:*",
|
||||||
"@logto/core-kit": "workspace:*",
|
"@logto/core-kit": "workspace:*",
|
||||||
"@logto/schemas": "workspace:*",
|
"@logto/schemas": "workspace:*",
|
||||||
"@logto/shared": "workspace:*",
|
"@logto/shared": "workspace:*",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { cloudApiIndicator } from '@logto/schemas';
|
import { cloudApiIndicator } from '@logto/schemas';
|
||||||
import type { RequestContext } from '@withtyped/server';
|
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 dotenv from 'dotenv';
|
||||||
import { findUp } from 'find-up';
|
import { findUp } from 'find-up';
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ const { listen } = createServer({
|
||||||
withPathname(
|
withPathname(
|
||||||
'/api',
|
'/api',
|
||||||
compose<RequestContext>()
|
compose<RequestContext>()
|
||||||
|
.and(withBody())
|
||||||
.and(withAuth({ endpoint: EnvSet.global.logtoEndpoint, audience: cloudApiIndicator }))
|
.and(withAuth({ endpoint: EnvSet.global.logtoEndpoint, audience: cloudApiIndicator }))
|
||||||
.and(router.routes())
|
.and(router.routes())
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 { adminTenantId } from '@logto/schemas';
|
||||||
|
import { trySafe } from '@logto/shared';
|
||||||
|
import { RequestError } from '@withtyped/server';
|
||||||
|
|
||||||
import type { Queries } from '#src/queries/index.js';
|
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 {
|
export class ServicesLibrary {
|
||||||
constructor(public readonly queries: Queries) {}
|
constructor(public readonly queries: Queries) {}
|
||||||
|
@ -13,4 +20,80 @@ export class ServicesLibrary {
|
||||||
|
|
||||||
return application.customClientMetadata.tenantId;
|
return application.customClientMetadata.tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdminTenantLogtoConnectors(): Promise<LogtoConnector[]> {
|
||||||
|
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<EmailConnector> =>
|
||||||
|
connector.type === ConnectorType.Email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connector) {
|
||||||
|
throw new RequestError('Unable to find email connector', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sendMessage } = connector;
|
||||||
|
|
||||||
|
return sendMessage(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
22
packages/cloud/src/queries/connector.ts
Normal file
22
packages/cloud/src/queries/connector.ts
Normal file
|
@ -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<typeof createConnectorsQuery>;
|
||||||
|
|
||||||
|
export const createConnectorsQuery = (client: Queryable<PostgreSql>) => {
|
||||||
|
const findAllConnectors = async (tenantId: string) => {
|
||||||
|
const { rows } = await client.query<Connector>(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 };
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { parseDsn } from '#src/utils/postgres.js';
|
import { parseDsn } from '#src/utils/postgres.js';
|
||||||
|
|
||||||
import { createApplicationsQueries } from './application.js';
|
import { createApplicationsQueries } from './application.js';
|
||||||
|
import { createConnectorsQuery } from './connector.js';
|
||||||
import { createTenantsQueries } from './tenants.js';
|
import { createTenantsQueries } from './tenants.js';
|
||||||
import { createUsersQueries } from './users.js';
|
import { createUsersQueries } from './users.js';
|
||||||
|
|
||||||
|
@ -14,4 +15,5 @@ export class Queries {
|
||||||
public readonly tenants = createTenantsQueries(this.client);
|
public readonly tenants = createTenantsQueries(this.client);
|
||||||
public readonly users = createUsersQueries(this.client);
|
public readonly users = createUsersQueries(this.client);
|
||||||
public readonly applications = createApplicationsQueries(this.client);
|
public readonly applications = createApplicationsQueries(this.client);
|
||||||
|
public readonly connectors = createConnectorsQuery(this.client);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,12 @@ import { MockServicesLibrary } from '#src/test-utils/libraries.js';
|
||||||
|
|
||||||
import { servicesRoutes } from './services.js';
|
import { servicesRoutes } from './services.js';
|
||||||
|
|
||||||
|
const mockSendMessagePayload = {
|
||||||
|
to: 'logto@gmail.com',
|
||||||
|
type: 'SignIn',
|
||||||
|
payload: { code: '1234' },
|
||||||
|
};
|
||||||
|
|
||||||
describe('POST /api/services/send-email', () => {
|
describe('POST /api/services/send-email', () => {
|
||||||
const library = new MockServicesLibrary();
|
const library = new MockServicesLibrary();
|
||||||
const router = servicesRoutes(library);
|
const router = servicesRoutes(library);
|
||||||
|
@ -13,7 +19,9 @@ describe('POST /api/services/send-email', () => {
|
||||||
it('should throw 403 when lack of permission', async () => {
|
it('should throw 403 when lack of permission', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
router.routes()(
|
router.routes()(
|
||||||
buildRequestAuthContext('POST /services/send-email')(),
|
buildRequestAuthContext('POST /services/send-email', {
|
||||||
|
body: { data: mockSendMessagePayload },
|
||||||
|
})(),
|
||||||
noop,
|
noop,
|
||||||
createHttpContext()
|
createHttpContext()
|
||||||
)
|
)
|
||||||
|
@ -24,9 +32,12 @@ describe('POST /api/services/send-email', () => {
|
||||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||||
|
|
||||||
await router.routes()(
|
await router.routes()(
|
||||||
buildRequestAuthContext('POST /services/send-email')([CloudScope.SendEmail]),
|
buildRequestAuthContext('POST /services/send-email', {
|
||||||
|
body: { data: mockSendMessagePayload },
|
||||||
|
})([CloudScope.SendEmail]),
|
||||||
async ({ status }) => {
|
async ({ status }) => {
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
|
expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload);
|
||||||
},
|
},
|
||||||
createHttpContext()
|
createHttpContext()
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { sendMessagePayloadGuard } from '@logto/connector-kit';
|
||||||
import { CloudScope } from '@logto/schemas';
|
import { CloudScope } from '@logto/schemas';
|
||||||
import { createRouter, RequestError } from '@withtyped/server';
|
import { createRouter, RequestError } from '@withtyped/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { ServicesLibrary } from '#src/libraries/services.js';
|
import type { ServicesLibrary } from '#src/libraries/services.js';
|
||||||
import type { WithAuthContext } from '#src/middleware/with-auth.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) =>
|
export const servicesRoutes = (library: ServicesLibrary) =>
|
||||||
createRouter<WithAuthContext, '/services'>('/services').post(
|
createRouter<WithAuthContext, '/services'>('/services').post(
|
||||||
'/send-email',
|
'/send-email',
|
||||||
{},
|
{ body: z.object({ data: sendMessagePayloadGuard }) },
|
||||||
async (context, next) => {
|
async (context, next) => {
|
||||||
if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) {
|
if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) {
|
||||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
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);
|
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
|
||||||
|
|
||||||
// TODO send email
|
// TODO limitation control
|
||||||
console.log(tenantId);
|
console.log(tenantId);
|
||||||
|
|
||||||
|
await library.sendEmail(context.guarded.body.data);
|
||||||
|
|
||||||
return next({ ...context, status: 201 });
|
return next({ ...context, status: 201 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,4 +21,8 @@ export class MockServicesLibrary implements ServicesLibrary {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTenantIdFromApplicationId = jest.fn<Promise<string>, [string]>();
|
public getTenantIdFromApplicationId = jest.fn<Promise<string>, [string]>();
|
||||||
|
|
||||||
|
public sendEmail = jest.fn();
|
||||||
|
|
||||||
|
public getAdminTenantLogtoConnectors = jest.fn();
|
||||||
}
|
}
|
||||||
|
|
27
packages/cloud/src/utils/connector/index.ts
Normal file
27
packages/cloud/src/utils/connector/index.ts
Normal file
|
@ -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;
|
||||||
|
};
|
11
packages/cloud/src/utils/connector/types.ts
Normal file
11
packages/cloud/src/utils/connector/types.ts
Normal file
|
@ -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 extends AllConnector = AllConnector> = T & {
|
||||||
|
validateConfig: (config: unknown) => void;
|
||||||
|
} & { dbEntry: Connector };
|
|
@ -214,10 +214,17 @@ export type EmailConnector = BaseConnector<ConnectorType.Email> & {
|
||||||
sendMessage: SendMessageFunction;
|
sendMessage: SendMessageFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SendMessageFunction = (
|
export const sendMessagePayloadGuard = z.object({
|
||||||
data: { to: string; type: VerificationCodeType; payload: { code: string } },
|
to: z.string(),
|
||||||
config?: unknown
|
type: verificationCodeTypeGuard,
|
||||||
) => Promise<unknown>;
|
payload: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendMessagePayload = z.infer<typeof sendMessagePayloadGuard>;
|
||||||
|
|
||||||
|
export type SendMessageFunction = (data: SendMessagePayload, config?: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
// MARK: Social connector
|
// MARK: Social connector
|
||||||
export type SocialConnector = BaseConnector<ConnectorType.Social> & {
|
export type SocialConnector = BaseConnector<ConnectorType.Social> & {
|
||||||
|
|
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
|
@ -27,7 +27,7 @@ importers:
|
||||||
|
|
||||||
packages/cli:
|
packages/cli:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@logto/connector-kit': workspace:1.0.0-rc.2
|
'@logto/connector-kit': workspace:*
|
||||||
'@logto/core-kit': workspace:*
|
'@logto/core-kit': workspace:*
|
||||||
'@logto/schemas': workspace:*
|
'@logto/schemas': workspace:*
|
||||||
'@logto/shared': workspace:*
|
'@logto/shared': workspace:*
|
||||||
|
@ -111,6 +111,7 @@ importers:
|
||||||
packages/cloud:
|
packages/cloud:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@logto/cli': workspace:*
|
'@logto/cli': workspace:*
|
||||||
|
'@logto/connector-kit': workspace:*
|
||||||
'@logto/core-kit': workspace:*
|
'@logto/core-kit': workspace:*
|
||||||
'@logto/schemas': workspace:*
|
'@logto/schemas': workspace:*
|
||||||
'@logto/shared': workspace:*
|
'@logto/shared': workspace:*
|
||||||
|
@ -143,6 +144,7 @@ importers:
|
||||||
zod: ^3.20.2
|
zod: ^3.20.2
|
||||||
dependencies:
|
dependencies:
|
||||||
'@logto/cli': link:../cli
|
'@logto/cli': link:../cli
|
||||||
|
'@logto/connector-kit': link:../toolkit/connector-kit
|
||||||
'@logto/core-kit': link:../toolkit/core-kit
|
'@logto/core-kit': link:../toolkit/core-kit
|
||||||
'@logto/schemas': link:../schemas
|
'@logto/schemas': link:../schemas
|
||||||
'@logto/shared': link:../shared
|
'@logto/shared': link:../shared
|
||||||
|
|
Loading…
Add table
Reference in a new issue