From 9fc6091051549d5fe2e8ac551a1a2cc80f7def9a Mon Sep 17 00:00:00 2001 From: "{authemail@qq.com}" Date: Mon, 19 Dec 2022 17:50:35 +0800 Subject: [PATCH] feat(tencent-mail): add tencent mail connector --- packages/connector-tencent-email/README.md | 1 + .../docs/config-template.json | 72 +++++++++ .../connector-tencent-email/jest.config.ts | 1 + packages/connector-tencent-email/logo.svg | 12 ++ packages/connector-tencent-email/package.json | 56 +++++++ .../connector-tencent-email/src/constant.ts | 21 +++ packages/connector-tencent-email/src/http.ts | 123 +++++++++++++++ .../connector-tencent-email/src/index.test.ts | 89 +++++++++++ packages/connector-tencent-email/src/index.ts | 146 ++++++++++++++++++ packages/connector-tencent-email/src/mock.ts | 64 ++++++++ packages/connector-tencent-email/src/types.ts | 63 ++++++++ .../tsconfig.base.json | 10 ++ .../tsconfig.build.json | 5 + .../connector-tencent-email/tsconfig.json | 7 + .../tsconfig.test.json | 7 + pnpm-lock.yaml | 39 +++++ 16 files changed, 716 insertions(+) create mode 100644 packages/connector-tencent-email/README.md create mode 100644 packages/connector-tencent-email/docs/config-template.json create mode 100644 packages/connector-tencent-email/jest.config.ts create mode 100644 packages/connector-tencent-email/logo.svg create mode 100644 packages/connector-tencent-email/package.json create mode 100644 packages/connector-tencent-email/src/constant.ts create mode 100644 packages/connector-tencent-email/src/http.ts create mode 100644 packages/connector-tencent-email/src/index.test.ts create mode 100644 packages/connector-tencent-email/src/index.ts create mode 100644 packages/connector-tencent-email/src/mock.ts create mode 100644 packages/connector-tencent-email/src/types.ts create mode 100644 packages/connector-tencent-email/tsconfig.base.json create mode 100644 packages/connector-tencent-email/tsconfig.build.json create mode 100644 packages/connector-tencent-email/tsconfig.json create mode 100644 packages/connector-tencent-email/tsconfig.test.json diff --git a/packages/connector-tencent-email/README.md b/packages/connector-tencent-email/README.md new file mode 100644 index 00000000..178a19ef --- /dev/null +++ b/packages/connector-tencent-email/README.md @@ -0,0 +1 @@ +# Tencent mail connector diff --git a/packages/connector-tencent-email/docs/config-template.json b/packages/connector-tencent-email/docs/config-template.json new file mode 100644 index 00000000..670ff243 --- /dev/null +++ b/packages/connector-tencent-email/docs/config-template.json @@ -0,0 +1,72 @@ +{ + "accessKeyId": "", + "accessKeySecret": "", + "region": "ap-hongkong", + "fromAddress": "", + "fromName": "", + "replyAddress": "", + "templates": [ + { + "fromAddress": "", + "fromName": "", + "replyAddress": "", + "usageType": "SignIn", + "subject": "", + "templateId": "", + "params": [ + { + "name": "", + "value": "code or toAddress or fromAddress or fromName or replayAddress or subject" + }, + { + "name": "", + "value": "code or toAddress or fromAddress or fromName or replayAddress or subject" + } + ] + }, + { + "usageType": "Register", + "templateId": "", + "subject": "", + "params": [ + { + "name": "code", + "value": "code" + } + ] + }, + { + "usageType": "ForgotPassword", + "templateId": "", + "subject": "", + "params": [ + { + "name": "code", + "value": "code" + } + ] + }, + { + "usageType": "Continue", + "templateId": "", + "subject": "", + "params": [ + { + "name": "code", + "value": "code" + } + ] + }, + { + "usageType": "Test", + "templateId": "", + "subject": "", + "params": [ + { + "name": "code", + "value": "code" + } + ] + } + ] +} diff --git a/packages/connector-tencent-email/jest.config.ts b/packages/connector-tencent-email/jest.config.ts new file mode 100644 index 00000000..0a9aa1b2 --- /dev/null +++ b/packages/connector-tencent-email/jest.config.ts @@ -0,0 +1 @@ +export { default } from '@silverhand/jest-config'; diff --git a/packages/connector-tencent-email/logo.svg b/packages/connector-tencent-email/logo.svg new file mode 100644 index 00000000..cf2d8d90 --- /dev/null +++ b/packages/connector-tencent-email/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/connector-tencent-email/package.json b/packages/connector-tencent-email/package.json new file mode 100644 index 00000000..6bf62304 --- /dev/null +++ b/packages/connector-tencent-email/package.json @@ -0,0 +1,56 @@ +{ + "name": "@logto/connector-tencent-email", + "version": "1.0.0-beta.13", + "description": "Tencent Email Service connector implementation.", + "main": "./lib/index.js", + "exports": "./lib/index.js", + "author": "Silverhand Inc. ", + "license": "MIT", + "files": [ + "lib", + "docs", + "logo.svg", + "README.md" + ], + "scripts": { + "precommit": "lint-staged", + "build": "rm -rf lib/ && ncc build src/index.ts -o lib", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "jest", + "test:coverage": "jest --coverage --silent", + "prepack": "pnpm build" + }, + "dependencies": { + "@logto/connector-kit": "^1.0.0-beta.26", + "@silverhand/essentials": "^1.2.0", + "@silverhand/jest-config": "1.2.2", + "got": "^11.8.2", + "zod": "^3.14.3" + }, + "devDependencies": { + "@jest/types": "^28.1.3", + "@silverhand/eslint-config": "1.3.0", + "@silverhand/ts-config": "1.2.1", + "@types/jest": "^28.1.6", + "@types/node": "^16.3.1", + "@vercel/ncc": "^0.36.0", + "eslint": "^8.21.0", + "jest": "^28.1.3", + "lint-staged": "^13.0.0", + "nock": "^13.2.2", + "prettier": "^2.7.1", + "typescript": "^4.7.4" + }, + "engines": { + "node": "^16.0.0" + }, + "eslintConfig": { + "extends": "@silverhand" + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/connector-tencent-email/src/constant.ts b/packages/connector-tencent-email/src/constant.ts new file mode 100644 index 00000000..4e67c6d6 --- /dev/null +++ b/packages/connector-tencent-email/src/constant.ts @@ -0,0 +1,21 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; + +export const endpoint = 'ses.tencentcloudapi.com'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'tencent-email-service', + target: 'tencent-mail', + platform: null, + name: { + en: 'Tencent Mail Service', + 'zh-CN': '腾讯云邮件服务', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'Tencent', + 'zh-CN': 'Tencent', + }, + readme: './README.md', + configTemplate: './docs/config-template.json', +}; diff --git a/packages/connector-tencent-email/src/http.ts b/packages/connector-tencent-email/src/http.ts new file mode 100644 index 00000000..4dac863c --- /dev/null +++ b/packages/connector-tencent-email/src/http.ts @@ -0,0 +1,123 @@ +import type { BinaryToTextEncoding } from 'crypto'; +import crypto from 'crypto'; + +import got from 'got'; + +import type { TencentErrorResponse, TencentSuccessResponse } from '@/types'; +import { tencentErrorResponse } from '@/types'; + +import { endpoint } from './constant'; + +function sha256Hmac(message: string, secret: string): string; +function sha256Hmac(message: string, secret: string, encoding: BinaryToTextEncoding): Buffer; + +function sha256Hmac(message: string, secret: string, encoding?: BinaryToTextEncoding) { + const hmac = crypto.createHmac('sha256', secret); + + return encoding ? hmac.update(message).digest(encoding) : hmac.update(message).digest(); +} + +function getHash(message: string, encoding: BinaryToTextEncoding = 'hex') { + const hash = crypto.createHash('sha256'); + + return hash.update(message).digest(encoding); +} + +function getDate(timestamp: number) { + const date = new Date(timestamp * 1000); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth().toString().padStart(2, '0'); + const day = date.getUTCDate().toString().padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +export function isErrorResponse(response: unknown): response is TencentErrorResponse { + const result = tencentErrorResponse.safeParse(response); + + return result.success; +} + +export function request( + parameters: { + fromEmailAddress: string; + replyAddress: string; + destination: string; + template: { + templateId: string; + templateData: string; + }; + subject: string; + }, + config: { + secretId: string; + secretKey: string; + region: string; + } +) { + const { secretId, secretKey, region } = config; + const timestamp = Math.floor(Date.now() / 1000); + const date = getDate(timestamp); + const service = 'ses'; + + const firstPayload = { + FromEmailAddress: parameters.fromEmailAddress, + ReplyToAddresses: parameters.replyAddress, + Destination: [parameters.destination], + Template: { + TemplateID: Number(parameters.template.templateId), + TemplateData: parameters.template.templateData, + }, + Subject: parameters.subject, + }; + + const payload = JSON.stringify(firstPayload); + + const hashedRequestPayload = getHash(payload); + const signedHeaders = 'content-type;host'; + const httpRequestMethod = 'POST'; + const canonicalUri = '/'; + const canonicalQueryString = ''; + const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${endpoint}\n`; + + const canonicalRequest = [ + httpRequestMethod, + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload, + ].join('\n'); + + const algorithm = 'TC3-HMAC-SHA256'; + const hashedCanonicalRequest = getHash(canonicalRequest); + const credentialScope = `${date}/${service}/tc3_request`; + const stringToSign = [algorithm, timestamp, credentialScope, hashedCanonicalRequest].join('\n'); + + const secretDate = sha256Hmac(date, `TC3${secretKey}`); + const secretService = sha256Hmac(service, secretDate); + const secretSigning = sha256Hmac('tc3_request', secretService); + const signature = sha256Hmac(stringToSign, secretSigning, 'hex').toString(); + const credential = `${secretId}/${credentialScope}`; + + const authorization = [ + algorithm, + `Credential=${credential},`, + `SignedHeaders=${signedHeaders},`, + `Signature=${signature}`, + ].join(' '); + + return got.post(`https://${endpoint}`, { + headers: { + Authorization: authorization, + 'Content-Type': 'application/json; charset=utf-8', + Host: endpoint, + 'X-TC-Action': 'SendEmail', + 'X-TC-Timestamp': String(timestamp), + 'X-TC-Version': '2020-10-02', + 'X-TC-Region': region, + }, + body: payload, + responseType: 'json', + }); +} diff --git a/packages/connector-tencent-email/src/index.test.ts b/packages/connector-tencent-email/src/index.test.ts new file mode 100644 index 00000000..92a037b0 --- /dev/null +++ b/packages/connector-tencent-email/src/index.test.ts @@ -0,0 +1,89 @@ +import { ConnectorError, ConnectorErrorCodes, MessageTypes } from '@logto/connector-kit'; +import nock from 'nock'; + +import { endpoint } from '@/constant'; + +import createConnector from '.'; +import { errorConfig, mockedConfig, mockedOptionConfig } from './mock'; + +const getSuccess1Config = jest.fn().mockResolvedValue(mockedConfig); +const getSuccess2Config = jest.fn().mockResolvedValue(mockedOptionConfig); +const getErrorConfig = jest.fn().mockResolvedValue(errorConfig); + +describe('Tencent mail connector', () => { + it('should not throw errors using config definition method 1', async () => { + await expect(createConnector({ getConfig: getSuccess1Config })).resolves.not.toThrow(); + }); + + it('init without throwing errors, config define method 2', async () => { + await expect(createConnector({ getConfig: getSuccess2Config })).resolves.not.toThrow(); + }); + + it('throws with invalid config', async () => { + const connector = await createConnector({ getConfig: getErrorConfig }); + await expect( + connector.sendMessage({ + to: '', + type: MessageTypes.Register, + payload: { code: '' }, + }) + ).rejects.toThrow(); + }); +}); + +describe('Tencent mail connector params error', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + it('sendMessage request error', async () => { + nock(`https://${endpoint}`) + .post('/') + .reply(200, { + Response: { + RequestId: '123456', + Error: { + Code: 'InvalidParameterValue.InvalidToAddress', + Message: 'Invalid to address.', + }, + }, + }); + + const connector = await createConnector({ getConfig: getSuccess1Config }); + + await expect( + connector.sendMessage({ + to: '', + type: MessageTypes.Test, + payload: { code: '' }, + }) + ).rejects.toThrowError( + new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + 'Tencent email response error: Invalid to address.' + ) + ); + }); + + it('sendMessage request success', async () => { + nock(`https://${endpoint}`) + .post('/') + .reply(200, { + Response: { + RequestId: '123456', + MessageId: '123456', + }, + }); + + const connector = await createConnector({ getConfig: getSuccess1Config }); + + await expect( + connector.sendMessage({ + to: '', + type: MessageTypes.Test, + payload: { code: '' }, + }) + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/connector-tencent-email/src/index.ts b/packages/connector-tencent-email/src/index.ts new file mode 100644 index 00000000..76632842 --- /dev/null +++ b/packages/connector-tencent-email/src/index.ts @@ -0,0 +1,146 @@ +import type { + CreateConnector, + EmailConnector, + GetConnectorConfig, + SendMessageFunction, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + ConnectorType, + validateConfig, +} from '@logto/connector-kit'; +import { assert } from '@silverhand/essentials'; +import { HTTPError } from 'got'; + +import { isErrorResponse, request } from '@/http'; +import type { MailTemplateType, SendTencentMailConfig, ValueNames } from '@/types'; +import { sendTencentMailConfigGuard } from '@/types'; + +import { defaultMetadata } from './constant'; + +function getTemplate(templates: MailTemplateType[], usageType: string): MailTemplateType { + const template = templates.find((template) => template.usageType === usageType); + assert( + template, + new ConnectorError( + ConnectorErrorCodes.TemplateNotFound, + `Template not found for type: ${usageType}` + ) + ); + + return template; +} + +function buildFromName(fromName: string, fromAddress: string): string { + return fromName.trim().length > 0 ? `${fromName} <${fromAddress}>` : fromAddress; +} + +function buildTemplatePayload( + template: MailTemplateType, + templateParameters: Record +): Record { + return template.params.reduce>((accumulator, parameter) => { + const { name, value } = parameter; + + return { + ...accumulator, + [name]: templateParameters[value], + }; + }, {}); +} + +const sendMessage = + ( + getConfig: GetConnectorConfig + ): SendMessageFunction => // eslint-disable-next-line complexity + async (data, inputConfig) => { + const { to, type, payload } = data; + const config = inputConfig ?? (await getConfig(defaultMetadata.id)); + validateConfig(config, sendTencentMailConfigGuard); + const { accessKeyId, accessKeySecret, region, fromAddress, fromName, replyAddress, templates } = + config; + + const mailTemplate: MailTemplateType = getTemplate(templates, type); + + const fromAddressValue = mailTemplate.fromAddress ?? fromAddress ?? ''; + + assert( + fromAddressValue.length > 0, + new ConnectorError( + ConnectorErrorCodes.InvalidConfig, + `mail params not found, fromAddress is required` + ) + ); + + const fromNameValue = mailTemplate.fromName ?? fromName ?? ''; + + const templateParameters: Record = { + code: payload.code, + toAddress: to, + fromAddress: fromAddressValue, + fromName: buildFromName(fromNameValue, fromAddressValue), + replyAddress: mailTemplate.replyAddress ?? replyAddress ?? '', + subject: mailTemplate.subject, + }; + + const emailTemplateParametersPayload = buildTemplatePayload(mailTemplate, templateParameters); + + try { + const response = await request( + { + fromEmailAddress: templateParameters.fromAddress, + replyAddress: templateParameters.replyAddress, + destination: templateParameters.toAddress, + subject: templateParameters.subject, + template: { + templateData: JSON.stringify(emailTemplateParametersPayload), + templateId: mailTemplate.templateId, + }, + }, + { + region, + secretId: accessKeyId, + secretKey: accessKeySecret, + } + ); + const { body: rawBody } = response; + + if (isErrorResponse(rawBody)) { + throw new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + `Tencent email response error: ${rawBody.Response.Error.Message}` + ); + } + const { Response } = rawBody; + assert( + Response, + new ConnectorError(ConnectorErrorCodes.InvalidResponse, `Tencent email response not found`) + ); + + return response; + } catch (error: unknown) { + if (error instanceof ConnectorError) { + throw error; + } + + if (error instanceof HTTPError) { + throw new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + `Tencent email response error: ${error.message}` + ); + } + throw new ConnectorError(ConnectorErrorCodes.General, `Tencent email unknown error`); + } + }; + +const createSendGridMailConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Email, + configGuard: sendTencentMailConfigGuard, + sendMessage: sendMessage(getConfig), + }; +}; + +export default createSendGridMailConnector; diff --git a/packages/connector-tencent-email/src/mock.ts b/packages/connector-tencent-email/src/mock.ts new file mode 100644 index 00000000..6fb55822 --- /dev/null +++ b/packages/connector-tencent-email/src/mock.ts @@ -0,0 +1,64 @@ +import type { SendTencentMailConfig } from '@/types'; + +export const mockedConfig: SendTencentMailConfig = { + accessKeyId: 'some-access-key-id', + accessKeySecret: 'some-access-key-secret', + region: 'some-region', + fromAddress: 'some-from-address', + fromName: 'some-from-name', + replyAddress: 'some-replay-address', + templates: [ + { + usageType: 'Test', + subject: 'Logto Test Template', + templateId: '123456', + params: [ + { + name: 'code', + value: 'code', + }, + ], + }, + ], +}; + +export const mockedOptionConfig: SendTencentMailConfig = { + accessKeyId: 'some-access-key-id', + accessKeySecret: 'some-access-key-secret', + region: 'some-region', + templates: [ + { + fromAddress: 'some-from-address', + fromName: 'some-from-name', + replyAddress: 'some-replay-address', + usageType: 'Test', + subject: 'Logto Test Template', + templateId: '123456', + params: [ + { + name: 'code', + value: 'code', + }, + ], + }, + ], +}; + +export const errorConfig: SendTencentMailConfig = { + accessKeyId: 'some-access-key-id', + accessKeySecret: 'some-access-key-secret', + region: 'some-region', + templates: [ + { + usageType: 'Test', + subject: 'Logto Test Template', + templateId: '123456', + params: [ + { + name: 'code', + value: 'code', + }, + ], + }, + ], +}; diff --git a/packages/connector-tencent-email/src/types.ts b/packages/connector-tencent-email/src/types.ts new file mode 100644 index 00000000..adf5def8 --- /dev/null +++ b/packages/connector-tencent-email/src/types.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; + +export const fromInfo = z.object({ + fromAddress: z.string().optional(), + fromName: z.string().optional(), + replyAddress: z.string().optional(), +}); + +export const ValueNames = [ + 'code', + 'toAddress', + 'fromAddress', + 'fromName', + 'replyAddress', + 'subject', +] as const; + +export const mailTemplateParameters = z.object({ + name: z.string(), + value: z.enum(ValueNames), +}); + +export const mailTemplate = z + .object({ + subject: z.string(), + usageType: z.string(), + templateId: z.string(), + params: z.array(mailTemplateParameters), + }) + .merge(fromInfo); + +export type MailTemplateType = z.infer; + +export const sendTencentMailConfigGuard = z + .object({ + accessKeyId: z.string(), + accessKeySecret: z.string(), + region: z.string(), + templates: z.array(mailTemplate), + }) + .merge(fromInfo); + +export type SendTencentMailConfig = z.infer; + +export const tencentErrorResponse = z.object({ + Response: z.object({ + Error: z.object({ + Code: z.string(), + Message: z.string(), + }), + }), +}); + +export declare type TencentErrorResponse = z.infer; + +export const tencentSuccessResponse = z.object({ + Response: z.object({ + MessageId: z.string(), + RequestId: z.string(), + }), +}); + +export declare type TencentSuccessResponse = z.infer; diff --git a/packages/connector-tencent-email/tsconfig.base.json b/packages/connector-tencent-email/tsconfig.base.json new file mode 100644 index 00000000..848a915f --- /dev/null +++ b/packages/connector-tencent-email/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/packages/connector-tencent-email/tsconfig.build.json b/packages/connector-tencent-email/tsconfig.build.json new file mode 100644 index 00000000..d42923dd --- /dev/null +++ b/packages/connector-tencent-email/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base", + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/connector-tencent-email/tsconfig.json b/packages/connector-tencent-email/tsconfig.json new file mode 100644 index 00000000..71ff434a --- /dev/null +++ b/packages/connector-tencent-email/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["src", "jest.config.ts"] +} diff --git a/packages/connector-tencent-email/tsconfig.test.json b/packages/connector-tencent-email/tsconfig.test.json new file mode 100644 index 00000000..1424a155 --- /dev/null +++ b/packages/connector-tencent-email/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false, + "allowJs": true, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84f4cd11..d46e3534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,45 @@ importers: prettier: 2.7.1 typescript: 4.8.2 + packages/connector-tencent-email: + specifiers: + '@jest/types': ^28.1.3 + '@logto/connector-kit': ^1.0.0-beta.26 + '@silverhand/eslint-config': 1.3.0 + '@silverhand/essentials': ^1.2.0 + '@silverhand/jest-config': 1.2.2 + '@silverhand/ts-config': 1.2.1 + '@types/jest': ^28.1.6 + '@types/node': ^16.3.1 + '@vercel/ncc': ^0.34.0 + eslint: ^8.21.0 + got: ^11.8.2 + jest: ^28.1.3 + lint-staged: ^13.0.0 + nock: ^13.2.2 + prettier: ^2.7.1 + typescript: ^4.7.4 + zod: ^3.14.3 + dependencies: + '@logto/connector-kit': 1.0.0-beta.26_zod@3.18.0 + '@silverhand/essentials': 1.2.1 + '@silverhand/jest-config': 1.2.2_556mfp7b5dutuj2jcrj5i7zc5q + got: 11.8.5 + zod: 3.18.0 + devDependencies: + '@jest/types': 28.1.3 + '@silverhand/eslint-config': 1.3.0_er5sflmxjcrgfvrsp3qwdvxste + '@silverhand/ts-config': 1.2.1_typescript@4.8.2 + '@types/jest': 28.1.8 + '@types/node': 16.11.56 + '@vercel/ncc': 0.34.0 + eslint: 8.23.0 + jest: 28.1.3_@types+node@16.11.56 + lint-staged: 13.0.3 + nock: 13.2.9 + prettier: 2.7.1 + typescript: 4.8.2 + packages/connector-twilio-sms: specifiers: '@jest/types': ^28.1.3